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:
Codex Agent
2025-10-26 14:44:47 +01:00
parent 6290a3a448
commit ecf5a23b28
59 changed files with 3900 additions and 691 deletions

View File

@@ -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 ?? {});
}

View 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>
);
}

View 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');
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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">

View File

@@ -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, ¤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';
}

View File

@@ -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 &amp; 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';
}

View File

@@ -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')

View File

@@ -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

View File

@@ -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 }),
}));

View File

@@ -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} />

View File

@@ -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';

View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ENABLE_TENANT_SWITCHER?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View 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();
}

View File

@@ -75,27 +75,27 @@ return [
'table' => [
'tenant' => 'Mandant',
'join' => 'Beitreten',
'join_tokens_total' => 'Join-Tokens: :count',
'join_tokens_missing' => 'Noch keine Join-Tokens erstellt',
'join_tokens_total' => 'Einladungen: :count',
'join_tokens_missing' => 'Noch keine Einladungen erstellt',
],
'actions' => [
'toggle_active' => 'Aktiv umschalten',
'join_link_qr' => 'Beitrittslink / QR',
'join_link_qr' => 'Einladungslink & QR',
'download_photos' => 'Alle Fotos herunterladen',
],
'modal' => [
'join_link_heading' => 'Beitrittslink der Veranstaltung',
'join_link_heading' => 'Einladungslink der Veranstaltung',
],
'messages' => [
'join_link_copied' => 'Beitrittslink kopiert',
'join_link_copied' => 'Einladungslink kopiert',
],
'join_link' => [
'event_label' => 'Veranstaltung',
'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Join-Tokens unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
'deprecated_notice' => 'Der direkte Zugriff über den Event-Slug :slug wurde deaktiviert. Teile die Einladungslinks unten oder öffne in der Admin-App „QR & Einladungen“, um neue Codes zu verwalten.',
'open_admin' => 'Admin-App öffnen',
'link_label' => 'Beitrittslink',
'link_label' => 'Einladungslink',
'copy_link' => 'Kopieren',
'no_tokens' => 'Noch keine Join-Tokens vorhanden. Erstelle im Admin-Bereich ein Token, um dein Event zu teilen.',
'no_tokens' => 'Noch keine Einladungen vorhanden. Erstelle im Admin-Bereich eine Einladung, um dein Event zu teilen.',
'token_default' => 'Einladung #:id',
'token_usage' => 'Nutzung: :usage / :limit',
'token_active' => 'Aktiv',

View File

@@ -75,26 +75,26 @@ return [
'table' => [
'tenant' => 'Tenant',
'join' => 'Join',
'join_tokens_total' => 'Join tokens: :count',
'join_tokens_missing' => 'No join tokens created yet',
'join_tokens_total' => 'Invitations: :count',
'join_tokens_missing' => 'No invitations created yet',
],
'actions' => [
'toggle_active' => 'Toggle Active',
'join_link_qr' => 'Join Link / QR',
'join_link_qr' => 'Invitation Link & QR',
'download_photos' => 'Download all photos',
],
'modal' => [
'join_link_heading' => 'Event Join Link',
'join_link_heading' => 'Event Invitation Link',
],
'messages' => [
'join_link_copied' => 'Join link copied',
'join_link_copied' => 'Invitation link copied',
],
'join_link' => [
'event_label' => 'Event',
'slug_label' => 'Slug: :slug',
'link_label' => 'Join Link',
'link_label' => 'Invitation Link',
'copy_link' => 'Copy',
'no_tokens' => 'No tokens available yet. Create a token in the admin app to share your event.',
'no_tokens' => 'No invitations yet. Create one in the admin app to share your event.',
'token_default' => 'Invitation #:id',
'token_usage' => 'Usage: :usage / :limit',
'token_active' => 'Active',
@@ -102,7 +102,7 @@ return [
'layouts_heading' => 'Printable layouts',
'layouts_fallback' => 'Open layout overview',
'token_expiry' => 'Expires at :date',
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.',
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.',
'open_admin' => 'Open admin app',
],
'analytics' => [