überarbeitung des event-admins fortgesetzt

This commit is contained in:
Codex Agent
2025-11-25 13:03:42 +01:00
parent fd788ef770
commit 596dcbf18a
20 changed files with 998 additions and 2210 deletions

View File

@@ -169,15 +169,15 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<div className="space-y-1">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-start justify-between gap-3 px-4 py-4 sm:flex-nowrap sm:items-center sm:px-6">
<div className="min-w-0 space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">{title}</h1>
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
{disableCommandShelf ? <EventSwitcher compact /> : null}
{actions}
<NotificationCenter />

View File

@@ -31,7 +31,6 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
} from '../constants';
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
@@ -192,11 +191,11 @@ export function CommandShelf() {
href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug),
},
{
key: 'toolkit',
label: t('commandShelf.actions.toolkit.label', 'Event-Day Toolkit'),
key: 'members',
label: t('eventMenu.guests', 'Team & Gäste'),
description: t('commandShelf.actions.toolkit.desc', 'Broadcasts, Aufgaben & Quicklinks.'),
icon: MessageSquare,
href: ADMIN_EVENT_TOOLKIT_PATH(slug),
href: ADMIN_EVENT_MEMBERS_PATH(slug),
},
];
@@ -275,7 +274,7 @@ export function CommandShelf() {
size="sm"
variant="ghost"
className="rounded-full text-rose-600 hover:bg-rose-50 dark:text-rose-200 dark:hover:bg-rose-200/10"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
>
<Sparkles className="mr-2 h-4 w-4" />
{t('commandShelf.cta.toolkit', 'Event-Day öffnen')}

View File

@@ -22,7 +22,6 @@ import {
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
} from '../constants';
@@ -37,7 +36,6 @@ function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'toolkit', label: t('eventMenu.toolkit', 'Toolkit'), href: ADMIN_EVENT_TOOLKIT_PATH(slug) },
];
}

View File

@@ -29,7 +29,6 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`);
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`);

View File

@@ -99,5 +99,133 @@
"hint": "Bitte versuche es erneut oder lade die Seite neu.",
"retry": "Erneut laden"
}
},
"welcome": {
"eyebrow": "Event Admin",
"title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.",
"subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.",
"badge": "Fotos, Aufgaben & Einladungen an einem Ort",
"loginPrompt": "Bereits Kunde? Login oben rechts.",
"cta": {
"login": "Login",
"open": "Event Admin öffnen",
"packages": "Pakete ansehen",
"how": "So funktioniert's"
},
"stats": {
"events": "Events begleitet",
"photos": "Fotos moderiert",
"teams": "Teams"
},
"features": {
"title": "Was du steuern kannst",
"subtitle": "Alles an einem Ort",
"branding": {
"title": "Branding & Layout",
"description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss."
},
"tasks": {
"title": "Aufgaben & Emotion-Sets",
"description": "Sammlungen importieren oder eigene Aufgaben erstellen mobil abhakbar."
},
"moderation": {
"title": "Foto-Moderation",
"description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen."
},
"invites": {
"title": "Einladungen & QR",
"description": "Links und Druckvorlagen generieren mit Paketlimits im Blick."
}
},
"steps": {
"title": "So funktioniert's",
"subtitle": "In drei Schritten bereit",
"prepare": {
"title": "Vorbereiten",
"description": "Event anlegen, Branding setzen, Aufgaben aktivieren.",
"accent": "Setup"
},
"share": {
"title": "Teilen & Einladen",
"description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.",
"accent": "Share"
},
"run": {
"title": "Live moderieren",
"description": "Uploads prüfen, Highlights pushen und nach dem Event die Galerie teilen.",
"accent": "Live"
}
},
"plans": {
"title": "Pakete im Überblick",
"subtitle": "Wähle das passende Kontingent",
"hint": "Starter, Standard oder Reseller alles mit Moderation & Einladungen.",
"starter": {
"title": "Starter",
"badge": "Für ein Event",
"p1": "1 Event, Basis-Branding",
"p2": "Aufgaben & Einladungen inklusive",
"p3": "Moderation & Galerie-Link"
},
"standard": {
"title": "Standard",
"badge": "Beliebt",
"highlight": "Mehr Kontingent & Branding",
"p1": "Mehr Events pro Jahr",
"p2": "Erweitertes Branding & Layouts",
"p3": "Support bei Live-Events"
},
"reseller": {
"title": "Reseller S",
"badge": "Für Dienstleister",
"highlight": "Mehrere Events parallel verwalten",
"p1": "Bis zu 5 Events pro Paket",
"p2": "Aufgaben-Sammlungen und Vorlagen",
"p3": "Teamrollen & Rechteverwaltung"
}
},
"audience": {
"title": "Für wen?",
"subtitle": "Endkunden & Reseller im Blick",
"endcustomers": {
"title": "Endkund:innen",
"description": "Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen."
},
"resellers": {
"title": "Reseller & Agenturen",
"description": "Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen."
},
"cta": "Wenige Klicks bis zum Start"
},
"preview": {
"title": "Was dich erwartet",
"items": [
"Moderation, Aufgaben und Einladungen als Schnellzugriff",
"Sticky Actions auf Mobile für den Eventtag",
"Paket-Status & Limits jederzeit sichtbar"
]
},
"highlight": {
"moderation": "Live-Moderation",
"moderationHint": "Approve/Hide, Highlights, Galerie-Link",
"tasks": "Aufgaben & Emotion-Sets",
"tasksHint": "Sammlungen importieren oder eigene erstellen"
},
"theme": {
"label": "Darstellung",
"aria": "Darstellung umschalten",
"light": "Hell",
"dark": "Dunkel"
},
"menu": {
"open": "Menü öffnen",
"close": "Menü schließen",
"title": "Navigation"
},
"footer": {
"eyebrow": "Bereit?",
"title": "Melde dich an oder prüfe die Pakete",
"subtitle": "Login für bestehende Kunden, Pakete für neue Teams."
}
}
}

View File

@@ -99,5 +99,133 @@
"hint": "Please try again or refresh the page.",
"retry": "Retry"
}
},
"welcome": {
"eyebrow": "Event Admin",
"title": "Event branding, tasks & photo moderation in one app.",
"subtitle": "Prepare your event, share invites, moderate uploads live and release the gallery afterwards.",
"badge": "Photos, tasks & invites in one place",
"loginPrompt": "Already a customer? Login in the top right.",
"cta": {
"login": "Login",
"open": "Open Event Admin",
"packages": "View packages",
"how": "How it works"
},
"stats": {
"events": "Events supported",
"photos": "Photos moderated",
"teams": "Teams"
},
"features": {
"title": "What you control",
"subtitle": "Everything in one place",
"branding": {
"title": "Branding & Layout",
"description": "Colors, typography, QR layouts and invites in one flow."
},
"tasks": {
"title": "Tasks & Emotion sets",
"description": "Import collections or create your own tasks mobile checklists included."
},
"moderation": {
"title": "Photo moderation",
"description": "Review uploads instantly, mark highlights and share the gallery link."
},
"invites": {
"title": "Invites & QR",
"description": "Generate links and print templates with package limits in sight."
}
},
"steps": {
"title": "How it works",
"subtitle": "Ready in three steps",
"prepare": {
"title": "Prepare",
"description": "Create the event, set branding, enable tasks.",
"accent": "Setup"
},
"share": {
"title": "Share & Invite",
"description": "Distribute QR/links, pick missions, onboard your team.",
"accent": "Share"
},
"run": {
"title": "Run live",
"description": "Review uploads, push highlights and share the gallery afterwards.",
"accent": "Live"
}
},
"plans": {
"title": "Packages at a glance",
"subtitle": "Choose the right quota",
"hint": "Starter, Standard or Reseller all include moderation & invites.",
"starter": {
"title": "Starter",
"badge": "For one event",
"p1": "1 event, basic branding",
"p2": "Tasks & invites included",
"p3": "Moderation & gallery link"
},
"standard": {
"title": "Standard",
"badge": "Popular",
"highlight": "More quota & branding",
"p1": "More events per year",
"p2": "Extended branding & layouts",
"p3": "Support on live days"
},
"reseller": {
"title": "Reseller S",
"badge": "For pros",
"highlight": "Manage multiple events",
"p1": "Up to 5 events per package",
"p2": "Task collections and templates",
"p3": "Team roles & permissions"
}
},
"audience": {
"title": "Who is it for?",
"subtitle": "Built for hosts and resellers",
"endcustomers": {
"title": "Event hosts",
"description": "Set up fast, moderate on mobile and share the gallery afterwards."
},
"resellers": {
"title": "Resellers & agencies",
"description": "Track multiple events, monitor quotas and reuse templates."
},
"cta": "Just a few clicks to go live"
},
"preview": {
"title": "What to expect",
"items": [
"Quick access to moderation, tasks and invites",
"Sticky actions on mobile for the event day",
"Package status & limits always visible"
]
},
"highlight": {
"moderation": "Live moderation",
"moderationHint": "Approve/Hide, highlights, gallery link",
"tasks": "Tasks & emotion sets",
"tasksHint": "Import collections or create your own"
},
"theme": {
"label": "Appearance",
"aria": "Toggle appearance",
"light": "Light",
"dark": "Dark"
},
"menu": {
"open": "Open menu",
"close": "Close menu",
"title": "Navigation"
},
"footer": {
"eyebrow": "Ready?",
"title": "Log in or view packages",
"subtitle": "Login for existing customers, packages for new teams."
}
}
}

View File

@@ -1,117 +0,0 @@
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";
import { FrostedSurface } from "../../components/tenant";
export default function WelcomeEventSetupPage() {
const navigate = useNavigate();
const { markStep } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ lastStep: "event-setup" });
}, [markStep]);
return (
<TenantWelcomeLayout
eyebrow={t("eventSetup.layout.eyebrow")}
title={t("eventSetup.layout.title")}
subtitle={t("eventSetup.layout.subtitle")}
>
<WelcomeStepCard
step={4}
totalSteps={4}
title={t("eventSetup.step.title")}
description={t("eventSetup.step.description")}
icon={ClipboardCheck}
>
<div className="grid gap-4 md:grid-cols-3">
{[
{
id: "story",
title: t("eventSetup.tiles.story.title"),
copy: t("eventSetup.tiles.story.copy"),
icon: Sparkles,
},
{
id: "team",
title: t("eventSetup.tiles.team.title"),
copy: t("eventSetup.tiles.team.copy"),
icon: Globe,
},
{
id: "launch",
title: t("eventSetup.tiles.launch.title"),
copy: t("eventSetup.tiles.launch.copy"),
icon: ArrowRight,
},
].map((item) => (
<FrostedSurface
key={item.id}
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<item.icon className="size-5" />
</span>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.copy}</p>
</FrostedSurface>
))}
</div>
<FrostedSurface className="mt-6 flex flex-col items-start gap-3 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-rose-400 dark:text-rose-200">{t("eventSetup.cta.heading")}</h4>
<p className="text-sm text-slate-600 dark:text-slate-400">{t("eventSetup.cta.description")}</p>
<Button
size="lg"
className="mt-2 rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => {
markStep({ lastStep: "event-create-intent" });
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
{t("eventSetup.cta.button")}
<ArrowRight className="ml-2 size-4" />
</Button>
</FrostedSurface>
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
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",
},
{
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: 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,172 +0,0 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sparkles, Users, Camera, CalendarDays, ChevronRight, CreditCard, UserPlus, Palette } from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeHero,
OnboardingHighlightsGrid,
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { ADMIN_WELCOME_PACKAGES_PATH, ADMIN_EVENTS_PATH } from "../../constants";
import { ChecklistRow, FrostedSurface } from "../../components/tenant";
export default function WelcomeLandingPage() {
const navigate = useNavigate();
const { markStep, progress } = useOnboardingProgress();
const { t } = useTranslation("onboarding");
React.useEffect(() => {
markStep({ welcomeSeen: true, lastStep: "landing" });
}, [markStep]);
const progressStatus = React.useMemo(
() => ({
complete: t("landingProgress.status.complete"),
pending: t("landingProgress.status.pending"),
}),
[t]
);
const progressSteps = React.useMemo(
() => [
{
key: "package",
icon: <CreditCard className="h-5 w-5" />,
label: t("landingProgress.steps.package.title"),
hint: t("landingProgress.steps.package.hint"),
completed: progress.packageSelected,
},
{
key: "invite",
icon: <UserPlus className="h-5 w-5" />,
label: t("landingProgress.steps.invite.title"),
hint: t("landingProgress.steps.invite.hint"),
completed: progress.inviteCreated,
},
{
key: "branding",
icon: <Palette className="h-5 w-5" />,
label: t("landingProgress.steps.branding.title"),
hint: t("landingProgress.steps.branding.hint"),
completed: progress.brandingConfigured,
},
],
[progress.packageSelected, progress.inviteCreated, progress.brandingConfigured, t]
);
return (
<TenantWelcomeLayout
eyebrow={t("layout.eyebrow")}
title={t("layout.title")}
subtitle={t("layout.subtitle")}
footer={
<>
<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)}
>
{t("layout.jumpToDashboard")}
<ChevronRight className="size-4" />
</button>
</>
}
>
<WelcomeHero
eyebrow={t("hero.eyebrow")}
title={t("hero.title")}
scriptTitle={t("hero.scriptTitle")}
description={t("hero.description")}
actions={[
{
label: t("hero.primary.label"),
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
},
{
label: t("hero.secondary.label"),
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
variant: "outline",
},
]}
/>
<FrostedSurface className="space-y-4 rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">
{t("landingProgress.eyebrow")}
</p>
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
{t("landingProgress.title")}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t("landingProgress.description")}
</p>
</div>
<div className="grid gap-3">
{progressSteps.map((step) => (
<ChecklistRow
key={step.key}
icon={step.icon}
label={step.label}
hint={step.hint}
completed={step.completed}
status={progressStatus}
/>
))}
</div>
</FrostedSurface>
<OnboardingHighlightsGrid
items={[
{
id: "gallery",
icon: Camera,
title: t("highlights.gallery.title"),
description: t("highlights.gallery.description"),
badge: t("highlights.gallery.badge"),
},
{
id: "team",
icon: Users,
title: t("highlights.team.title"),
description: t("highlights.team.description"),
},
{
id: "story",
icon: Sparkles,
title: t("highlights.story.title"),
description: t("highlights.story.description"),
},
]}
/>
<OnboardingCTAList
actions={[
{
id: "choose-package",
label: t("ctaList.choosePackage.label"),
description: t("ctaList.choosePackage.description"),
onClick: () => navigate(ADMIN_WELCOME_PACKAGES_PATH),
icon: Sparkles,
buttonLabel: t("ctaList.choosePackage.button"),
},
{
id: "create-event",
label: t("ctaList.createEvent.label"),
description: t("ctaList.createEvent.description"),
onClick: () => navigate(ADMIN_EVENTS_PATH),
icon: CalendarDays,
buttonLabel: t("ctaList.createEvent.button"),
variant: "secondary",
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -1,511 +0,0 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Receipt,
ShieldCheck,
ArrowRight,
ArrowLeft,
CreditCard,
AlertTriangle,
Info,
Loader2,
} from "lucide-react";
import {
TenantWelcomeLayout,
WelcomeStepCard,
OnboardingCTAList,
useOnboardingProgress,
} from "..";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { FrostedSurface } from "../../components/tenant";
import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH } from "../../constants";
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
createTenantPaddleCheckout,
} from "../../api";
import { cn } from "@/lib/utils";
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
className?: 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 PaddleCheckout({ packageId, onSuccess, t, className }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
const handleCheckout = React.useCallback(async () => {
try {
setStatus('processing');
setError(null);
const { checkout_url } = await createTenantPaddleCheckout(packageId);
window.open(checkout_url, '_blank', 'noopener');
setStatus('success');
onSuccess();
} catch (err) {
console.error('[Onboarding] Paddle checkout failed', err);
setStatus('error');
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
}
}, [packageId, onSuccess, t]);
return (
<FrostedSurface
className={cn(
"space-y-4 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100",
className
)}
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-400 dark:text-rose-200">
{t('summary.paddle.sectionTitle')}
</p>
<p className="text-base font-semibold text-slate-900 dark:text-slate-100">
{t('summary.paddle.heading')}
</p>
</div>
{error ? (
<div
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">{t('summary.paddle.errorTitle')}</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">{error}</p>
</div>
</div>
) : null}
<Button
size="lg"
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
disabled={status === 'processing'}
onClick={handleCheckout}
>
{status === 'processing' ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t('summary.paddle.processing')}
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
{t('summary.paddle.cta')}
</>
)}
</Button>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('summary.paddle.hint')}</p>
</FrostedSurface>
);
}
export default function WelcomeOrderSummaryPage() {
const navigate = useNavigate();
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 } = useLocaleFormats(locale);
const packageIdFromState =
typeof location.state === "object" && location.state !== null && "packageId" in location.state
? (location.state as { packageId?: number | string | null }).packageId
: undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
React.useEffect(() => {
if (!selectedPackageId && packagesState.status !== "loading") {
navigate(ADMIN_WELCOME_PACKAGES_PATH, { replace: true });
}
}, [selectedPackageId, packagesState.status, navigate]);
React.useEffect(() => {
markStep({ lastStep: "summary" });
}, [markStep]);
const packageDetails =
packagesState.status === "success" && selectedPackageId
? packagesState.catalog.find((pkg) => pkg.id === selectedPackageId)
: null;
const activePackage =
packagesState.status === "success"
? packagesState.purchasedPackages.find((pkg) => pkg.package_id === selectedPackageId)
: null;
const isSubscription = Boolean(packageDetails?.features?.subscription);
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
const priceText =
progress.selectedPackage?.priceText ??
(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={t("summary.layout.eyebrow")}
title={t("summary.layout.title")}
subtitle={t("summary.layout.subtitle")}
footer={
<button
type="button"
className="inline-flex items-center gap-2 text-sm font-medium text-brand-rose hover:text-rose-600"
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
>
<ArrowLeft className="size-4" />
{t("summary.footer.back")}
</button>
}
>
<WelcomeStepCard
step={3}
totalSteps={4}
title={t("summary.step.title")}
description={t("summary.step.description")}
icon={Receipt}
>
{packagesState.status === "loading" && (
<FrostedSurface className="flex items-center gap-3 rounded-2xl border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("summary.state.loading")}
</FrostedSurface>
)}
{packagesState.status === "error" && (
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-rose-300/40 bg-rose-100/15 p-6 text-sm text-rose-700 shadow-md shadow-rose-200/30 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<AlertTriangle className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.errorTitle")}
</p>
<p className="text-sm text-rose-600/80 dark:text-rose-200/80">
{packagesState.message ?? t("summary.state.errorDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && !packageDetails && (
<FrostedSurface
role="alert"
className="flex items-start gap-3 rounded-2xl border border-white/20 bg-white/15 p-6 text-sm text-slate-800 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-200"
>
<Info className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.state.missingTitle")}
</p>
<p className="text-sm text-slate-700/90 dark:text-slate-300/80">
{t("summary.state.missingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packagesState.status === "success" && packageDetails && (
<div className="grid gap-4">
<FrostedSurface className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-white shadow-xl shadow-rose-300/25 dark:border-slate-800/70 dark:bg-slate-950/85">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_65%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.3),_transparent_65%)]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950/65 via-slate-900/10 to-transparent mix-blend-overlay" />
<div className="relative z-10 flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-3">
<Badge className="w-fit rounded-full border border-white/40 bg-white/20 px-4 py-1 text-[10px] font-semibold uppercase tracking-[0.4em] text-white shadow-inner shadow-white/20">
{t(isSubscription ? "summary.details.subscription" : "summary.details.creditPack")}
</Badge>
<h3 className="font-display text-2xl font-semibold tracking-tight text-white md:text-3xl">
{packageDetails.name}
</h3>
</div>
<div className="flex flex-col items-start gap-2 text-left sm:items-end sm:text-right">
{priceText ? (
<Badge className="rounded-full border border-white/40 bg-white/15 px-4 py-2 text-base font-semibold text-white shadow-inner shadow-white/20">
{priceText}
</Badge>
) : null}
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-white/70">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</span>
</div>
</div>
<dl className="grid gap-4 text-sm text-white/85 md:grid-cols-2">
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.photosTitle")}
</dt>
<dd>
{packageDetails.max_photos
? 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 className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.guestsTitle")}
</dt>
<dd>
{packageDetails.max_guests
? t("summary.details.section.guestsValue", { count: packageDetails.max_guests })
: t("summary.details.section.guestsUnlimited")}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.featuresTitle")}
</dt>
<dd>
{featuresList.length ? (
<div className="flex flex-wrap gap-2">
{featuresList.map((feature) => (
<Badge
key={feature}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{feature}
</Badge>
))}
</div>
) : (
<span className="text-white/70">{t("summary.details.section.featuresNone")}</span>
)}
</dd>
</div>
<div className="space-y-1">
<dt className="text-xs font-semibold uppercase tracking-wide text-white/70">
{t("summary.details.section.statusTitle")}
</dt>
<dd>
<Badge className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white">
{activePackage
? t("summary.details.section.statusActive")
: t("summary.details.section.statusInactive")}
</Badge>
</dd>
</div>
</dl>
{detailBadges.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detailBadges.map((badge) => (
<Badge
key={badge}
className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-white"
>
{badge}
</Badge>
))}
</div>
) : null}
</div>
</FrostedSurface>
{!activePackage && (
<FrostedSurface
role="status"
className="flex items-start gap-3 rounded-2xl border border-amber-200/40 bg-amber-100/15 p-5 text-sm text-amber-800 shadow-md shadow-amber-200/20 dark:border-amber-300/30 dark:bg-amber-500/10 dark:text-amber-100"
>
<ShieldCheck className="size-4 shrink-0 text-current" />
<div className="space-y-1">
<p className="text-sm font-semibold uppercase tracking-wide">
{t("summary.status.pendingTitle")}
</p>
<p className="text-sm text-amber-700/90 dark:text-amber-100/80">
{t("summary.status.pendingDescription")}
</p>
</div>
</FrostedSurface>
)}
{packageDetails.price === 0 && (
<FrostedSurface className="space-y-3 rounded-2xl border border-emerald-200/40 bg-emerald-100/15 p-6 text-sm text-emerald-900 shadow-md shadow-emerald-200/20 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100">
<p>{t("summary.free.description")}</p>
{freeAssignStatus === "success" ? (
<div className="space-y-1 rounded-xl border border-emerald-200/60 bg-emerald-50/30 p-4 text-sm text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-500/20 dark:text-emerald-50">
<p className="font-semibold">{t("summary.free.successTitle")}</p>
<p>{t("summary.free.successDescription")}</p>
</div>
) : (
<Button
onClick={async () => {
if (!packageDetails) {
return;
}
setFreeAssignStatus("loading");
setFreeAssignError(null);
try {
await assignFreeTenantPackage(packageDetails.id);
setFreeAssignStatus("success");
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
} catch (error) {
console.error("[Onboarding] Free package assignment failed", error);
setFreeAssignStatus("error");
setFreeAssignError(
error instanceof Error ? error.message : t("summary.free.errorMessage")
);
}
}}
disabled={freeAssignStatus === "loading"}
className="mt-2 w-full rounded-full bg-gradient-to-r from-[#34d399] via-[#10b981] to-[#059669] text-white shadow-lg shadow-emerald-300/30 hover:from-[#22c783] hover:via-[#0fa776] hover:to-[#047857]"
>
{freeAssignStatus === "loading" ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("summary.free.progress")}
</>
) : (
t("summary.free.activate")
)}
</Button>
)}
{freeAssignStatus === "error" && freeAssignError ? (
<div
role="alert"
className="rounded-xl border border-rose-300/40 bg-rose-100/15 p-4 text-sm text-rose-700 shadow-inner shadow-rose-200/20 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200"
>
<p className="font-semibold">{t("summary.free.failureTitle")}</p>
<p>{freeAssignError}</p>
</div>
) : null}
</FrostedSurface>
)}
{requiresPayment && (
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
)}
<FrostedSurface className="flex flex-col gap-3 rounded-2xl border border-white/20 p-6 text-slate-900 shadow-inner shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100">
<h4 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{t("summary.nextStepsTitle")}</h4>
<ol className="list-decimal space-y-2 pl-5 text-sm text-slate-600 dark:text-slate-300">
{nextSteps.map((step, index) => (
<li key={`${step}-${index}`}>{step}</li>
))}
</ol>
</FrostedSurface>
</div>
)}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
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",
},
{
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" });
navigate(ADMIN_WELCOME_EVENT_PATH);
},
icon: ArrowRight,
},
]}
/>
</TenantWelcomeLayout>
);
}
export { PaddleCheckout };

View File

@@ -1,283 +0,0 @@
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 { FrostedSurface } from "../../components/tenant/frosted-surface";
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") {
const active = packagesState.activePackage;
markStep({
packageSelected: Boolean(active),
lastStep: "packages",
selectedPackage: active
? {
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,
serverStep: active ? "package_selected" : undefined,
meta: active
? {
packages: [active.package_id],
is_active: active.active,
}
: undefined,
});
}
}, [packagesState, markStep, currencyFormatter, t]);
const handleSelectPackage = React.useCallback(
(pkg: Package) => {
const priceText =
typeof pkg.price === "number" ? currencyFormatter.format(pkg.price) : t("packages.card.onRequest");
markStep({
packageSelected: true,
lastStep: "packages",
selectedPackage: {
id: pkg.id,
name: pkg.name,
priceText,
isSubscription: Boolean(pkg.features?.subscription),
},
serverStep: "package_selected",
meta: { packages: [pkg.id] },
});
navigate(ADMIN_WELCOME_SUMMARY_PATH, { state: { packageId: pkg.id } });
},
[currencyFormatter, markStep, navigate, t]
);
const renderPackageList = () => {
if (packagesState.status === "loading") {
return (
<FrostedSurface className="flex items-center gap-3 border border-white/20 p-6 text-sm text-slate-600 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-300">
<Loader2 className="size-5 animate-spin text-rose-400" />
{t("packages.state.loading")}
</FrostedSurface>
);
}
if (packagesState.status === "error") {
return (
<Alert variant="destructive" className="border-rose-300/60 bg-rose-50/80 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/15 dark:text-rose-200">
<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-white/20 bg-white/70 text-slate-700 shadow-sm dark:border-slate-800/60 dark:bg-slate-950/80 dark:text-slate-300">
<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 (
<FrostedSurface
key={pkg.id}
className="flex flex-col gap-4 border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-rose-400 dark:text-rose-200">
{t(isSubscription ? "packages.card.subscription" : "packages.card.creditPack")}
</p>
<h3 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{pkg.name}</h3>
</div>
<span className="text-lg font-medium text-rose-400 dark:text-rose-200">{priceText}</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">
{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-rose-400 dark:text-rose-200">
{badges.map((badge) => (
<span key={badge} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{badge}
</span>
))}
{featureLabels.map((feature) => (
<span key={feature} className="rounded-full bg-rose-100/80 px-3 py-1 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-100">
{feature}
</span>
))}
{isActive ? (
<span className="rounded-full bg-sky-100/80 px-3 py-1 text-sky-600 shadow-inner shadow-sky-200/40 dark:bg-sky-500/20 dark:text-sky-200">
{t("packages.card.active")}
</span>
) : null}
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
size="lg"
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
onClick={() => handleSelectPackage(pkg)}
disabled={isActive}
>
{isActive ? t("packages.card.active") : t("packages.card.select")}
<ArrowRight className="ml-2 size-4" />
</Button>
{purchased ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t("packages.card.purchased", {
date: purchased.purchased_at
? dateFormatter.format(new Date(purchased.purchased_at))
: t("packages.card.purchasedUnknown"),
})}
</p>
) : null}
<Button
variant="ghost"
className="text-sm text-slate-600 hover:text-rose-400 dark:text-slate-400 dark:hover:text-rose-200"
onClick={() => navigate(ADMIN_BILLING_PATH)}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("packages.card.viewBilling")}
</Button>
</div>
</FrostedSurface>
);
})}
</div>
);
};
return (
<TenantWelcomeLayout
eyebrow={t("packages.layout.eyebrow")}
title={t("packages.layout.title")}
subtitle={t("packages.layout.subtitle")}
>
<WelcomeStepCard
step={2}
totalSteps={4}
title={t("packages.step.title")}
description={t("packages.step.description")}
icon={CreditCard}
>
{renderPackageList()}
</WelcomeStepCard>
<OnboardingCTAList
actions={[
{
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",
},
{
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,
},
]}
/>
</TenantWelcomeLayout>
);
}

View File

@@ -19,14 +19,7 @@ import {
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
} from '../components/tenant';
import { FrostedSurface, SectionCard, SectionHeader } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
@@ -177,10 +170,6 @@ export default function BillingPage() {
void loadAll();
}, [loadAll]);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate],
);
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
@@ -188,107 +177,63 @@ export default function BillingPage() {
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
const heroSupporting: string[] = [
activePackage
? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name })
: t('billing.hero.summary.inactive', 'Noch kein aktives Paket wählt ein Kontingent, das zu euch passt.'),
t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length })
];
const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`;
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => void loadAll(true)}
disabled={loading}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('billing.actions.refresh')}
</Button>
const computedRemainingEvents = React.useMemo(() => {
if (!activePackage) {
return null;
}
const used = activePackage.used_events ?? 0;
if (activePackage.remaining_events !== null && activePackage.remaining_events !== undefined) {
return activePackage.remaining_events;
}
const allowance = activePackage.package_limits?.max_events_per_year ?? 1;
return Math.max(0, allowance - used);
}, [activePackage]);
const normalizedActivePackage = React.useMemo(() => {
if (!activePackage) return null;
return {
...activePackage,
remaining_events: computedRemainingEvents ?? activePackage.remaining_events,
};
}, [activePackage, computedRemainingEvents]);
const topWarning = React.useMemo(() => {
const warnings = buildPackageWarnings(
normalizedActivePackage,
(key, options) => t(key, options),
formatDate,
'billing.sections.overview.warnings',
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => window.location.assign(packagesHref)}
>
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
return warnings[0];
}, [formatDate, normalizedActivePackage, t]);
const activeWarnings = React.useMemo(
() => buildPackageWarnings(normalizedActivePackage, t, formatDate, 'billing.sections.overview.warnings'),
[normalizedActivePackage, t, formatDate],
);
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
const topWarning = activeWarnings[0];
const billingStats = React.useMemo(
() => [
{
key: 'package',
label: t('billing.stats.package.label', 'Aktives Paket'),
value: activePackage?.package_name ?? t('billing.stats.package.empty', 'Keines'),
helper: activePackage?.expires_at
? t('billing.stats.package.helper', { date: formatDate(activePackage.expires_at) })
: t('billing.stats.package.helper', { date: '—' }),
tone: 'pink' as const,
},
const billingStats = React.useMemo(() => {
if (!activePackage) {
return [] as const;
}
const used = activePackage.used_events ?? 0;
const remaining = computedRemainingEvents ?? 0;
return [
{
key: 'events',
label: t('billing.stats.events.label', 'Genutzte Events'),
value: activePackage?.used_events ?? 0,
helper: t('billing.stats.events.helper', { count: activePackage?.remaining_events ?? 0 }),
value: used,
helper: t('billing.stats.events.helper', { count: remaining }),
tone: 'amber' as const,
},
{
key: 'addons',
label: t('billing.stats.addons.label', 'Add-ons'),
value: addonHistory.length,
helper: t('billing.stats.addons.helper', 'Historie insgesamt'),
tone: 'sky' as const,
},
{
key: 'transactions',
label: t('billing.stats.transactions.label', 'Transaktionen'),
value: transactions.length,
helper: t('billing.stats.transactions.helper', 'Synchronisierte Zahlungen'),
tone: 'emerald' as const,
},
],
[activePackage, addonHistory.length, transactions.length, formatDate, t]
);
const heroAside = (
<FrostedSurface className="space-y-4 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/85">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{t('billing.hero.activePackage', 'Aktuelles Paket')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
{activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{nextRenewalLabel}</p>
<p className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-200">{formatDate(activePackage?.expires_at)}</p>
</div>
{topWarning ? (
<div className="rounded-xl border border-amber-200/60 bg-amber-50/80 p-3 text-xs text-amber-800 shadow-inner shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200">
{topWarning.message}
</div>
) : null}
</FrostedSurface>
);
];
}, [activePackage, computedRemainingEvents, t]);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
<TenantHeroCard
badge={heroBadge}
title={t('billing.title')}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
@@ -300,7 +245,6 @@ export default function BillingPage() {
<BillingSkeleton />
) : (
<>
<BillingStatGrid stats={billingStats} />
<BillingWarningBanner warnings={activeWarnings} t={t} />
<SectionCard className="mt-6 space-y-5">
<SectionHeader
@@ -327,7 +271,7 @@ export default function BillingPage() {
value={activePackage.used_events ?? 0}
tone="amber"
helper={t('billing.sections.overview.cards.used.helper', {
count: activePackage.remaining_events ?? 0,
count: computedRemainingEvents ?? 0,
})}
/>
<InfoCard
@@ -811,8 +755,14 @@ function buildPackageWarnings(
const warnings: PackageWarning[] = [];
const remaining = typeof pkg.remaining_events === 'number' ? pkg.remaining_events : null;
const allowance = pkg.package_limits?.max_events_per_year;
const used = pkg.used_events ?? 0;
const totalEvents = allowance ?? (remaining !== null ? remaining + used : null);
if (remaining !== null) {
// Warnungen nur, wenn das Paket tatsächlich mehr als 1 Event umfasst oder das Limit unbekannt ist.
const shouldWarn = totalEvents === null ? true : totalEvents > 1;
if (remaining !== null && shouldWarn) {
if (remaining <= 0) {
warnings.push({
id: `${pkg.id}-no-events`,

View File

@@ -201,10 +201,6 @@ export default function DashboardPage() {
if (loading) {
return;
}
if (!progress.eventCreated && events.length === 0 && !location.pathname.startsWith(ADMIN_WELCOME_BASE_PATH)) {
navigate(ADMIN_WELCOME_BASE_PATH, { replace: true });
return;
}
if (events.length > 0 && !progress.eventCreated) {
const primary = events[0];
markStep({
@@ -517,17 +513,6 @@ export default function DashboardPage() {
[translate, navigate, hasEventContext],
);
const dashboardTabs = React.useMemo(
() => [
{ key: 'overview', label: translate('tabs.overview', 'Überblick'), href: `${ADMIN_HOME_PATH}#overview` },
{ key: 'live', label: translate('tabs.live', 'Live'), href: `${ADMIN_HOME_PATH}#live` },
{ key: 'setup', label: translate('tabs.setup', 'Vorbereitung'), href: `${ADMIN_HOME_PATH}#setup` },
{ key: 'recap', label: translate('tabs.recap', 'Nachbereitung'), href: `${ADMIN_HOME_PATH}#recap` },
],
[translate]
);
const currentDashboardTab = React.useMemo(() => (location.hash?.replace('#', '') || 'overview'), [location.hash]);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', {
@@ -579,7 +564,7 @@ export default function DashboardPage() {
);
return (
<AdminLayout title={adminTitle} subtitle={adminSubtitle} tabs={dashboardTabs} currentTabKey={currentDashboardTab}>
<AdminLayout title={adminTitle} subtitle={adminSubtitle}>
{errorMessage && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>

View File

@@ -3,7 +3,6 @@ import React from 'react';
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
ArrowLeft,
Bell,
Camera,
@@ -15,10 +14,10 @@ import {
Printer,
QrCode,
PlugZap,
RefreshCw,
Smile,
Sparkles,
ShoppingCart,
Menu,
Users,
} from 'lucide-react';
import toast from 'react-hot-toast';
@@ -61,21 +60,13 @@ import {
import {
SectionCard,
SectionHeader,
ActionGrid,
TenantHeroCard,
} from '../components/tenant';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { filterEmotionsByEventType } from '../lib/emotions';
import { buildEventTabs } from '../lib/eventTabs';
type EventDetailPageProps = {
mode?: 'detail' | 'toolkit';
};
type ToolkitState = {
data: EventToolkit | null;
loading: boolean;
@@ -90,7 +81,7 @@ type WorkspaceState = {
error: string | null;
};
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
export default function EventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -214,9 +205,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
const toolkitData = toolkit.data;
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const subtitle = mode === 'toolkit'
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const subtitle = t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
const limitWarnings = React.useMemo(
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
@@ -266,23 +255,6 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
[event?.slug],
);
const eventTabs = React.useMemo(() => {
if (!event) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
photos: toolkitData?.photos?.pending?.length ?? event.photo_count ?? 0,
tasks: toolkitData?.tasks?.summary.total ?? event.tasks_count ?? 0,
invites: toolkitData?.invites?.summary.active ?? event.active_invites_count ?? event.total_invites_count ?? 0,
});
}, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]);
const isRecapRoute = React.useMemo(
() => location.pathname.endsWith('/recap'),
[location.pathname],
);
const shownWarningToasts = React.useRef<Set<string>>(new Set());
//const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
@@ -379,8 +351,6 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
<AdminLayout
title={eventName}
subtitle={subtitle}
tabs={eventTabs}
currentTabKey={isRecapRoute ? 'recap' : 'overview'}
>
{error && (
<Alert variant="destructive">
@@ -455,30 +425,38 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
<WorkspaceSkeleton />
) : event ? (
<div className="space-y-6">
<EventHeroCardSection
event={event}
stats={stats}
onRefresh={() => { void load(); }}
loading={state.busy}
navigate={navigate}
/>
<Tabs defaultValue={isRecapRoute ? 'recap' : 'overview'} className="space-y-6">
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 dark:bg-white/5 sm:grid-cols-3">
<TabsTrigger value="overview">{t('events.workspace.tabs.overview', 'Überblick')}</TabsTrigger>
<TabsTrigger value="setup">{t('events.workspace.tabs.setup', 'Vorbereitung')}</TabsTrigger>
<TabsTrigger value="recap">{t('events.workspace.tabs.recap', 'Nachbereitung')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<QuickActionsCard slug={event.slug} busy={busy} onToggle={handleToggle} navigate={navigate} />
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('events.workspace.hero.badge', 'Event')}
</p>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white">{resolveName(event.name)}</h1>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => { void handleToggle(); }}
disabled={busy}
className="rounded-full border-slate-200"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</div>
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
</TabsContent>
<TabsContent value="setup" className="space-y-6">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
<InviteSummary
@@ -486,7 +464,6 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
</div>
<BrandingMissionCard
event={event}
invites={toolkitData?.invites}
@@ -496,7 +473,6 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
/>
{event.addons?.length ? (
<SectionCard>
<SectionHeader
@@ -506,19 +482,14 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
<AddonSummaryList addons={event.addons} t={(key, fallback) => t(key, fallback)} />
</SectionCard>
) : null}
</TabsContent>
<TabsContent value="recap" className="space-y-6">
<GalleryShareCard
invites={toolkitData?.invites}
onManageInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
/>
{event.limits?.gallery ? (
<GalleryStatusCard gallery={event.limits.gallery} />
) : null}
{event.limits?.gallery ? <GalleryStatusCard gallery={event.limits.gallery} /> : null}
<FeedbackCard slug={event.slug} />
</TabsContent>
</Tabs>
<QuickActionsMenu slug={event.slug} navigate={navigate} />
</div>
</div>
) : (
<SectionCard>
@@ -543,78 +514,6 @@ function resolveName(name: TenantEvent['name']): string {
return 'Event';
}
function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
event: TenantEvent;
stats: EventStats | null;
onRefresh: () => void;
loading: boolean;
navigate: ReturnType<typeof useNavigate>;
}) {
const { t } = useTranslation('management');
const statusLabel = getStatusLabel(event, t);
const supporting = [
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
t('events.workspace.hero.metrics', {
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
count: stats?.uploads_total ?? stats?.total ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
}),
];
const aside = (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
<InfoRow
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
label={t('events.workspace.fields.status', 'Status')}
value={statusLabel}
/>
<InfoRow
icon={<CalendarIcon />}
label={t('events.workspace.fields.date', 'Eventdatum')}
value={formatDate(event.event_date)}
/>
<InfoRow
icon={<Users className="h-4 w-4 text-sky-500" />}
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
/>
</div>
);
return (
<TenantHeroCard
badge={t('events.workspace.hero.badge', 'Event')}
title={resolveName(event.name)}
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
supporting={supporting}
primaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
</Button>
)}
secondaryAction={(
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
</Button>
)}
aside={aside}
>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
className="rounded-full border-slate-200"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('events.actions.refresh', 'Aktualisieren')}
</Button>
</div>
</TenantHeroCard>
);
}
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
const { t } = useTranslation('management');
@@ -656,125 +555,53 @@ function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stat
</div>
)}
<div className="flex flex-wrap gap-2 pt-2">
<Button onClick={onToggle} disabled={busy} className="bg-gradient-to-r from-pink-500 via-rose-500 to-purple-500 text-white shadow-md shadow-rose-200/60">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Button>
</div>
</div>
</SectionCard>
);
}
function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; busy: boolean; onToggle: () => void | Promise<void>; navigate: ReturnType<typeof useNavigate> }) {
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
const { t } = useTranslation('management');
const gridItems = [
{
key: 'photos',
icon: <Camera className="h-4 w-4" />,
label: t('events.quickActions.moderate', 'Fotos moderieren'),
description: t('events.quickActions.moderateDesc', 'Prüfe Uploads und veröffentliche Highlights.'),
onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)),
},
{
key: 'tasks',
icon: <Sparkles className="h-4 w-4" />,
label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'),
description: t('events.quickActions.tasksDesc', 'Passe Story-Prompts und Moderation an.'),
onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)),
},
{
key: 'invites',
icon: <QrCode className="h-4 w-4" />,
label: t('events.quickActions.invites', 'Layouts & QR verwalten'),
description: t('events.quickActions.invitesDesc', 'Aktualisiere QR-Kits und Einladungslayouts.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`),
},
{
key: 'roles',
icon: <Users className="h-4 w-4" />,
label: t('events.quickActions.roles', 'Team & Rollen anpassen'),
description: t('events.quickActions.rolesDesc', 'Verwalte Moderatoren und Co-Leads.'),
onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)),
},
{
key: 'photobooth',
icon: <PlugZap className="h-4 w-4" />,
label: t('events.quickActions.photobooth', 'Photobooth anbinden'),
description: t('events.quickActions.photoboothDesc', 'FTP-Link aktivieren und Zugangsdaten kopieren.'),
onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)),
},
{
key: 'print',
icon: <Printer className="h-4 w-4" />,
label: t('events.quickActions.print', 'Layouts als PDF drucken'),
description: t('events.quickActions.printDesc', 'Exportiere QR-Sets für den Druck.'),
onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`),
},
const actions = [
{ key: 'photos', icon: <Camera className="h-4 w-4" />, label: t('events.quickActions.moderate', 'Fotos moderieren'), onClick: () => navigate(ADMIN_EVENT_PHOTOS_PATH(slug)) },
{ key: 'tasks', icon: <Sparkles className="h-4 w-4" />, label: t('events.quickActions.tasks', 'Aufgaben bearbeiten'), onClick: () => navigate(ADMIN_EVENT_TASKS_PATH(slug)) },
{ key: 'invites', icon: <QrCode className="h-4 w-4" />, label: t('events.quickActions.invites', 'Layouts & QR verwalten'), onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=layout`) },
{ key: 'roles', icon: <Users className="h-4 w-4" />, label: t('events.quickActions.roles', 'Team & Rollen anpassen'), onClick: () => navigate(ADMIN_EVENT_MEMBERS_PATH(slug)) },
{ key: 'photobooth', icon: <PlugZap className="h-4 w-4" />, label: t('events.quickActions.photobooth', 'Photobooth anbinden'), onClick: () => navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(slug)) },
{ key: 'print', icon: <Printer className="h-4 w-4" />, label: t('events.quickActions.print', 'Layouts als PDF drucken'), onClick: () => navigate(`${ADMIN_EVENT_INVITES_PATH(slug)}?tab=export`) },
];
return (
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.quickActions.badge', 'Schnellaktionen')}
title={t('events.quickActions.title', 'Schnellaktionen')}
description={t('events.quickActions.subtitle', 'Nutze die wichtigsten Schritte vor und während deines Events.')}
/>
<ActionGrid items={gridItems} columns={1} />
<div className="flex flex-wrap gap-2">
<Button onClick={() => { void onToggle(); }} disabled={busy} variant="outline" className="rounded-full border-rose-200 text-rose-600 hover:bg-rose-50">
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('events.quickActions.toggle', 'Status ändern')}
<Sheet>
<SheetTrigger asChild>
<Button
size="icon"
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full bg-rose-500 text-white shadow-xl shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-2 focus-visible:ring-rose-300"
aria-label={t('events.quickActions.badge', 'Schnellaktionen')}
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-3xl border border-slate-200 bg-white/95 p-4 text-slate-900 dark:border-white/10 dark:bg-slate-950">
<SheetHeader>
<SheetTitle className="text-lg font-semibold">{t('events.quickActions.title', 'Starte in die Moderation')}</SheetTitle>
</SheetHeader>
<div className="mt-4 grid gap-2">
{actions.map((action) => (
<Button
key={action.key}
variant="ghost"
className="flex items-center justify-start gap-3 rounded-2xl border border-slate-200 bg-white/90 text-left text-sm font-semibold text-slate-800 hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
</div>
</SectionCard>
);
}
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) {
const { t } = useTranslation('management');
const cards = [
{
icon: <Camera className="h-5 w-5 text-emerald-500" />,
label: t('events.metrics.uploadsTotal', 'Uploads gesamt'),
value: metrics?.uploads_total ?? stats?.uploads_total ?? 0,
},
{
icon: <Camera className="h-5 w-5 text-sky-500" />,
label: t('events.metrics.uploads24h', 'Uploads (24h)'),
value: metrics?.uploads_24h ?? stats?.uploads_24h ?? 0,
},
{
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
label: t('events.metrics.pending', 'Fotos in Moderation'),
value: metrics?.pending_photos ?? stats?.pending_photos ?? 0,
},
{
icon: <QrCode className="h-5 w-5 text-indigo-500" />,
label: t('events.metrics.activeInvites', 'Aktive Einladungen'),
value: metrics?.active_invites ?? 0,
},
];
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<SectionCard key={card.label} className="p-4">
<div className="flex items-center gap-4">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-white/10">
{card.icon}
</span>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{card.label}</p>
<p className="text-2xl font-semibold text-slate-900 dark:text-white">{card.value}</p>
</div>
</div>
</SectionCard>
))}
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { AdminLayout } from '../components/AdminLayout';
import {
@@ -96,6 +97,16 @@ export default function EventFormPage() {
queryKey: ['tenant', 'event-types'],
queryFn: getEventTypes,
});
const sortedEventTypes = React.useMemo(() => {
if (!eventTypes) {
return [];
}
return [...eventTypes].sort((a, b) => {
const aName = (a.name as string) ?? '';
const bName = (b.name as string) ?? '';
return aName.localeCompare(bName, undefined, { sensitivity: 'base' });
});
}, [eventTypes]);
const { data: packageOverview, isLoading: overviewLoading } = useQuery({
queryKey: ['tenant', 'packages', 'overview'],
@@ -400,9 +411,20 @@ export default function EventFormPage() {
return [];
}
return Object.entries(selectedPackage.features)
const normalizeLabel = (key: string) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' ');
const raw = selectedPackage.features as unknown;
if (Array.isArray(raw)) {
return raw.filter(Boolean).map((key) => normalizeLabel(String(key)));
}
if (typeof raw === 'object' && raw !== null) {
return Object.entries(raw)
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
.map(([key]) => normalizeLabel(key));
}
return [];
}, [selectedPackage]);
const packageExpiresLabel = formatDate(eventPackageMeta?.expiresAt ?? activePackage?.expires_at ?? null);
@@ -507,7 +529,7 @@ export default function EventFormPage() {
<Select
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
disabled={eventTypesLoading || !eventTypes?.length}
disabled={eventTypesLoading || !sortedEventTypes.length}
>
<SelectTrigger id="event-type">
<SelectValue
@@ -515,32 +537,76 @@ export default function EventFormPage() {
/>
</SelectTrigger>
<SelectContent>
{eventTypes?.map((eventType) => (
{sortedEventTypes.map((eventType) => (
<SelectItem key={eventType.id} value={String(eventType.id)}>
{eventType.icon ? `${eventType.icon} ${eventType.name}` : eventType.name}
{eventType.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
{!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
</p>
) : null}
</div>
<div className="sm:col-span-2">
<Card className="border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
</div>
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
<Checkbox
id="event-published"
checked={form.isPublished}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))}
/>
<div>
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
Event sofort veroeffentlichen
</Label>
<p className="text-xs text-slate-600">
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
</>
) : (
<>
<Save className="h-4 w-4" /> Speichern
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
Abbrechen
</Button>
</div>
<div className="sm:col-span-2 mt-6">
<Accordion type="single" collapsible defaultValue="package">
<AccordionItem value="package" className="border-0">
<AccordionTrigger className="rounded-2xl bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-4 py-3 text-left text-white shadow-md shadow-pink-500/20 hover:no-underline">
<div className="flex flex-wrap items-center gap-3">
<Badge className="bg-white/25 text-white backdrop-blur">
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
</Badge>
<span className="font-semibold">{packageNameDisplay}</span>
{packagePriceLabel ? (
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
<span className="text-xs font-semibold uppercase tracking-widest text-white/90">
{packagePriceLabel}
</span>
) : null}
</div>
</AccordionTrigger>
<AccordionContent>
<Card className="mt-3 border-0 bg-gradient-to-br from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-xl shadow-pink-500/25">
<CardHeader className="space-y-4">
<div className="space-y-2">
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
{packageNameDisplay}
@@ -604,44 +670,9 @@ export default function EventFormPage() {
</div>
</CardContent>
</Card>
</div>
</div>
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
<Checkbox
id="event-published"
checked={form.isPublished}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))}
/>
<div>
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
Event sofort veroeffentlichen
</Label>
<p className="text-xs text-slate-600">
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim() || !form.eventTypeId}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
</>
) : (
<>
<Save className="h-4 w-4" /> Speichern
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
Abbrechen
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</form>
)}

View File

@@ -30,7 +30,6 @@ import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
@@ -817,7 +816,7 @@ export default function EventInvitesPage(): React.ReactElement {
<Button
size="sm"
variant="ghost"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))}
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="hover:text-foreground"
>
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}

View File

@@ -3,5 +3,5 @@ import React from 'react';
import EventDetailPage from './EventDetailPage';
export default function EventToolkitPage() {
return <EventDetailPage mode="toolkit" />;
return <EventDetailPage />;
}

View File

@@ -5,16 +5,7 @@ import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lu
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
ActionGrid,
} from '../components/tenant';
import { FrostedSurface, SectionCard } from '../components/tenant';
import { cn } from '@/lib/utils';
import { AdminLayout } from '../components/AdminLayout';
@@ -23,15 +14,12 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
adminPath,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
@@ -77,143 +65,8 @@ export default function EventsPage() {
() => rows.filter((event) => event.status === 'published').length,
[rows],
);
const nextEvent = React.useMemo(() => {
return (
rows
.filter((event) => event.event_date)
.slice()
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
return dateA - dateB;
})[0] ?? null
);
}, [rows]);
const statItems = React.useMemo(
() => [
{
key: 'total',
label: t('events.list.stats.total', 'Events gesamt'),
value: totalEvents,
},
{
key: 'published',
label: t('events.list.stats.published', 'Veröffentlicht'),
value: publishedEvents,
},
{
key: 'drafts',
label: t('events.list.stats.drafts', 'Entwürfe'),
value: Math.max(0, totalEvents - publishedEvents),
},
nextEvent
? {
key: 'next',
label: t('events.list.stats.nextEvent', 'Nächstes Event'),
value: formatDate(nextEvent.event_date),
}
: null,
].filter(Boolean) as { key: string; label: string; value: string | number }[],
[t, totalEvents, publishedEvents, nextEvent],
);
const actionItems = React.useMemo(
() => [
{
key: 'new',
label: t('events.list.actions.create', 'Neues Event'),
description: t('events.list.actions.createDescription', 'Starte mit einem frischen Setup.'),
onClick: () => navigate(adminPath('/events/new')),
},
{
key: 'welcome',
label: t('events.list.actions.guidedSetup', 'Geführte Einrichtung'),
description: t('events.list.actions.guidedSetupDescription', 'Springe zurück in den Onboarding-Flow.'),
onClick: () => navigate(ADMIN_WELCOME_BASE_PATH),
},
{
key: 'settings',
label: t('events.list.actions.settings', 'Einstellungen öffnen'),
description: t('events.list.actions.settingsDescription', 'Passe Farben, Branding und Aufgabenpakete an.'),
onClick: () => navigate(ADMIN_SETTINGS_PATH),
},
],
[t, navigate],
);
const pageTitle = translateManagement('events.list.title', 'Deine Events');
const pageSubtitle = translateManagement(
'events.list.subtitle',
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
);
const heroDescription = t(
'events.list.hero.description',
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
);
const heroSummaryCopy = totalEvents > 0
? t('events.list.hero.summary', ':count Events aktiv verwaltet halte Aufgaben und Uploads im Blick.', { count: totalEvents })
: t('events.list.hero.summary_empty', 'Noch keine Events starte jetzt mit deinem ersten Konzept.');
const heroSecondaryCopy = t(
'events.list.hero.secondary',
'Erstelle Events im Admin, begleite Gäste live vor Ort und prüfe Kennzahlen im Marketing-Dashboard.'
);
const heroBadge = t('events.list.badge.dashboard', 'Tenant Dashboard');
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(adminPath('/events/new'))}
>
{t('events.list.actions.create', 'Neues Event')}
</Button>
);
const heroSecondaryAction = (
<Button size="sm" className={tenantHeroSecondaryButtonClass} asChild>
<Link to={ADMIN_SETTINGS_PATH}>
{t('events.list.actions.settings', 'Einstellungen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
);
const heroAside = (
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-pink-200/20 dark:border-white/20 dark:bg-white/10">
<div className="space-y-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('events.list.hero.published_label', 'Veröffentlichte Events')}
</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">{publishedEvents}</p>
<p className="text-xs text-slate-500">
{t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })}
</p>
</div>
{nextEvent ? (
<div className="rounded-xl border border-pink-100 bg-pink-50/70 p-4 text-slate-900 shadow-inner shadow-pink-200/50">
<p className="text-xs uppercase tracking-wide text-pink-600">
{t('events.list.hero.next_label', 'Nächstes Event')}
</p>
<p className="mt-1 text-sm font-semibold">{renderName(nextEvent.name)}</p>
<p className="text-xs text-slate-600">{formatDate(nextEvent.event_date)}</p>
{nextEvent.slug ? (
<Button
variant="ghost"
size="sm"
className="mt-3 h-8 justify-start px-3 text-pink-600 hover:bg-pink-100"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(nextEvent.slug))}
>
{t('events.list.hero.open_event', 'Event öffnen')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
) : (
<p className="rounded-xl border border-dashed border-pink-200/70 bg-white/70 p-4 text-xs text-slate-600">
{t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')}
</p>
)}
</div>
</FrostedSurface>
);
const draftEvents = totalEvents - publishedEvents;
const [statusFilter, setStatusFilter] = React.useState<'all' | 'published' | 'draft'>('all');
const filteredRows = React.useMemo(() => {
@@ -225,21 +78,6 @@ export default function EventsPage() {
}
return rows;
}, [rows, statusFilter]);
const overviewDescription = React.useMemo(() => {
if (loading) {
return t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …');
}
if (filteredRows.length === 0) {
if (statusFilter === 'published') {
return t('events.list.overview.empty_published', 'Noch keine veröffentlichten Events.');
}
if (statusFilter === 'draft') {
return t('events.list.overview.empty_drafts', 'Keine Entwürfe nutze die Zeit für dein nächstes Event.');
}
return t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.');
}
return t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: filteredRows.length });
}, [filteredRows.length, loading, statusFilter, t]);
const filterOptions: Array<{ key: 'all' | 'published' | 'draft'; label: string; count: number }> = [
{ key: 'all', label: t('events.list.filters.all', 'Alle'), count: totalEvents },
{ key: 'published', label: t('events.list.filters.published', 'Live'), count: publishedEvents },
@@ -247,7 +85,7 @@ export default function EventsPage() {
];
return (
<AdminLayout title={pageTitle} subtitle={pageSubtitle}>
<AdminLayout title={pageTitle}>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
@@ -255,32 +93,7 @@ export default function EventsPage() {
</Alert>
)}
<TenantHeroCard
badge={heroBadge}
title={pageTitle}
description={heroDescription}
supporting={[heroSummaryCopy, heroSecondaryCopy]}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('events.list.badge', 'Events')}
title={t('events.list.overview.title', 'Übersicht')}
description={overviewDescription}
endSlot={(
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-slate-800 shadow-inner shadow-white/20 dark:border-white/15 dark:bg-white/10 dark:text-white"
>
{t('events.list.badge.dashboard', 'Tenant Dashboard')}
</button>
)}
/>
<StatCarousel items={statItems} />
<ActionGrid items={actionItems} columns={1} />
<div className="flex gap-2 overflow-x-auto pb-1">
{filterOptions.map((option) => (
<button
@@ -370,7 +183,7 @@ function EventCard({
{ key: 'tasks', label: translate('events.list.actions.tasks', 'Tasks'), to: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: translate('events.list.actions.invites', 'QR-Einladungen'), to: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'photobooth', label: translate('events.list.actions.photobooth', 'Photobooth'), to: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
{ key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_TOOLKIT_PATH(slug) },
{ key: 'toolkit', label: translate('events.list.actions.toolkit', 'Toolkit'), to: ADMIN_EVENT_VIEW_PATH(slug) },
];
return (

View File

@@ -1,94 +1,61 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import {
ArrowRight,
Camera,
Clock3,
Heart,
MessageCircle,
CheckCircle2,
Layers,
ListChecks,
Menu,
Moon,
Palette,
QrCode,
ShieldCheck,
Smartphone,
Sparkles,
SunMedium,
Sun,
Wand2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { FrostedSurface } from '../components/tenant';
import { useAppearance } from '@/hooks/use-appearance';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useAuth } from '../auth/context';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { navigateToHref } from '../lib/navigation';
import { getCurrentLocale } from '../lib/locale';
const heroStats = [
{ label: 'Events begleitet', value: '2.100+' },
{ label: 'Fotos kuratiert', value: '680k' },
{ label: 'Mission Cards live', value: '120+' },
];
type Feature = {
key: string;
title: string;
description: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
};
const featureCards = [
{
icon: Sparkles,
badge: 'Welcome Flow',
title: 'Geführte Einrichtung',
description:
'Ein roter Faden von eurer ersten Mission bis zum Event-Branding. Alles läuft dort, wo ihr später auch Events steuert.',
},
{
icon: QrCode,
badge: 'Gäste einladen',
title: 'Links & QR-Cards teilen',
description:
'Mit einem Fingertipp entstehen Einladungslinks, QR-Codes und TWA-Links für Android & iOS ganz ohne App Store Hürden.',
},
{
icon: Camera,
badge: 'Live Galerie',
title: 'Moderieren wie im Dashboard',
description:
'Markiert Highlights, ordnet Emotions oder sperrt Fotos exakt die Tools aus dem Event Admin, nur mit Willkommens-Glow.',
},
];
type Step = {
key: string;
title: string;
description: string;
accent: string;
};
const timelineSteps = [
{
title: 'Mission Cards wählen',
description: 'Kuratiert Aufgaben, die eure Gäste ins Erzählen bringen. Jeder Schritt speichert automatisch.',
},
{
title: 'Event Branding festlegen',
description: 'Farben, Story, Cover alles in einer Ansicht. Vorschau zeigt sofort, wie der Gastzugang wirkt.',
},
{
title: 'Link teilen & feiern',
description: 'QR-Code oder Link verschicken, fertig. Gäste landen direkt in eurer Galerie und können ohne Login starten.',
},
];
const supportHighlights = [
{
icon: ShieldCheck,
title: 'Datenschutz-ready',
description: 'Keine versteckten Tracker, DSGVO-konformes Hosting und Kontrolle, wer Fotos sieht.',
},
{
icon: Clock3,
title: 'Offline nutzbar',
description: 'Uploads puffern automatisch, falls das WLAN in der Location aussetzt.',
},
{
icon: MessageCircle,
title: 'Crew an eurer Seite',
description: 'Direkter Chat zum Fotospiel-Team aus der App heraus oder via hallo@fotospiel.de.',
},
];
type Plan = {
key: string;
title: string;
badge?: string;
highlight?: string;
points: string[];
};
export default function WelcomeTeaserPage() {
const [isRedirecting, setIsRedirecting] = React.useState(false);
const [mode, setMode] = React.useState<'dark' | 'light'>('dark');
const isLightMode = mode === 'light';
const flowSectionRef = React.useRef<HTMLDivElement | null>(null);
const { t } = useTranslation('common');
const { status } = useAuth();
const { appearance, updateAppearance } = useAppearance();
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
@@ -98,395 +65,428 @@ export default function WelcomeTeaserPage() {
};
}, []);
React.useEffect(() => {
document.body.classList.toggle('tenant-admin-welcome-light', isLightMode);
document.body.classList.toggle('tenant-admin-welcome-dark', !isLightMode);
}, [isLightMode]);
const theme = React.useMemo(
() => ({
rootBackground: isLightMode
? 'bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-900'
: 'bg-slate-950 text-white',
aurora: isLightMode
? 'bg-[radial-gradient(ellipse_at_top,_rgba(255,179,205,0.55),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(148,187,233,0.45),_transparent_60%)]'
: 'bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.25),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_62%)]',
overlay: isLightMode
? 'bg-gradient-to-br from-white/85 via-rose-50/70 to-sky-50/70'
: 'bg-gradient-to-br from-slate-950/95 via-slate-950/80 to-[#150b1f]/90',
headerBadge: isLightMode ? 'border-rose-100 bg-white text-rose-500' : 'border-white/30 bg-white/5 text-white',
headerSubtitle: isLightMode ? 'text-slate-600' : 'text-white/70',
heroEyebrow: isLightMode ? 'text-rose-500' : 'text-rose-200',
heroTitle: isLightMode ? 'text-slate-900' : 'text-white',
heroBody: isLightMode ? 'text-slate-600' : 'text-white/75',
ghostButton: isLightMode ? 'text-slate-700 hover:bg-slate-100' : 'text-white/80 hover:bg-white/10',
statCard: isLightMode
? 'border-rose-100/70 bg-white/90 text-slate-900 shadow-rose-100/50'
: 'border-white/15 bg-white/10 text-white shadow-rose-500/30',
featureCard: isLightMode
? 'border-rose-100 bg-white text-slate-900 shadow-rose-100/40'
: 'border-white/15 bg-white/10 text-white shadow-slate-900/40',
timelineCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/5 text-white',
timelineDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
supportCard: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
supportDescription: isLightMode ? 'text-slate-600' : 'text-white/70',
ctaSurface: isLightMode ? 'border-slate-200 bg-white text-slate-900' : 'border-white/15 bg-white/10 text-white',
ctaDetail: isLightMode ? 'text-slate-600' : 'text-white/70',
footer: isLightMode
? 'border-t border-slate-200/80 bg-white/70 text-slate-500'
: 'border-t border-white/10 bg-black/20 text-white/60',
}),
[isLightMode]
);
const toggleMode = React.useCallback(() => {
setMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
const handleLoginRedirect = React.useCallback(() => {
if (isRedirecting) {
return;
if (status === 'authenticated') {
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
}
setIsRedirecting(true);
const locale = getCurrentLocale();
const packagesHref = `/${locale}/packages`;
const howItWorksHref = locale === 'de' ? `/${locale}/so-funktionierts` : `/${locale}/how-it-works`;
const params = new URLSearchParams(window.location.search);
const rawReturnTo = params.get('return_to');
const { finalTarget, encodedFinal } = resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH);
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
target.searchParams.set('return_to', encodedFinal ?? encodeReturnTo(finalTarget));
const nextHref = `${target.pathname}${target.search}`;
navigateToHref(nextHref);
}, [isRedirecting]);
const features: Feature[] = [
{
key: 'branding',
title: t('welcome.features.branding.title', 'Branding & Layout'),
description: t('welcome.features.branding.description', 'Farben, Schriften, QR-Layouts und Einladungen in einem Fluss.'),
icon: Palette,
},
{
key: 'tasks',
title: t('welcome.features.tasks.title', 'Aufgaben & Emotion-Sets'),
description: t('welcome.features.tasks.description', 'Sammlungen importieren oder eigene Aufgaben erstellen mobil abhakbar.'),
icon: ListChecks,
},
{
key: 'moderation',
title: t('welcome.features.moderation.title', 'Foto-Moderation'),
description: t('welcome.features.moderation.description', 'Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen.'),
icon: ShieldCheck,
},
{
key: 'invites',
title: t('welcome.features.invites.title', 'Einladungen & QR'),
description: t('welcome.features.invites.description', 'Links und Druckvorlagen generieren mit Paketlimits im Blick.'),
icon: QrCode,
},
];
const handleScrollToFlow = React.useCallback(() => {
flowSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
const steps: Step[] = [
{
key: 'prepare',
title: t('welcome.steps.prepare.title', 'Vorbereiten'),
description: t('welcome.steps.prepare.description', 'Event anlegen, Branding setzen, Aufgaben aktivieren.'),
accent: t('welcome.steps.prepare.accent', 'Setup'),
},
{
key: 'share',
title: t('welcome.steps.share.title', 'Teilen & Einladen'),
description: t('welcome.steps.share.description', 'QRs/Links verteilen, Missionen auswählen, Team onboarden.'),
accent: t('welcome.steps.share.accent', 'Share'),
},
{
key: 'run',
title: t('welcome.steps.run.title', 'Live moderieren'),
description: t('welcome.steps.run.description', 'Uploads prüfen, Highlights pushen und nach dem Event die Galerie teilen.'),
accent: t('welcome.steps.run.accent', 'Live'),
},
];
const plans: Plan[] = [
{
key: 'starter',
title: t('welcome.plans.starter.title', 'Starter'),
badge: t('welcome.plans.starter.badge', 'Für ein Event'),
points: [
t('welcome.plans.starter.p1', '1 Event, Basis-Branding'),
t('welcome.plans.starter.p2', 'Aufgaben & Einladungen inklusive'),
t('welcome.plans.starter.p3', 'Moderation & Galerie-Link'),
],
},
{
key: 'standard',
title: t('welcome.plans.standard.title', 'Standard'),
badge: t('welcome.plans.standard.badge', 'Beliebt'),
highlight: t('welcome.plans.standard.highlight', 'Mehr Kontingent & Branding'),
points: [
t('welcome.plans.standard.p1', 'Mehr Events pro Jahr'),
t('welcome.plans.standard.p2', 'Erweitertes Branding & Layouts'),
t('welcome.plans.standard.p3', 'Support bei Live-Events'),
],
},
{
key: 'reseller',
title: t('welcome.plans.reseller.title', 'Reseller S'),
badge: t('welcome.plans.reseller.badge', 'Für Dienstleister'),
highlight: t('welcome.plans.reseller.highlight', 'Mehrere Events parallel verwalten'),
points: [
t('welcome.plans.reseller.p1', 'Bis zu 5 Events pro Paket'),
t('welcome.plans.reseller.p2', 'Aufgaben-Sammlungen und Vorlagen'),
t('welcome.plans.reseller.p3', 'Teamrollen & Rechteverwaltung'),
],
},
];
const audienceCards = [
{
key: 'endcustomers',
title: t('welcome.audience.endcustomers.title', 'Endkund:innen'),
description: t('welcome.audience.endcustomers.description', 'Schnell einrichten, mobil moderieren und nach dem Event die Galerie teilen.'),
icon: Smartphone,
},
{
key: 'resellers',
title: t('welcome.audience.resellers.title', 'Reseller & Agenturen'),
description: t('welcome.audience.resellers.description', 'Mehrere Events im Blick behalten, Kontingente überwachen und Vorlagen nutzen.'),
icon: Layers,
},
];
const previewRaw = t('welcome.preview.items', {
defaultValue: [
'Moderation, Aufgaben und Einladungen als Schnellzugriff',
'Sticky Actions auf Mobile für den Eventtag',
'Paket-Status & Limits jederzeit sichtbar',
],
returnObjects: true,
});
const previewBullets = Array.isArray(previewRaw) ? previewRaw : [String(previewRaw)];
const themeLabel = appearance === 'dark' ? t('welcome.theme.dark', 'Dunkel') : t('welcome.theme.light', 'Hell');
const handleLogin = React.useCallback(() => {
navigateToHref(ADMIN_LOGIN_PATH);
}, []);
const handlePackages = React.useCallback(() => navigateToHref(packagesHref), [packagesHref]);
const handleHow = React.useCallback(() => navigateToHref(howItWorksHref), [howItWorksHref]);
const handleThemeToggle = React.useCallback(() => {
updateAppearance(appearance === 'dark' ? 'light' : 'dark');
}, [appearance, updateAppearance]);
const renderMenuActions = () => (
<div className="flex flex-col gap-3">
<Button size="sm" onClick={handleLogin} className="justify-between">
<span>{t('welcome.cta.login', 'Login')}</span>
<ArrowRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handlePackages} className="justify-between">
<span>{t('welcome.cta.packages', 'Pakete ansehen')}</span>
<ArrowRight className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleHow} className="justify-between">
<span>{t('welcome.cta.how', "So funktioniert's")}</span>
<ArrowRight className="h-4 w-4" />
</Button>
<div className="flex items-center justify-between rounded-xl border border-slate-200/70 px-4 py-3 text-sm dark:border-white/10">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">{t('welcome.theme.label', 'Darstellung')}</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{themeLabel}</p>
</div>
<Button variant="outline" size="icon" aria-label={t('welcome.theme.aria', 'Darstellung umschalten')} onClick={handleThemeToggle}>
{appearance === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200/70 px-4 py-3 text-sm dark:border-white/10">
<span className="text-slate-700 dark:text-slate-200">{t('app.languageSwitch')}</span>
<LanguageSwitcher />
</div>
</div>
);
return (
<div className={cn('relative min-h-svh overflow-hidden transition-colors duration-500', theme.rootBackground)}>
<div className="relative min-h-svh overflow-hidden bg-gradient-to-b from-rose-50 via-white to-slate-50 text-slate-900 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-white">
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 motion-safe:animate-[aurora_24s_ease-in-out_infinite]',
theme.aurora
)}
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_20%,rgba(255,137,170,0.35),transparent_45%),radial-gradient(circle_at_90%_0%,rgba(96,165,250,0.25),transparent_45%),radial-gradient(circle_at_50%_90%,rgba(16,185,129,0.15),transparent_45%)] opacity-70 dark:opacity-30"
/>
<div aria-hidden className={cn('absolute inset-0 transition-colors duration-500', theme.overlay)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/70 via-white/40 to-transparent dark:from-slate-950/90 dark:via-slate-950/70 dark:to-slate-950/80" />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 pb-4 pt-8">
<div className="space-y-1">
<Badge
variant="outline"
className={cn(
'border text-[11px] font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.headerBadge
)}
>
Event Admin · Fotospiel
</Badge>
<p className={cn('text-sm transition-colors duration-300', theme.headerSubtitle)}>
Euer mobiles Kontrollzentrum für Events, Workshops und Feiern
</p>
<div className="relative z-10 mx-auto flex w-full max-w-6xl flex-col gap-10 px-4 pb-16 pt-8 sm:px-6 lg:px-10 lg:pt-12">
<header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-rose-500 text-lg font-semibold text-white shadow-lg shadow-rose-200/60 dark:bg-rose-400 dark:text-slate-900">
FS
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-rose-600 dark:text-rose-200">
{t('welcome.eyebrow', 'Event Admin')}
</p>
<p className="text-sm font-semibold text-slate-900 dark:text-white">Fotospiel.app</p>
</div>
</div>
<div className="hidden items-center gap-2 sm:flex">
<Button
variant="ghost"
size="sm"
onClick={handleThemeToggle}
aria-label={t('welcome.theme.aria', 'Darstellung umschalten')}
className="rounded-full border border-slate-200/80 bg-white/80 text-slate-700 hover:border-rose-200 hover:text-rose-700 dark:border-white/10 dark:bg-white/10 dark:text-white"
>
{appearance === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="hidden sm:inline">{themeLabel}</span>
</Button>
<LanguageSwitcher />
<Button
type="button"
variant="outline"
className={cn(
'items-center gap-2 border text-xs font-semibold uppercase tracking-wide',
isLightMode
? 'border-slate-200 text-slate-700 hover:bg-slate-100'
: 'border-white/30 text-white hover:bg-white/10'
)}
onClick={toggleMode}
>
{isLightMode ? (
<>
<Moon className="h-4 w-4" />
Dark Mode
</>
) : (
<>
<SunMedium className="h-4 w-4" />
Light Mode
</>
)}
<Button variant="outline" size="sm" onClick={handlePackages} className="border-rose-200 text-rose-700 hover:bg-rose-50">
{t('welcome.cta.packages', 'Pakete ansehen')}
</Button>
<Button
type="button"
variant="outline"
className={cn(
'border text-sm font-semibold transition-colors duration-300',
isLightMode ? 'border-slate-200 text-slate-700 hover:bg-slate-100' : 'border-white/30 text-white hover:bg-white/10'
)}
onClick={handleLoginRedirect}
disabled={isRedirecting}
>
{isRedirecting ? 'Weiterleitung …' : 'Login öffnen'}
<Button size="sm" onClick={handleLogin} className="bg-rose-500 text-white hover:bg-rose-600">
{t('welcome.cta.login', 'Login')}
</Button>
</div>
<div className="sm:hidden">
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={isMenuOpen ? t('welcome.menu.close', 'Menü schließen') : t('welcome.menu.open', 'Menü öffnen')}
className="rounded-full border-rose-200 bg-white/80 text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-white"
>
{isMenuOpen ? <Sparkles className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
</SheetTrigger>
<SheetContent side="right" className="bg-white/95 text-slate-900 dark:bg-slate-950 dark:text-white">
<SheetHeader>
<SheetTitle className="text-lg font-semibold">{t('welcome.menu.title', 'Navigation')}</SheetTitle>
</SheetHeader>
{renderMenuActions()}
</SheetContent>
</Sheet>
</div>
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-12 px-6 pb-16 pt-4">
<section className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-8">
<main className="flex flex-col gap-12">
<section className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
<Badge variant="secondary" className="bg-rose-100/80 text-rose-700 hover:bg-rose-100 dark:bg-rose-400/20 dark:text-rose-100">
<Sparkles className="h-3.5 w-3.5" />
{t('welcome.badge', 'Fotos, Aufgaben & Einladungen an einem Ort')}
</Badge>
<div className="flex items-center gap-2 rounded-full bg-white/70 px-3 py-1 text-xs font-medium text-slate-700 shadow-sm shadow-rose-200/50 ring-1 ring-slate-200/70 dark:bg-white/5 dark:text-white dark:ring-white/10">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
{t('welcome.loginPrompt', 'Bereits Kunde? Login oben rechts.')}
</div>
</div>
<div className="space-y-4">
<p
className={cn(
'text-sm font-semibold uppercase tracking-[0.4em] transition-colors duration-300',
theme.heroEyebrow
)}
>
Willkommen im Event-Kontrollzentrum
</p>
<h1
className={cn(
'font-display text-4xl font-semibold leading-tight transition-colors duration-300 md:text-5xl',
theme.heroTitle
)}
>
Alles für eure Gästegeschichte in einer App
<h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-slate-900 dark:text-white sm:text-4xl">
{t('welcome.title', 'Event-Branding, Aufgaben & Foto-Moderation in einer App.')}
</h1>
<p
className={cn(
'text-base leading-relaxed transition-colors duration-300 md:text-lg',
theme.heroBody
)}
>
Der neue Startscreen begrüßt euch wie eine mobile App, führt euch in den Welcome Flow und lässt euch
jederzeit zurück ins Dashboard springen, sobald ihr bereit seid.
<p className="text-lg text-slate-700 dark:text-slate-200">
{t('welcome.subtitle', 'Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.')}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button type="button" size="lg" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Event Admin öffnen'}
<div className="flex flex-wrap items-center gap-3">
<Button size="lg" onClick={handleLogin} className="bg-rose-500 text-white hover:bg-rose-600">
{t('welcome.cta.open', 'Event Admin öffnen')}
<ArrowRight className="h-4 w-4" />
</Button>
<Button
type="button"
size="lg"
variant="ghost"
className={cn('transition-colors duration-300', theme.ghostButton)}
onClick={handleScrollToFlow}
>
Geführten Ablauf ansehen
<Heart className="h-4 w-4" />
<Button variant="outline" size="lg" onClick={handleHow} className="border-rose-200 text-rose-700 hover:bg-rose-50">
{t('welcome.cta.how', "So funktioniert's")}
</Button>
<Button variant="ghost" size="lg" onClick={handlePackages} className="text-slate-700 hover:text-rose-700 dark:text-white">
{t('welcome.cta.packages', 'Pakete ansehen')}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{heroStats.map((stat) => (
<FrostedSurface
key={stat.label}
className={cn(
'rounded-3xl border px-5 py-4 shadow-2xl backdrop-blur transition-colors duration-300',
theme.statCard
)}
>
<p className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>{stat.value}</p>
<p
className={cn(
'text-xs font-semibold uppercase tracking-wide',
isLightMode ? 'text-slate-500' : 'text-white/70'
)}
>
{stat.label}
</p>
</FrostedSurface>
))}
</div>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-0 -z-10 translate-y-6 scale-105 rounded-[40px] bg-gradient-to-br from-rose-500/40 via-white/5 to-sky-500/30 blur-3xl" />
<div className="relative rounded-[40px] border border-white/15 bg-white/95 p-6 text-slate-900 shadow-[0_40px_90px_rgba(15,23,42,0.55)]">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Fotospiel</span>
<span>Event Admin</span>
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-rose-100/80 via-white/70 to-sky-100/60 blur-3xl dark:from-rose-400/10 dark:via-slate-900 dark:to-sky-400/10" aria-hidden />
<div className="relative flex h-full flex-col gap-4 rounded-3xl border border-slate-200/60 bg-white/80 p-6 shadow-lg shadow-rose-100/40 backdrop-blur dark:border-white/10 dark:bg-white/5 dark:shadow-none">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-white">
<Wand2 className="h-4 w-4 text-rose-500" />
{t('welcome.preview.title', 'Was dich erwartet')}
</div>
<div className="mt-4 rounded-3xl bg-slate-900/5 p-4">
<div className="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Heute</span>
<span>Live</span>
</div>
<div className="mt-4 space-y-3">
{['Mission Pack auswählen', 'Event Branding finalisieren', 'Einladungslink teilen'].map((item) => (
<div key={item} className="flex items-center justify-between rounded-2xl bg-white p-3 text-sm shadow">
<div>
<p className="font-semibold text-slate-900">{item}</p>
<p className="text-xs text-slate-500">Welcome Flow</p>
</div>
<Wand2 className="h-5 w-5 text-rose-500" />
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
{previewBullets.map((item) => (
<div key={item} className="flex items-start gap-2 rounded-2xl border border-slate-200/70 bg-white/70 px-3 py-2 text-left dark:border-white/10 dark:bg-white/5">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<p>{item}</p>
</div>
))}
</div>
<div className="mt-auto grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
<div className="rounded-2xl border border-dashed border-rose-200/70 bg-rose-50/70 p-4 dark:border-rose-200/30 dark:bg-rose-200/10">
<p className="text-[11px] uppercase tracking-[0.3em] text-rose-500">{t('welcome.highlight.moderation', 'Live-Moderation')}</p>
<p className="mt-2 font-semibold text-slate-900 dark:text-white">{t('welcome.highlight.moderationHint', 'Approve/Hide, Highlights, Galerie-Link')}</p>
</div>
<div className="mt-6 rounded-3xl bg-slate-900 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-3 text-xs uppercase tracking-[0.3em] text-white/70">
<span>Live Moments</span>
<span>+28</span>
</div>
<div className="space-y-4 px-5 py-6">
{[
{ title: 'Anna lädt 6 Fotos hoch', subtitle: 'Event · Gartenpalast' },
{ title: 'Max reagiert auf Mission „Emotionen“', subtitle: 'Tasks · Emotion Board' },
{ title: 'QR-Link 124x gescannt', subtitle: 'Einladungen · Photobooth' },
].map((entry) => (
<div key={entry.title} className="space-y-1">
<p className="text-sm font-semibold">{entry.title}</p>
<p className="text-xs text-white/70">{entry.subtitle}</p>
</div>
))}
<div className="rounded-2xl border border-dashed border-sky-200/80 bg-sky-50/80 p-4 dark:border-sky-200/40 dark:bg-sky-200/10">
<p className="text-[11px] uppercase tracking-[0.3em] text-sky-600">{t('welcome.highlight.tasks', 'Aufgaben & Emotion-Sets')}</p>
<p className="mt-2 font-semibold text-slate-900 dark:text-white">{t('welcome.highlight.tasksHint', 'Sammlungen importieren oder eigene erstellen')}</p>
</div>
</div>
</div>
</div>
</section>
<section className="grid gap-6 lg:grid-cols-3">
{featureCards.map((feature) => (
<FrostedSurface
key={feature.title}
className={cn(
'flex flex-col gap-4 rounded-3xl border p-6 shadow-lg backdrop-blur transition-colors duration-300',
theme.featureCard
)}
>
<div
className={cn('flex items-center gap-3 text-sm', isLightMode ? 'text-rose-600' : 'text-white/80')}
>
<feature.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-400' : 'text-rose-200')} />
<span>{feature.badge}</span>
<section className="space-y-4">
<div className="flex flex-col gap-2">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.features.title', 'Was du steuern kannst')}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.features.subtitle', 'Alles an einem Ort')}</h2>
</div>
<h2 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{features.map((feature) => (
<div
key={feature.key}
className="group flex h-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-md dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<feature.icon className="h-4 w-4 text-rose-500" />
{feature.title}
</h2>
<p
className={cn('text-sm leading-relaxed', isLightMode ? 'text-slate-600' : 'text-white/75')}
>
{feature.description}
</p>
</FrostedSurface>
))}
</section>
<section ref={flowSectionRef} className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
<FrostedSurface
className={cn('rounded-3xl border p-6 transition-colors duration-300', theme.timelineCard)}
>
<p
className={cn(
'text-xs font-semibold uppercase tracking-[0.4em]',
isLightMode ? 'text-rose-500' : 'text-rose-200'
)}
>
Guided Journey
</p>
<h3 className={cn('mt-3 text-3xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
So leitet euch der Welcome Flow
</h3>
<div className="mt-6 space-y-4">
{timelineSteps.map((step, index) => (
<div
key={step.title}
className={cn(
'flex gap-4 rounded-2xl border p-4 transition-colors duration-300',
isLightMode ? 'border-slate-200 bg-white' : 'border-white/15 bg-white/5'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-2xl text-lg font-semibold',
isLightMode ? 'bg-rose-100 text-rose-600' : 'bg-rose-500/15 text-white'
)}
>
{index + 1}
</div>
<div>
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{step.title}
</p>
<p className={cn('text-sm', theme.timelineDescription)}>{step.description}</p>
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{feature.description}</p>
</div>
))}
</div>
</FrostedSurface>
<div className="space-y-6">
<FrostedSurface className="flex flex-col gap-4 rounded-3xl border-white/15 bg-white/95 p-6 text-slate-900 shadow-2xl">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
<span>Mobile Greeting</span>
<span>Neu</span>
</div>
<p className="text-2xl font-semibold text-slate-900">Fühlt sich an wie eine native App</p>
<p className="text-sm text-slate-600">
Mit großen Touch-Flächen, Animationen und frosted Cards wirkt der Startscreen wie die mobile Version
des Event Admins perfekt für TWA und Capacitor Builds.
</p>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">CTA</p>
<p className="text-lg font-semibold text-slate-900">Event Admin öffnen</p>
<p className="text-sm text-slate-600">Leitet direkt zum OAuth Login</p>
</div>
<div className="rounded-2xl bg-slate-900/5 p-4">
<p className="text-xs text-slate-500">Scroll Action</p>
<p className="text-lg font-semibold text-slate-900">Journey ansehen</p>
<p className="text-sm text-slate-600">Smooth Scroll bis zur Timeline</p>
</div>
</div>
</FrostedSurface>
<div className="grid gap-4 sm:grid-cols-3">
{supportHighlights.map((support) => (
<FrostedSurface
key={support.title}
className={cn(
'flex flex-col gap-2 rounded-2xl border p-4 transition-colors duration-300',
theme.supportCard
)}
>
<support.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-500' : 'text-rose-200')} />
<p className={cn('font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
{support.title}
</p>
<p className={cn('text-sm', theme.supportDescription)}>{support.description}</p>
</FrostedSurface>
))}
</div>
</div>
</section>
<FrostedSurface
<section className="space-y-4">
<div className="flex flex-col gap-2">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.steps.title', "So funktioniert's")}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.steps.subtitle', 'In drei Schritten bereit')}</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{steps.map((step) => (
<div
key={step.key}
className="flex h-full flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{step.accent}
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{step.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-300">{step.description}</p>
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-2">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.plans.title', 'Pakete im Überblick')}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.plans.subtitle', 'Wähle das passende Kontingent')}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('welcome.plans.hint', 'Starter, Standard oder Reseller alles mit Moderation & Einladungen.')}</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.key}
className={cn(
'flex flex-col gap-4 rounded-3xl border px-6 py-5 transition-colors duration-300 md:flex-row md:items-center md:justify-between',
theme.ctaSurface
'flex h-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-5 text-left shadow-sm dark:border-white/10 dark:bg-white/5',
plan.highlight ? 'ring-1 ring-rose-200 dark:ring-rose-300/30' : ''
)}
>
<div>
<p className={cn('text-sm uppercase tracking-[0.4em]', theme.ctaDetail)}>Bereit?</p>
<h3 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
Wechselt ins Dashboard, sobald euer Flow steht.
</h3>
<p className={cn('text-sm', theme.ctaDetail)}>
Alle Schritte lassen sich später direkt aus dem Event Admin wieder öffnen.
</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-rose-100/80 text-rose-700 dark:bg-rose-300/20 dark:text-rose-100">
{plan.badge ?? t('welcome.plans.badge', 'Paket')}
</Badge>
{plan.highlight ? (
<Badge className="bg-emerald-500/15 text-emerald-700 dark:bg-emerald-400/20 dark:text-emerald-200">
{plan.highlight}
</Badge>
) : null}
</div>
<Button type="button" size="lg" className="self-start md:self-auto" onClick={handleLoginRedirect} disabled={isRedirecting}>
{isRedirecting ? 'Weiterleitung …' : 'Zum Login'}
<ArrowRight className="h-4 w-4" />
<div className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-white">
<Sparkles className="h-4 w-4 text-rose-500" />
{plan.title}
</div>
<ul className="space-y-2 text-sm text-slate-600 dark:text-slate-300">
{plan.points.map((point) => (
<li key={point} className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<span>{point}</span>
</li>
))}
</ul>
<div className="mt-auto">
<Button variant="outline" size="sm" className="border-rose-200 text-rose-700 hover:bg-rose-50" onClick={handlePackages}>
{t('welcome.cta.packages', 'Pakete ansehen')} <ArrowRight className="h-4 w-4" />
</Button>
</FrostedSurface>
</main>
</div>
</div>
))}
</div>
</section>
<footer className={cn('py-6 text-center text-xs transition-colors duration-300', theme.footer)}>
Fotospiel · Eure Gäste gestalten eure Lieblingsmomente
</footer>
<section className="space-y-4">
<div className="flex flex-col gap-2">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.audience.title', 'Für wen?')}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.audience.subtitle', 'Endkunden & Reseller im Blick')}</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
{audienceCards.map((audience) => (
<div
key={audience.key}
className="flex h-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-lg font-semibold text-slate-900 dark:text-white">
<audience.icon className="h-5 w-5 text-rose-500" />
{audience.title}
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{audience.description}</p>
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
<ArrowRight className="h-3.5 w-3.5" />
{t('welcome.audience.cta', 'Wenige Klicks bis zum Start')}
</div>
</div>
))}
</div>
</section>
<section className="rounded-3xl border border-slate-200 bg-white/85 p-6 shadow-lg shadow-rose-100/40 backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.footer.eyebrow', 'Bereit?')}</p>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">{t('welcome.footer.title', 'Melde dich an oder prüfe die Pakete')}</h3>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('welcome.footer.subtitle', 'Login für bestehende Kunden, Pakete für neue Teams.')}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="lg" onClick={handleLogin} className="bg-rose-500 text-white hover:bg-rose-600">
{t('welcome.cta.login', 'Login')} <ArrowRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="lg" onClick={handlePackages} className="border-rose-200 text-rose-700 hover:bg-rose-50">
{t('welcome.cta.packages', 'Pakete ansehen')}
</Button>
</div>
</div>
</section>
</main>
</div>
</div>
);

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { describe, expect, it, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
import WelcomeTeaserPage from '../WelcomeTeaserPage';
const navigateMock = vi.fn();
@@ -14,7 +16,27 @@ vi.mock('../../lib/navigation', () => ({
navigateToHref: (href: string) => navigateMock(href),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'unauthenticated' }),
}));
vi.mock('@/hooks/use-appearance', async () => {
const ReactImport = await import('react');
return {
useAppearance: () => {
const [appearance, setAppearance] = ReactImport.useState<'light' | 'dark'>('light');
return { appearance, updateAppearance: setAppearance };
},
};
});
describe('WelcomeTeaserPage', () => {
const renderWithI18n = () => render(
<I18nextProvider i18n={i18n}>
<WelcomeTeaserPage />
</I18nextProvider>
);
afterEach(() => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
vi.clearAllMocks();
@@ -22,7 +44,7 @@ describe('WelcomeTeaserPage', () => {
});
it('applies the tenant admin theme classes while mounted', () => {
const { unmount } = render(<WelcomeTeaserPage />);
const { unmount } = renderWithI18n();
expect(document.body.classList.contains('tenant-admin-theme')).toBe(true);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(true);
@@ -33,25 +55,25 @@ describe('WelcomeTeaserPage', () => {
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(false);
});
it('shows the hero stats and triggers the login redirect CTA', async () => {
render(<WelcomeTeaserPage />);
expect(screen.getByText('Events begleitet')).toBeInTheDocument();
it('shows the hero CTA and triggers the login redirect', async () => {
renderWithI18n();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /event admin öffnen/i }));
const loginButton = screen.getAllByRole('button', { name: /login/i })[0];
await user.click(loginButton);
expect(navigateMock).toHaveBeenCalledWith(expect.stringContaining('/event-admin/login'));
});
it('allows switching between light and dark presentation modes', async () => {
render(<WelcomeTeaserPage />);
renderWithI18n();
const user = userEvent.setup();
const toggle = screen.getByRole('button', { name: /light mode/i });
const toggle = screen.getByLabelText(/welcome\.theme\.aria|darstellung|appearance/i);
expect(toggle).toHaveTextContent(/hell|light/i);
await user.click(toggle);
expect(toggle).toHaveTextContent(/dark mode/i);
expect(toggle).toHaveTextContent(/dunkel|dark/i);
});
});

View File

@@ -32,10 +32,6 @@ const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage'));
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'));
const LogoutPage = React.lazy(() => import('./pages/LogoutPage'));
const LiveRedirectPage = React.lazy(() => import('./pages/LiveRedirectPage'));
const WelcomeLandingPage = React.lazy(() => import('./onboarding/pages/WelcomeLandingPage'));
const WelcomePackagesPage = React.lazy(() => import('./onboarding/pages/WelcomePackagesPage'));
const WelcomeEventSetupPage = React.lazy(() => import('./onboarding/pages/WelcomeEventSetupPage'));
const WelcomeOrderSummaryPage = React.lazy(() => import('./onboarding/pages/WelcomeOrderSummaryPage'));
function RequireAuth() {
const { status } = useAuth();
@@ -119,10 +115,6 @@ export const router = createBrowserRouter([
{ path: 'settings', element: <RequireAdminAccess><SettingsPage /></RequireAdminAccess> },
{ path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <RequireAdminAccess><ProfilePage /></RequireAdminAccess> },
{ path: 'welcome', element: <RequireAdminAccess><WelcomeLandingPage /></RequireAdminAccess> },
{ path: 'welcome/packages', element: <RequireAdminAccess><WelcomePackagesPage /></RequireAdminAccess> },
{ path: 'welcome/summary', element: <RequireAdminAccess><WelcomeOrderSummaryPage /></RequireAdminAccess> },
{ path: 'welcome/event', element: <RequireAdminAccess><WelcomeEventSetupPage /></RequireAdminAccess> },
],
},
],