fixed event join token handling in the event admin. created new seeders with new tenants and package purchases. added new playwright test scenarios.
This commit is contained in:
@@ -18,16 +18,37 @@ export type EventJoinTokenLayout = {
|
||||
download_urls: Record<string, string>;
|
||||
};
|
||||
|
||||
export type TenantEventType = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
icon: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type TenantEvent = {
|
||||
id: number;
|
||||
name: string | Record<string, string>;
|
||||
slug: string;
|
||||
event_date: string | null;
|
||||
event_type_id: number | null;
|
||||
event_type: TenantEventType | null;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
description?: string | null;
|
||||
photo_count?: number;
|
||||
like_count?: number;
|
||||
package?: {
|
||||
id: number | string | null;
|
||||
name: string | null;
|
||||
price: number | null;
|
||||
purchased_at: string | null;
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TenantPhoto = {
|
||||
@@ -208,8 +229,8 @@ export type EventMember = {
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
|
||||
type EventListResponse = { data?: TenantEvent[] };
|
||||
type EventResponse = { data: TenantEvent };
|
||||
type EventListResponse = { data?: JsonValue[] };
|
||||
type EventResponse = { data: JsonValue };
|
||||
|
||||
export type EventJoinToken = {
|
||||
id: number;
|
||||
@@ -226,13 +247,14 @@ export type EventJoinToken = {
|
||||
layouts: EventJoinTokenLayout[];
|
||||
layouts_url: string | null;
|
||||
};
|
||||
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
|
||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||
|
||||
type EventSavePayload = {
|
||||
name: string;
|
||||
slug: string;
|
||||
date?: string;
|
||||
event_type_id: number;
|
||||
event_date?: string;
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
package_id?: number;
|
||||
@@ -322,13 +344,51 @@ function pickTranslatedText(translations: Record<string, string>, fallback: stri
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeEvent(event: TenantEvent): TenantEvent {
|
||||
function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEventType | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translations = normalizeTranslationMap((raw as JsonValue).name ?? {}, undefined, true);
|
||||
const fallback = typeof (raw as JsonValue).name === 'string' ? (raw as JsonValue).name : 'Event';
|
||||
|
||||
return {
|
||||
...event,
|
||||
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
|
||||
id: Number((raw as JsonValue).id ?? 0),
|
||||
slug: String((raw as JsonValue).slug ?? ''),
|
||||
name: pickTranslatedText(translations, fallback ?? 'Event'),
|
||||
name_translations: translations,
|
||||
icon: ((raw as JsonValue).icon ?? null) as string | null,
|
||||
settings: ((raw as JsonValue).settings ?? {}) as Record<string, unknown>,
|
||||
created_at: (raw as JsonValue).created_at ?? null,
|
||||
updated_at: (raw as JsonValue).updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
|
||||
const normalized: TenantEvent = {
|
||||
...(event as Record<string, unknown>),
|
||||
id: Number(event.id ?? 0),
|
||||
name: event.name ?? '',
|
||||
slug: String(event.slug ?? ''),
|
||||
event_date: typeof event.event_date === 'string'
|
||||
? event.event_date
|
||||
: (typeof event.date === 'string' ? event.date : null),
|
||||
event_type_id: event.event_type_id !== undefined && event.event_type_id !== null
|
||||
? Number(event.event_type_id)
|
||||
: null,
|
||||
event_type: normalizedType,
|
||||
status: (event.status ?? 'draft') as TenantEvent['status'],
|
||||
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
|
||||
description: event.description ?? null,
|
||||
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
||||
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
||||
package: event.package ?? null,
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
||||
return {
|
||||
id: photo.id,
|
||||
@@ -574,6 +634,15 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||
return normalizeEvent(data.data);
|
||||
}
|
||||
|
||||
export async function getEventTypes(): Promise<TenantEventType[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
|
||||
const rows = Array.isArray(data.data) ? data.data : [];
|
||||
return rows
|
||||
.map((row) => normalizeEventType(row))
|
||||
.filter((row): row is TenantEventType => Boolean(row));
|
||||
}
|
||||
|
||||
export async function getEventPhotos(slug: string): Promise<TenantPhoto[]> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
||||
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
|
||||
@@ -602,7 +671,7 @@ export async function deletePhoto(slug: string, id: number): Promise<void> {
|
||||
|
||||
export async function toggleEvent(slug: string): Promise<TenantEvent> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ message: string; data: TenantEvent }>(response, 'Failed to toggle event');
|
||||
const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event');
|
||||
return normalizeEvent(data.data);
|
||||
}
|
||||
|
||||
@@ -621,7 +690,7 @@ export async function getEventStats(slug: string): Promise<EventStats> {
|
||||
|
||||
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
|
||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load join tokens');
|
||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
|
||||
const list = Array.isArray(payload.data) ? payload.data : [];
|
||||
return list.map(normalizeJoinToken);
|
||||
}
|
||||
@@ -636,7 +705,7 @@ export async function createInviteLink(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invite');
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
}
|
||||
|
||||
@@ -651,7 +720,7 @@ export async function revokeEventJoinToken(
|
||||
options.body = JSON.stringify({ reason });
|
||||
}
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke join token');
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
}
|
||||
|
||||
|
||||
75
resources/js/admin/components/DevTenantSwitcher.tsx
Normal file
75
resources/js/admin/components/DevTenantSwitcher.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DEV_TENANT_KEYS = [
|
||||
{ key: 'lumen', label: 'Lumen Moments' },
|
||||
{ key: 'storycraft', label: 'Storycraft Weddings' },
|
||||
{ key: 'viewfinder', label: 'Viewfinder Studios' },
|
||||
{ key: 'pixel', label: 'Pixel & Co (dormant)' },
|
||||
] as const;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
fotospielDemoAuth?: {
|
||||
clients: Record<string, string>;
|
||||
loginAs: (tenantKey: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function DevTenantSwitcher() {
|
||||
const helper = window.fotospielDemoAuth;
|
||||
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
|
||||
|
||||
if (!helper) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleLogin(key: string) {
|
||||
if (!helper) return;
|
||||
setLoggingIn(key);
|
||||
try {
|
||||
await helper.loginAs(key);
|
||||
} catch (error) {
|
||||
console.error('[DevAuth] Switch failed', error);
|
||||
setLoggingIn(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<strong className="text-amber-800">Demo tenants</strong>
|
||||
<span className="text-xs uppercase tracking-wide text-amber-600">Dev mode</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700">
|
||||
Select a seeded tenant to mint OAuth tokens and jump straight into their admin space. Available only in development builds.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{DEV_TENANT_KEYS.map(({ key, label }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
className="w-full border-amber-200 text-amber-800 hover:bg-amber-50"
|
||||
disabled={Boolean(loggingIn)}
|
||||
onClick={() => void handleLogin(key)}
|
||||
>
|
||||
{loggingIn === key ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verbinde...
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-amber-600">
|
||||
Console: <code>fotospielDemoAuth.loginAs('lumen')</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
resources/js/admin/dev-tools.ts
Normal file
145
resources/js/admin/dev-tools.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true') {
|
||||
type StoredTokens = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const CLIENTS: Record<string, string> = {
|
||||
lumen: import.meta.env.VITE_OAUTH_CLIENT_ID || 'tenant-admin-app',
|
||||
storycraft: 'demo-tenant-admin-storycraft',
|
||||
viewfinder: 'demo-tenant-admin-viewfinder',
|
||||
pixel: 'demo-tenant-admin-pixel',
|
||||
};
|
||||
|
||||
const scopes = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
const baseUrl = window.location.origin;
|
||||
const redirectUri = `${baseUrl}/event-admin/auth/callback`;
|
||||
|
||||
async function loginAs(label: string): Promise<void> {
|
||||
const clientId = CLIENTS[label];
|
||||
if (!clientId) {
|
||||
console.warn('[DevAuth] Unknown tenant key', label);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await fetchTokens(clientId);
|
||||
localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(tokens));
|
||||
window.location.assign('/event-admin/dashboard');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('[DevAuth] Failed to login', error.message);
|
||||
} else {
|
||||
console.error('[DevAuth] Failed to login', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTokens(clientId: string): Promise<StoredTokens> {
|
||||
const verifier = randomString(32);
|
||||
const challenge = await sha256(verifier);
|
||||
const state = randomString(12);
|
||||
|
||||
const authorizeParams = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: scopes,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`);
|
||||
verifyState(callbackUrl.searchParams.get('state'), state);
|
||||
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('Authorize response missing code');
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch('/api/v1/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed with ${tokenResponse.status}`);
|
||||
}
|
||||
|
||||
const body = await tokenResponse.json();
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
||||
|
||||
return {
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000,
|
||||
scope: body.scope,
|
||||
};
|
||||
}
|
||||
|
||||
function randomString(bytes: number): string {
|
||||
const buffer = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buffer);
|
||||
return base64Url(buffer);
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64Url(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function base64Url(data: Uint8Array): string {
|
||||
const binary = Array.from(data, (byte) => String.fromCharCode(byte)).join('');
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
const api = { loginAs, clients: CLIENTS };
|
||||
|
||||
console.info('[DevAuth] Demo tenant helpers ready', Object.keys(CLIENTS));
|
||||
|
||||
// @ts-expect-error Dev helper for debugging only.
|
||||
window.fotospielDemoAuth = api;
|
||||
// @ts-expect-error Dev helper for debugging only.
|
||||
globalThis.fotospielDemoAuth = api;
|
||||
}
|
||||
|
||||
function requestAuthorization(url: string): Promise<URL> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||
if (xhr.status >= 200 && xhr.status < 400 && responseUrl) {
|
||||
resolve(new URL(responseUrl, window.location.origin));
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Authorize request failed'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function verifyState(returnedState: string | null, expectedState: string): void {
|
||||
if (returnedState && returnedState !== expectedState) {
|
||||
throw new Error('Authorize state mismatch');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Credits und Aufgaben im Blick."
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard konnte nicht geladen werden."
|
||||
@@ -27,6 +27,7 @@
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
"activeEvents": "Aktive Events",
|
||||
"publishedHint": "{{count}} veröffentlicht",
|
||||
"newPhotos": "Neue Fotos (7 Tage)",
|
||||
@@ -50,9 +51,9 @@
|
||||
"label": "Tasks organisieren",
|
||||
"description": "Sorge für klare Verantwortungen."
|
||||
},
|
||||
"manageCredits": {
|
||||
"label": "Credits verwalten",
|
||||
"description": "Sieh dir Balance & Ledger an."
|
||||
"managePackages": {
|
||||
"label": "Pakete verwalten",
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
@@ -68,5 +69,78 @@
|
||||
"planning": "In Planung",
|
||||
"noDate": "Kein Datum"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "Neues Event",
|
||||
"allEvents": "Alle Events",
|
||||
"guidedSetup": "Guided Setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant-Admin",
|
||||
"greeting": "Hallo {{name}}!",
|
||||
"subtitle": "Behalte deine Events, Pakete und Aufgaben im Blick."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard konnte nicht geladen werden."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Fehler"
|
||||
},
|
||||
"welcomeCard": {
|
||||
"title": "Starte mit der Welcome Journey",
|
||||
"summary": "Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit geführten Schritten.",
|
||||
"body1": "Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.",
|
||||
"body2": "Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.",
|
||||
"cta": "Jetzt starten"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Kurzer Überblick",
|
||||
"description": "Wichtigste Kennzahlen deines Tenants auf einen Blick.",
|
||||
"noPackage": "Kein aktives Paket",
|
||||
"stats": {
|
||||
"activePackage": "Aktives Paket",
|
||||
"activeEvents": "Aktive Events",
|
||||
"publishedHint": "{{count}} veröffentlicht",
|
||||
"newPhotos": "Neue Fotos (7 Tage)",
|
||||
"taskProgress": "Task-Fortschritt",
|
||||
"credits": "Credits",
|
||||
"lowCredits": "Auffüllen empfohlen"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Schnellaktionen",
|
||||
"description": "Starte durch mit den wichtigsten Aktionen.",
|
||||
"createEvent": {
|
||||
"label": "Event erstellen",
|
||||
"description": "Plane dein nächstes Highlight."
|
||||
},
|
||||
"moderatePhotos": {
|
||||
"label": "Fotos moderieren",
|
||||
"description": "Prüfe neue Uploads."
|
||||
},
|
||||
"organiseTasks": {
|
||||
"label": "Tasks organisieren",
|
||||
"description": "Sorge für klare Verantwortungen."
|
||||
},
|
||||
"managePackages": {
|
||||
"label": "Pakete verwalten",
|
||||
"description": "Aktives Paket und Historie einsehen."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Kommende Events",
|
||||
"description": "Die nächsten Termine inklusive Status & Zugriff.",
|
||||
"settings": "Einstellungen öffnen",
|
||||
"empty": {
|
||||
"message": "Noch keine Termine geplant. Lege dein erstes Event an!",
|
||||
"cta": "Event planen"
|
||||
},
|
||||
"status": {
|
||||
"live": "Live",
|
||||
"planning": "In Planung",
|
||||
"noDate": "Kein Datum"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"billing": {
|
||||
"title": "Billing und Credits",
|
||||
"subtitle": "Verwalte Guthaben, Pakete und Abrechnungen.",
|
||||
"title": "Pakete & Abrechnung",
|
||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Billing-Daten konnten nicht geladen werden.",
|
||||
"more": "Weitere Ledger-Einträge konnten nicht geladen werden."
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Credits und Status",
|
||||
"description": "Dein aktuelles Guthaben und das aktive Reseller-Paket.",
|
||||
"title": "Paketübersicht",
|
||||
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
||||
"empty": "Noch kein Paket aktiv.",
|
||||
"emptyBadge": "Kein aktives Paket",
|
||||
"cards": {
|
||||
"balance": {
|
||||
"label": "Verfügbare Credits"
|
||||
"package": {
|
||||
"label": "Aktives Paket",
|
||||
"helper": "Aktuell zugewiesen"
|
||||
},
|
||||
"used": {
|
||||
"label": "Genutzte Events",
|
||||
@@ -26,34 +29,35 @@
|
||||
"label": "Preis (netto)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Ablauf",
|
||||
"helper": "Automatisch verlängern, falls aktiv"
|
||||
"label": "Läuft ab",
|
||||
"helper": "Automatische Verlängerung, falls aktiv"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Reseller-Pakete.",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Ablauf"
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Credit Ledger",
|
||||
"description": "Alle Zu- und Abbuchungen deines Credits-Kontos.",
|
||||
"empty": "Noch keine Ledger-Einträge vorhanden.",
|
||||
"loadMore": "Mehr laden",
|
||||
"reasons": {
|
||||
"purchase": "Credit-Kauf",
|
||||
"usage": "Verbrauch",
|
||||
"manual": "Manuelle Anpassung"
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
@@ -241,5 +245,57 @@
|
||||
"submit": "Emotion speichern"
|
||||
}
|
||||
}
|
||||
,
|
||||
"management": {
|
||||
"billing": {
|
||||
"title": "Pakete & Abrechnung",
|
||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||
"actions": {
|
||||
"refresh": "Aktualisieren",
|
||||
"exportCsv": "Export als CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Paketdaten konnten nicht geladen werden.",
|
||||
"more": "Weitere Einträge konnten nicht geladen werden."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Paketübersicht",
|
||||
"description": "Dein aktives Paket und die wichtigsten Kennzahlen.",
|
||||
"empty": "Noch kein Paket aktiv.",
|
||||
"emptyBadge": "Kein aktives Paket",
|
||||
"cards": {
|
||||
"package": {
|
||||
"label": "Aktives Paket",
|
||||
"helper": "Aktuell zugewiesen"
|
||||
},
|
||||
"used": {
|
||||
"label": "Genutzte Events",
|
||||
"helper": "Verfügbar: {{count}}"
|
||||
},
|
||||
"price": {
|
||||
"label": "Preis (netto)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Läuft ab",
|
||||
"helper": "Automatische Verlängerung, falls aktiv"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktuelle und vergangene Pakete.",
|
||||
"empty": "Noch keine Pakete gebucht.",
|
||||
"card": {
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"used": "Genutzte Events",
|
||||
"available": "Verfügbar",
|
||||
"expires": "Läuft ab"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, credits, and tasks on track."
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard could not be loaded."
|
||||
@@ -27,6 +27,7 @@
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
"activeEvents": "Active events",
|
||||
"publishedHint": "{{count}} published",
|
||||
"newPhotos": "New photos (7 days)",
|
||||
@@ -50,9 +51,9 @@
|
||||
"label": "Organise tasks",
|
||||
"description": "Assign clear responsibilities."
|
||||
},
|
||||
"manageCredits": {
|
||||
"label": "Manage credits",
|
||||
"description": "Review balance and ledger."
|
||||
"managePackages": {
|
||||
"label": "Manage packages",
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
@@ -68,5 +69,78 @@
|
||||
"planning": "In planning",
|
||||
"noDate": "No date"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"actions": {
|
||||
"newEvent": "New Event",
|
||||
"allEvents": "All events",
|
||||
"guidedSetup": "Guided setup"
|
||||
},
|
||||
"welcome": {
|
||||
"fallbackName": "Tenant Admin",
|
||||
"greeting": "Welcome, {{name}}!",
|
||||
"subtitle": "Keep your events, packages, and tasks on track."
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Dashboard could not be loaded."
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"welcomeCard": {
|
||||
"title": "Start with the welcome journey",
|
||||
"summary": "Discover the storytelling elements, choose your package, and create your first event with guided steps.",
|
||||
"body1": "We guide you through packages, tasks, and gallery setup so your event shines.",
|
||||
"body2": "You can return to the welcome journey at any time, even once events are live.",
|
||||
"cta": "Start now"
|
||||
},
|
||||
"overview": {
|
||||
"title": "At a glance",
|
||||
"description": "Key tenant metrics at a glance.",
|
||||
"noPackage": "No active package",
|
||||
"stats": {
|
||||
"activePackage": "Active package",
|
||||
"activeEvents": "Active events",
|
||||
"publishedHint": "{{count}} published",
|
||||
"newPhotos": "New photos (7 days)",
|
||||
"taskProgress": "Task progress",
|
||||
"credits": "Credits",
|
||||
"lowCredits": "Top up recommended"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick actions",
|
||||
"description": "Jump straight to the most important actions.",
|
||||
"createEvent": {
|
||||
"label": "Create event",
|
||||
"description": "Plan your next highlight."
|
||||
},
|
||||
"moderatePhotos": {
|
||||
"label": "Moderate photos",
|
||||
"description": "Review new uploads."
|
||||
},
|
||||
"organiseTasks": {
|
||||
"label": "Organise tasks",
|
||||
"description": "Assign clear responsibilities."
|
||||
},
|
||||
"managePackages": {
|
||||
"label": "Manage packages",
|
||||
"description": "View your active package and history."
|
||||
}
|
||||
},
|
||||
"upcoming": {
|
||||
"title": "Upcoming events",
|
||||
"description": "The next dates including status and quick access.",
|
||||
"settings": "Open settings",
|
||||
"empty": {
|
||||
"message": "No events scheduled yet. Create your first one!",
|
||||
"cta": "Plan event"
|
||||
},
|
||||
"status": {
|
||||
"live": "Live",
|
||||
"planning": "In planning",
|
||||
"noDate": "No date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"billing": {
|
||||
"title": "Billing & credits",
|
||||
"subtitle": "Manage balances, packages, and invoicing.",
|
||||
"title": "Packages & billing",
|
||||
"subtitle": "Manage your purchased packages and track their durations.",
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Unable to load billing data.",
|
||||
"more": "Unable to load more ledger entries."
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Credits & status",
|
||||
"description": "Your current balance and active reseller package.",
|
||||
"title": "Package overview",
|
||||
"description": "Your active package and the most important metrics.",
|
||||
"empty": "No active package yet.",
|
||||
"emptyBadge": "No active package",
|
||||
"cards": {
|
||||
"balance": {
|
||||
"label": "Available credits"
|
||||
"package": {
|
||||
"label": "Active package",
|
||||
"helper": "Currently assigned"
|
||||
},
|
||||
"used": {
|
||||
"label": "Events used",
|
||||
@@ -27,13 +30,13 @@
|
||||
},
|
||||
"expires": {
|
||||
"label": "Expires",
|
||||
"helper": "Auto-renews when active"
|
||||
"helper": "Auto-renews if enabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of active and past reseller packages.",
|
||||
"description": "Overview of active and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
@@ -42,17 +45,18 @@
|
||||
"available": "Remaining",
|
||||
"expires": "Expires"
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Credit ledger",
|
||||
"description": "All credit additions and deductions.",
|
||||
"empty": "No ledger entries recorded yet.",
|
||||
"loadMore": "Load more",
|
||||
"reasons": {
|
||||
"purchase": "Credit purchase",
|
||||
"usage": "Usage",
|
||||
"manual": "Manual adjustment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"used": "Used events",
|
||||
"available": "Available",
|
||||
"expires": "Expires"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -241,4 +245,56 @@
|
||||
"submit": "Save emotion"
|
||||
}
|
||||
}
|
||||
,
|
||||
"management": {
|
||||
"billing": {
|
||||
"title": "Packages & billing",
|
||||
"subtitle": "Manage your purchased packages and track their durations.",
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"exportCsv": "Export CSV"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Unable to load package data.",
|
||||
"more": "Unable to load more entries."
|
||||
},
|
||||
"sections": {
|
||||
"overview": {
|
||||
"title": "Package overview",
|
||||
"description": "Your active package and the most important metrics.",
|
||||
"empty": "No active package yet.",
|
||||
"emptyBadge": "No active package",
|
||||
"cards": {
|
||||
"package": {
|
||||
"label": "Active package",
|
||||
"helper": "Currently assigned"
|
||||
},
|
||||
"used": {
|
||||
"label": "Events used",
|
||||
"helper": "Remaining: {{count}}"
|
||||
},
|
||||
"price": {
|
||||
"label": "Price (net)"
|
||||
},
|
||||
"expires": {
|
||||
"label": "Expires",
|
||||
"helper": "Auto-renews if enabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
"empty": "No packages purchased yet.",
|
||||
"card": {
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"used": "Used events",
|
||||
"available": "Available",
|
||||
"expires": "Expires"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import './i18n';
|
||||
import './dev-tools';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { OnboardingProgressProvider } from './onboarding';
|
||||
import { DevTenantSwitcher } from './components/DevTenantSwitcher';
|
||||
|
||||
const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
|
||||
|
||||
initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
@@ -27,6 +31,7 @@ createRoot(rootEl).render(
|
||||
<RouterProvider router={router} />
|
||||
</OnboardingProgressProvider>
|
||||
</AuthProvider>
|
||||
{enableDevSwitcher ? <DevTenantSwitcher /> : null}
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
|
||||
|
||||
import {
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
createTenantPayPalOrder,
|
||||
captureTenantPayPalOrder,
|
||||
} from "../../api";
|
||||
import { getStripe } from '@/utils/stripe';
|
||||
|
||||
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
|
||||
@@ -267,10 +267,7 @@ export default function WelcomeOrderSummaryPage() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
|
||||
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
|
||||
const stripePromise = React.useMemo(
|
||||
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||
[stripePublishableKey]
|
||||
);
|
||||
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
||||
|
||||
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
|
||||
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { Loader2, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -9,21 +9,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
CreditLedgerEntry,
|
||||
getCreditBalance,
|
||||
getCreditLedger,
|
||||
getTenantPackagesOverview,
|
||||
PaginationMeta,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
import { getTenantPackagesOverview, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
type LedgerState = {
|
||||
entries: CreditLedgerEntry[];
|
||||
meta: PaginationMeta | null;
|
||||
};
|
||||
|
||||
export default function BillingPage() {
|
||||
const { t, i18n } = useTranslation(['management', 'dashboard']);
|
||||
const locale = React.useMemo(
|
||||
@@ -31,13 +19,10 @@ export default function BillingPage() {
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const [balance, setBalance] = React.useState<number>(0);
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [ledger, setLedger] = React.useState<LedgerState>({ entries: [], meta: null });
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
|
||||
const formatDate = React.useCallback(
|
||||
(value: string | null | undefined) => {
|
||||
@@ -57,111 +42,53 @@ export default function BillingPage() {
|
||||
[locale]
|
||||
);
|
||||
|
||||
const resolveReason = React.useCallback(
|
||||
(reason: string) => {
|
||||
switch (reason) {
|
||||
case 'purchase':
|
||||
return t('management.billing.ledger.reasons.purchase', 'Credit Kauf');
|
||||
case 'usage':
|
||||
return t('management.billing.ledger.reasons.usage', 'Verbrauch');
|
||||
case 'manual':
|
||||
return t('management.billing.ledger.reasons.manual', 'Manuelle Anpassung');
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const packageLabels = React.useMemo(
|
||||
() => ({
|
||||
statusActive: t('management.billing.packages.card.statusActive', 'Aktiv'),
|
||||
statusInactive: t('management.billing.packages.card.statusInactive', 'Inaktiv'),
|
||||
used: t('management.billing.packages.card.used', 'Genutzte Events'),
|
||||
available: t('management.billing.packages.card.available', 'Verfügbar'),
|
||||
expires: t('management.billing.packages.card.expires', 'Ablauf'),
|
||||
statusActive: t('billing.sections.packages.card.statusActive'),
|
||||
statusInactive: t('billing.sections.packages.card.statusInactive'),
|
||||
used: t('billing.sections.packages.card.used'),
|
||||
available: t('billing.sections.packages.card.available'),
|
||||
expires: t('billing.sections.packages.card.expires'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function loadAll() {
|
||||
const loadAll = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [balanceResult, packagesResult, ledgerResult] = await Promise.all([
|
||||
safeCall(() => getCreditBalance()),
|
||||
safeCall(() => getTenantPackagesOverview()),
|
||||
safeCall(() => getCreditLedger(1)),
|
||||
]);
|
||||
|
||||
if (balanceResult?.balance !== undefined) {
|
||||
setBalance(balanceResult.balance);
|
||||
}
|
||||
|
||||
if (packagesResult) {
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
}
|
||||
|
||||
if (ledgerResult) {
|
||||
setLedger({ entries: ledgerResult.data, meta: ledgerResult.meta });
|
||||
} else {
|
||||
setLedger({ entries: [], meta: null });
|
||||
}
|
||||
const packagesResult = await getTenantPackagesOverview();
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.'));
|
||||
setError(t('billing.errors.load'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
async function loadMore() {
|
||||
if (!ledger.meta || loadingMore) {
|
||||
return;
|
||||
}
|
||||
const { current_page, last_page } = ledger.meta;
|
||||
if (current_page >= last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const next = await getCreditLedger(current_page + 1);
|
||||
setLedger({
|
||||
entries: [...ledger.entries, ...next.data],
|
||||
meta: next.meta,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('management.billing.actions.refresh', 'Aktualisieren')}
|
||||
{t('billing.actions.refresh')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('management.billing.title', 'Billing und Credits')}
|
||||
subtitle={t('management.billing.subtitle', 'Verwalte Guthaben, Pakete und Abrechnungen.')}
|
||||
title={t('billing.title')}
|
||||
subtitle={t('billing.subtitle')}
|
||||
actions={actions}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -174,43 +101,50 @@ export default function BillingPage() {
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<CreditCard className="h-5 w-5 text-pink-500" />
|
||||
{t('management.billing.sections.overview.title', 'Credits und Status')}
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('billing.sections.overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
|
||||
{t('billing.sections.overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
|
||||
{activePackage ? activePackage.package_name : 'Kein aktives Paket'}
|
||||
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.balance.label', 'Verfügbare Credits')}
|
||||
value={balance}
|
||||
tone="pink"
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.used.label', 'Genutzte Events')}
|
||||
value={activePackage?.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('management.billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage?.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.price.label', 'Preis (netto)')}
|
||||
value={formatCurrency(activePackage?.price ?? null, activePackage?.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage?.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('management.billing.sections.overview.cards.expires.label', 'Ablauf')}
|
||||
value={formatDate(activePackage?.expires_at)}
|
||||
tone="emerald"
|
||||
helper={t('management.billing.sections.overview.cards.expires.helper', 'Automatisch verlängern, falls aktiv')}
|
||||
/>
|
||||
<CardContent>
|
||||
{activePackage ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.package.label')}
|
||||
value={activePackage.package_name}
|
||||
tone="pink"
|
||||
helper={t('billing.sections.overview.cards.package.helper')}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.price.label')}
|
||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||
tone="sky"
|
||||
helper={activePackage.currency ?? 'EUR'}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.expires.label')}
|
||||
value={formatDate(activePackage.expires_at)}
|
||||
tone="emerald"
|
||||
helper={t('billing.sections.overview.cards.expires.helper')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -218,15 +152,15 @@ export default function BillingPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('management.billing.packages.title', 'Paket Historie')}
|
||||
{t('billing.sections.packages.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
|
||||
{t('billing.sections.packages.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{packages.length === 0 ? (
|
||||
<EmptyState message={t('management.billing.packages.empty', 'Noch keine Pakete gebucht.')} />
|
||||
<EmptyState message={t('billing.sections.packages.empty')} />
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<PackageCard
|
||||
@@ -242,61 +176,11 @@ export default function BillingPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
{t('management.billing.ledger.title', 'Credit Ledger')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('management.billing.ledger.description', 'Alle Zu- und Abbuchungen deines Credits-Kontos.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('management.billing.actions.exportCsv', 'Export als CSV')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ledger.entries.length === 0 ? (
|
||||
<EmptyState message={t('management.billing.ledger.empty', 'Noch keine Ledger-Einträge vorhanden.')} />
|
||||
) : (
|
||||
<>
|
||||
{ledger.entries.map((entry) => (
|
||||
<LedgerRow
|
||||
key={`${entry.id}-${entry.created_at}`}
|
||||
entry={entry}
|
||||
resolveReason={resolveReason}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
{ledger.meta && ledger.meta.current_page < ledger.meta.last_page && (
|
||||
<Button variant="outline" className="w-full" onClick={() => void loadMore()} disabled={loadingMore}>
|
||||
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.billing.ledger.loadMore', 'Mehr laden')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function safeCall<T>(callback: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.warn('[Tenant Billing] optional endpoint fehlgeschlagen', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
@@ -372,33 +256,6 @@ function PackageCard({
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerRow({
|
||||
entry,
|
||||
resolveReason,
|
||||
formatDate,
|
||||
}: {
|
||||
entry: CreditLedgerEntry;
|
||||
resolveReason: (reason: string) => string;
|
||||
formatDate: (value: string | null | undefined) => string;
|
||||
}) {
|
||||
const positive = entry.delta >= 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{resolveReason(entry.reason)}</p>
|
||||
{entry.note && <p className="text-xs text-slate-500">{entry.note}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-sm font-semibold ${positive ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{positive ? '+' : ''}
|
||||
{entry.delta}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{formatDate(entry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, Camera, CreditCard, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
DashboardSummary,
|
||||
getCreditBalance,
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
@@ -35,7 +34,6 @@ import { useOnboardingProgress } from '../onboarding';
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
events: TenantEvent[];
|
||||
credits: number;
|
||||
activePackage: TenantPackageSummary | null;
|
||||
loading: boolean;
|
||||
errorKey: string | null;
|
||||
@@ -46,11 +44,23 @@ export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
const { progress, markStep } = useOnboardingProgress();
|
||||
const { t, i18n } = useTranslation(['dashboard', 'common']);
|
||||
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const translate = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
const value = t(key, options);
|
||||
if (value === `dashboard.${key}`) {
|
||||
const fallback = i18n.t(`dashboard:${key}`, options);
|
||||
return fallback === `dashboard:${key}` ? value : fallback;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
[t, i18n],
|
||||
);
|
||||
const [state, setState] = React.useState<DashboardState>({
|
||||
summary: null,
|
||||
events: [],
|
||||
credits: 0,
|
||||
activePackage: null,
|
||||
loading: true,
|
||||
errorKey: null,
|
||||
@@ -60,10 +70,9 @@ export default function DashboardPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [summary, events, credits, packages] = await Promise.all([
|
||||
const [summary, events, packages] = await Promise.all([
|
||||
getDashboardSummary().catch(() => null),
|
||||
getEvents().catch(() => [] as TenantEvent[]),
|
||||
getCreditBalance().catch(() => ({ balance: 0 })),
|
||||
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
|
||||
]);
|
||||
|
||||
@@ -71,12 +80,11 @@ export default function DashboardPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
|
||||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
credits: credits.balance ?? 0,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
@@ -97,7 +105,7 @@ export default function DashboardPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { summary, events, credits, activePackage, loading, errorKey } = state;
|
||||
const { summary, events, activePackage, loading, errorKey } = state;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
@@ -112,10 +120,10 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||||
|
||||
const greetingName = user?.name ?? t('dashboard.welcome.fallbackName');
|
||||
const greetingTitle = t('dashboard.welcome.greeting', { name: greetingName });
|
||||
const subtitle = t('dashboard.welcome.subtitle');
|
||||
const errorMessage = errorKey ? t(`dashboard.errors.${errorKey}`) : null;
|
||||
const greetingName = user?.name ?? translate('welcome.fallbackName');
|
||||
const greetingTitle = translate('welcome.greeting', { name: greetingName });
|
||||
const subtitle = translate('welcome.subtitle');
|
||||
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
|
||||
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
|
||||
const upcomingEvents = getUpcomingEvents(events);
|
||||
@@ -127,10 +135,10 @@ export default function DashboardPage() {
|
||||
className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('dashboard.actions.newEvent')}
|
||||
<Plus className="h-4 w-4" /> {translate('actions.newEvent')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-brand-rose-soft text-brand-rose">
|
||||
<CalendarDays className="h-4 w-4" /> {t('dashboard.actions.allEvents')}
|
||||
<CalendarDays className="h-4 w-4" /> {translate('actions.allEvents')}
|
||||
</Button>
|
||||
{events.length === 0 && (
|
||||
<Button
|
||||
@@ -138,7 +146,7 @@ export default function DashboardPage() {
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" /> {t('dashboard.actions.guidedSetup')}
|
||||
<Sparkles className="h-4 w-4" /> {translate('actions.guidedSetup')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -162,24 +170,23 @@ export default function DashboardPage() {
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
{t('dashboard.welcomeCard.title')}
|
||||
{translate('welcomeCard.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
|
||||
geführten Schritten.
|
||||
{translate('welcomeCard.summary')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p>{t('dashboard.welcomeCard.body1')}</p>
|
||||
<p>{t('dashboard.welcomeCard.body2')}</p>
|
||||
<p>{translate('welcomeCard.body1')}</p>
|
||||
<p>{translate('welcomeCard.body2')}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="rounded-full bg-rose-500 text-white shadow-lg shadow-rose-400/40 hover:bg-rose-500/90"
|
||||
onClick={() => navigate(ADMIN_WELCOME_BASE_PATH)}
|
||||
>
|
||||
{t('dashboard.welcomeCard.cta')}
|
||||
{translate('welcomeCard.cta')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -190,74 +197,75 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-brand-rose" />
|
||||
{t('dashboard.overview.title')}
|
||||
{translate('overview.title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.overview.description')}
|
||||
{translate('overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
||||
{activePackage?.package_name ?? t('dashboard.overview.noPackage')}
|
||||
{activePackage?.package_name ?? translate('overview.noPackage')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.activeEvents')}
|
||||
label={translate('overview.stats.activeEvents')}
|
||||
value={summary?.active_events ?? publishedEvents.length}
|
||||
hint={t('dashboard.overview.stats.publishedHint', { count: publishedEvents.length })}
|
||||
hint={translate('overview.stats.publishedHint', { count: publishedEvents.length })}
|
||||
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.newPhotos')}
|
||||
label={translate('overview.stats.newPhotos')}
|
||||
value={summary?.new_photos ?? 0}
|
||||
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.taskProgress')}
|
||||
label={translate('overview.stats.taskProgress')}
|
||||
value={`${Math.round(summary?.task_progress ?? 0)}%`}
|
||||
icon={<Users className="h-5 w-5 text-amber-500" />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.overview.stats.credits')}
|
||||
value={credits}
|
||||
hint={credits <= 1 ? t('dashboard.overview.stats.lowCredits') : undefined}
|
||||
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
{activePackage ? (
|
||||
<StatCard
|
||||
label={translate('overview.stats.activePackage')}
|
||||
value={activePackage.package_name}
|
||||
icon={<Sparkles className="h-5 w-5 text-sky-500" />}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.quickActions.title')}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{translate('quickActions.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.quickActions.description')}
|
||||
{translate('quickActions.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<QuickAction
|
||||
icon={<Plus className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.createEvent.label')}
|
||||
description={t('dashboard.quickActions.createEvent.description')}
|
||||
label={translate('quickActions.createEvent.label')}
|
||||
description={translate('quickActions.createEvent.description')}
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Camera className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.moderatePhotos.label')}
|
||||
description={t('dashboard.quickActions.moderatePhotos.description')}
|
||||
label={translate('quickActions.moderatePhotos.label')}
|
||||
description={translate('quickActions.moderatePhotos.description')}
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.organiseTasks.label')}
|
||||
description={t('dashboard.quickActions.organiseTasks.description')}
|
||||
label={translate('quickActions.organiseTasks.label')}
|
||||
description={translate('quickActions.organiseTasks.description')}
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<CreditCard className="h-5 w-5" />}
|
||||
label={t('dashboard.quickActions.manageCredits.label')}
|
||||
description={t('dashboard.quickActions.manageCredits.description')}
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
label={translate('quickActions.managePackages.label')}
|
||||
description={translate('quickActions.managePackages.description')}
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -266,21 +274,21 @@ export default function DashboardPage() {
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl text-slate-900">{t('dashboard.upcoming.title')}</CardTitle>
|
||||
<CardTitle className="text-xl text-slate-900">{translate('upcoming.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('dashboard.upcoming.description')}
|
||||
{translate('upcoming.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('dashboard.upcoming.settings')}
|
||||
{translate('upcoming.settings')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<EmptyState
|
||||
message={t('dashboard.upcoming.empty.message')}
|
||||
ctaLabel={t('dashboard.upcoming.empty.cta')}
|
||||
message={translate('upcoming.empty.message')}
|
||||
ctaLabel={translate('upcoming.empty.cta')}
|
||||
onCta={() => navigate(adminPath('/events/new'))}
|
||||
/>
|
||||
) : (
|
||||
@@ -291,10 +299,10 @@ export default function DashboardPage() {
|
||||
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
|
||||
locale={dateLocale}
|
||||
labels={{
|
||||
live: t('dashboard.upcoming.status.live'),
|
||||
planning: t('dashboard.upcoming.status.planning'),
|
||||
open: t('common:actions.open'),
|
||||
noDate: t('dashboard.upcoming.status.noDate'),
|
||||
live: translate('upcoming.status.live'),
|
||||
planning: translate('upcoming.status.planning'),
|
||||
open: tc('actions.open'),
|
||||
noDate: translate('upcoming.status.noDate'),
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -309,7 +317,6 @@ export default function DashboardPage() {
|
||||
|
||||
function buildSummaryFallback(
|
||||
events: TenantEvent[],
|
||||
balance: number,
|
||||
activePackage: TenantPackageSummary | null
|
||||
): DashboardSummary {
|
||||
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
|
||||
@@ -319,7 +326,6 @@ function buildSummaryFallback(
|
||||
active_events: activeEvents.length,
|
||||
new_photos: totalPhotos,
|
||||
task_progress: 0,
|
||||
credit_balance: balance,
|
||||
upcoming_events: activeEvents.length,
|
||||
active_package: activePackage
|
||||
? {
|
||||
@@ -471,10 +477,3 @@ function DashboardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Camera, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,6 +10,7 @@ import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createInviteLink,
|
||||
EventJoinToken,
|
||||
EventJoinTokenLayout,
|
||||
EventStats as TenantEventStats,
|
||||
getEvent,
|
||||
getEventJoinTokens,
|
||||
@@ -151,7 +152,7 @@ export default function EventDetailPage() {
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
|
||||
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
@@ -263,22 +264,22 @@ export default function EventDetailPage() {
|
||||
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen & Drucklayouts
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks & QR-Layouts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Verwalte Join-Tokens fuer dein Event. Jede Einladung enthaelt einen eindeutigen Token, QR-Code und
|
||||
downloadbare PDF/SVG-Layouts.
|
||||
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
|
||||
Vokabular.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||
<p>
|
||||
Teile den generierten Link oder drucke die Layouts aus, um Gaeste sicher ins Event zu leiten. Tokens lassen
|
||||
sich jederzeit rotieren oder deaktivieren.
|
||||
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
|
||||
kannst du jederzeit erneuern oder deaktivieren.
|
||||
</p>
|
||||
{tokens.length > 0 && (
|
||||
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
||||
Aktive Tokens: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
||||
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
||||
{tokens.length}
|
||||
</p>
|
||||
)}
|
||||
@@ -286,7 +287,7 @@ export default function EventDetailPage() {
|
||||
|
||||
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
Join-Token erzeugen
|
||||
Einladung erstellen
|
||||
</Button>
|
||||
|
||||
{inviteLink && (
|
||||
@@ -298,7 +299,7 @@ export default function EventDetailPage() {
|
||||
<div className="space-y-3">
|
||||
{tokens.length > 0 ? (
|
||||
tokens.map((token) => (
|
||||
<JoinTokenRow
|
||||
<InvitationCard
|
||||
key={token.id}
|
||||
token={token}
|
||||
onCopy={() => handleCopy(token)}
|
||||
@@ -308,8 +309,8 @@ export default function EventDetailPage() {
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
|
||||
Noch keine Tokens vorhanden. Erzeuge jetzt den ersten Token, um QR-Codes und Drucklayouts
|
||||
herunterzuladen.
|
||||
Es gibt noch keine Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
|
||||
herunterzuladen und zu teilen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -371,7 +372,7 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JoinTokenRow({
|
||||
function InvitationCard({
|
||||
token,
|
||||
onCopy,
|
||||
onRevoke,
|
||||
@@ -383,121 +384,150 @@ function JoinTokenRow({
|
||||
revoking: boolean;
|
||||
}) {
|
||||
const status = getTokenStatus(token);
|
||||
const availableLayouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
|
||||
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
|
||||
const isAutoGenerated = Boolean(metadata.auto_generated);
|
||||
|
||||
const statusClassname =
|
||||
status === 'Aktiv'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: status === 'Abgelaufen'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-200 text-slate-700';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{token.label || `Einladung #${token.id}`}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
status === 'Aktiv'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: status === 'Abgelaufen'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-all font-mono text-xs text-slate-600">{token.url}</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Nutzung: {token.usage_count}
|
||||
{token.usage_limit ? ` / ${token.usage_limit}` : ''}
|
||||
</span>
|
||||
{token.expires_at && <span>Gültig bis {formatDateTime(token.expires_at)}</span>}
|
||||
{token.created_at && <span>Erstellt {formatDateTime(token.created_at)}</span>}
|
||||
</div>
|
||||
{availableLayouts.length > 0 && (
|
||||
<div className="space-y-3 rounded-xl border border-amber-100 bg-white/80 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-amber-600">Drucklayouts</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{availableLayouts.map((layout) => {
|
||||
const formatEntries = Array.isArray(layout.formats)
|
||||
? layout.formats
|
||||
.map((format) => {
|
||||
const normalized = String(format ?? '').toLowerCase();
|
||||
const href =
|
||||
layout.download_urls?.[normalized] ??
|
||||
layout.download_urls?.[String(format ?? '')] ??
|
||||
null;
|
||||
|
||||
return {
|
||||
format: normalized,
|
||||
label: String(format ?? '').toUpperCase(),
|
||||
href,
|
||||
};
|
||||
})
|
||||
.filter((entry) => Boolean(entry.href))
|
||||
: [];
|
||||
|
||||
if (formatEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={layout.id} className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-white p-3 shadow-sm">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-800">{layout.name}</div>
|
||||
{layout.subtitle && <div className="text-xs text-slate-500">{layout.subtitle}</div>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formatEntries.map((entry) => (
|
||||
<Button
|
||||
asChild
|
||||
key={`${layout.id}-${entry.format}`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={entry.href as string} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{entry.label}
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-900">{token.label?.trim() || `Einladung #${token.id}`}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
|
||||
{isAutoGenerated ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
Standard
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{!availableLayouts.length && token.layouts_url && (
|
||||
<div className="rounded-xl border border-amber-100 bg-white/70 p-3 text-xs text-slate-600">
|
||||
Drucklayouts stehen für diesen Token bereit. Öffne den Layout-Link, um PDF- oder SVG-Versionen zu laden.
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{token.url}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
Link kopieren
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 md:items-center md:justify-start">
|
||||
{token.layouts_url && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<span>Nutzung: {usageLabel}</span>
|
||||
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
|
||||
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{token.layouts_url ? (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={token.layouts_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
Layout-Übersicht
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
onClick={onRevoke}
|
||||
disabled={revoking || token.revoked_at !== null || !token.is_active}
|
||||
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
<a href={token.layouts_url} target="_blank" rel="noreferrer">
|
||||
<Download className="h-3 w-3" />
|
||||
<span className="ml-1">Layouts</span>
|
||||
</a>
|
||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onCopy} className="border-amber-200 text-amber-700 hover:bg-amber-100">
|
||||
Kopieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRevoke}
|
||||
disabled={revoking || token.revoked_at !== null || !token.is_active}
|
||||
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
|
||||
>
|
||||
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layouts.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{layouts.map((layout) => (
|
||||
<LayoutPreviewCard key={layout.id} layout={layout} />
|
||||
))}
|
||||
</div>
|
||||
) : token.layouts_url ? (
|
||||
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
|
||||
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
|
||||
const gradient = layout.preview?.background_gradient;
|
||||
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
|
||||
const gradientStyle = stops.length
|
||||
? {
|
||||
backgroundImage: `linear-gradient(${gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
||||
}
|
||||
: {
|
||||
backgroundColor: layout.preview?.background ?? '#F8FAFC',
|
||||
};
|
||||
const textColor = layout.preview?.text ?? '#0F172A';
|
||||
|
||||
const formats = Array.isArray(layout.formats) ? layout.formats : [];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-amber-100 bg-white shadow-sm">
|
||||
<div className="relative h-28">
|
||||
<div className="absolute inset-0" style={gradientStyle} />
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
|
||||
<span className="w-fit rounded-full bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||
QR-Layout
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold leading-tight">{layout.name}</div>
|
||||
{layout.subtitle ? (
|
||||
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-3">
|
||||
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formats.map((format) => {
|
||||
const key = String(format ?? '').toLowerCase();
|
||||
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = String(format ?? '').toUpperCase() || 'PDF';
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
key={`${layout.id}-${label}`}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-200 text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -547,4 +577,3 @@ function renderName(name: TenantEvent['name']): string {
|
||||
}
|
||||
return 'Unbenanntes Event';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,50 @@ import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
|
||||
import { createEvent, getEvent, getTenantPackagesOverview, updateEvent, getPackages, getEventTypes } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
|
||||
|
||||
interface EventFormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
eventTypeId: number | null;
|
||||
package_id: number;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
type PackageHighlight = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
basic_uploads: 'Uploads inklusive',
|
||||
unlimited_sharing: 'Unbegrenztes Teilen',
|
||||
no_watermark: 'Kein Wasserzeichen',
|
||||
custom_branding: 'Eigenes Branding',
|
||||
custom_tasks: 'Eigene Aufgaben',
|
||||
watermark_allowed: 'Wasserzeichen erlaubt',
|
||||
branding_allowed: 'Branding-Optionen',
|
||||
};
|
||||
|
||||
type EventPackageMeta = {
|
||||
id: number;
|
||||
name: string;
|
||||
purchasedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export default function EventFormPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -36,7 +59,8 @@ export default function EventFormPage() {
|
||||
name: '',
|
||||
slug: '',
|
||||
date: '',
|
||||
package_id: 1, // Default Free package
|
||||
eventTypeId: null,
|
||||
package_id: 0,
|
||||
isPublished: false,
|
||||
});
|
||||
const [autoSlug, setAutoSlug] = React.useState(true);
|
||||
@@ -44,12 +68,65 @@ export default function EventFormPage() {
|
||||
const [loading, setLoading] = React.useState(isEdit);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
|
||||
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
|
||||
|
||||
const { data: packages, isLoading: packagesLoading } = useQuery({
|
||||
queryKey: ['packages', 'endcustomer'],
|
||||
queryFn: () => getPackages('endcustomer'),
|
||||
});
|
||||
|
||||
const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({
|
||||
queryKey: ['tenant', 'event-types'],
|
||||
queryFn: getEventTypes,
|
||||
});
|
||||
|
||||
const { data: packageOverview, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['tenant', 'packages', 'overview'],
|
||||
queryFn: getTenantPackagesOverview,
|
||||
});
|
||||
|
||||
const activePackage = packageOverview?.activePackage ?? null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEdit || !activePackage?.package_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
if (prev.package_id === activePackage.package_id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
package_id: activePackage.package_id,
|
||||
};
|
||||
});
|
||||
|
||||
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
|
||||
}, [isEdit, activePackage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventTypes || eventTypes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => {
|
||||
if (prev.eventTypeId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
eventTypeId: eventTypes[0]!.id,
|
||||
};
|
||||
});
|
||||
}, [eventTypes, isEdit]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isEdit || !slugParam) {
|
||||
@@ -69,9 +146,20 @@ export default function EventFormPage() {
|
||||
name,
|
||||
slug: event.slug,
|
||||
date: event.event_date ? event.event_date.slice(0, 10) : '',
|
||||
eventTypeId: event.event_type_id ?? prev.eventTypeId,
|
||||
isPublished: event.status === 'published',
|
||||
package_id: event.package?.id ? Number(event.package.id) : prev.package_id,
|
||||
}));
|
||||
setOriginalSlug(event.slug);
|
||||
setReadOnlyPackageName(event.package?.name ?? null);
|
||||
setEventPackageMeta(event.package
|
||||
? {
|
||||
id: Number(event.package.id),
|
||||
name: event.package.name ?? (typeof event.package === 'string' ? event.package : ''),
|
||||
purchasedAt: event.package.purchased_at ?? null,
|
||||
expiresAt: event.package.expires_at ?? null,
|
||||
}
|
||||
: null);
|
||||
setAutoSlug(false);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -116,17 +204,30 @@ export default function EventFormPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.eventTypeId) {
|
||||
setError('Bitte waehle einen Event-Typ aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
|
||||
const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
|
||||
|
||||
const shouldIncludePackage = !isEdit
|
||||
&& packageIdForSubmit
|
||||
&& (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id);
|
||||
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
package_id: form.package_id,
|
||||
date: form.date || undefined,
|
||||
event_type_id: form.eventTypeId,
|
||||
event_date: form.date || undefined,
|
||||
status,
|
||||
...(shouldIncludePackage && packageIdForSubmit
|
||||
? { package_id: Number(packageIdForSubmit) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -148,6 +249,77 @@ export default function EventFormPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePackageId = form.package_id || activePackage?.package_id || null;
|
||||
|
||||
const selectedPackage = React.useMemo(() => {
|
||||
if (!packages || !packages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (effectivePackageId) {
|
||||
return packages.find((pkg) => pkg.id === effectivePackageId) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [packages, effectivePackageId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!readOnlyPackageName && selectedPackage?.name) {
|
||||
setReadOnlyPackageName(selectedPackage.name);
|
||||
}
|
||||
}, [readOnlyPackageName, selectedPackage]);
|
||||
|
||||
const packageNameDisplay = readOnlyPackageName
|
||||
?? selectedPackage?.name
|
||||
?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden');
|
||||
|
||||
const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null
|
||||
? formatCurrency(selectedPackage.price)
|
||||
: null;
|
||||
|
||||
const packageHighlights = React.useMemo<PackageHighlight[]>(() => {
|
||||
const highlights: PackageHighlight[] = [];
|
||||
|
||||
if (selectedPackage?.max_photos) {
|
||||
highlights.push({
|
||||
label: 'Fotos',
|
||||
value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedPackage?.max_guests) {
|
||||
highlights.push({
|
||||
label: 'Gäste',
|
||||
value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedPackage?.gallery_days) {
|
||||
highlights.push({
|
||||
label: 'Galerie',
|
||||
value: `${selectedPackage.gallery_days} Tage online`,
|
||||
});
|
||||
}
|
||||
|
||||
return highlights;
|
||||
}, [selectedPackage]);
|
||||
|
||||
const featureTags = React.useMemo(() => {
|
||||
if (!selectedPackage?.features) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(selectedPackage.features)
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
|
||||
}, [selectedPackage]);
|
||||
|
||||
const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null);
|
||||
|
||||
const remainingEventsLabel = typeof activePackage?.remaining_events === 'number'
|
||||
? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket`
|
||||
: null;
|
||||
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -205,8 +377,8 @@ export default function EventFormPage() {
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Diese Kennung wird intern verwendet. Gaeste erhalten Zugriff ausschliesslich ueber Join-Tokens und deren
|
||||
QR-/Layout-Downloads.
|
||||
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
|
||||
Einladungslinks und die dazugehoerigen QR-Layouts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -219,56 +391,107 @@ export default function EventFormPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="package_id">Package</Label>
|
||||
<Label htmlFor="event-type">Event-Typ</Label>
|
||||
<Select
|
||||
value={form.package_id.toString()}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
|
||||
disabled={packagesLoading || !packages?.length}
|
||||
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
|
||||
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
|
||||
disabled={eventTypesLoading || !eventTypes?.length}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
|
||||
<SelectTrigger id="event-type">
|
||||
<SelectValue
|
||||
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
{packages?.length ? (
|
||||
<SelectContent>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
||||
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
<SelectContent>
|
||||
{eventTypes?.map((eventType) => (
|
||||
<SelectItem key={eventType.id} value={String(eventType.id)}>
|
||||
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{packagesLoading ? (
|
||||
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
|
||||
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
|
||||
</p>
|
||||
) : null}
|
||||
{!packagesLoading && (!packages || packages.length === 0) ? (
|
||||
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
|
||||
) : null}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">Package-Details</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Package auswaehlen</DialogTitle>
|
||||
<DialogDescription>Waehlen Sie das Package fuer Ihr Event. Hoehere Packages bieten mehr Limits und Features.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{packages?.map((pkg) => (
|
||||
<div key={pkg.id} className="p-4 border rounded">
|
||||
<h3 className="font-semibold">{pkg.name}</h3>
|
||||
<p>{pkg.price} EUR</p>
|
||||
<ul className="text-sm">
|
||||
<li>Max Fotos: {pkg.max_photos}</li>
|
||||
<li>Max Gaeste: {pkg.max_guests}</li>
|
||||
<li>Galerie: {pkg.gallery_days} Tage</li>
|
||||
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Card className="border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Badge className="bg-white/25 text-white backdrop-blur">
|
||||
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
|
||||
</Badge>
|
||||
{packagePriceLabel ? (
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
|
||||
{packagePriceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
|
||||
{packageNameDisplay}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-pink-50">
|
||||
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{packageExpiresLabel ? (
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||||
Galerie aktiv bis {packageExpiresLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{remainingEventsLabel ? (
|
||||
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pb-6">
|
||||
{packageHighlights.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{packageHighlights.map((highlight) => (
|
||||
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
|
||||
<p className="text-white/70">{highlight.label}</p>
|
||||
<p className="font-semibold text-white">{highlight.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{featureTags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{featureTags.map((feature) => (
|
||||
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/75">
|
||||
{(packagesLoading || overviewLoading)
|
||||
? 'Paketdetails werden geladen...'
|
||||
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30"
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
>
|
||||
Abrechnung öffnen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/70 hover:bg-white/10"
|
||||
disabled
|
||||
>
|
||||
Upgrade-Optionen demnächst
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +514,7 @@ export default function EventFormPage() {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.slug.trim()}
|
||||
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
>
|
||||
{saving ? (
|
||||
@@ -326,6 +549,43 @@ function FormSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number | null | undefined): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: value % 1 === 0 ? 0 : 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${value} €`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
|
||||
@@ -350,6 +350,11 @@ function TaskDialog({
|
||||
saving: boolean;
|
||||
isEditing: boolean;
|
||||
}) {
|
||||
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
@@ -357,7 +362,7 @@ function TaskDialog({
|
||||
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<form className="space-y-4" onSubmit={handleFormSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-title">Titel</Label>
|
||||
<Input
|
||||
|
||||
@@ -42,7 +42,6 @@ vi.mock('../../onboarding', () => ({
|
||||
vi.mock('../../api', () => ({
|
||||
getDashboardSummary: vi.fn().mockResolvedValue(null),
|
||||
getEvents: vi.fn().mockResolvedValue([]),
|
||||
getCreditBalance: vi.fn().mockResolvedValue({ balance: 0 }),
|
||||
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import React, { useMemo, useRef, useEffect, useCallback, Suspense, lazy } from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Steps } from "@/components/ui/Steps";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -7,10 +7,11 @@ import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
|
||||
import type { CheckoutPackage, CheckoutStepId } from "./types";
|
||||
import { PackageStep } from "./steps/PackageStep";
|
||||
import { AuthStep } from "./steps/AuthStep";
|
||||
import { PaymentStep } from "./steps/PaymentStep";
|
||||
import { ConfirmationStep } from "./steps/ConfirmationStep";
|
||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||
|
||||
const PaymentStep = lazy(() => import('./steps/PaymentStep').then((module) => ({ default: module.PaymentStep })));
|
||||
|
||||
interface CheckoutWizardProps {
|
||||
initialPackage: CheckoutPackage;
|
||||
packageOptions: CheckoutPackage[];
|
||||
@@ -52,6 +53,14 @@ const baseStepConfig: { id: CheckoutStepId; titleKey: string; descriptionKey: st
|
||||
detailsKey: 'checkout.confirmation_step.description'
|
||||
},
|
||||
];
|
||||
|
||||
const PaymentStepFallback: React.FC = () => (
|
||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
||||
<div className="mb-4 h-4 w-52 animate-pulse rounded bg-muted" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: string; privacyHtml: string }> = ({ stripePublishableKey, paypalClientId, privacyHtml }) => {
|
||||
const { t } = useTranslation('marketing');
|
||||
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
|
||||
@@ -144,7 +153,9 @@ const WizardBody: React.FC<{ stripePublishableKey: string; paypalClientId: strin
|
||||
{currentStep === "package" && <PackageStep />}
|
||||
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
|
||||
{currentStep === "payment" && (
|
||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
||||
<Suspense fallback={<PaymentStepFallback />}>
|
||||
<PaymentStep stripePublishableKey={stripePublishableKey} paypalClientId={paypalClientId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{currentStep === "confirmation" && (
|
||||
<ConfirmationStep onViewProfile={handleViewProfile} onGoToAdmin={handleGoToAdmin} />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useStripe, useElements, PaymentElement, Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { PayPalButtons, PayPalScriptProvider } from '@paypal/react-paypal-js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useCheckoutWizard } from '../WizardContext';
|
||||
import { getStripe } from '@/utils/stripe';
|
||||
|
||||
interface PaymentStepProps {
|
||||
stripePublishableKey: string;
|
||||
@@ -243,10 +243,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
|
||||
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
|
||||
const [processingProvider, setProcessingProvider] = useState<Provider | null>(null);
|
||||
|
||||
const stripePromise = useMemo(
|
||||
() => (stripePublishableKey ? loadStripe(stripePublishableKey) : null),
|
||||
[stripePublishableKey]
|
||||
);
|
||||
const stripePromise = useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
|
||||
const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]);
|
||||
const isReseller = selectedPackage?.type === 'reseller';
|
||||
|
||||
|
||||
8
resources/js/types/vite-env.d.ts
vendored
8
resources/js/types/vite-env.d.ts
vendored
@@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ENABLE_TENANT_SWITCHER?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
21
resources/js/utils/stripe.ts
Normal file
21
resources/js/utils/stripe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Stripe } from '@stripe/stripe-js';
|
||||
|
||||
const stripePromiseCache = new Map<string, Promise<Stripe | null>>();
|
||||
|
||||
export async function getStripe(publishableKey?: string): Promise<Stripe | null> {
|
||||
if (!publishableKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stripePromiseCache.has(publishableKey)) {
|
||||
const promise = import('@stripe/stripe-js').then(({ loadStripe }) => loadStripe(publishableKey));
|
||||
stripePromiseCache.set(publishableKey, promise);
|
||||
}
|
||||
|
||||
return stripePromiseCache.get(publishableKey) ?? null;
|
||||
}
|
||||
|
||||
export function clearStripeCache(): void {
|
||||
stripePromiseCache.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user