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

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