überarbeitung des event-admins fortgesetzt
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user