Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.

This commit is contained in:
Codex Agent
2025-10-12 10:32:37 +02:00
parent d04e234ca0
commit 9394c3171e
73 changed files with 3277 additions and 911 deletions

View File

@@ -126,6 +126,20 @@ export type EventMember = {
type EventListResponse = { data?: TenantEvent[] };
type EventResponse = { data: TenantEvent };
export type EventJoinToken = {
id: number;
token: string;
url: string;
label: string | null;
usage_limit: number | null;
usage_count: number;
expires_at: string | null;
revoked_at: string | null;
is_active: boolean;
created_at: string | null;
metadata: Record<string, unknown>;
};
type CreatedEventResponse = { message: string; data: TenantEvent; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -256,6 +270,22 @@ function normalizeMember(member: JsonValue): EventMember {
};
}
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
return {
id: Number(raw.id ?? 0),
token: String(raw.token ?? ''),
url: String(raw.url ?? ''),
label: raw.label ?? null,
usage_limit: raw.usage_limit ?? null,
usage_count: Number(raw.usage_count ?? 0),
expires_at: raw.expires_at ?? null,
revoked_at: raw.revoked_at ?? null,
is_active: Boolean(raw.is_active),
created_at: raw.created_at ?? null,
metadata: (raw.metadata ?? {}) as Record<string, unknown>,
};
}
function eventEndpoint(slug: string): string {
return `/api/v1/tenant/events/${encodeURIComponent(slug)}`;
}
@@ -337,9 +367,40 @@ export async function getEventStats(slug: string): Promise<EventStats> {
};
}
export async function createInviteLink(slug: string): Promise<{ link: string; token: string }> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' });
return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite');
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 list = Array.isArray(payload.data) ? payload.data : [];
return list.map(normalizeJoinToken);
}
export async function createInviteLink(
slug: string,
payload?: { label?: string; usage_limit?: number; expires_at?: string }
): Promise<EventJoinToken> {
const body = JSON.stringify(payload ?? {});
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invite');
return normalizeJoinToken(data.data ?? {});
}
export async function revokeEventJoinToken(
slug: string,
tokenId: number,
reason?: string
): Promise<EventJoinToken> {
const options: RequestInit = { method: 'DELETE' };
if (reason) {
options.headers = { 'Content-Type': 'application/json' };
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');
return normalizeJoinToken(data.data ?? {});
}
export type Package = {

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import {
ADMIN_HOME_PATH,
@@ -8,13 +9,14 @@ import {
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
} from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
const navItems = [
{ to: ADMIN_HOME_PATH, label: 'Dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, label: 'Events' },
{ to: ADMIN_TASKS_PATH, label: 'Tasks' },
{ to: ADMIN_BILLING_PATH, label: 'Billing' },
{ to: ADMIN_SETTINGS_PATH, label: 'Einstellungen' },
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
{ to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
];
interface AdminLayoutProps {
@@ -25,6 +27,8 @@ interface AdminLayoutProps {
}
export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) {
const { t } = useTranslation('common');
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme');
return () => {
@@ -37,11 +41,14 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
<header className="border-b border-brand-rose-soft bg-brand-card/90 shadow-brand-primary backdrop-blur-md">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">Fotospiel Tenant Admin</p>
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">{t('app.brand')}</p>
<h1 className="font-display text-3xl font-semibold text-brand-slate">{title}</h1>
{subtitle && <p className="mt-1 text-sm font-sans-marketing text-brand-navy/75">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
<div className="flex flex-wrap items-center gap-2">
<LanguageSwitcher />
{actions}
</div>
</div>
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium text-brand-navy/80">
{navItems.map((item) => (
@@ -58,7 +65,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
)
}
>
{item.label}
{t(item.labelKey)}
</NavLink>
))}
</nav>

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { Check, Languages } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import i18n from '../i18n';
type SupportedLocale = 'de' | 'en';
const SUPPORTED_LANGUAGES: Array<{ code: SupportedLocale; labelKey: string }> = [
{ code: 'de', labelKey: 'language.de' },
{ code: 'en', labelKey: 'language.en' },
];
function getCsrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name=\"csrf-token\"]')?.content ?? '';
}
async function persistLocale(locale: SupportedLocale): Promise<void> {
const response = await fetch('/set-locale', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ locale }),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`locale update failed with status ${response.status}`);
}
}
export function LanguageSwitcher() {
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = (i18n.language || document.documentElement.lang || 'de') as SupportedLocale;
const changeLanguage = React.useCallback(
async (locale: SupportedLocale) => {
if (locale === currentLocale || pendingLocale) {
return;
}
setPendingLocale(locale);
try {
await persistLocale(locale);
await i18n.changeLanguage(locale);
document.documentElement.setAttribute('lang', locale);
} catch (error) {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.error('Failed to switch language', error);
}
} finally {
setPendingLocale(null);
}
},
[currentLocale, pendingLocale]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
aria-label={t('app.languageSwitch')}
>
<Languages className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">{t('app.languageSwitch')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => {
const isActive = currentLocale === code;
const isPending = pendingLocale === code;
return (
<DropdownMenuItem
key={code}
onSelect={(event) => {
event.preventDefault();
changeLanguage(code);
}}
className="flex items-center justify-between gap-3"
disabled={isPending}
>
<span>{t(labelKey)}</span>
{(isActive || isPending) && <Check className="h-4 w-4 text-brand-rose" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,61 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import deCommon from './locales/de/common.json';
import enCommon from './locales/en/common.json';
import deDashboard from './locales/de/dashboard.json';
import enDashboard from './locales/en/dashboard.json';
import deOnboarding from './locales/de/onboarding.json';
import enOnboarding from './locales/en/onboarding.json';
import deManagement from './locales/de/management.json';
import enManagement from './locales/en/management.json';
const DEFAULT_NAMESPACE = 'common';
const resources = {
de: {
common: deCommon,
dashboard: deDashboard,
onboarding: deOnboarding,
management: deManagement,
},
en: {
common: enCommon,
dashboard: enDashboard,
onboarding: enOnboarding,
management: enManagement,
},
} as const;
const FALLBACK_LOCALE = 'de';
if (!i18n.isInitialized) {
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: FALLBACK_LOCALE,
lng: document.documentElement.lang || undefined,
supportedLngs: ['de', 'en'],
defaultNS: DEFAULT_NAMESPACE,
interpolation: {
escapeValue: false,
},
detection: {
order: ['htmlTag', 'localStorage', 'cookie', 'navigator'],
caches: ['localStorage'],
htmlTag: document.documentElement,
},
returnEmptyString: false,
})
.catch((error) => {
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.error('Failed to initialize i18n', error);
}
});
}
export default i18n;

View File

@@ -0,0 +1,21 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"languageSwitch": "Sprache"
},
"navigation": {
"dashboard": "Dashboard",
"events": "Events",
"tasks": "Aufgaben",
"billing": "Abrechnung",
"settings": "Einstellungen"
},
"language": {
"de": "Deutsch",
"en": "Englisch"
},
"actions": {
"open": "Öffnen",
"viewAll": "Alle anzeigen"
}
}

View File

@@ -0,0 +1,72 @@
{
"actions": {
"newEvent": "Neues Event",
"allEvents": "Alle Events",
"guidedSetup": "Guided Setup"
},
"welcome": {
"fallbackName": "Tenant-Admin",
"greeting": "Hallo {{name}}!",
"subtitle": "Behalte deine Events, Credits 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": {
"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."
},
"manageCredits": {
"label": "Credits verwalten",
"description": "Sieh dir Balance & Ledger an."
}
},
"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

@@ -0,0 +1,150 @@
{
"billing": {
"title": "Billing und Credits",
"subtitle": "Verwalte Guthaben, Pakete und Abrechnungen.",
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV"
},
"errors": {
"load": "Billing-Daten konnten nicht geladen werden.",
"more": "Weitere Ledger-Einträge konnten nicht geladen werden."
},
"sections": {
"overview": {
"title": "Credits und Status",
"description": "Dein aktuelles Guthaben und das aktive Reseller-Paket.",
"cards": {
"balance": {
"label": "Verfügbare Credits"
},
"used": {
"label": "Genutzte Events",
"helper": "Verfügbar: {{count}}"
},
"price": {
"label": "Preis (netto)"
},
"expires": {
"label": "Ablauf",
"helper": "Automatisch verlängern, falls aktiv"
}
}
},
"packages": {
"title": "Paket-Historie",
"description": "Übersicht über aktive und vergangene Reseller-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"
}
}
}
},
"members": {
"title": "Event-Mitglieder",
"subtitle": "Verwalte Moderatoren, Admins und Helfer für dieses Event.",
"actions": {
"back": "Zurück zur Übersicht"
},
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"load": "Mitglieder konnten nicht geladen werden.",
"emailRequired": "Bitte gib eine E-Mail-Adresse ein.",
"invite": "Einladung konnte nicht verschickt werden.",
"remove": "Mitglied konnte nicht entfernt werden."
},
"alerts": {
"notFoundTitle": "Event nicht gefunden",
"notFoundDescription": "Bitte kehre zur Eventliste zurück.",
"lockedTitle": "Feature noch nicht aktiviert",
"lockedDescription": "Die Mitgliederverwaltung ist für dieses Event noch nicht verfügbar. Bitte kontaktiere den Support, um das Feature freizuschalten."
},
"sections": {
"list": {
"title": "Mitglieder",
"empty": "Noch keine Mitglieder eingeladen."
},
"invite": {
"title": "Neues Mitglied einladen"
}
},
"labels": {
"status": "Status: {{status}}",
"joined": "Beigetreten: {{date}}"
},
"form": {
"emailLabel": "E-Mail",
"emailPlaceholder": "person@example.com",
"nameLabel": "Name (optional)",
"namePlaceholder": "Name",
"roleLabel": "Rolle",
"rolePlaceholder": "Rolle wählen",
"submit": "Einladung senden"
},
"roles": {
"tenantAdmin": "Tenant-Admin",
"member": "Mitglied",
"guest": "Gast"
},
"statuses": {
"published": "Veröffentlicht",
"draft": "Entwurf",
"active": "Aktiv"
},
"eventStatus": "Status: {{status}}",
"events": {
"untitled": "Unbenanntes Event"
}
},
"tasks": {
"title": "Event-Tasks",
"subtitle": "Verwalte Aufgaben, die diesem Event zugeordnet sind.",
"actions": {
"back": "Zurück zur Übersicht",
"assign": "Ausgewählte Tasks zuweisen"
},
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"load": "Event-Tasks konnten nicht geladen werden.",
"assign": "Tasks konnten nicht zugewiesen werden."
},
"alerts": {
"notFoundTitle": "Event nicht gefunden",
"notFoundDescription": "Bitte kehre zur Eventliste zurück."
},
"eventStatus": "Status: {{status}}",
"sections": {
"assigned": {
"title": "Zugeordnete Tasks",
"empty": "Noch keine Tasks zugewiesen."
},
"library": {
"title": "Tasks aus Bibliothek hinzufügen",
"empty": "Keine Tasks in der Bibliothek gefunden."
}
},
"priorities": {
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch",
"urgent": "Dringend"
}
}
}

View File

@@ -0,0 +1,268 @@
{
"layout": {
"eyebrow": "Fotospiel Tenant Admin",
"title": "Willkommen im Event-Erlebnisstudio",
"subtitle": "Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie alles optimiert für mobile Hosts.",
"alreadyFamiliar": "Schon vertraut mit Fotospiel?",
"jumpToDashboard": "Direkt zum Dashboard"
},
"hero": {
"eyebrow": "Dein Event, deine Bühne",
"title": "Gestalte das nächste Fotospiel Erlebnis",
"scriptTitle": "Einmalig für Gäste, mühelos für dich.",
"description": "Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie.",
"primary": {
"label": "Pakete entdecken",
"button": "Pakete entdecken"
},
"secondary": {
"label": "Events anzeigen",
"button": "Bestehende Events anzeigen"
}
},
"highlights": {
"gallery": {
"title": "Premium Gästegalerie",
"description": "Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
"badge": "Neu"
},
"team": {
"title": "Flexibles Team-Onboarding",
"description": "Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben."
},
"story": {
"title": "Storytelling in Etappen",
"description": "Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise."
}
},
"ctaList": {
"choosePackage": {
"label": "Dein Eventpaket auswählen",
"description": "Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
"button": "Weiter zu Paketen"
},
"createEvent": {
"label": "Event vorbereiten",
"description": "Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
"button": "Zum Event-Manager"
}
},
"packages": {
"layout": {
"eyebrow": "Schritt 2",
"title": "Wähle dein Eventpaket",
"subtitle": "Fotospiel bietet flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
},
"step": {
"title": "Aktiviere die passenden Credits",
"description": "Sichere dir Kapazität für dein nächstes Event. Du kannst jederzeit upgraden bezahle nur, was du brauchst."
},
"state": {
"loading": "Pakete werden geladen …",
"errorTitle": "Fehler beim Laden",
"errorDescription": "Bitte versuche es erneut oder kontaktiere den Support.",
"emptyTitle": "Der Katalog ist leer",
"emptyDescription": "Aktuell sind keine Pakete verfügbar. Wende dich an den Support, um neue Angebote freizuschalten."
},
"card": {
"subscription": "Abo",
"creditPack": "Credit-Paket",
"description": "Sofort einsatzbereit für dein nächstes Event.",
"descriptionWithPhotos": "Bis zu {{count}} Fotos inklusive perfekt für lebendige Reportagen.",
"active": "Aktives Paket",
"select": "Paket wählen",
"onRequest": "Auf Anfrage",
"purchased": "Bereits gekauft am {{date}}",
"purchasedUnknown": "unbekanntem Datum",
"badges": {
"guests": "{{count}} Gäste",
"days": "{{count}} Tage Galerie",
"photos": "{{count}} Fotos"
}
},
"features": {
"subscription": "Abo-Verlängerung",
"priority_support": "Priorisierter Support",
"custom_domain": "Eigene Domain",
"analytics": "Analytics",
"team_management": "Teamverwaltung",
"moderation_tools": "Moderationstools",
"prints": "Print-Uploads"
},
"cta": {
"billing": {
"label": "Direkt zum Billing",
"description": "Falls du schon weißt, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.",
"button": "Billing öffnen"
},
"summary": {
"label": "Bestellübersicht anzeigen",
"description": "Prüfe Paketdetails und entscheide, ob du direkt zahlen oder später fortfahren möchtest.",
"button": "Weiter zur Übersicht"
}
}
},
"summary": {
"layout": {
"eyebrow": "Schritt 3",
"title": "Bestellübersicht",
"subtitle": "Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
},
"footer": {
"back": "Zurück zur Paketauswahl"
},
"step": {
"title": "Deine Auswahl im Überblick",
"description": "Du kannst sofort abrechnen oder das Setup fortsetzen und später bezahlen."
},
"state": {
"loading": "Wir prüfen verfügbare Pakete …",
"errorTitle": "Paketdaten derzeit nicht verfügbar",
"errorDescription": "Bitte versuche es erneut oder kontaktiere den Support.",
"missingTitle": "Keine Paketauswahl gefunden",
"missingDescription": "Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich Daten geändert haben."
},
"details": {
"subscription": "Abo",
"creditPack": "Credit-Paket",
"photos": "Bis zu {{count}} Fotos",
"galleryDays": "Galerie {{count}} Tage",
"guests": "{{count}} Gäste",
"infinity": "∞",
"features": {
"subscription": "Abo",
"priority_support": "Priorisierter Support",
"custom_domain": "Eigene Domain",
"analytics": "Analytics",
"team_management": "Teamverwaltung",
"moderation_tools": "Moderationstools",
"prints": "Print-Uploads"
},
"section": {
"photosTitle": "Fotos & Galerie",
"photosValue": "Bis zu {{count}} Fotos, Galerie {{days}} Tage",
"photosUnlimited": "Unbegrenzte Fotos, flexible Galerie",
"guestsTitle": "Gäste & Team",
"guestsValue": "{{count}} Gäste inklusive, Co-Hosts frei planbar",
"guestsUnlimited": "Unbegrenzte Gästeliste",
"featuresTitle": "Highlights",
"featuresNone": "Standard",
"statusTitle": "Status",
"statusActive": "Bereits gebucht",
"statusInactive": "Noch nicht gebucht"
}
},
"status": {
"pendingTitle": "Abrechnung steht noch aus",
"pendingDescription": "Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket."
},
"free": {
"description": "Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup weitermachen.",
"activate": "Gratis-Paket aktivieren",
"progress": "Aktivierung läuft …",
"successTitle": "Gratis-Paket aktiviert",
"successDescription": "Deine Credits wurden hinzugefügt. Weiter geht's mit dem Event-Setup.",
"failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden.",
},
"stripe": {
"sectionTitle": "Kartenzahlung (Stripe)",
"heading": "Kartenzahlung",
"notReady": "Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.",
"genericError": "Zahlung fehlgeschlagen. Bitte erneut versuchen.",
"missingPaymentId": "Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).",
"completionFailed": "Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.",
"errorTitle": "Zahlung fehlgeschlagen",
"submitting": "Zahlung wird bestätigt …",
"submit": "Jetzt bezahlen",
"hint": "Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.",
"loading": "Zahlungsdetails werden geladen …",
"unavailableTitle": "Stripe nicht verfügbar",
"unavailableDescription": "Stripe konnte nicht initialisiert werden.",
"missingKey": "Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.",
"intentFailed": "Stripe-Zahlung konnte nicht vorbereitet werden."
},
"paypal": {
"sectionTitle": "PayPal",
"heading": "PayPal",
"createFailed": "PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.",
"captureFailed": "PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.",
"errorTitle": "PayPal-Fehler",
"genericError": "PayPal hat ein Problem gemeldet. Bitte versuche es später erneut.",
"missingOrderId": "PayPal hat keine Order-ID geliefert.",
"cancelled": "PayPal-Zahlung wurde abgebrochen.",
"hint": "PayPal leitet dich ggf. weiter, um die Zahlung zu bestätigen. Anschließend kommst du automatisch zurück.",
"notConfiguredTitle": "PayPal nicht konfiguriert",
"notConfiguredDescription": "Hinterlege VITE_PAYPAL_CLIENT_ID, damit Gastgeber optional mit PayPal bezahlen können."
},
"nextStepsTitle": "Nächste Schritte",
"nextSteps": [
"Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.",
"Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.",
"Vor dem Go-Live Credits prüfen und Gäste-Link teilen."
],
"cta": {
"billing": {
"label": "Abrechnung starten",
"description": "Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).",
"button": "Zu Billing & Zahlung"
},
"setup": {
"label": "Mit Event-Setup fortfahren",
"description": "Du kannst später jederzeit zur Abrechnung zurückkehren.",
"button": "Weiter zum Setup"
}
}
},
"eventSetup": {
"layout": {
"eyebrow": "Schritt 4",
"title": "Bereite dein erstes Event vor",
"subtitle": "Fülle wenige Details aus, lade Co-Hosts ein und öffne deine Gästegalerie für das große Ereignis."
},
"step": {
"title": "Event-Setup in Minuten",
"description": "Wir führen dich durch Name, Datum, Mood und Aufgaben. Danach kannst du Fotos moderieren und Gäste live begleiten."
},
"tiles": {
"story": {
"title": "Story & Stimmung",
"copy": "Wähle Bildsprache, Farben und Emotionskarten für dein Event."
},
"team": {
"title": "Team organisieren",
"copy": "Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu."
},
"launch": {
"title": "Go-Live vorbereiten",
"copy": "Erstelle QR-Codes, teste die Gästegalerie und kommuniziere den Ablauf."
}
},
"cta": {
"heading": "Bereit für dein erstes Event?",
"description": "Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die Gästegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zurückkehren.",
"button": "Event erstellen"
},
"actions": {
"back": {
"label": "Noch einmal Pakete prüfen",
"description": "Vergleiche Preise oder aktualisiere dein derzeitiges Paket.",
"button": "Zu Paketen"
},
"dashboard": {
"label": "Zum Dashboard",
"description": "Springe ins Management, um bestehende Events zu bearbeiten.",
"button": "Dashboard öffnen"
},
"events": {
"label": "Eventübersicht",
"description": "Behalte den Überblick über aktive und archivierte Events.",
"button": "Eventliste"
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"app": {
"brand": "Fotospiel Tenant Admin",
"languageSwitch": "Language"
},
"navigation": {
"dashboard": "Dashboard",
"events": "Events",
"tasks": "Tasks",
"billing": "Billing",
"settings": "Settings"
},
"language": {
"de": "German",
"en": "English"
},
"actions": {
"open": "Open",
"viewAll": "View all"
}
}

View File

@@ -0,0 +1,72 @@
{
"actions": {
"newEvent": "New Event",
"allEvents": "All events",
"guidedSetup": "Guided setup"
},
"welcome": {
"fallbackName": "Tenant Admin",
"greeting": "Welcome, {{name}}!",
"subtitle": "Keep your events, credits, 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": {
"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."
},
"manageCredits": {
"label": "Manage credits",
"description": "Review balance and ledger."
}
},
"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

@@ -0,0 +1,149 @@
{
"billing": {
"title": "Billing & credits",
"subtitle": "Manage balances, packages, and invoicing.",
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV"
},
"errors": {
"load": "Unable to load billing data.",
"more": "Unable to load more ledger entries."
},
"sections": {
"overview": {
"title": "Credits & status",
"description": "Your current balance and active reseller package.",
"cards": {
"balance": {
"label": "Available credits"
},
"used": {
"label": "Events used",
"helper": "Remaining: {{count}}"
},
"price": {
"label": "Price (net)"
},
"expires": {
"label": "Expires",
"helper": "Auto-renews when active"
}
}
},
"packages": {
"title": "Package history",
"description": "Overview of active and past reseller packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusInactive": "Inactive",
"used": "Events used",
"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"
}
}
}
},
"members": {
"title": "Event members",
"subtitle": "Manage moderators, admins, and helpers for this event.",
"actions": {
"back": "Back to overview"
},
"errors": {
"missingSlug": "No event slug provided.",
"load": "Could not load members.",
"emailRequired": "Please provide an email address.",
"invite": "Invitation could not be sent.",
"remove": "Member could not be removed."
},
"alerts": {
"notFoundTitle": "Event not found",
"notFoundDescription": "Please return to the event list.",
"lockedTitle": "Feature not enabled",
"lockedDescription": "Member management isnt available for this event yet. Contact support to activate the feature."
},
"sections": {
"list": {
"title": "Members",
"empty": "No members invited yet."
},
"invite": {
"title": "Invite new member"
}
},
"labels": {
"status": "Status: {{status}}",
"joined": "Joined: {{date}}"
},
"form": {
"emailLabel": "Email",
"emailPlaceholder": "person@example.com",
"nameLabel": "Name (optional)",
"namePlaceholder": "Name",
"roleLabel": "Role",
"rolePlaceholder": "Select role",
"submit": "Send invitation"
},
"roles": {
"tenantAdmin": "Tenant admin",
"member": "Member",
"guest": "Guest"
},
"statuses": {
"published": "Published",
"draft": "Draft",
"active": "Active"
},
"eventStatus": "Status: {{status}}",
"events": {
"untitled": "Untitled event"
}
},
"tasks": {
"title": "Event tasks",
"subtitle": "Manage tasks associated with this event.",
"actions": {
"back": "Back to overview",
"assign": "Assign selected tasks"
},
"errors": {
"missingSlug": "No event slug provided.",
"load": "Event tasks could not be loaded.",
"assign": "Tasks could not be assigned."
},
"alerts": {
"notFoundTitle": "Event not found",
"notFoundDescription": "Please return to the event list."
},
"eventStatus": "Status: {{status}}",
"sections": {
"assigned": {
"title": "Assigned tasks",
"empty": "No tasks assigned yet."
},
"library": {
"title": "Add tasks from library",
"empty": "No tasks found in the library."
}
},
"priorities": {
"low": "Low",
"medium": "Medium",
"high": "High",
"urgent": "Urgent"
}
}
}

View File

@@ -0,0 +1,264 @@
{
"layout": {
"eyebrow": "Fotospiel Tenant Admin",
"title": "Welcome to your event studio",
"subtitle": "Begin with an inspired introduction, secure your package, and craft the perfect guest gallery all optimised for mobile hosts.",
"alreadyFamiliar": "Already familiar with Fotospiel?",
"jumpToDashboard": "Jump to dashboard"
},
"hero": {
"eyebrow": "Your event, your stage",
"title": "Design the next Fotospiel experience",
"scriptTitle": "Memorable for guests, effortless for you.",
"description": "In just a few steps you guide guests through a magical photo journey complete with storytelling, tasks, and a moderated gallery.",
"primary": {
"label": "Explore packages",
"button": "Explore packages"
},
"secondary": {
"label": "View events",
"button": "View existing events"
}
},
"highlights": {
"gallery": {
"title": "Premium guest gallery",
"description": "Curate photos in real time, highlight favourites, and share QR codes in a tap.",
"badge": "New"
},
"team": {
"title": "Flexible team onboarding",
"description": "Invite co-hosts, assign roles, and stay on top of moderation and tasks."
},
"story": {
"title": "Storytelling in chapters",
"description": "Guided tasks and emotion cards turn every event into a memorable journey."
}
},
"ctaList": {
"choosePackage": {
"label": "Choose your package",
"description": "Reserve credits or subscriptions to activate events instantly. Flexible options for any event size.",
"button": "Continue to packages"
},
"createEvent": {
"label": "Prepare event",
"description": "Collect event details, plan tasks, and ensure a smooth flow before the big day.",
"button": "Go to event manager"
}
},
"packages": {
"layout": {
"eyebrow": "Step 2",
"title": "Choose your package",
"subtitle": "Fotospiel supports flexible pricing: single-use credits or subscriptions covering multiple events."
},
"step": {
"title": "Activate the right credits",
"description": "Secure capacity for your next event. Upgrade at any time only pay for what you need."
},
"state": {
"loading": "Loading packages …",
"errorTitle": "Failed to load",
"errorDescription": "Please try again or contact support.",
"emptyTitle": "Catalogue is empty",
"emptyDescription": "No packages are currently available. Reach out to support to enable new offers."
},
"card": {
"subscription": "Subscription",
"creditPack": "Credit pack",
"description": "Ready for your next event right away.",
"descriptionWithPhotos": "Up to {{count}} photos included perfect for vibrant storytelling.",
"active": "Active package",
"select": "Select package",
"onRequest": "On request",
"purchased": "Purchased on {{date}}",
"purchasedUnknown": "unknown date",
"badges": {
"guests": "{{count}} guests",
"days": "{{count}} gallery days",
"photos": "{{count}} photos"
}
},
"features": {
"subscription": "Subscription",
"priority_support": "Priority support",
"custom_domain": "Custom domain",
"analytics": "Analytics",
"team_management": "Team management",
"moderation_tools": "Moderation tools",
"prints": "Print uploads"
},
"cta": {
"billing": {
"label": "Go to billing",
"description": "Already know what you need? Jump straight to the billing area you know.",
"button": "Open billing"
},
"summary": {
"label": "View order summary",
"description": "Review package details and decide whether to pay now or later.",
"button": "Continue to summary"
}
}
},
"summary": {
"layout": {
"eyebrow": "Step 3",
"title": "Order summary",
"subtitle": "Review package, price, and payment before proceeding to the event setup."
},
"footer": {
"back": "Back to package selection"
},
"step": {
"title": "Your selection at a glance",
"description": "Hand off to billing now or continue setup and pay later."
},
"state": {
"loading": "Checking available packages …",
"errorTitle": "Package data temporarily unavailable",
"errorDescription": "Please try again or contact support.",
"missingTitle": "No package selected",
"missingDescription": "Select a package first or refresh if data changed."
},
"details": {
"subscription": "Subscription",
"creditPack": "Credit pack",
"photos": "Up to {{count}} photos",
"galleryDays": "{{count}} gallery days",
"guests": "{{count}} guests",
"infinity": "∞",
"features": {
"subscription": "Subscription",
"priority_support": "Priority support",
"custom_domain": "Custom domain",
"analytics": "Analytics",
"team_management": "Team management",
"moderation_tools": "Moderation tools",
"prints": "Print uploads"
},
"section": {
"photosTitle": "Photos & gallery",
"photosValue": "Up to {{count}} photos, gallery {{days}} days",
"photosUnlimited": "Unlimited photos, flexible gallery",
"guestsTitle": "Guests & team",
"guestsValue": "{{count}} guests included, co-hosts flexible",
"guestsUnlimited": "Unlimited guest list",
"featuresTitle": "Highlights",
"featuresNone": "Standard",
"statusTitle": "Status",
"statusActive": "Already purchased",
"statusInactive": "Not purchased yet"
}
},
"status": {
"pendingTitle": "Payment still pending",
"pendingDescription": "You can start preparing the event. An active package is required before going live."
},
"free": {
"description": "This package is free. Assign it to your tenant and continue immediately.",
"activate": "Activate free package",
"progress": "Activating …",
"successTitle": "Free package activated",
"successDescription": "Credits added. Continue with the setup.",
"failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated."
},
"stripe": {
"sectionTitle": "Card payment (Stripe)",
"heading": "Card payment",
"notReady": "Payment module not ready yet. Please refresh.",
"genericError": "Payment failed. Please try again.",
"missingPaymentId": "Could not confirm payment (missing payment ID).",
"completionFailed": "Purchase not recorded yet. Contact support with your payment confirmation.",
"errorTitle": "Payment failed",
"submitting": "Confirming payment …",
"submit": "Pay now",
"hint": "Secure checkout via Stripe. You'll receive confirmation once recorded.",
"loading": "Loading payment details …",
"unavailableTitle": "Stripe unavailable",
"unavailableDescription": "Stripe could not be initialised.",
"missingKey": "Stripe publishable key missing. Configure VITE_STRIPE_PUBLISHABLE_KEY.",
"intentFailed": "Stripe could not prepare the payment."
},
"paypal": {
"sectionTitle": "PayPal",
"heading": "PayPal",
"createFailed": "PayPal order could not be created. Please try again.",
"captureFailed": "PayPal payment could not be captured. Contact support if funds were withdrawn.",
"errorTitle": "PayPal error",
"genericError": "PayPal reported a problem. Please try again later.",
"missingOrderId": "PayPal did not return an order ID.",
"cancelled": "PayPal payment was cancelled.",
"hint": "PayPal may redirect you briefly to confirm. You'll return automatically afterwards.",
"notConfiguredTitle": "PayPal not configured",
"notConfiguredDescription": "Provide VITE_PAYPAL_CLIENT_ID so hosts can pay with PayPal."
},
"nextStepsTitle": "Next steps",
"nextSteps": [
"Optional: finish billing (Stripe/PayPal) inside the billing area.",
"Complete the event setup and configure tasks, team, and gallery.",
"Check credits before go-live and share your guest link."
],
"cta": {
"billing": {
"label": "Start billing",
"description": "Opens the billing area with Stripe, PayPal, and credit options.",
"button": "Go to billing"
},
"setup": {
"label": "Continue to event setup",
"description": "You can return to billing any time.",
"button": "Continue to setup"
}
}
},
"eventSetup": {
"layout": {
"eyebrow": "Step 4",
"title": "Prepare your first event",
"subtitle": "Fill in a few details, invite co-hosts, and open your guest gallery for the big day."
},
"step": {
"title": "Event setup in minutes",
"description": "We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live."
},
"tiles": {
"story": {
"title": "Story & mood",
"copy": "Pick imagery, colours, and emotion cards for your event."
},
"team": {
"title": "Organise your team",
"copy": "Invite moderators or photographers and assign roles."
},
"launch": {
"title": "Prepare go-live",
"copy": "Create QR codes, test the gallery, and align the run of show."
}
},
"cta": {
"heading": "Ready for your first event?",
"description": "You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
"button": "Create event"
},
"actions": {
"back": {
"label": "Review packages again",
"description": "Compare pricing or update your current package.",
"button": "Back to packages"
},
"dashboard": {
"label": "Go to dashboard",
"description": "Jump into management to edit existing events.",
"button": "Open dashboard"
},
"events": {
"label": "Event overview",
"description": "Keep track of active and archived events.",
"button": "Open event list"
}
}
}
}

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/context';
import { router } from './router';
import '../../css/app.css';
import './i18n';
import { initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
@@ -46,7 +46,10 @@ export function TenantWelcomeLayout({
</p>
)}
</div>
{headerAction && <div className="flex shrink-0 items-center">{headerAction}</div>}
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
{headerAction}
</div>
</header>
<main className="flex flex-1 flex-col gap-8">
@@ -64,4 +67,4 @@ export function TenantWelcomeLayout({
);
}
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';

View File

@@ -1,54 +1,57 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ClipboardCheck, Sparkles, Globe, ArrowRight } from 'lucide-react';
import React from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ClipboardCheck, Sparkles, Globe, ArrowRight } from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from '..';
import { Button } from '@/components/ui/button';
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from '../../constants';
} from "..";
import { Button } from "@/components/ui/button";
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENTS_PATH, ADMIN_HOME_PATH } from "../../constants";
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ lastStep: 'event-setup' });
markStep({ lastStep: "event-setup" });
}, [markStep]);
return (
<TenantWelcomeLayout
eyebrow="Schritt 4"
title="Bereite dein erstes Event vor"
subtitle="F<>lle wenige Details aus, lade Co-Hosts ein und <20>ffne deine G<>stegalerie f<>r das gro<72>e Ereignis."
eyebrow={t("eventSetup.layout.eyebrow")}
title={t("eventSetup.layout.title")}
subtitle={t("eventSetup.layout.subtitle")}
>
<WelcomeStepCard
step={4}
totalSteps={4}
title="Event-Setup in Minuten"
description="Wir f<>hren dich durch Name, Datum, Mood und Aufgaben. Danach kannst du Fotos moderieren und G<>ste live begleiten."
title={t("eventSetup.step.title")}
description={t("eventSetup.step.description")}
icon={ClipboardCheck}
>
<div className="grid gap-4 md:grid-cols-3">
{[
{
id: 'story',
title: 'Story & Stimmung',
copy: 'W<>hle Bildsprache, Farben und Emotionskarten f<>r dein Event.',
id: "story",
title: t("eventSetup.tiles.story.title"),
copy: t("eventSetup.tiles.story.copy"),
icon: Sparkles,
},
{
id: 'team',
title: 'Team organisieren',
copy: 'Lade Moderator*innen oder Fotograf*innen ein und teile Rollen zu.',
id: "team",
title: t("eventSetup.tiles.team.title"),
copy: t("eventSetup.tiles.team.copy"),
icon: Globe,
},
{
id: 'launch',
title: 'Go-Live vorbereiten',
copy: 'Erstelle QR-Codes, teste die G<>stegalerie und kommuniziere den Ablauf.',
id: "launch",
title: t("eventSetup.tiles.launch.title"),
copy: t("eventSetup.tiles.launch.copy"),
icon: ArrowRight,
},
].map((item) => (
@@ -66,20 +69,17 @@ export default function WelcomeEventSetupPage() {
</div>
<div className="mt-6 flex flex-col items-start gap-3 rounded-3xl border border-brand-rose-soft bg-brand-sky-soft/40 p-6 text-brand-navy">
<h4 className="text-lg font-semibold text-brand-rose">Bereit f<EFBFBD>r dein erstes Event?</h4>
<p className="text-sm text-brand-navy/80">
Du wechselst jetzt in den Event-Manager. Dort kannst du Tasks zuweisen, Mitglieder einladen und die
G<EFBFBD>stegalerie testen. Keine Sorge: Du kannst jederzeit zur Welcome Journey zur<EFBFBD>ckkehren.
</p>
<h4 className="text-lg font-semibold text-brand-rose">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-brand-navy/80">{t("eventSetup.cta.description")}</p>
<Button
size="lg"
className="mt-2 rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => {
markStep({ lastStep: 'event-create-intent' });
markStep({ lastStep: "event-create-intent" });
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
Event erstellen
{t("eventSetup.cta.button")}
<ArrowRight className="ml-2 size-4" />
</Button>
</div>
@@ -88,29 +88,29 @@ export default function WelcomeEventSetupPage() {
<OnboardingCTAList
actions={[
{
id: 'back',
label: 'Noch einmal Pakete pr<70>fen',
description: 'Vergleiche Preise oder aktualisiere dein derzeitiges Paket.',
buttonLabel: 'Zu Paketen',
id: "back",
label: t("eventSetup.actions.back.label"),
description: t("eventSetup.actions.back.description"),
buttonLabel: t("eventSetup.actions.back.button"),
onClick: () => navigate(-1),
variant: 'secondary',
variant: "secondary",
},
{
id: 'dashboard',
label: 'Zum Dashboard',
description: 'Springe ins Management, um bestehende Events zu bearbeiten.',
buttonLabel: 'Dashboard <20>ffnen',
id: "dashboard",
label: t("eventSetup.actions.dashboard.label"),
description: t("eventSetup.actions.dashboard.description"),
buttonLabel: t("eventSetup.actions.dashboard.button"),
onClick: () => navigate(ADMIN_HOME_PATH),
},
{
id: 'events',
label: 'Event<6E>bersicht',
description: 'Behalte den <20>berblick <20>ber alle aktiven und archivierten Events.',
buttonLabel: 'Eventliste',
id: "events",
label: t("eventSetup.actions.events.label"),
description: t("eventSetup.actions.events.description"),
buttonLabel: t("eventSetup.actions.events.button"),
onClick: () => navigate(ADMIN_EVENTS_PATH),
},
]}
/>
</TenantWelcomeLayout>
);
}
}

View File

@@ -1,6 +1,8 @@
import React from "react";
import React from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sparkles, Users, Camera, CalendarDays, ChevronRight } from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeHero,
@@ -13,6 +15,7 @@ import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants"
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ welcomeSeen: true, lastStep: "landing" });
@@ -20,38 +23,36 @@ export default function WelcomeLandingPage() {
return (
<TenantWelcomeLayout
eyebrow="Fotospiel Tenant Admin"
title="Willkommen im Event-Erlebnisstudio"
subtitle="Starte mit einer inspirierten Einführung, sichere dir dein Event-Paket und kreiere die perfekte Gästegalerie alles optimiert für mobile Hosts."
eyebrow={t("layout.eyebrow")}
title={t("layout.title")}
subtitle={t("layout.subtitle")}
footer={
<>
<span className="text-brand-navy/80">Schon vertraut mit Fotospiel?</span>
<span className="text-brand-navy/80">{t("layout.alreadyFamiliar")}</span>
<button
type="button"
className="inline-flex items-center gap-1 text-sm font-semibold text-brand-rose hover:text-[var(--brand-rose-strong)]"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
Direkt zum Dashboard
{t("layout.jumpToDashboard")}
<ChevronRight className="size-4" />
</button>
</>
}
>
<WelcomeHero
eyebrow="Dein Event, deine Bühne"
title="Gestalte das nächste Fotospiel Erlebnis"
scriptTitle="Einmalig für Gäste, mühelos für dich."
description="Mit nur wenigen Schritten führst du deine Gäste durch ein magisches Fotoabenteuer inklusive Storytelling, Aufgaben und moderierter Galerie."
eyebrow={t("hero.eyebrow")}
title={t("hero.title")}
scriptTitle={t("hero.scriptTitle")}
description={t("hero.description")}
actions={[
{
label: "Pakete entdecken",
buttonLabel: "Pakete entdecken",
label: t("hero.primary.label"),
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
},
{
label: "Events anzeigen",
buttonLabel: "Bestehende Events anzeigen",
label: t("hero.secondary.label"),
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
variant: "outline",
@@ -64,24 +65,21 @@ export default function WelcomeLandingPage() {
{
id: "gallery",
icon: Camera,
title: "Premium Gästegalerie",
description:
"Kuratiere Fotos in Echtzeit, markiere Highlights und teile QR-Codes mit einem Tap.",
badge: "Neu",
title: t("highlights.gallery.title"),
description: t("highlights.gallery.description"),
badge: t("highlights.gallery.badge"),
},
{
id: "team",
icon: Users,
title: "Flexibles Team-Onboarding",
description:
"Lade Co-Hosts ein, weise Rollen zu und behalte den Überblick über Moderation und Aufgaben.",
title: t("highlights.team.title"),
description: t("highlights.team.description"),
},
{
id: "sparkles",
id: "story",
icon: Sparkles,
title: "Storytelling in Etappen",
description:
"Geführte Aufgaben und Emotionskarten machen jedes Event zu einer erinnerungswürdigen Reise.",
title: t("highlights.story.title"),
description: t("highlights.story.description"),
},
]}
/>
@@ -90,21 +88,19 @@ export default function WelcomeLandingPage() {
actions={[
{
id: "choose-package",
label: "Dein Eventpaket auswählen",
description:
"Reserviere Credits oder Abos, um sofort Events zu aktivieren. Flexible Optionen für jede Eventgröße.",
label: t("ctaList.choosePackage.label"),
description: t("ctaList.choosePackage.description"),
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
buttonLabel: "Weiter zu Paketen",
buttonLabel: t("ctaList.choosePackage.button"),
},
{
id: "create-event",
label: "Event vorbereiten",
description:
"Sammle Eventdetails, plane Aufgaben und sorge für einen reibungslosen Ablauf noch vor dem Tag des Events.",
label: t("ctaList.createEvent.label"),
description: t("ctaList.createEvent.description"),
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
buttonLabel: "Zum Event-Manager",
buttonLabel: t("ctaList.createEvent.button"),
variant: "secondary",
},
]}

View File

@@ -1,39 +1,93 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Receipt, ShieldCheck, ArrowRight, ArrowLeft, CreditCard, AlertTriangle, Loader2 } from 'lucide-react';
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Receipt,
ShieldCheck,
ArrowRight,
ArrowLeft,
CreditCard,
AlertTriangle,
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 {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from '..';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants';
import { useTenantPackages } from '../hooks/useTenantPackages';
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
completeTenantPackagePurchase,
createTenantPackagePaymentIntent,
createTenantPayPalOrder,
captureTenantPayPalOrder,
} from '../../api';
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
} from "../../api";
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? '';
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? '';
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
type StripeCheckoutProps = {
clientSecret: string;
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
};
function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeCheckoutProps) {
type PayPalCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
currency?: string;
};
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
}),
[locale]
);
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
[locale]
);
return { currencyFormatter, dateFormatter };
}
function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string): string {
const translationKey = `summary.details.features.${key}`;
const translated = t(translationKey);
if (translated !== translationKey) {
return translated;
}
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = React.useState(false);
@@ -42,7 +96,7 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
setError('Zahlungsmodul noch nicht bereit. Bitte lade die Seite neu.');
setError(t("summary.stripe.notReady"));
return;
}
@@ -54,25 +108,25 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
confirmParams: {
return_url: window.location.href,
},
redirect: 'if_required',
redirect: "if_required",
});
if (result.error) {
setError(result.error.message ?? 'Zahlung fehlgeschlagen. Bitte erneut versuchen.');
setError(result.error.message ?? t("summary.stripe.genericError"));
setSubmitting(false);
return;
}
const paymentIntent = result.paymentIntent;
const paymentMethodId =
typeof paymentIntent?.payment_method === 'string'
typeof paymentIntent?.payment_method === "string"
? paymentIntent.payment_method
: typeof paymentIntent?.id === 'string'
? paymentIntent.id
: null;
: typeof paymentIntent?.id === "string"
? paymentIntent.id
: null;
if (!paymentMethodId) {
setError('Zahlung konnte nicht bestätigt werden (fehlende Zahlungs-ID).');
setError(t("summary.stripe.missingPaymentId"));
setSubmitting(false);
return;
}
@@ -84,11 +138,11 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
});
onSuccess();
} catch (purchaseError) {
console.error('[Onboarding] Purchase completion failed', purchaseError);
console.error("[Onboarding] Purchase completion failed", purchaseError);
setError(
purchaseError instanceof Error
? purchaseError.message
: 'Der Kauf wurde bei uns noch nicht verbucht. Bitte kontaktiere den Support mit deiner Zahlungsbestätigung.'
: t("summary.stripe.completionFailed")
);
setSubmitting(false);
}
@@ -97,12 +151,12 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
return (
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
<div className="space-y-3">
<p className="text-sm font-medium text-brand-slate">Kartenzahlung</p>
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
<PaymentElement id="payment-element" />
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Zahlung fehlgeschlagen</AlertTitle>
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -114,110 +168,94 @@ function StripeCheckoutForm({ clientSecret, packageId, onSuccess }: StripeChecko
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Zahlung wird bestätigt ...
{t("summary.stripe.submitting")}
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
Jetzt bezahlen
{t("summary.stripe.submit")}
</>
)}
</Button>
<p className="text-xs text-brand-navy/70">
Sicherer Checkout über Stripe. Du erhältst eine Bestätigung, sobald der Kauf verbucht wurde.
</p>
<input type="hidden" value={clientSecret} />
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
</form>
);
}
type PayPalCheckoutProps = {
packageId: number;
onSuccess: () => void;
currency?: string;
};
function PayPalCheckout({ packageId, onSuccess, currency = 'EUR' }: PayPalCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'creating' | 'capturing' | 'error' | 'success'>('idle');
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
const [error, setError] = React.useState<string | null>(null);
const handleCreateOrder = React.useCallback(async () => {
try {
setStatus('creating');
setStatus("creating");
const orderId = await createTenantPayPalOrder(packageId);
setStatus('idle');
setStatus("idle");
setError(null);
return orderId;
} catch (err) {
console.error('[Onboarding] PayPal create order failed', err);
setStatus('error');
console.error("[Onboarding] PayPal create order failed", err);
setStatus("error");
setError(
err instanceof Error
? err.message
: 'PayPal-Bestellung konnte nicht erstellt werden. Bitte versuche es erneut.'
err instanceof Error ? err.message : t("summary.paypal.createFailed")
);
throw err;
}
}, [packageId]);
}, [packageId, t]);
const handleApprove = React.useCallback(
async (orderId: string) => {
try {
setStatus('capturing');
setStatus("capturing");
await captureTenantPayPalOrder(orderId);
setStatus('success');
setStatus("success");
setError(null);
onSuccess();
} catch (err) {
console.error('[Onboarding] PayPal capture failed', err);
setStatus('error');
console.error("[Onboarding] PayPal capture failed", err);
setStatus("error");
setError(
err instanceof Error
? err.message
: 'PayPal-Zahlung konnte nicht abgeschlossen werden. Bitte kontaktiere den Support, falls der Betrag bereits abgebucht wurde.'
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
);
throw err;
}
},
[onSuccess]
[onSuccess, t]
);
return (
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<p className="text-sm font-medium text-brand-slate">PayPal</p>
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>PayPal-Fehler</AlertTitle>
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<PayPalButtons
style={{ layout: 'vertical' }}
style={{ layout: "vertical" }}
forceReRender={[packageId, currency]}
createOrder={async () => handleCreateOrder()}
onApprove={async (data) => {
if (!data.orderID) {
setError('PayPal hat keine Order-ID geliefert.');
setStatus('error');
setError(t("summary.paypal.missingOrderId"));
setStatus("error");
return;
}
await handleApprove(data.orderID);
}}
onError={(err) => {
console.error('[Onboarding] PayPal onError', err);
setStatus('error');
setError('PayPal hat ein Problem gemeldet. Bitte versuche es in wenigen Minuten erneut.');
console.error("[Onboarding] PayPal onError", err);
setStatus("error");
setError(t("summary.paypal.genericError"));
}}
onCancel={() => {
setStatus('idle');
setError('PayPal-Zahlung wurde abgebrochen.');
setStatus("idle");
setError(t("summary.paypal.cancelled"));
}}
disabled={status === 'creating' || status === 'capturing'}
disabled={status === "creating" || status === "capturing"}
/>
<p className="text-xs text-brand-navy/70">
PayPal leitet dich ggf. kurz weiter, um die Zahlung zu bestätigen. Anschließend wirst du automatisch zum Setup
zurückgebracht.
</p>
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
</div>
);
}
@@ -227,27 +265,30 @@ export default function WelcomeOrderSummaryPage() {
const location = useLocation();
const { progress, markStep } = useOnboardingProgress();
const packagesState = useTenantPackages();
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const packageIdFromState = typeof location.state === 'object' ? (location.state as any)?.packageId : undefined;
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
React.useEffect(() => {
if (!selectedPackageId && packagesState.status !== 'loading') {
if (!selectedPackageId && packagesState.status !== "loading") {
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
}
}, [selectedPackageId, packagesState.status, navigate]);
React.useEffect(() => {
markStep({ lastStep: 'summary' });
markStep({ lastStep: "summary" });
}, [markStep]);
const packageDetails =
packagesState.status === 'success' && selectedPackageId
packagesState.status === "success" && selectedPackageId
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
: null;
const activePackage =
packagesState.status === 'success'
packagesState.status === "success"
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
: null;
@@ -255,65 +296,86 @@ export default function WelcomeOrderSummaryPage() {
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
const [intentStatus, setIntentStatus] = React.useState<'idle' | 'loading' | 'error' | 'ready'>('idle');
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
const [intentError, setIntentError] = React.useState<string | null>(null);
const [freeAssignStatus, setFreeAssignStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!requiresPayment || !packageDetails) {
setClientSecret(null);
setIntentStatus('idle');
setIntentStatus("idle");
setIntentError(null);
return;
}
if (!stripePromise) {
setIntentError('Stripe Publishable Key fehlt. Bitte konfiguriere VITE_STRIPE_PUBLISHABLE_KEY.');
setIntentStatus('error');
setIntentError(t("summary.stripe.missingKey"));
setIntentStatus("error");
return;
}
let cancelled = false;
setIntentStatus('loading');
setIntentStatus("loading");
setIntentError(null);
createTenantPackagePaymentIntent(packageDetails.id)
.then((secret) => {
if (cancelled) return;
setClientSecret(secret);
setIntentStatus('ready');
setIntentStatus("ready");
})
.catch((error) => {
console.error('[Onboarding] Failed to create payment intent', error);
console.error("[Onboarding] Payment intent failed", error);
if (cancelled) return;
setIntentError(
error instanceof Error
? error.message
: 'PaymentIntent konnte nicht erstellt werden. Bitte später erneut versuchen.'
);
setIntentStatus('error');
setIntentStatus("error");
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
});
return () => {
cancelled = true;
};
}, [requiresPayment, packageDetails?.id]);
}, [requiresPayment, packageDetails, t]);
const priceText =
progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === 'number'
? new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
}).format(packageDetails.price)
(packageDetails && typeof packageDetails.price === "number"
? currencyFormatter.format(packageDetails.price)
: null);
const detailBadges = React.useMemo(() => {
if (!packageDetails) {
return [] as string[];
}
const badges: string[] = [];
if (packageDetails.max_photos) {
badges.push(t("summary.details.photos", { count: packageDetails.max_photos }));
}
if (packageDetails.gallery_days) {
badges.push(t("summary.details.galleryDays", { count: packageDetails.gallery_days }));
}
if (packageDetails.max_guests) {
badges.push(t("summary.details.guests", { count: packageDetails.max_guests }));
}
return badges;
}, [packageDetails, t]);
const featuresList = React.useMemo(() => {
if (!packageDetails) {
return [] as string[];
}
return Object.entries(packageDetails.features ?? {})
.filter(([, enabled]) => Boolean(enabled))
.map(([feature]) => humanizeFeature(t, feature));
}, [packageDetails, t]);
const nextSteps = t("summary.nextSteps", { returnObjects: true }) as string[];
return (
<TenantWelcomeLayout
eyebrow="Schritt 3"
title="Bestellübersicht"
subtitle="Prüfe Paket, Preis und Abrechnung bevor du zum Event-Setup wechselst."
eyebrow={t("summary.layout.eyebrow")}
title={t("summary.layout.title")}
subtitle={t("summary.layout.subtitle")}
footer={
<button
type="button"
@@ -321,50 +383,48 @@ export default function WelcomeOrderSummaryPage() {
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
>
<ArrowLeft className="size-4" />
Zurück zur Paketauswahl
{t("summary.footer.back")}
</button>
}
>
<WelcomeStepCard
step={3}
totalSteps={4}
title="Deine Auswahl im Überblick"
description="Du kannst sofort an die Abrechnung übergeben oder das Setup fortsetzen und später bezahlen."
title={t("summary.step.title")}
description={t("summary.step.description")}
icon={Receipt}
>
{packagesState.status === 'loading' && (
{packagesState.status === "loading" && (
<div className="flex items-center gap-3 rounded-2xl bg-slate-50 p-6 text-sm text-brand-navy/80">
<CreditCard className="size-5 text-brand-rose" />
Wir prüfen verfügbare Pakete
{t("summary.state.loading")}
</div>
)}
{packagesState.status === 'error' && (
{packagesState.status === "error" && (
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>Paketdaten derzeit nicht verfügbar</AlertTitle>
<AlertTitle>{t("summary.state.errorTitle")}</AlertTitle>
<AlertDescription>
{packagesState.message} Du kannst das Event trotzdem vorbereiten und später zurückkehren.
{packagesState.message ?? t("summary.state.errorDescription")}
</AlertDescription>
</Alert>
)}
{packagesState.status === 'success' && !packageDetails && (
{packagesState.status === "success" && !packageDetails && (
<Alert>
<AlertTitle>Keine Paketauswahl gefunden</AlertTitle>
<AlertDescription>
Bitte wähle zuerst ein Paket aus oder aktualisiere die Seite, falls sich die Daten geändert haben.
</AlertDescription>
<AlertTitle>{t("summary.state.missingTitle")}</AlertTitle>
<AlertDescription>{t("summary.state.missingDescription")}</AlertDescription>
</Alert>
)}
{packagesState.status === 'success' && packageDetails && (
{packagesState.status === "success" && packageDetails && (
<div className="grid gap-4">
<div className="rounded-3xl border border-brand-rose-soft bg-brand-card p-6 shadow-md shadow-rose-100/40">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-brand-rose">
{isSubscription ? 'Abo' : 'Credit-Paket'}
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</p>
<h3 className="text-2xl font-semibold text-brand-slate">{packageDetails.name}</h3>
</div>
@@ -376,59 +436,59 @@ export default function WelcomeOrderSummaryPage() {
</div>
<dl className="mt-6 grid gap-3 text-sm text-brand-navy/80 md:grid-cols-2">
<div>
<dt className="font-semibold text-brand-slate">Fotos & Galerie</dt>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.photosTitle")}</dt>
<dd>
{packageDetails.max_photos
? `Bis zu ${packageDetails.max_photos} Fotos, Galerie ${packageDetails.gallery_days ?? '∞'} Tage`
: 'Unbegrenzte Fotos, flexible Galerie'}
? t("summary.details.section.photosValue", {
count: packageDetails.max_photos,
days: packageDetails.gallery_days ?? t("summary.details.infinity"),
})
: t("summary.details.section.photosUnlimited")}
</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">Gäste & Team</dt>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.guestsTitle")}</dt>
<dd>
{packageDetails.max_guests
? `${packageDetails.max_guests} Gäste inklusive, Co-Hosts frei planbar`
: 'Unbegrenzte Gästeliste'}
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">Highlights</dt>
<dd>
{Object.entries(packageDetails.features ?? {})
.filter(([, enabled]) => enabled)
.map(([feature]) => feature.replace(/_/g, ' '))
.join(', ') || 'Standard'}
</dd>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.featuresTitle")}</dt>
<dd>{featuresList.length ? featuresList.join(", ") : t("summary.details.section.featuresNone")}</dd>
</div>
<div>
<dt className="font-semibold text-brand-slate">Status</dt>
<dd>{activePackage ? 'Bereits gebucht' : 'Noch nicht gebucht'}</dd>
<dt className="font-semibold text-brand-slate">{t("summary.details.section.statusTitle")}</dt>
<dd>{activePackage ? t("summary.details.section.statusActive") : t("summary.details.section.statusInactive")}</dd>
</div>
</dl>
{detailBadges.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-wide text-brand-rose">
{detailBadges.map((badge) => (
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
{badge}
</span>
))}
</div>
)}
</div>
{!activePackage && (
<Alert className="rounded-2xl border-brand-rose-soft bg-brand-rose-soft/60 text-rose-700">
<ShieldCheck className="size-4" />
<AlertTitle>Abrechnung steht noch aus</AlertTitle>
<AlertDescription>
Du kannst das Event bereits vorbereiten. Spätestens zur Veröffentlichung benötigst du ein aktives Paket.
</AlertDescription>
<AlertTitle>{t("summary.status.pendingTitle")}</AlertTitle>
<AlertDescription>{t("summary.status.pendingDescription")}</AlertDescription>
</Alert>
)}
{packageDetails.price === 0 && (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-6">
<p className="text-sm text-emerald-700">
Dieses Paket ist kostenlos. Du kannst es sofort deinem Tenant zuweisen und direkt mit dem Setup
weitermachen.
</p>
{freeAssignStatus === 'success' ? (
<p className="text-sm text-emerald-700">{t("summary.free.description")}</p>
{freeAssignStatus === "success" ? (
<Alert className="mt-4 border-emerald-200 bg-white text-emerald-600">
<AlertTitle>Gratis-Paket aktiviert</AlertTitle>
<AlertDescription>
Deine Credits wurden hinzugefügt. Weiter geht&apos;s mit dem Event-Setup.
</AlertDescription>
<AlertTitle>{t("summary.free.successTitle")}</AlertTitle>
<AlertDescription>{t("summary.free.successDescription")}</AlertDescription>
</Alert>
) : (
<Button
@@ -436,37 +496,37 @@ export default function WelcomeOrderSummaryPage() {
if (!packageDetails) {
return;
}
setFreeAssignStatus('loading');
setFreeAssignStatus("loading");
setFreeAssignError(null);
try {
await assignFreeTenantPackage(packageDetails.id);
setFreeAssignStatus('success');
setFreeAssignStatus("success");
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
} catch (error) {
console.error('[Onboarding] Free package assignment failed', error);
setFreeAssignStatus('error');
console.error("[Onboarding] Free package assignment failed", error);
setFreeAssignStatus("error");
setFreeAssignError(
error instanceof Error ? error.message : 'Kostenloses Paket konnte nicht aktiviert werden.'
error instanceof Error ? error.message : t("summary.free.errorMessage")
);
}
}}
disabled={freeAssignStatus === 'loading'}
disabled={freeAssignStatus === "loading"}
className="mt-4 rounded-full bg-brand-teal text-white shadow-md shadow-emerald-300/40 hover:opacity-90"
>
{freeAssignStatus === 'loading' ? (
{freeAssignStatus === "loading" ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Aktivierung läuft ...
{t("summary.free.progress")}
</>
) : (
'Gratis-Paket aktivieren'
t("summary.free.activate")
)}
</Button>
)}
{freeAssignStatus === 'error' && freeAssignError && (
{freeAssignStatus === "error" && freeAssignError && (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Aktivierung fehlgeschlagen</AlertTitle>
<AlertTitle>{t("summary.free.failureTitle")}</AlertTitle>
<AlertDescription>{freeAssignError}</AlertDescription>
</Alert>
)}
@@ -476,20 +536,20 @@ export default function WelcomeOrderSummaryPage() {
{requiresPayment && (
<div className="space-y-6">
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">Kartenzahlung (Stripe)</h4>
{intentStatus === 'loading' && (
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
{intentStatus === "loading" && (
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
<Loader2 className="size-4 animate-spin text-brand-rose" />
Zahlungsdetails werden geladen
{t("summary.stripe.loading")}
</div>
)}
{intentStatus === 'error' && (
{intentStatus === "error" && (
<Alert variant="destructive">
<AlertTitle>Stripe nicht verfügbar</AlertTitle>
<AlertDescription>{intentError}</AlertDescription>
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
</Alert>
)}
{intentStatus === 'ready' && clientSecret && stripePromise && (
{intentStatus === "ready" && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripeCheckoutForm
clientSecret={clientSecret}
@@ -498,6 +558,7 @@ export default function WelcomeOrderSummaryPage() {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</Elements>
)}
@@ -505,9 +566,14 @@ export default function WelcomeOrderSummaryPage() {
{paypalClientId ? (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">PayPal</h4>
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
<PayPalScriptProvider
options={{ 'client-id': paypalClientId, currency: 'EUR', intent: 'CAPTURE' }}
options={{
clientId: paypalClientId,
"client-id": paypalClientId,
currency: "EUR",
intent: "CAPTURE",
}}
>
<PayPalCheckout
packageId={packageDetails.id}
@@ -515,26 +581,25 @@ export default function WelcomeOrderSummaryPage() {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</PayPalScriptProvider>
</div>
) : (
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
<AlertTitle>PayPal nicht konfiguriert</AlertTitle>
<AlertDescription>
Hinterlege `VITE_PAYPAL_CLIENT_ID`, damit Gastgeber optional mit PayPal bezahlen können.
</AlertDescription>
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
</Alert>
)}
</div>
)}
<div className="flex flex-col gap-3 rounded-2xl bg-brand-card p-6 shadow-inner shadow-rose-100/40">
<h4 className="text-lg font-semibold text-brand-slate">Nächste Schritte</h4>
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-brand-navy/80">
<li>Optional: Abrechnung abschließen (Stripe/PayPal) im Billing-Bereich.</li>
<li>Event-Setup durchlaufen und Aufgaben, Team & Galerie konfigurieren.</li>
<li>Vor dem Go-Live Credits prüfen und Gäste-Link teilen.</li>
{nextSteps.map((step, index) => (
<li key={`${step}-${index}`}>{step}</li>
))}
</ol>
</div>
</div>
@@ -544,21 +609,21 @@ export default function WelcomeOrderSummaryPage() {
<OnboardingCTAList
actions={[
{
id: 'checkout',
label: 'Abrechnung starten',
description: 'Öffnet den Billing-Bereich mit allen Kaufoptionen (Stripe, PayPal, Credits).',
buttonLabel: 'Zu Billing & Zahlung',
id: "checkout",
label: t("summary.cta.billing.label"),
description: t("summary.cta.billing.description"),
buttonLabel: t("summary.cta.billing.button"),
href: ADMIN_BILLING_PATH,
icon: CreditCard,
variant: 'secondary',
variant: "secondary",
},
{
id: 'continue-to-setup',
label: 'Mit Event-Setup fortfahren',
description: 'Du kannst später jederzeit zur Abrechnung zurückkehren.',
buttonLabel: 'Weiter zum Setup',
id: "continue-to-setup",
label: t("summary.cta.setup.label"),
description: t("summary.cta.setup.description"),
buttonLabel: t("summary.cta.setup.button"),
onClick: () => {
markStep({ lastStep: 'event-setup' });
markStep({ lastStep: "event-setup" });
navigate(ADMIN_WELCOME_EVENT_PATH);
},
icon: ArrowRight,
@@ -568,12 +633,3 @@ export default function WelcomeOrderSummaryPage() {
</TenantWelcomeLayout>
);
}

View File

@@ -1,60 +1,95 @@
import React from 'react';
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import React from "react";
import { CreditCard, ShoppingBag, ArrowRight, Loader2, AlertCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from '..';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from '../../constants';
import { useTenantPackages } from '../hooks/useTenantPackages';
import { Package } from '../../api';
} from "..";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_SUMMARY_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import { Package } from "../../api";
const DEFAULT_CURRENCY = "EUR";
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: DEFAULT_CURRENCY,
minimumFractionDigits: 0,
}),
[locale]
);
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}),
[locale]
);
return { currencyFormatter, dateFormatter };
}
function formatFeatureLabel(t: ReturnType<typeof useTranslation>["t"], key: string): string {
const translationKey = `packages.features.${key}`;
const translated = t(translationKey);
if (translated !== translationKey) {
return translated;
}
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export default function WelcomePackagesPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const packagesState = useTenantPackages();
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
React.useEffect(() => {
if (packagesState.status === 'success') {
if (packagesState.status === "success") {
const active = packagesState.activePackage;
markStep({
packageSelected: Boolean(packagesState.activePackage),
lastStep: 'packages',
selectedPackage: packagesState.activePackage
packageSelected: Boolean(active),
lastStep: "packages",
selectedPackage: active
? {
id: packagesState.activePackage.package_id,
name: packagesState.activePackage.package_name,
priceText: packagesState.activePackage.price
? new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: packagesState.activePackage.currency ?? 'EUR',
minimumFractionDigits: 0,
}).format(packagesState.activePackage.price)
: null,
isSubscription: false,
id: active.package_id,
name: active.package_name,
priceText:
typeof active.price === "number"
? currencyFormatter.format(active.price)
: t("packages.card.onRequest"),
isSubscription: Boolean(active.package_limits?.subscription),
}
: null,
});
}
}, [packagesState, markStep]);
}, [packagesState, markStep, currencyFormatter, t]);
const handleSelectPackage = React.useCallback(
(pkg: Package) => {
const priceText =
typeof pkg.price === 'number'
? new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
}).format(pkg.price)
: 'Auf Anfrage';
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest");
markStep({
packageSelected: true,
lastStep: package-,
lastStep: "packages",
selectedPackage: {
id: pkg.id,
name: pkg.name,
@@ -65,131 +100,158 @@ export default function WelcomePackagesPage() {
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
},
[markStep, navigate]
[currencyFormatter, markStep, navigate, t]
);
const renderPackageList = () => {
if (packagesState.status === "loading") {
return (
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
<Loader2 className="size-5 animate-spin text-brand-rose" />
{t("packages.state.loading")}
</div>
);
}
if (packagesState.status === "error") {
return (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>{t("packages.state.errorTitle")}</AlertTitle>
<AlertDescription>{packagesState.message ?? t("packages.state.errorDescription")}</AlertDescription>
</Alert>
);
}
if (packagesState.status === "success" && packagesState.catalog.length === 0) {
return (
<Alert variant="default" className="border-brand-rose-soft bg-brand-card/60 text-brand-navy">
<AlertTitle>{t("packages.state.emptyTitle")}</AlertTitle>
<AlertDescription>{t("packages.state.emptyDescription")}</AlertDescription>
</Alert>
);
}
if (packagesState.status !== "success") {
return null;
}
return (
<div className="grid gap-4 md:grid-cols-2">
{packagesState.catalog.map((pkg) => {
const isActive = packagesState.activePackage?.package_id === pkg.id;
const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id);
const featureLabels = Object.entries(pkg.features ?? {})
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => formatFeatureLabel(t, key));
const isSubscription = Boolean(pkg.features?.subscription);
const priceText =
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price ?? 0) : t("packages.card.onRequest");
const badges: string[] = [];
if (pkg.max_guests) {
badges.push(t("packages.card.badges.guests", { count: pkg.max_guests }));
}
if (pkg.gallery_days) {
badges.push(t("packages.card.badges.days", { count: pkg.gallery_days }));
}
if (pkg.max_photos) {
badges.push(t("packages.card.badges.photos", { count: pkg.max_photos }));
}
return (
<div
key={pkg.id}
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
</p>
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
</div>
<p className="text-sm text-brand-navy/80">
{pkg.max_photos
? t("packages.card.descriptionWithPhotos", { count: pkg.max_photos })
: t("packages.card.description")}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
{badges.map((badge) => (
<span key={badge} className="rounded-full bg-brand-rose-soft px-3 py-1">
{badge}
</span>
))}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
{feature}
</span>
))}
{isActive && (
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">
{t("packages.card.active")}
</span>
)}
</div>
<Button
size="lg"
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => handleSelectPackage(pkg)}
>
{t("packages.card.select")}
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased && (
<p className="text-xs text-brand-rose">
{t("packages.card.purchased", {
date: purchased.purchased_at
? dateFormatter.format(new Date(purchased.purchased_at))
: t("packages.card.purchasedUnknown"),
})}
</p>
)}
</div>
);
})}
</div>
);
};
return (
<TenantWelcomeLayout
eyebrow="Schritt 2"
title="W<>hle dein Eventpaket"
subtitle="Fotospiel unterst<73>tzt flexible Preismodelle: einmalige Credits oder Abos, die mehrere Events abdecken."
eyebrow={t("packages.layout.eyebrow")}
title={t("packages.layout.title")}
subtitle={t("packages.layout.subtitle")}
>
<WelcomeStepCard
step={2}
totalSteps={4}
title="Aktiviere die passenden Credits"
description="Sichere dir Kapazit<69>t f<>r dein n<>chstes Event. Du kannst jederzeit upgraden <20> bezahle nur, was du wirklich brauchst."
title={t("packages.step.title")}
description={t("packages.step.description")}
icon={CreditCard}
>
{packagesState.status === 'loading' && (
<div className="flex items-center gap-3 rounded-2xl bg-brand-sky-soft/60 p-6 text-sm text-brand-navy/80">
<Loader2 className="size-5 animate-spin text-brand-rose" />
Pakete werden geladen <EFBFBD>
</div>
)}
{packagesState.status === 'error' && (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{packagesState.message}</AlertDescription>
</Alert>
)}
{packagesState.status === 'success' && (
<div className="grid gap-4 md:grid-cols-2">
{packagesState.catalog.map((pkg) => {
const isActive = packagesState.activePackage?.package_id === pkg.id;
const purchased = packagesState.purchasedPackages.find((tenantPkg) => tenantPkg.package_id === pkg.id);
const featureLabels = Object.entries(pkg.features ?? {})
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => key.replace(/_/g, ' '));
const isSubscription = Boolean(pkg.features?.subscription);
const priceText =
typeof pkg.price === 'number'
? new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
}).format(pkg.price)
: 'Auf Anfrage';
return (
<div
key={pkg.id}
className="flex flex-col gap-4 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-brand-rose">
{isSubscription ? 'Abo' : 'Credit-Paket'}
</p>
<h3 className="text-xl font-semibold text-brand-slate">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-brand-rose">{priceText}</span>
</div>
<p className="text-sm text-brand-navy/80">
{pkg.max_photos
? Bis zu Fotos inklusive <EFBFBD> perfekt f<EFBFBD>r lebendige Reportagen.
: 'Sofort einsatzbereit f<>r dein n<>chstes Event.'}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-wide text-brand-rose">
{pkg.max_guests && (
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.max_guests} G<EFBFBD>ste}</span>
)}
{pkg.gallery_days && (
<span className="rounded-full bg-brand-rose-soft px-3 py-1">{${pkg.gallery_days} Tage Galerie}</span>
)}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-brand-rose-soft px-3 py-1">
{feature}
</span>
))}
{isActive && (
<span className="rounded-full bg-brand-sky-soft px-3 py-1 text-brand-navy">Aktives Paket</span>
)}
</div>
<Button
size="lg"
className="mt-auto rounded-full bg-brand-rose text-white shadow-lg shadow-rose-300/40 hover:bg-[var(--brand-rose-strong)]"
onClick={() => handleSelectPackage(pkg)}
>
Paket w<EFBFBD>hlen
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased && (
<p className="text-xs text-brand-rose">
Bereits gekauft am{' '}
{purchased.purchased_at
? new Date(purchased.purchased_at).toLocaleDateString('de-DE')
: 'unbekanntem Datum'}
</p>
)}
</div>
);
})}
</div>
)}
{renderPackageList()}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
id: 'skip-to-checkout',
label: 'Direkt zum Billing',
description:
'Falls du schon wei<65>t, welches Paket du brauchst, gelangst du hier zum bekannten Abrechnungsbereich.',
buttonLabel: 'Billing <20>ffnen',
id: "skip-to-billing",
label: t("packages.cta.billing.label"),
description: t("packages.cta.billing.description"),
buttonLabel: t("packages.cta.billing.button"),
href: ADMIN_BILLING_PATH,
icon: ShoppingBag,
variant: 'secondary',
variant: "secondary",
},
{
id: 'continue',
label: 'Bestell<6C>bersicht anzeigen',
description: 'Pr<50>fe Paketdetails und entscheide, ob du direkt zahlen oder sp<73>ter fortfahren m<>chtest.',
buttonLabel: 'Weiter zur <20>bersicht',
id: "continue",
label: t("packages.cta.summary.label"),
description: t("packages.cta.summary.description"),
buttonLabel: t("packages.cta.summary.button"),
onClick: () => navigate(ADMIN_WELCOME_SUMMARY_PATH),
icon: ArrowRight,
},
@@ -197,4 +259,4 @@ export default function WelcomePackagesPage() {
/>
</TenantWelcomeLayout>
);
}
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import React from 'react';
import { CreditCard, Download, Loader2, RefreshCw, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -24,6 +25,12 @@ type LedgerState = {
};
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
() => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'),
[i18n.language]
);
const [balance, setBalance] = React.useState<number>(0);
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
@@ -32,6 +39,51 @@ export default function BillingPage() {
const [error, setError] = React.useState<string | null>(null);
const [loadingMore, setLoadingMore] = React.useState(false);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
},
[locale]
);
const formatCurrency = React.useCallback(
(value: number | null | undefined, currency = 'EUR') => {
if (value === null || value === undefined) return '--';
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value);
},
[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'),
}),
[t]
);
React.useEffect(() => {
void loadAll();
}, []);
@@ -62,7 +114,7 @@ export default function BillingPage() {
}
} catch (err) {
if (!isAuthError(err)) {
setError('Billing Daten konnten nicht geladen werden.');
setError(t('management.billing.errors.load', 'Billing Daten konnten nicht geladen werden.'));
}
} finally {
setLoading(false);
@@ -87,7 +139,7 @@ export default function BillingPage() {
});
} catch (err) {
if (!isAuthError(err)) {
setError('Weitere Ledger Eintraege konnten nicht geladen werden.');
setError(t('management.billing.errors.more', 'Weitere Ledger Eintraege konnten nicht geladen werden.'));
}
} finally {
setLoadingMore(false);
@@ -97,19 +149,19 @@ export default function BillingPage() {
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" />}
Aktualisieren
{t('management.billing.actions.refresh', 'Aktualisieren')}
</Button>
);
return (
<AdminLayout
title="Billing und Credits"
subtitle="Verwalte Guthaben, Pakete und Abrechnungen."
title={t('management.billing.title', 'Billing und Credits')}
subtitle={t('management.billing.subtitle', 'Verwalte Guthaben, Pakete und Abrechnungen.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -123,10 +175,10 @@ export default function BillingPage() {
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<CreditCard className="h-5 w-5 text-pink-500" />
Credits und Status
{t('management.billing.sections.overview.title', 'Credits und Status')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Dein aktuelles Guthaben und das aktive Reseller Paket.
{t('management.billing.sections.overview.description', 'Dein aktuelles Guthaben und das aktive Reseller Paket.')}
</CardDescription>
</div>
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
@@ -134,24 +186,30 @@ export default function BillingPage() {
</Badge>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard label="Verfuegbare Credits" value={balance} tone="pink" />
<InfoCard
label="Genutzte Events"
value={activePackage?.used_events ?? 0}
tone="amber"
helper={`Verfuegbar: ${activePackage?.remaining_events ?? 0}`}
label={t('management.billing.sections.overview.cards.balance.label', 'Verfügbare Credits')}
value={balance}
tone="pink"
/>
<InfoCard
label="Preis (netto)"
value={formatCurrency(activePackage?.price)}
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="Ablauf"
label={t('management.billing.sections.overview.cards.expires.label', 'Ablauf')}
value={formatDate(activePackage?.expires_at)}
tone="emerald"
helper="Automatisch verlaengern falls aktiv"
helper={t('management.billing.sections.overview.cards.expires.helper', 'Automatisch verlängern, falls aktiv')}
/>
</CardContent>
</Card>
@@ -160,18 +218,25 @@ 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" />
Paket Historie
{t('management.billing.packages.title', 'Paket Historie')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Uebersicht ueber aktive und vergangene Reseller Pakete.
{t('management.billing.packages.description', 'Übersicht über aktive und vergangene Reseller Pakete.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{packages.length === 0 ? (
<EmptyState message="Noch keine Pakete gebucht." />
<EmptyState message={t('management.billing.packages.empty', 'Noch keine Pakete gebucht.')} />
) : (
packages.map((pkg) => (
<PackageCard key={pkg.id} pkg={pkg} isActive={pkg.active} />
<PackageCard
key={pkg.id}
pkg={pkg}
isActive={Boolean(pkg.active)}
labels={packageLabels}
formatDate={formatDate}
formatCurrency={formatCurrency}
/>
))
)}
</CardContent>
@@ -182,28 +247,33 @@ export default function BillingPage() {
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-sky-500" />
Credit Ledger
{t('management.billing.ledger.title', 'Credit Ledger')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Alle Zu- und Abbuchungen deines Credits Kontos.
{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" />
Export als CSV
{t('management.billing.actions.exportCsv', 'Export als CSV')}
</Button>
</CardHeader>
<CardContent className="space-y-2">
{ledger.entries.length === 0 ? (
<EmptyState message="Noch keine Ledger Eintraege vorhanden." />
<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} />
<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" /> : 'Mehr laden'}
{loadingMore ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.billing.ledger.loadMore', 'Mehr laden')}
</Button>
)}
</>
@@ -254,36 +324,68 @@ function InfoCard({
);
}
function PackageCard({ pkg, isActive }: { pkg: TenantPackageSummary; isActive: boolean }) {
function PackageCard({
pkg,
isActive,
labels,
formatDate,
formatCurrency,
}: {
pkg: TenantPackageSummary;
isActive: boolean;
labels: {
statusActive: string;
statusInactive: string;
used: string;
available: string;
expires: string;
};
formatDate: (value: string | null | undefined) => string;
formatCurrency: (value: number | null | undefined, currency?: string) => string;
}) {
return (
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900">{pkg.package_name}</h3>
<p className="text-xs text-slate-600">
{formatDate(pkg.purchased_at)} - {formatCurrency(pkg.price)} {pkg.currency ?? 'EUR'}
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
</p>
</div>
<Badge className={isActive ? 'bg-amber-500/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
{isActive ? 'Aktiv' : 'Inaktiv'}
{isActive ? labels.statusActive : labels.statusInactive}
</Badge>
</div>
<Separator className="my-3" />
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
<span>Genutzte Events: {pkg.used_events}</span>
<span>Verfuegbar: {pkg.remaining_events ?? '--'}</span>
<span>Ablauf: {formatDate(pkg.expires_at)}</span>
<span>
{labels.used}: {pkg.used_events}
</span>
<span>
{labels.available}: {pkg.remaining_events ?? '--'}
</span>
<span>
{labels.expires}: {formatDate(pkg.expires_at)}
</span>
</div>
</div>
);
}
function LedgerRow({ entry }: { entry: CreditLedgerEntry }) {
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">{mapReason(entry.reason)}</p>
<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">
@@ -327,28 +429,3 @@ function BillingSkeleton() {
</div>
);
}
function mapReason(reason: string): string {
switch (reason) {
case 'purchase':
return 'Credit Kauf';
case 'usage':
return 'Verbrauch';
case 'manual':
return 'Manuelle Anpassung';
default:
return reason;
}
}
function formatDate(value: string | null | undefined): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatCurrency(value: number | null | undefined): string {
if (value === null || value === undefined) return '--';
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -37,7 +38,7 @@ interface DashboardState {
credits: number;
activePackage: TenantPackageSummary | null;
loading: boolean;
error: string | null;
errorKey: string | null;
}
export default function DashboardPage() {
@@ -45,13 +46,14 @@ export default function DashboardPage() {
const location = useLocation();
const { user } = useAuth();
const { progress, markStep } = useOnboardingProgress();
const { t, i18n } = useTranslation(['dashboard', 'common']);
const [state, setState] = React.useState<DashboardState>({
summary: null,
events: [],
credits: 0,
activePackage: null,
loading: true,
error: null,
errorKey: null,
});
React.useEffect(() => {
@@ -71,19 +73,19 @@ export default function DashboardPage() {
const fallbackSummary = buildSummaryFallback(events, credits.balance ?? 0, packages.activePackage);
setState({
summary: summary ?? fallbackSummary,
events,
credits: credits.balance ?? 0,
activePackage: packages.activePackage,
loading: false,
error: null,
});
setState({
summary: summary ?? fallbackSummary,
events,
credits: credits.balance ?? 0,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
error: 'Dashboard konnte nicht geladen werden.',
errorKey: 'loadFailed',
loading: false,
}));
}
@@ -95,7 +97,7 @@ export default function DashboardPage() {
};
}, []);
const { summary, events, credits, activePackage, loading, error } = state;
const { summary, events, credits, activePackage, loading, errorKey } = state;
React.useEffect(() => {
if (loading) {
@@ -110,6 +112,12 @@ 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 dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const upcomingEvents = getUpcomingEvents(events);
const publishedEvents = events.filter((event) => event.status === 'published');
@@ -119,10 +127,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" /> Neues Event
<Plus className="h-4 w-4" /> {t('dashboard.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" /> Alle Events
<CalendarDays className="h-4 w-4" /> {t('dashboard.actions.allEvents')}
</Button>
{events.length === 0 && (
<Button
@@ -130,22 +138,18 @@ 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" /> Guided Setup
<Sparkles className="h-4 w-4" /> {t('dashboard.actions.guidedSetup')}
</Button>
)}
</>
);
return (
<AdminLayout
title={`Hallo ${user?.name ?? 'Tenant-Admin'}!`}
subtitle="Behalte deine Events, Credits und Aufgaben im Blick."
actions={actions}
>
{error && (
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={actions}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
@@ -158,24 +162,24 @@ 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" />
Starte mit der Welcome Journey
{t('dashboard.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.
Lerne die Storytelling-Elemente kennen, wähle dein Paket und erstelle dein erstes Event mit
geführten Schritten.
</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>Wir begleiten dich durch Pakete, Aufgaben und Galerie-Konfiguration, damit dein Event glänzt.</p>
<p>Du kannst jederzeit zur Welcome Journey zurückkehren, auch wenn bereits Events laufen.</p>
<p>{t('dashboard.welcomeCard.body1')}</p>
<p>{t('dashboard.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)}
>
Jetzt starten
{t('dashboard.welcomeCard.cta')}
</Button>
</CardContent>
</Card>
@@ -186,37 +190,37 @@ 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" />
Kurzer Ueberblick
{t('dashboard.overview.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Wichtigste Kennzahlen deines Tenants auf einen Blick.
{t('dashboard.overview.description')}
</CardDescription>
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{activePackage?.package_name ?? 'Kein aktives Package'}
{activePackage?.package_name ?? t('dashboard.overview.noPackage')}
</Badge>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Aktive Events"
label={t('dashboard.overview.stats.activeEvents')}
value={summary?.active_events ?? publishedEvents.length}
hint={`${publishedEvents.length} veroeffentlicht`}
hint={t('dashboard.overview.stats.publishedHint', { count: publishedEvents.length })}
icon={<CalendarDays className="h-5 w-5 text-brand-rose" />}
/>
<StatCard
label="Neue Fotos (7 Tage)"
label={t('dashboard.overview.stats.newPhotos')}
value={summary?.new_photos ?? 0}
icon={<Camera className="h-5 w-5 text-fuchsia-500" />}
/>
<StatCard
label="Task-Fortschritt"
label={t('dashboard.overview.stats.taskProgress')}
value={`${Math.round(summary?.task_progress ?? 0)}%`}
icon={<Users className="h-5 w-5 text-amber-500" />}
/>
<StatCard
label="Credits"
label={t('dashboard.overview.stats.credits')}
value={credits}
hint={credits <= 1 ? 'Auffuellen empfohlen' : undefined}
hint={credits <= 1 ? t('dashboard.overview.stats.lowCredits') : undefined}
icon={<CreditCard className="h-5 w-5 text-sky-500" />}
/>
</CardContent>
@@ -225,35 +229,35 @@ 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">Schnellaktionen</CardTitle>
<CardTitle className="text-xl text-slate-900">{t('dashboard.quickActions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
Starte durch mit den wichtigsten Aktionen.
{t('dashboard.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="Event erstellen"
description="Plane dein naechstes Highlight."
label={t('dashboard.quickActions.createEvent.label')}
description={t('dashboard.quickActions.createEvent.description')}
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
/>
<QuickAction
icon={<Camera className="h-5 w-5" />}
label="Fotos moderieren"
description="Pruefe neue Uploads."
label={t('dashboard.quickActions.moderatePhotos.label')}
description={t('dashboard.quickActions.moderatePhotos.description')}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
/>
<QuickAction
icon={<Users className="h-5 w-5" />}
label="Tasks organisieren"
description="Sorge fuer klare Verantwortungen."
label={t('dashboard.quickActions.organiseTasks.label')}
description={t('dashboard.quickActions.organiseTasks.description')}
onClick={() => navigate(ADMIN_TASKS_PATH)}
/>
<QuickAction
icon={<CreditCard className="h-5 w-5" />}
label="Credits verwalten"
description="Sieh dir Balance & Ledger an."
label={t('dashboard.quickActions.manageCredits.label')}
description={t('dashboard.quickActions.manageCredits.description')}
onClick={() => navigate(ADMIN_BILLING_PATH)}
/>
</CardContent>
@@ -262,21 +266,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">Kommende Events</CardTitle>
<CardTitle className="text-xl text-slate-900">{t('dashboard.upcoming.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">
Die naechsten Termine inklusive Status & Zugriff.
{t('dashboard.upcoming.description')}
</CardDescription>
</div>
<Button variant="outline" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
Einstellungen oeffnen
{t('dashboard.upcoming.settings')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message="Noch keine Termine geplant. Lege dein erstes Event an!"
ctaLabel="Event planen"
message={t('dashboard.upcoming.empty.message')}
ctaLabel={t('dashboard.upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
) : (
@@ -285,6 +289,13 @@ export default function DashboardPage() {
key={event.id}
event={event}
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'),
}}
/>
))
)}
@@ -383,23 +394,44 @@ function QuickAction({
);
}
function UpcomingEventRow({ event, onView }: { event: TenantEvent; onView: () => void }) {
function UpcomingEventRow({
event,
onView,
locale,
labels,
}: {
event: TenantEvent;
onView: () => void;
locale: string;
labels: {
live: string;
planning: string;
open: string;
noDate: string;
};
}) {
const date = event.event_date ? new Date(event.event_date) : null;
const formattedDate = date
? date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
: 'Kein Datum';
? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
: labels.noDate;
return (
<div className="flex flex-col gap-2 rounded-2xl border border-sky-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
{event.status === 'published' ? 'Live' : 'In Planung'}
</Badge>
<Button size="sm" variant="outline" onClick={onView} className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40">
Oeffnen
</Button>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
<div className="flex items-center gap-2">
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
{event.status === 'published' ? labels.live : labels.planning}
</Badge>
<Button
size="sm"
variant="outline"
onClick={onView}
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
{labels.open}
</Button>
</div>
</div>
</div>
);

View File

@@ -7,7 +7,17 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import { createInviteLink, getEvent, getEventStats, TenantEvent, EventStats as TenantEventStats, toggleEvent } from '../api';
import {
createInviteLink,
EventJoinToken,
EventStats as TenantEventStats,
getEvent,
getEventJoinTokens,
getEventStats,
TenantEvent,
toggleEvent,
revokeEventJoinToken,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
@@ -20,6 +30,7 @@ import {
interface State {
event: TenantEvent | null;
stats: TenantEventStats | null;
tokens: EventJoinToken[];
inviteLink: string | null;
error: string | null;
loading: boolean;
@@ -35,11 +46,14 @@ export default function EventDetailPage() {
const [state, setState] = React.useState<State>({
event: null,
stats: null,
tokens: [],
inviteLink: null,
error: null,
loading: true,
busy: false,
});
const [creatingToken, setCreatingToken] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
if (!slug) {
@@ -49,17 +63,22 @@ export default function EventDetailPage() {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
const [eventData, statsData, joinTokens] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventJoinTokens(slug),
]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
tokens: joinTokens,
loading: false,
inviteLink: prev.inviteLink,
}));
} catch (err) {
if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] }));
}
}, [slug]);
@@ -88,26 +107,58 @@ export default function EventDetailPage() {
}
async function handleInvite() {
if (!slug) return;
setState((prev) => ({ ...prev, busy: true, error: null }));
if (!slug || creatingToken) return;
setCreatingToken(true);
setState((prev) => ({ ...prev, error: null }));
try {
const { link } = await createInviteLink(slug);
setState((prev) => ({ ...prev, inviteLink: link, busy: false }));
const token = await createInviteLink(slug);
setState((prev) => ({
...prev,
inviteLink: token.url,
tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)],
}));
try {
await navigator.clipboard.writeText(link);
await navigator.clipboard.writeText(token.url);
} catch {
// clipboard may be unavailable, ignore silently
}
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.', busy: false }));
} else {
setState((prev) => ({ ...prev, busy: false }));
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' }));
}
}
setCreatingToken(false);
}
async function handleCopy(token: EventJoinToken) {
try {
await navigator.clipboard.writeText(token.url);
setState((prev) => ({ ...prev, inviteLink: token.url }));
} catch (err) {
console.warn('Clipboard copy failed', err);
}
}
const { event, stats, inviteLink, error, loading, busy } = state;
async function handleRevoke(token: EventJoinToken) {
if (!slug || token.revoked_at) return;
setRevokingId(token.id);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await revokeEventJoinToken(slug, token.id);
setState((prev) => ({
...prev,
tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)),
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Token konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
}
}
const { event, stats, tokens, inviteLink, error, loading, busy } = state;
const actions = (
<>
@@ -219,15 +270,32 @@ export default function EventDetailPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<Button onClick={handleInvite} disabled={busy} className="w-full">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladungslink kopieren
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladungslink erzeugen
</Button>
{inviteLink && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
</p>
)}
<div className="space-y-3">
{tokens.length > 0 ? (
tokens.map((token) => (
<JoinTokenRow
key={token.id}
token={token}
onCopy={() => handleCopy(token)}
onRevoke={() => handleRevoke(token)}
revoking={revokingId === token.id}
/>
))
) : (
<p className="text-xs text-slate-500">
Noch keine Einladungen erstellt. Nutze den Button, um einen neuen QR-Link zu generieren.
</p>
)}
</div>
</CardContent>
</Card>
@@ -286,6 +354,63 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
);
}
function JoinTokenRow({
token,
onCopy,
onRevoke,
revoking,
}: {
token: EventJoinToken;
onCopy: () => void;
onRevoke: () => void;
revoking: boolean;
}) {
const status = getTokenStatus(token);
return (
<div className="flex flex-col gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-2">
<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>
</div>
<div className="flex gap-2">
<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>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
@@ -295,6 +420,32 @@ function formatDate(iso: string | null): string {
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function formatDateTime(iso: string | null): string {
if (!iso) return 'unbekannt';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'unbekannt';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (token.revoked_at) return 'Deaktiviert';
if (token.expires_at) {
const expiry = new Date(token.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen';
}
}
return token.is_active ? 'Aktiv' : 'Deaktiviert';
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Mail, Sparkles, Trash2, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -35,6 +36,11 @@ const emptyInvite: InviteForm = {
};
export default function EventMembersPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
() => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'),
[i18n.language]
);
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
@@ -48,9 +54,52 @@ export default function EventMembersPage() {
const [inviting, setInviting] = React.useState(false);
const [membersUnavailable, setMembersUnavailable] = React.useState(false);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
},
[locale]
);
const roleLabels = React.useMemo(
() => ({
tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'),
member: t('management.members.roles.member', 'Mitglied'),
guest: t('management.members.roles.guest', 'Gast'),
}),
[t]
);
const statusLabels = React.useMemo(
() => ({
published: t('management.members.statuses.published', 'Veröffentlicht'),
draft: t('management.members.statuses.draft', 'Entwurf'),
active: t('management.members.statuses.active', 'Aktiv'),
}),
[t]
);
const resolveRole = React.useCallback(
(role: string) => roleLabels[role as keyof typeof roleLabels] ?? role,
[roleLabels]
);
const resolveMemberStatus = React.useCallback(
(status?: string | null) => {
if (!status) {
return statusLabels.active;
}
return statusLabels[status as keyof typeof statusLabels] ?? status;
},
[statusLabels]
);
React.useEffect(() => {
if (!slug) {
setError('Kein Event-Slug angegeben.');
setError(t('management.members.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
return;
}
@@ -71,7 +120,7 @@ export default function EventMembersPage() {
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
setMembersUnavailable(true);
} else if (!isAuthError(err)) {
setError('Mitglieder konnten nicht geladen werden.');
setError(t('management.members.errors.load', 'Mitglieder konnten nicht geladen werden.'));
}
} finally {
if (!cancelled) {
@@ -83,13 +132,13 @@ export default function EventMembersPage() {
return () => {
cancelled = true;
};
}, [slug]);
}, [slug, t]);
async function handleInvite(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!slug) return;
if (!invite.email.trim()) {
setError('Bitte gib eine E-Mail-Adresse ein.');
setError(t('management.members.errors.emailRequired', 'Bitte gib eine E-Mail-Adresse ein.'));
return;
}
setInviting(true);
@@ -106,7 +155,7 @@ export default function EventMembersPage() {
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
setMembersUnavailable(true);
} else if (!isAuthError(err)) {
setError('Einladung konnte nicht verschickt werden.');
setError(t('management.members.errors.invite', 'Einladung konnte nicht verschickt werden.'));
}
} finally {
setInviting(false);
@@ -121,7 +170,7 @@ export default function EventMembersPage() {
setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
} catch (err) {
if (!isAuthError(err)) {
setError('Mitglied konnte nicht entfernt werden.');
setError(t('management.members.errors.remove', 'Mitglied konnte nicht entfernt werden.'));
}
}
}
@@ -129,19 +178,19 @@ export default function EventMembersPage() {
const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" />
Zurueck zur Uebersicht
{t('management.members.actions.back', 'Zurück zur Übersicht')}
</Button>
);
return (
<AdminLayout
title="Event Mitglieder"
subtitle="Verwalte Moderatoren, Admins und Helfer fuer dieses Event."
title={t('management.members.title', 'Event-Mitglieder')}
subtitle={t('management.members.subtitle', 'Verwalte Moderatoren, Admins und Helfer für dieses Event.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -150,34 +199,38 @@ export default function EventMembersPage() {
<MembersSkeleton />
) : !event ? (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
<AlertTitle>{t('management.members.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('management.members.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
</Alert>
) : (
<>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
{t('management.members.eventStatus', {
status: event.status === 'published' ? statusLabels.published : statusLabels.draft,
})}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" />
Mitglieder
{t('management.members.sections.list.title', 'Mitglieder')}
</h3>
{membersUnavailable ? (
<Alert>
<AlertTitle>Feature noch nicht aktiviert</AlertTitle>
<AlertTitle>{t('management.members.alerts.lockedTitle', 'Feature noch nicht aktiviert')}</AlertTitle>
<AlertDescription>
Die Mitgliederverwaltung ist fuer dieses Event noch nicht verfuegbar. Bitte kontaktiere den Support,
um das Feature freizuschalten.
{t(
'management.members.alerts.lockedDescription',
'Die Mitgliederverwaltung ist für dieses Event noch nicht verfügbar. Bitte kontaktiere den Support, um das Feature freizuschalten.'
)}
</AlertDescription>
</Alert>
) : members.length === 0 ? (
<EmptyState message="Noch keine Mitglieder eingeladen." />
<EmptyState message={t('management.members.sections.list.empty', 'Noch keine Mitglieder eingeladen.')} />
) : (
<div className="space-y-2">
{members.map((member) => (
@@ -189,13 +242,23 @@ export default function EventMembersPage() {
<p className="text-sm font-semibold text-slate-900">{member.name}</p>
<p className="text-xs text-slate-600">{member.email}</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span>Status: {member.status ?? 'aktiv'}</span>
{member.joined_at && <span>Beigetreten: {formatDate(member.joined_at)}</span>}
<span>
{t('management.members.labels.status', {
status: resolveMemberStatus(member.status),
})}
</span>
{member.joined_at && (
<span>
{t('management.members.labels.joined', {
date: formatDate(member.joined_at),
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapRole(member.role)}
{resolveRole(member.role)}
</Badge>
<Button variant="outline" size="sm" onClick={() => void handleRemove(member)}>
<Trash2 className="h-4 w-4" />
@@ -210,48 +273,48 @@ export default function EventMembersPage() {
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Users className="h-4 w-4 text-emerald-500" />
Neues Mitglied einladen
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
</h3>
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
<div className="space-y-2">
<Label htmlFor="invite-email">E-Mail</Label>
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
<Input
id="invite-email"
type="email"
placeholder="person@example.com"
placeholder={t('management.members.form.emailPlaceholder', 'person@example.com')}
value={invite.email}
onChange={(event) => setInvite((prev) => ({ ...prev, email: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Label htmlFor="invite-name">{t('management.members.form.nameLabel', 'Name (optional)')}</Label>
<Input
id="invite-name"
placeholder="Name"
placeholder={t('management.members.form.namePlaceholder', 'Name')}
value={invite.name}
onChange={(event) => setInvite((prev) => ({ ...prev, name: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Rolle</Label>
<Label htmlFor="invite-role">{t('management.members.form.roleLabel', 'Rolle')}</Label>
<Select
value={invite.role}
onValueChange={(value) => setInvite((prev) => ({ ...prev, role: value }))}
>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Rolle waehlen" />
<SelectValue placeholder={t('management.members.form.rolePlaceholder', 'Rolle wählen')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant_admin">Tenant-Admin</SelectItem>
<SelectItem value="member">Mitglied</SelectItem>
<SelectItem value="guest">Gast</SelectItem>
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
<SelectItem value="member">{roleLabels.member}</SelectItem>
<SelectItem value="guest">{roleLabels.guest}</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" className="w-full" disabled={inviting}>
{inviting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
{' '}Einladung senden
{' '}{t('management.members.form.submit', 'Einladung senden')}
</Button>
</form>
</section>
@@ -281,29 +344,9 @@ function MembersSkeleton() {
);
}
function renderName(name: TenantEvent['name']): string {
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
}
function mapRole(role: string): string {
switch (role) {
case 'tenant_admin':
return 'Tenant-Admin';
case 'member':
return 'Mitglied';
case 'guest':
return 'Gast';
default:
return role;
}
}
function formatDate(value: string | null | undefined): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, PlusCircle, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -21,6 +22,7 @@ import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH } from '../constants';
export default function EventTasksPage() {
const { t } = useTranslation(['management', 'dashboard']);
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
@@ -34,9 +36,17 @@ export default function EventTasksPage() {
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const statusLabels = React.useMemo(
() => ({
published: t('management.members.statuses.published', 'Veröffentlicht'),
draft: t('management.members.statuses.draft', 'Entwurf'),
}),
[t]
);
React.useEffect(() => {
if (!slug) {
setError('Kein Event-Slug angegeben.');
setError(t('management.tasks.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
return;
}
@@ -58,7 +68,7 @@ export default function EventTasksPage() {
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError('Event-Tasks konnten nicht geladen werden.');
setError(t('management.tasks.errors.load', 'Event-Tasks konnten nicht geladen werden.'));
}
} finally {
if (!cancelled) {
@@ -70,7 +80,7 @@ export default function EventTasksPage() {
return () => {
cancelled = true;
};
}, [slug]);
}, [slug, t]);
async function handleAssign() {
if (!event || selected.length === 0) return;
@@ -84,7 +94,7 @@ export default function EventTasksPage() {
setSelected([]);
} catch (err) {
if (!isAuthError(err)) {
setError('Tasks konnten nicht zugewiesen werden.');
setError(t('management.tasks.errors.assign', 'Tasks konnten nicht zugewiesen werden.'));
}
} finally {
setSaving(false);
@@ -94,19 +104,19 @@ export default function EventTasksPage() {
const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" />
Zurueck zur Uebersicht
{t('management.tasks.actions.back', 'Zurück zur Übersicht')}
</Button>
);
return (
<AdminLayout
title="Event Tasks"
subtitle="Verwalte Aufgaben, die diesem Event zugeordnet sind."
title={t('management.tasks.title', 'Event-Tasks')}
subtitle={t('management.tasks.subtitle', 'Verwalte Aufgaben, die diesem Event zugeordnet sind.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Hinweis</AlertTitle>
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -115,26 +125,28 @@ export default function EventTasksPage() {
<TaskSkeleton />
) : !event ? (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte kehre zur Eventliste zurueck.</AlertDescription>
<AlertTitle>{t('management.tasks.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('management.tasks.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
</Alert>
) : (
<>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name)}</CardTitle>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
Status: {event.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
{t('management.tasks.eventStatus', {
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
})}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2">
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" />
Zugeordnete Tasks
{t('management.tasks.sections.assigned.title', 'Zugeordnete Tasks')}
</h3>
{assignedTasks.length === 0 ? (
<EmptyState message="Noch keine Tasks zugewiesen." />
<EmptyState message={t('management.tasks.sections.assigned.empty', 'Noch keine Tasks zugewiesen.')} />
) : (
<div className="space-y-2">
{assignedTasks.map((task) => (
@@ -142,7 +154,7 @@ export default function EventTasksPage() {
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-900">{task.title}</p>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority)}
{mapPriority(task.priority, t)}
</Badge>
</div>
{task.description && <p className="mt-1 text-xs text-slate-600">{task.description}</p>}
@@ -155,11 +167,11 @@ export default function EventTasksPage() {
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<PlusCircle className="h-4 w-4 text-emerald-500" />
Tasks aus Bibliothek hinzufuegen
{t('management.tasks.sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
</h3>
<div className="space-y-2 rounded-2xl border border-emerald-100 bg-white/90 p-3 shadow-sm max-h-72 overflow-y-auto">
{availableTasks.length === 0 ? (
<EmptyState message="Keine Tasks in der Bibliothek gefunden." />
<EmptyState message={t('management.tasks.sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : (
availableTasks.map((task) => (
<label key={task.id} className="flex items-start gap-3 rounded-xl border border-transparent p-2 transition hover:border-emerald-200">
@@ -180,7 +192,7 @@ export default function EventTasksPage() {
)}
</div>
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ausgewaehlte Tasks zuweisen'}
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
</Button>
</section>
</CardContent>
@@ -209,22 +221,22 @@ function TaskSkeleton() {
);
}
function mapPriority(priority: TenantTask['priority']): string {
function mapPriority(priority: TenantTask['priority'], translate: (key: string, defaultValue: string) => string): string {
switch (priority) {
case 'low':
return 'Niedrig';
return translate('management.tasks.priorities.low', 'Niedrig');
case 'high':
return 'Hoch';
return translate('management.tasks.priorities.high', 'Hoch');
case 'urgent':
return 'Dringend';
return translate('management.tasks.priorities.urgent', 'Dringend');
default:
return 'Mittel';
return translate('management.tasks.priorities.medium', 'Mittel');
}
}
function renderName(name: TenantEvent['name']): string {
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? 'Unbenanntes Event';
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
}