überarbeitung des event-admins fortgesetzt
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
return warnings[0];
|
||||
}, [formatDate, normalizedActivePackage, t]);
|
||||
|
||||
const activeWarnings = React.useMemo(
|
||||
() => buildPackageWarnings(normalizedActivePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||
[normalizedActivePackage, t, formatDate],
|
||||
);
|
||||
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>
|
||||
);
|
||||
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
|
||||
@@ -322,14 +266,14 @@ export default function BillingPage() {
|
||||
tone="pink"
|
||||
helper={t('billing.sections.overview.cards.package.helper')}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: activePackage.remaining_events ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.used.label')}
|
||||
value={activePackage.used_events ?? 0}
|
||||
tone="amber"
|
||||
helper={t('billing.sections.overview.cards.used.helper', {
|
||||
count: computedRemainingEvents ?? 0,
|
||||
})}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t('billing.sections.overview.cards.price.label')}
|
||||
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,70 +425,71 @@ 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>
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
</TabsContent>
|
||||
<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>
|
||||
|
||||
<TabsContent value="setup" className="space-y-6">
|
||||
<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
|
||||
invites={toolkitData?.invites}
|
||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
<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
|
||||
invites={toolkitData?.invites}
|
||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
/>
|
||||
</div>
|
||||
<BrandingMissionCard
|
||||
event={event}
|
||||
invites={toolkitData?.invites}
|
||||
emotions={emotions}
|
||||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
/>
|
||||
{event.addons?.length ? (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BrandingMissionCard
|
||||
event={event}
|
||||
invites={toolkitData?.invites}
|
||||
emotions={emotions}
|
||||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
/>
|
||||
|
||||
{event.addons?.length ? (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||||
/>
|
||||
<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}
|
||||
<FeedbackCard slug={event.slug} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<AddonSummaryList addons={event.addons} t={(key, fallback) => t(key, fallback)} />
|
||||
</SectionCard>
|
||||
) : null}
|
||||
<GalleryShareCard
|
||||
invites={toolkitData?.invites}
|
||||
onManageInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
/>
|
||||
{event.limits?.gallery ? <GalleryStatusCard gallery={event.limits.gallery} /> : null}
|
||||
<FeedbackCard slug={event.slug} />
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
.filter(([, enabled]) => Boolean(enabled))
|
||||
.map(([key]) => FEATURE_LABELS[key] ?? key.replace(/_/g, ' '));
|
||||
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]) => 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,96 +537,19 @@ 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">
|
||||
<Badge className="bg-white/25 text-white backdrop-blur">
|
||||
{isEdit ? 'Gebuchtes Paket' : 'Aktives Paket'}
|
||||
</Badge>
|
||||
{packagePriceLabel ? (
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-white/90">
|
||||
{packagePriceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-2xl font-semibold tracking-tight text-white">
|
||||
{packageNameDisplay}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-pink-50">
|
||||
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{packageExpiresLabel ? (
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||||
Galerie aktiv bis {packageExpiresLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{remainingEventsLabel ? (
|
||||
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pb-6">
|
||||
{packageHighlights.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{packageHighlights.map((highlight) => (
|
||||
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
|
||||
<p className="text-white/70">{highlight.label}</p>
|
||||
<p className="font-semibold text-white">{highlight.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{featureTags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{featureTags.map((feature) => (
|
||||
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/75">
|
||||
{(packagesLoading || overviewLoading)
|
||||
? 'Paketdetails werden geladen...'
|
||||
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30"
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
>
|
||||
Abrechnung öffnen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/70 hover:bg-white/10"
|
||||
disabled
|
||||
>
|
||||
Upgrade-Optionen demnächst
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
|
||||
@@ -643,6 +588,92 @@ export default function EventFormPage() {
|
||||
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-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}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-pink-50">
|
||||
Du nutzt dieses Paket für dein Event. Upgrades und Add-ons folgen bald – bis dahin kannst du alle enthaltenen Leistungen voll ausschöpfen.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{packageExpiresLabel ? (
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/80">
|
||||
Galerie aktiv bis {packageExpiresLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{remainingEventsLabel ? (
|
||||
<p className="text-xs text-white/80">{remainingEventsLabel}</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pb-6">
|
||||
{packageHighlights.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{packageHighlights.map((highlight) => (
|
||||
<div key={`${highlight.label}-${highlight.value}`} className="rounded-xl bg-white/15 px-4 py-3 text-sm">
|
||||
<p className="text-white/70">{highlight.label}</p>
|
||||
<p className="font-semibold text-white">{highlight.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{featureTags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{featureTags.map((feature) => (
|
||||
<Badge key={feature} variant="secondary" className="bg-white/15 text-white backdrop-blur">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/75">
|
||||
{(packagesLoading || overviewLoading)
|
||||
? 'Paketdetails werden geladen...'
|
||||
: 'Für dieses Paket sind aktuell keine besonderen Features hinterlegt.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30"
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
>
|
||||
Abrechnung öffnen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/70 hover:bg-white/10"
|
||||
disabled
|
||||
>
|
||||
Upgrade-Optionen demnächst
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -3,5 +3,5 @@ import React from 'react';
|
||||
import EventDetailPage from './EventDetailPage';
|
||||
|
||||
export default function EventToolkitPage() {
|
||||
return <EventDetailPage mode="toolkit" />;
|
||||
return <EventDetailPage />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
}
|
||||
|
||||
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 locale = getCurrentLocale();
|
||||
const packagesHref = `/${locale}/packages`;
|
||||
const howItWorksHref = locale === 'de' ? `/${locale}/so-funktionierts` : `/${locale}/how-it-works`;
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
const toggleMode = React.useCallback(() => {
|
||||
setMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
}, []);
|
||||
|
||||
const handleLoginRedirect = React.useCallback(() => {
|
||||
if (isRedirecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRedirecting(true);
|
||||
|
||||
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 handleScrollToFlow = React.useCallback(() => {
|
||||
flowSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, []);
|
||||
|
||||
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="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="flex flex-wrap items-center gap-3">
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<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-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 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="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
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
className={cn('flex items-center gap-3 text-sm', isLightMode ? 'text-rose-600' : 'text-white/80')}
|
||||
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"
|
||||
>
|
||||
<feature.icon className={cn('h-5 w-5', isLightMode ? 'text-rose-400' : 'text-rose-200')} />
|
||||
<span>{feature.badge}</span>
|
||||
</div>
|
||||
<h2 className={cn('text-2xl font-semibold', isLightMode ? 'text-slate-900' : 'text-white')}>
|
||||
{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>
|
||||
<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}
|
||||
</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>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">{feature.description}</p>
|
||||
</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
|
||||
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
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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" />
|
||||
</Button>
|
||||
</FrostedSurface>
|
||||
<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 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 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>
|
||||
<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>
|
||||
</div>
|
||||
</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.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>
|
||||
|
||||
<footer className={cn('py-6 text-center text-xs transition-colors duration-300', theme.footer)}>
|
||||
Fotospiel · Eure Gäste gestalten eure Lieblingsmomente
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user