removed the old event admin components and pages

This commit is contained in:
Codex Agent
2025-12-12 13:38:06 +01:00
parent bbf8d4a0f4
commit 1719d96fed
85 changed files with 994 additions and 19981 deletions

View File

@@ -21,9 +21,10 @@ declare global {
type DevTenantSwitcherProps = { type DevTenantSwitcherProps = {
bottomOffset?: number; bottomOffset?: number;
variant?: 'floating' | 'inline';
}; };
export function DevTenantSwitcher({ bottomOffset = 16 }: DevTenantSwitcherProps) { export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) {
const helper = window.fotospielDemoAuth; const helper = window.fotospielDemoAuth;
const [loggingIn, setLoggingIn] = React.useState<string | null>(null); const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState<boolean>(() => { const [collapsed, setCollapsed] = React.useState<boolean>(() => {
@@ -55,6 +56,62 @@ export function DevTenantSwitcher({ bottomOffset = 16 }: DevTenantSwitcherProps)
return null; return null;
} }
if (variant === 'inline') {
if (collapsed) {
return (
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-3 py-1.5 text-xs font-semibold text-amber-700 shadow-sm shadow-amber-200/60 transition hover:bg-amber-50"
onClick={() => setCollapsed(false)}
>
<PanelRightOpen className="h-4 w-4" />
Demo tenants
</button>
);
}
return (
<div className="relative">
<div className="pointer-events-auto flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-xs shadow-xl shadow-amber-200/60">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<strong className="text-amber-800">Demo tenants</strong>
<span className="text-[10px] uppercase tracking-wide text-amber-600">Dev mode</span>
</div>
<button
type="button"
onClick={() => setCollapsed(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-600 transition hover:bg-amber-50"
aria-label="Switcher minimieren"
>
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
<div className="space-y-1">
{DEV_TENANT_KEYS.map(({ key, label }) => (
<Button
key={key}
variant="outline"
className="w-full border-amber-200 text-amber-800 hover:bg-amber-50"
disabled={Boolean(loggingIn)}
onClick={() => void handleLogin(key)}
>
{loggingIn === key ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verbinde...
</>
) : (
label
)}
</Button>
))}
</div>
</div>
</div>
);
}
if (collapsed) { if (collapsed) {
return ( return (
<button <button

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import type { EventAddonSummary } from '../../api';
type Props = {
addons: EventAddonSummary[];
t: (key: string, fallback: string, options?: Record<string, unknown>) => string;
};
export function AddonSummaryList({ addons, t }: Props) {
if (!addons.length) {
return null;
}
return (
<div className="space-y-3">
{addons.map((addon) => (
<div key={addon.id} className="flex flex-col gap-1 rounded-2xl border border-slate-200/70 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-white">{addon.label ?? addon.key}</p>
<p className="text-xs text-slate-500">
{buildSummary(addon, t)}
</p>
{addon.purchased_at ? (
<p className="text-xs text-slate-400">
{t('events.sections.addons.purchasedAt', `Purchased ${new Date(addon.purchased_at).toLocaleString()}`, {
date: new Date(addon.purchased_at).toLocaleString(),
})}
</p>
) : null}
</div>
<Badge variant={addon.status === 'completed' ? 'outline' : addon.status === 'pending' ? 'secondary' : 'destructive'}>
{t(`events.sections.addons.status.${addon.status}`, addon.status)}
</Badge>
</div>
))}
</div>
);
}
function buildSummary(addon: EventAddonSummary, t: (key: string, fallback: string, options?: Record<string, unknown>) => string): string {
const parts: string[] = [];
if (addon.extra_photos > 0) {
parts.push(
t('events.sections.addons.summary.photos', `+${addon.extra_photos} photos`, {
count: addon.extra_photos.toLocaleString(),
}),
);
}
if (addon.extra_guests > 0) {
parts.push(
t('events.sections.addons.summary.guests', `+${addon.extra_guests} guests`, {
count: addon.extra_guests.toLocaleString(),
}),
);
}
if (addon.extra_gallery_days > 0) {
parts.push(
t('events.sections.addons.summary.gallery', `+${addon.extra_gallery_days} days gallery`, {
count: addon.extra_gallery_days,
}),
);
}
return parts.join(' · ');
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { ShoppingCart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { EventAddonCatalogItem } from '../../api';
type Props = {
addons: EventAddonCatalogItem[];
scope: 'photos' | 'guests' | 'gallery';
onCheckout: (addonKey: string) => void;
busy?: boolean;
t: (key: string, fallback: string) => string;
};
const scopeDefaults: Record<Props['scope'], string[]> = {
photos: ['extra_photos_500', 'extra_photos_2000'],
guests: ['extra_guests_50', 'extra_guests_100'],
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
};
export function AddonsPicker({ addons, scope, onCheckout, busy, t }: Props) {
const options = React.useMemo(() => {
const whitelist = scopeDefaults[scope];
const filtered = addons.filter((addon) => whitelist.includes(addon.key));
return filtered.length ? filtered : addons;
}, [addons, scope]);
const [selected, setSelected] = React.useState<string | undefined>(() => options[0]?.key);
React.useEffect(() => {
setSelected(options[0]?.key);
}, [options]);
if (!options.length) {
return null;
}
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Select value={selected} onValueChange={(value) => setSelected(value)}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue placeholder={t('addons.selectPlaceholder', 'Add-on auswählen')} />
</SelectTrigger>
<SelectContent>
{options.map((addon) => (
<SelectItem key={addon.key} value={addon.key} disabled={!addon.price_id}>
{addon.label}
{!addon.price_id ? ' (kein Preis verknüpft)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!selected || busy || !options.find((a) => a.key === selected)?.price_id}
onClick={() => selected && onCheckout(selected)}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('addons.buyNow', 'Jetzt freischalten')}
</Button>
</div>
);
}

View File

@@ -1,400 +0,0 @@
import React from 'react';
import { Link, NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, CalendarDays, Settings } from 'lucide-react';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_BILLING_PATH,
} from '../constants';
import { registerApiErrorListener } from '../lib/apiError';
import { getDashboardSummary, getEvents, getTenantPackagesOverview } from '../api';
import { NotificationCenter } from './NotificationCenter';
import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
import { useAuth } from '../auth/context';
import { CommandShelf } from './CommandShelf';
type NavItem = {
key: string;
to: string;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
end?: boolean;
highlight?: boolean;
prefetchKey?: string;
};
type PageTab = {
key: string;
label: string;
href: string;
badge?: React.ReactNode;
};
interface AdminLayoutProps {
title: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
disableCommandShelf?: boolean;
tabs?: PageTab[];
currentTabKey?: string;
}
export function AdminLayout({ title, subtitle, actions, children, disableCommandShelf, tabs, currentTabKey }: AdminLayoutProps) {
const { t } = useTranslation('common');
const prefetchedPathsRef = React.useRef<Set<string>>(new Set());
const { events } = useEventContext();
const singleEvent = events.length === 1 ? events[0] : null;
const eventsPath = singleEvent?.slug ? ADMIN_EVENT_VIEW_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const eventsLabel = events.length === 1
? t('navigation.event', { defaultValue: 'Event' })
: t('navigation.events');
const photosPath = singleEvent?.slug ? ADMIN_EVENT_PHOTOS_PATH(singleEvent.slug) : ADMIN_EVENTS_PATH;
const billingLabel = t('navigation.billing', { defaultValue: 'Paket' });
const baseNavItems = React.useMemo<NavItem[]>(() => [
{
key: 'dashboard',
to: ADMIN_HOME_PATH,
label: t('navigation.dashboard'),
icon: LayoutDashboard,
end: true,
prefetchKey: ADMIN_HOME_PATH,
},
{
key: 'events',
to: eventsPath,
label: eventsLabel,
icon: CalendarDays,
end: Boolean(singleEvent?.slug),
highlight: events.length === 1,
prefetchKey: ADMIN_EVENTS_PATH,
},
{
key: 'billing',
to: ADMIN_BILLING_PATH,
label: billingLabel,
icon: Settings,
prefetchKey: ADMIN_BILLING_PATH,
},
], [eventsLabel, eventsPath, billingLabel, singleEvent, events.length, t]);
const { user } = useAuth();
const isMember = user?.role === 'member';
const navItems = React.useMemo(
() => baseNavItems.filter((item) => {
if (!isMember) {
return true;
}
return !['dashboard', 'billing'].includes(item.key);
}),
[baseNavItems, isMember],
);
const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () =>
Promise.all([
getDashboardSummary(),
getEvents(),
getTenantPackagesOverview(),
]).then(() => undefined),
[ADMIN_EVENTS_PATH]: () => getEvents().then(() => undefined),
[ADMIN_BILLING_PATH]: () => getTenantPackagesOverview().then(() => undefined),
[ADMIN_SETTINGS_PATH]: () => Promise.resolve(),
}), []);
const triggerPrefetch = React.useCallback(
(path: string) => {
if (prefetchedPathsRef.current.has(path)) {
return;
}
const runner = prefetchers[path as keyof typeof prefetchers];
if (!runner) {
return;
}
prefetchedPathsRef.current.add(path);
Promise.resolve(runner()).catch(() => {
prefetchedPathsRef.current.delete(path);
});
},
[prefetchers],
);
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme');
return () => {
document.body.classList.remove('tenant-admin-theme');
};
}, []);
React.useEffect(() => {
const unsubscribe = registerApiErrorListener((detail) => {
const fallback = t('errors.generic');
const message = detail?.message?.trim() ? detail.message : fallback;
toast.error(message, {
id: detail?.code ? `api-error-${detail.code}` : undefined,
});
});
return unsubscribe;
}, [t]);
return (
<div className="relative min-h-svh overflow-hidden bg-gradient-to-br from-rose-50 via-white to-slate-50 text-slate-900 dark:bg-slate-950 dark:text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_10%_10%,rgba(255,137,170,0.25),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(96,165,250,0.22),transparent_60%)] opacity-60 dark:opacity-30"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
<div className="relative z-10 flex min-h-svh flex-col">
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
<div className="mx-auto grid w-full max-w-6xl grid-cols-[1fr_auto] items-start gap-3 px-4 py-4 sm:px-6">
<div className="min-w-0 space-y-1">
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-white sm:text-2xl">{title}</h1>
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{disableCommandShelf ? <EventSwitcher compact /> : null}
{actions}
<NotificationCenter />
<UserMenu />
</div>
</div>
<nav className="hidden border-t border-slate-200/60 dark:border-white/5 sm:block">
<div className="mx-auto flex w-full max-w-6xl items-center gap-1 px-4 py-2 sm:px-6">
{navItems.map((item) => (
<NavLink
key={item.key}
to={item.to}
end={item.end}
onPointerEnter={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => triggerPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => triggerPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 rounded-2xl px-3 py-2 text-xs font-semibold uppercase tracking-wide transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
isActive
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
: cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-500 dark:text-slate-300',
'hover:text-slate-900 dark:hover:text-white'
)
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</div>
</nav>
{disableCommandShelf ? <EventMenuBar /> : <CommandShelf />}
{tabs && tabs.length ? <PageTabsNav tabs={tabs} currentKey={currentTabKey} /> : null}
</header>
<main className="relative z-10 mx-auto w-full max-w-5xl flex-1 px-4 pb-[calc(env(safe-area-inset-bottom,0)+5.5rem)] pt-5 sm:px-6 md:pb-16">
<div className="space-y-5">{children}</div>
</main>
<TenantMobileNav items={navItems} onPrefetch={triggerPrefetch} />
</div>
</div>
);
}
function PageTabsNav({ tabs, currentKey }: { tabs: PageTab[]; currentKey?: string }) {
const location = useLocation();
const { t } = useTranslation('common');
const [mobileOpen, setMobileOpen] = React.useState(false);
const isActive = (tab: PageTab): boolean => {
if (currentKey) {
return tab.key === currentKey;
}
return location.pathname === tab.href || location.pathname.startsWith(tab.href);
};
const activeTab = React.useMemo(() => tabs.find((tab) => isActive(tab)), [tabs, location.pathname, currentKey]);
const handleTabClick = React.useCallback(
(tab: PageTab) => {
setMobileOpen(false);
const [path, hash] = tab.href.split('#');
if (location.pathname === path && hash) {
window.location.hash = `#${hash}`;
}
},
[location.pathname],
);
return (
<div className="border-t border-slate-200/70 bg-white/80 backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-2 sm:px-6">
<div className="hidden gap-2 md:flex">
{tabs.map((tab) => {
const active = isActive(tab);
return (
<Link
key={tab.key}
to={tab.href}
onClick={() => handleTabClick(tab)}
className={cn(
'flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
active
? 'bg-rose-600 text-white shadow shadow-rose-300/40'
: 'bg-white text-slate-600 hover:text-slate-900 dark:bg-white/5 dark:text-slate-300 dark:hover:text-white'
)}
>
<span>{tab.label}</span>
{tab.badge !== undefined ? (
<Badge
variant={active ? 'secondary' : 'outline'}
className={cn(
active ? 'bg-white/20 text-white' : 'text-slate-600 dark:text-slate-300',
'rounded-full text-[11px]'
)}
>
{tab.badge}
</Badge>
) : null}
</Link>
);
})}
</div>
<div className="md:hidden">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl border border-slate-200/70 bg-white px-3 py-2 text-left text-sm font-semibold text-slate-700 shadow-sm dark:border-white/10 dark:bg-white/10 dark:text-slate-200"
>
<span>{activeTab?.label ?? t('navigation.tabs.active')}</span>
<span className="text-xs uppercase tracking-[0.3em] text-rose-500">{t('navigation.tabs.open')}</span>
</button>
</SheetTrigger>
<SheetContent
side="bottom"
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
>
<SheetHeader className="px-4 pt-0 text-left">
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
{t('navigation.tabs.title')}
</SheetTitle>
<SheetDescription>
{t('navigation.tabs.subtitle')}
</SheetDescription>
</SheetHeader>
<div className="mt-4 grid gap-2 px-4">
{tabs.map((tab) => {
const active = isActive(tab);
return (
<Link
key={`sheet-${tab.key}`}
to={tab.href}
onClick={() => {
handleTabClick(tab);
setMobileOpen(false);
}}
className={cn(
'flex items-center justify-between rounded-2xl border px-4 py-3 text-sm font-medium shadow-sm transition',
active
? 'border-rose-200 bg-rose-50 text-rose-700'
: 'border-slate-200 bg-white text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200'
)}
>
<span>{tab.label}</span>
{tab.badge !== undefined ? (
<Badge
variant={active ? 'secondary' : 'outline'}
className={cn(active ? 'bg-white/30 text-rose-700' : 'text-slate-600 dark:text-slate-200', 'rounded-full text-[11px]')}
>
{tab.badge}
</Badge>
) : null}
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
);
}
function TenantMobileNav({
items,
onPrefetch,
}: {
items: NavItem[];
onPrefetch: (path: string) => void;
}) {
const { t } = useTranslation('common');
return (
<nav className="md:hidden" aria-label={t('navigation.mobile', { defaultValue: 'Tenant Navigation' })}>
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 bottom-0 z-30 h-12 bg-gradient-to-t from-white via-white/40 to-transparent dark:from-slate-950 dark:via-slate-950/60 dark:to-transparent"
/>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-slate-200/80 bg-white/95 px-4 pb-[calc(env(safe-area-inset-bottom,0)+0.75rem)] pt-3 shadow-2xl shadow-rose-300/15 backdrop-blur supports-[backdrop-filter]:bg-white/90 dark:border-slate-800/70 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-xl items-center justify-around gap-1">
{items.map((item) => (
<NavLink
key={item.key}
to={item.to}
end={item.end}
onPointerEnter={() => onPrefetch(item.prefetchKey ?? item.to)}
onFocus={() => onPrefetch(item.prefetchKey ?? item.to)}
onTouchStart={() => onPrefetch(item.prefetchKey ?? item.to)}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:focus-visible:ring-offset-slate-950',
isActive
? 'bg-rose-600 text-white shadow-md shadow-rose-400/25'
: cn(
item.highlight
? 'text-rose-600 dark:text-rose-200'
: 'text-slate-600 dark:text-slate-300',
'hover:text-rose-700 dark:hover:text-rose-200'
)
)
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</div>
</div>
</nav>
);
}

View File

@@ -1,415 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Camera,
ClipboardList,
MessageSquare,
PlugZap,
PlusCircle,
QrCode,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
const MOBILE_SHELF_COACHMARK_KEY = 'tenant-admin:command-shelf-mobile-tip';
type CommandAction = {
key: string;
label: string;
description: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
href: string;
};
function formatNumber(value?: number | null): string {
if (typeof value !== 'number') {
return '';
}
if (value > 999) {
return `${(value / 1000).toFixed(1)}k`;
}
return String(value);
}
export function CommandShelf() {
const { events, activeEvent, isLoading, isError, refetch } = useEventContext();
const { t, i18n } = useTranslation('common');
const navigate = useNavigate();
const [mobileShelfOpen, setMobileShelfOpen] = React.useState(false);
const [coachmarkDismissed, setCoachmarkDismissed] = React.useState(() => {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(MOBILE_SHELF_COACHMARK_KEY) === '1';
});
if (isLoading) {
return (
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3">
<div className="h-6 w-40 animate-pulse rounded-lg bg-slate-200/70 dark:bg-white/10" />
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={`loading-${index.toString()}`}
className="h-20 animate-pulse rounded-2xl bg-slate-100/80 dark:bg-white/10"
/>
))}
</div>
</div>
</section>
);
}
if (isError) {
return (
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-amber-200/70 bg-amber-50/70 p-5 dark:border-amber-200/20 dark:bg-amber-500/10">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500" />
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-800 dark:text-white">
{t('commandShelf.error.title', 'Events konnten nicht geladen werden')}
</p>
<p className="text-xs text-slate-600 dark:text-slate-200">
{t('commandShelf.error.hint', 'Bitte versuche es erneut oder lade die Seite neu.')}
</p>
</div>
</div>
<div className="flex flex-wrap justify-between gap-3">
<EventSwitcher compact />
<Button
size="sm"
variant="outline"
className="rounded-full"
onClick={() => refetch()}
>
{t('commandShelf.error.retry', 'Erneut laden')}
</Button>
</div>
</div>
</section>
);
}
if (!events.length) {
// Hide the empty hero entirely; dashboard content already handles the zero-events case.
return null;
}
if (!activeEvent) {
return (
<section className="border-b border-slate-200/80 bg-white/80 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 rounded-3xl border border-slate-200 bg-white/80 p-5 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500" />
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-white">
{t('commandShelf.selectEvent.title', 'Kein aktives Event ausgewählt')}
</p>
<p className="text-xs text-slate-500 dark:text-slate-300">
{t('commandShelf.selectEvent.hint', 'Wähle unten ein Event aus, um Status und Aktionen zu sehen.')}
</p>
</div>
</div>
<div className="flex justify-end">
<EventSwitcher compact />
</div>
</div>
</section>
);
}
const slug = activeEvent.slug;
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const formattedDate = formatEventDate(activeEvent.event_date, locale);
const engagementMode = resolveEngagementMode(activeEvent);
const handleActionClick = React.useCallback((href: string, closeSheet = false) => {
if (closeSheet) {
setMobileShelfOpen(false);
}
navigate(href);
}, [navigate]);
const handleDismissCoachmark = React.useCallback(() => {
setCoachmarkDismissed(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem(MOBILE_SHELF_COACHMARK_KEY, '1');
}
}, []);
const showCoachmark = !coachmarkDismissed && !mobileShelfOpen;
const actionItems: CommandAction[] = [
{
key: 'photos',
label: t('commandShelf.actions.photos.label', 'Fotos moderieren'),
description: t('commandShelf.actions.photos.desc', 'Prüfe neue Uploads, Highlights & Sperren.'),
icon: Camera,
href: ADMIN_EVENT_PHOTOS_PATH(slug),
},
{
key: 'tasks',
label: t('commandShelf.actions.tasks.label', 'Aufgaben pflegen'),
description: t('commandShelf.actions.tasks.desc', 'Mission Cards & Moderation im Blick.'),
icon: ClipboardList,
href: ADMIN_EVENT_TASKS_PATH(slug),
},
{
key: 'invites',
label: t('commandShelf.actions.invites.label', 'QR-Codes'),
description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
href: ADMIN_EVENT_INVITES_PATH(slug),
},
{
key: 'photobooth',
label: t('commandShelf.actions.photobooth.label', 'Photobooth anbinden'),
description: t('commandShelf.actions.photobooth.desc', 'FTP-Zugang und Rate-Limits steuern.'),
icon: PlugZap,
href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug),
},
{
key: 'members',
label: t('eventMenu.guests', 'Team & Gäste'),
description: t('commandShelf.actions.toolkit.desc', 'Broadcasts, Aufgaben & Quicklinks.'),
icon: MessageSquare,
href: ADMIN_EVENT_MEMBERS_PATH(slug),
},
];
const metrics = [
{
key: 'photos',
label: t('commandShelf.metrics.photos', 'Uploads'),
value: activeEvent.photo_count,
hint: t('commandShelf.metrics.total', 'gesamt'),
},
{
key: 'pending',
label: t('commandShelf.metrics.pending', 'Moderation'),
value: activeEvent.pending_photo_count,
hint: t('commandShelf.metrics.pendingHint', 'offen'),
},
{
key: 'tasks',
label: t('commandShelf.metrics.tasks', 'Aufgaben'),
value: activeEvent.tasks_count,
hint: t('commandShelf.metrics.tasksHint', 'aktiv'),
},
{
key: 'invites',
label: t('commandShelf.metrics.invites', 'QR-Codes'),
value: activeEvent.active_invites_count ?? activeEvent.total_invites_count,
hint: t('commandShelf.metrics.invitesHint', 'live'),
},
];
const statusLabel = activeEvent.status === 'published'
? t('commandShelf.status.published', 'Veröffentlicht')
: t('commandShelf.status.draft', 'Entwurf');
const liveBadge = activeEvent.is_active
? t('commandShelf.status.live', 'Live für Gäste')
: t('commandShelf.status.hidden', 'Versteckt');
const engagementLabel = engagementMode === 'photo_only'
? t('commandShelf.status.photoOnly', 'Nur Foto-Modus')
: t('commandShelf.status.tasksMode', 'Mission Cards aktiv');
return (
<>
<section className="hidden border-b border-slate-200/80 bg-white/80 px-4 py-5 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/70 md:block">
<div className="mx-auto w-full max-w-6xl space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.4em] text-slate-500 dark:text-slate-300">
{t('commandShelf.sectionTitle', 'Aktuelles Event')}
</p>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{resolveEventDisplayName(activeEvent)}
</h2>
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
{statusLabel}
</Badge>
<Badge variant="outline" className="text-xs uppercase tracking-wide">
{liveBadge}
</Badge>
{engagementMode ? (
<Badge variant="outline" className="text-xs uppercase tracking-wide">
{engagementLabel}
</Badge>
) : null}
</div>
<p className="text-xs text-slate-500 dark:text-slate-300">
{formattedDate ? `${formattedDate} · ` : ''}
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<EventSwitcher compact />
<Button
size="sm"
variant="ghost"
className="rounded-full text-rose-600 hover:bg-rose-50 dark:text-rose-200 dark:hover:bg-rose-200/10"
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}
>
<Sparkles className="mr-2 h-4 w-4" />
{t('commandShelf.cta.toolkit', 'Event-Day öffnen')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-slate-500 dark:text-slate-300">
{metrics.map((metric) => (
<div key={metric.key} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-center dark:border-white/10">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
<p className="text-[10px] uppercase tracking-[0.3em]">
{metric.label}
</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
</div>
))}
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
{actionItems.map((action) => (
<button
key={action.key}
type="button"
onClick={() => handleActionClick(action.href)}
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-3 text-left transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:hover:border-rose-300/40"
>
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
</div>
</button>
))}
</div>
<EventMenuBar />
</div>
</section>
<section className="border-b border-slate-200/80 bg-white/85 px-4 py-4 backdrop-blur-sm dark:border-white/10 dark:bg-slate-950/80 md:hidden">
<div className="mx-auto w-full max-w-6xl space-y-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold text-slate-900 dark:text-white">{resolveEventDisplayName(activeEvent)}</h2>
<Badge className={cn(activeEvent.status === 'published' ? 'bg-emerald-500 text-white' : 'bg-amber-500 text-white')}>
{statusLabel}
</Badge>
<Badge variant="outline" className="text-xs uppercase tracking-wide">
{liveBadge}
</Badge>
</div>
<p className="text-xs text-slate-500 dark:text-slate-300">
{formattedDate ? `${formattedDate} · ` : ''}
{activeEvent.package?.name ?? t('commandShelf.packageFallback', 'Standard-Paket')}
</p>
{engagementMode ? (
<p className="text-[11px] uppercase tracking-[0.3em] text-slate-400 dark:text-slate-500">{engagementLabel}</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500 dark:text-slate-300">
{metrics.map((metric) => (
<div key={`mobile-${metric.key}`} className="rounded-2xl border border-slate-200/80 px-3 py-2 text-left dark:border-white/10">
<p className="text-base font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</p>
<p className="text-[11px] uppercase tracking-[0.3em]">{metric.label}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">{metric.hint}</p>
</div>
))}
</div>
{showCoachmark ? (
<div className="rounded-2xl border border-rose-200/70 bg-rose-50/80 px-4 py-3 text-sm text-rose-700 dark:border-rose-300/40 dark:bg-rose-300/20 dark:text-rose-100">
<p>{t('commandShelf.mobile.tip', 'Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.')}</p>
<div className="mt-2 flex justify-end">
<Button size="sm" variant="ghost" onClick={handleDismissCoachmark} className="text-rose-700 hover:bg-rose-100 dark:text-rose-100 dark:hover:bg-rose-300/20">
{t('commandShelf.mobile.tipCta', 'Verstanden')}
</Button>
</div>
</div>
) : null}
<Sheet open={mobileShelfOpen} onOpenChange={setMobileShelfOpen}>
<SheetTrigger asChild>
<Button className="w-full rounded-2xl bg-rose-600 text-white shadow-lg shadow-rose-400/30">
{t('commandShelf.mobile.openActions', 'Schnellaktionen öffnen')}
</Button>
</SheetTrigger>
<SheetContent
side="bottom"
className="rounded-t-3xl border-t border-slate-200/70 bg-white/95 pb-6 pt-6 dark:border-white/10 dark:bg-slate-950/95"
>
<div className="mx-auto flex w-full max-w-xl flex-col gap-4">
<div className="mx-auto h-1.5 w-12 rounded-full bg-slate-300" aria-hidden />
<SheetHeader className="px-4 pt-0 text-left">
<SheetTitle className="text-lg font-semibold text-slate-900 dark:text-white">
{t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')}
</SheetTitle>
<SheetDescription>
{t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und QR-Codes an einem Ort.')}
</SheetDescription>
</SheetHeader>
<div className="flex flex-wrap gap-2 px-4 text-xs text-slate-500 dark:text-slate-300">
{metrics.map((metric) => (
<div key={`sheet-${metric.key}`} className="rounded-xl border border-slate-200 px-3 py-1.5 dark:border-white/10">
<span className="text-sm font-semibold text-slate-900 dark:text-white">{formatNumber(metric.value)}</span>
<span className="ml-2 uppercase tracking-[0.25em]">{metric.label}</span>
</div>
))}
</div>
<div className="grid gap-2 px-4">
{actionItems.map((action) => (
<button
key={`sheet-action-${action.key}`}
type="button"
onClick={() => handleActionClick(action.href, true)}
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 text-left shadow-sm transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5"
>
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
</div>
</button>
))}
</div>
<div className="px-4">
<EventMenuBar />
</div>
</div>
</SheetContent>
</Sheet>
</div>
</section>
</>
);
}

View File

@@ -1,213 +0,0 @@
import React from 'react';
import { useNavigate, NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { type TenantEvent } from '../api';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useEventContext } from '../context/EventContext';
import {
ADMIN_EVENT_CREATE_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_BRANDING_PATH,
} from '../constants';
import { cn } from '@/lib/utils';
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
return [
{ key: 'summary', label: t('eventMenu.summary', 'Übersicht'), href: ADMIN_EVENT_VIEW_PATH(slug) },
{ key: 'photos', label: t('eventMenu.photos', 'Uploads'), href: ADMIN_EVENT_PHOTOS_PATH(slug) },
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
{ key: 'invites', label: t('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(slug) },
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
];
}
type EventSwitcherProps = {
buttonClassName?: string;
compact?: boolean;
};
export function EventSwitcher({ buttonClassName, compact = false }: EventSwitcherProps = {}) {
const { events, activeEvent, selectEvent } = useEventContext();
const { t, i18n } = useTranslation('common');
const navigate = useNavigate();
const [open, setOpen] = React.useState(false);
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const buttonLabel = activeEvent ? resolveEventDisplayName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
const buttonHint = activeEvent?.event_date
? formatEventDate(activeEvent.event_date, locale)
: events.length > 1
? t('eventSwitcher.multiple', 'Mehrere Events')
: t('eventSwitcher.empty', 'Noch kein Event');
const handleSelect = (event: TenantEvent) => {
selectEvent(event.slug ?? null);
setOpen(false);
if (event.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(event.slug));
}
};
const buttonClasses = cn(
'rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white',
compact && 'px-3 text-xs sm:text-sm',
buttonClassName,
);
const buttonLabelClasses = compact ? 'text-sm' : 'hidden sm:inline';
const hintClasses = compact
? 'text-xs text-slate-500 dark:text-slate-300'
: 'text-xs text-slate-500 dark:text-slate-300 sm:ml-2';
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className={buttonClasses}>
<CalendarDays className="mr-2 h-4 w-4" />
<span className={buttonLabelClasses}>{buttonLabel}</span>
<span className={hintClasses}>
{buttonHint}
</span>
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="rounded-t-3xl p-0">
<SheetHeader className="border-b border-slate-200 p-4 dark:border-white/10">
<SheetTitle>{t('eventSwitcher.title', 'Event auswählen')}</SheetTitle>
<SheetDescription>
{events.length === 0
? t('eventSwitcher.emptyDescription', 'Erstelle dein erstes Event, um loszulegen.')
: t('eventSwitcher.description', 'Wähle ein Event für die Bearbeitung oder lege ein neues an.')}
</SheetDescription>
</SheetHeader>
<div className="max-h-[60vh] overflow-y-auto p-4">
{events.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-600 dark:border-white/15 dark:text-slate-300">
{t('eventSwitcher.noEvents', 'Noch keine Events vorhanden.')}
</div>
) : (
<div className="space-y-2">
{events.map((event) => {
const isActive = activeEvent?.id === event.id;
const date = formatEventDate(event.event_date, locale);
return (
<button
key={event.id}
type="button"
onClick={() => handleSelect(event)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition hover:border-rose-200 dark:border-white/10 dark:bg-white/5',
isActive
? 'border-rose-500 bg-rose-50/70 text-rose-900 dark:border-rose-300 dark:bg-rose-200/10 dark:text-rose-100'
: 'bg-white text-slate-900'
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">{resolveEventDisplayName(event)}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
</div>
{isActive ? (
<Badge className="bg-rose-600 text-white">{t('eventSwitcher.active', 'Aktiv')}</Badge>
) : null}
</div>
</button>
);
})}
</div>
)}
<Button
size="sm"
variant="secondary"
className="mt-4 w-full rounded-full"
onClick={() => {
setOpen(false);
navigate(ADMIN_EVENT_CREATE_PATH);
}}
>
<PlusCircle className="mr-2 h-4 w-4" />
{t('eventSwitcher.create', 'Neues Event anlegen')}
</Button>
{activeEvent?.slug ? (
<div className="mt-6 space-y-3">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300">
{t('eventSwitcher.actions', 'Event-Funktionen')}
</p>
<div className="grid gap-2">
{buildEventLinks(activeEvent.slug, t).map((action) => (
<Button
key={action.key}
variant="ghost"
className="justify-between rounded-2xl border border-slate-200 bg-white text-left text-sm font-semibold text-slate-700 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
onClick={() => {
setOpen(false);
navigate(action.href);
}}
>
{action.label}
<ChevronDown className="rotate-[-90deg]" />
</Button>
))}
</div>
</div>
) : null}
</div>
</SheetContent>
</Sheet>
);
}
export function EventMenuBar() {
const { activeEvent } = useEventContext();
const { t } = useTranslation('common');
const location = useLocation();
if (!activeEvent?.slug) {
return null;
}
const links = buildEventLinks(activeEvent.slug, t);
return (
<div className="border-t border-slate-200 bg-white/80 px-4 py-2 dark:border-white/10 dark:bg-slate-950/80">
<div className="flex items-center gap-2 overflow-x-auto text-sm">
{links.map((link) => (
<NavLink
key={link.key}
to={link.href}
className={({ isActive }) =>
cn(
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold transition',
isActive || location.pathname.startsWith(link.href)
? 'bg-rose-600 text-white shadow shadow-rose-400/40'
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:text-rose-600 dark:bg-white/10 dark:text-white dark:ring-white/10'
)
}
>
{link.label}
</NavLink>
))}
</div>
</div>
);
}

View File

@@ -1,70 +0,0 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type ActionTone = 'primary' | 'secondary' | 'danger' | 'neutral';
export type FloatingAction = {
key: string;
label: string;
icon: LucideIcon;
onClick: () => void;
tone?: ActionTone;
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
};
export function FloatingActionBar({ actions, className }: { actions: FloatingAction[]; className?: string }): React.ReactElement | null {
if (!actions.length) {
return null;
}
const toneClasses: Record<ActionTone, string> = {
primary: 'bg-primary text-primary-foreground shadow-primary/25 hover:bg-primary/90 focus-visible:ring-primary/70 border border-primary/20',
secondary: 'bg-[var(--tenant-surface-strong)] text-[var(--tenant-foreground)] shadow-slate-300/60 hover:bg-[var(--tenant-surface)] focus-visible:ring-slate-200 border border-[var(--tenant-border-strong)]',
neutral: 'bg-white/90 text-slate-900 shadow-slate-200/80 hover:bg-white focus-visible:ring-slate-200 border border-slate-200 dark:bg-slate-800/80 dark:text-white dark:border-slate-700',
danger: 'bg-rose-500 text-white shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-rose-200 border border-rose-400/80',
};
return (
<div
className={cn(
'pointer-events-none fixed inset-x-4 bottom-[calc(env(safe-area-inset-bottom,0px)+72px)] z-50 sm:inset-auto sm:right-6 sm:bottom-6',
className
)}
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<div className="pointer-events-auto flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
{actions.map((action) => {
const Icon = action.icon;
const tone = action.tone ?? 'primary';
return (
<Button
key={action.key}
size="lg"
className={cn(
'group flex h-11 w-11 items-center justify-center gap-0 rounded-full p-0 text-sm font-semibold shadow-lg transition-all duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2',
toneClasses[tone]
)}
onClick={action.onClick}
disabled={action.disabled || action.loading}
aria-label={action.ariaLabel ?? action.label}
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{action.label}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -1,256 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-hot-toast';
import { AlertCircle, Send, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import type { GuestNotificationSummary, SendGuestNotificationPayload } from '../api';
import { listGuestNotifications, sendGuestNotification } from '../api';
const TYPE_OPTIONS = [
{ value: 'broadcast', label: 'Allgemein' },
{ value: 'support_tip', label: 'Support-Hinweis' },
{ value: 'upload_alert', label: 'Upload-Status' },
{ value: 'feedback_request', label: 'Feedback' },
];
const AUDIENCE_OPTIONS = [
{ value: 'all', label: 'Alle Gäste' },
{ value: 'guest', label: 'Einzelne Geräte-ID' },
];
type GuestBroadcastCardProps = {
eventSlug: string;
eventName?: string | null;
};
export function GuestBroadcastCard({ eventSlug, eventName }: GuestBroadcastCardProps) {
const { t } = useTranslation('management');
const [form, setForm] = React.useState({
title: '',
message: '',
type: 'broadcast',
audience: 'all',
guest_identifier: '',
cta_label: '',
cta_url: '',
expires_in_minutes: 120,
});
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
const [loadingHistory, setLoadingHistory] = React.useState(true);
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadHistory = React.useCallback(async () => {
setLoadingHistory(true);
try {
const data = await listGuestNotifications(eventSlug);
setHistory(data.slice(0, 5));
} catch (err) {
console.error(err);
} finally {
setLoadingHistory(false);
}
}, [eventSlug]);
React.useEffect(() => {
void loadHistory();
}, [loadHistory]);
function updateField(field: string, value: string): void {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setSubmitting(true);
setError(null);
const payload: SendGuestNotificationPayload = {
title: form.title.trim(),
message: form.message.trim(),
type: form.type,
audience: form.audience as 'all' | 'guest',
guest_identifier: form.audience === 'guest' ? form.guest_identifier.trim() : undefined,
expires_in_minutes: Number(form.expires_in_minutes) || undefined,
cta:
form.cta_label.trim() && form.cta_url.trim()
? { label: form.cta_label.trim(), url: form.cta_url.trim() }
: undefined,
};
try {
await sendGuestNotification(eventSlug, payload);
toast.success(t('events.notifications.toastSuccess', 'Nachricht gesendet.'));
setForm((prev) => ({
...prev,
title: '',
message: '',
guest_identifier: '',
cta_label: '',
cta_url: '',
}));
void loadHistory();
} catch (err) {
console.error(err);
setError(t('events.notifications.toastError', 'Nachricht konnte nicht gesendet werden.'));
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">
{t('events.notifications.description', 'Sende kurze Hinweise direkt an deine Gäste. Ideal für Programmpunkte, Upload-Hilfe oder Feedback-Aufrufe.')} {eventName && <span className="font-semibold text-foreground">{eventName}</span>}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-type">{t('events.notifications.type', 'Art der Nachricht')}</Label>
<select
id="notification-type"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.type}
onChange={(event) => updateField('type', event.target.value)}
>
{TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="notification-audience">{t('events.notifications.audience', 'Zielgruppe')}</Label>
<select
id="notification-audience"
className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.audience}
onChange={(event) => updateField('audience', event.target.value)}
>
{AUDIENCE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{form.audience === 'guest' && (
<div>
<Label htmlFor="notification-target">{t('events.notifications.target', 'Geräte-ID oder Gastname')}</Label>
<Input
id="notification-target"
value={form.guest_identifier}
onChange={(event) => updateField('guest_identifier', event.target.value)}
placeholder="z. B. device-123"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notification-title">{t('events.notifications.titleLabel', 'Überschrift')}</Label>
<Input
id="notification-title"
value={form.title}
onChange={(event) => updateField('title', event.target.value)}
placeholder={t('events.notifications.titlePlaceholder', 'Buffet schließt in 10 Minuten')}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="notification-message">{t('events.notifications.message', 'Nachricht')}</Label>
<Textarea
id="notification-message"
value={form.message}
onChange={(event) => updateField('message', event.target.value)}
rows={4}
placeholder={t('events.notifications.messagePlaceholder', 'Kommt zur Hauptbühne für das Gruppenfoto.')}
required
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-cta-label">{t('events.notifications.ctaLabel', 'CTA-Label (optional)')}</Label>
<Input
id="notification-cta-label"
value={form.cta_label}
onChange={(event) => updateField('cta_label', event.target.value)}
placeholder={t('events.notifications.ctaLabelPlaceholder', 'Zum Upload')}
/>
</div>
<div>
<Label htmlFor="notification-cta-url">{t('events.notifications.ctaUrl', 'CTA-Link')}</Label>
<Input
id="notification-cta-url"
value={form.cta_url}
onChange={(event) => updateField('cta_url', event.target.value)}
placeholder="https://... oder /e/token/queue"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="notification-expiry">{t('events.notifications.expiry', 'Automatisch ausblenden nach (Minuten)')}</Label>
<Input
id="notification-expiry"
type="number"
min={5}
max={2880}
value={form.expires_in_minutes}
onChange={(event) => updateField('expires_in_minutes', event.target.value)}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="inline-flex items-center gap-2" disabled={submitting}>
{submitting && <RefreshCw className="h-4 w-4 animate-spin" aria-hidden />}
{!submitting && <Send className="h-4 w-4" aria-hidden />}
{t('events.notifications.sendCta', 'Benachrichtigung senden')}
</Button>
</form>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">{t('events.notifications.historyTitle', 'Zuletzt versendet')}</p>
<Button variant="ghost" size="sm" className="gap-2" onClick={() => loadHistory()} disabled={loadingHistory}>
<RefreshCw className={`h-4 w-4 ${loadingHistory ? 'animate-spin' : ''}`} aria-hidden />
{t('events.notifications.reload', 'Aktualisieren')}
</Button>
</div>
{loadingHistory ? (
<p className="text-sm text-muted-foreground">{t('events.notifications.historyLoading', 'Verlauf wird geladen ...')}</p>
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('events.notifications.historyEmpty', 'Noch keine Benachrichtigungen versendet.')}</p>
) : (
<ul className="space-y-2">
{history.map((notification) => (
<li key={notification.id} className="rounded-lg border border-border bg-card px-3 py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<div>
<p className="font-semibold text-foreground">{notification.title}</p>
<p className="text-xs text-muted-foreground">
{new Date(notification.created_at ?? '').toLocaleString()} · {notification.type}
</p>
</div>
<Badge variant="outline">{notification.audience_scope === 'all' ? t('events.notifications.audienceAll', 'Alle') : t('events.notifications.audienceGuest', 'Gast')}</Badge>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import React from 'react';
import { Check, Languages } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
export function LanguageSwitcher() {
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale();
const changeLanguage = React.useCallback(
async (locale: SupportedLocale) => {
if (locale === currentLocale || pendingLocale) {
return;
}
setPendingLocale(locale);
try {
await switchLocale(locale);
} catch (error) {
if (import.meta.env.DEV) {
console.error('Failed to switch language', error);
}
} finally {
setPendingLocale(null);
}
},
[currentLocale, pendingLocale]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
aria-label={t('app.languageSwitch')}
>
<Languages className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">{t('app.languageSwitch')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => {
const isActive = currentLocale === code;
const isPending = pendingLocale === code;
return (
<DropdownMenuItem
key={code}
onSelect={(event) => {
event.preventDefault();
changeLanguage(code);
}}
className="flex items-center justify-between gap-3"
disabled={isPending}
>
<span>{t(labelKey)}</span>
{(isActive || isPending) && <Check className="h-4 w-4 text-brand-rose" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,296 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Bell, CheckCircle2, Clock, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { getDashboardSummary, getEvents, type DashboardSummary, type TenantEvent } from '../api';
import { ADMIN_EVENT_CREATE_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
export type NotificationTone = 'info' | 'warning' | 'success';
interface TenantNotification {
id: string;
title: string;
description?: string;
tone: NotificationTone;
action?: {
label: string;
onSelect: () => void;
};
}
export function NotificationCenter() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [notifications, setNotifications] = React.useState<TenantNotification[]>([]);
const [dismissed, setDismissed] = React.useState<Set<string>>(new Set());
const visibleNotifications = React.useMemo(
() => notifications.filter((notification) => !dismissed.has(notification.id)),
[notifications, dismissed]
);
const unreadCount = visibleNotifications.length;
const refresh = React.useCallback(async () => {
setLoading(true);
try {
const [events, summary] = await Promise.all([
getEvents().catch(() => [] as TenantEvent[]),
getDashboardSummary().catch(() => null as DashboardSummary | null),
]);
setNotifications(buildNotifications({
events,
summary,
navigate,
t,
}));
} catch (error) {
if (!isAuthError(error)) {
console.error('[NotificationCenter] Failed to load data', error);
}
} finally {
setLoading(false);
}
}, [navigate, t]);
React.useEffect(() => {
refresh();
}, [refresh]);
const handleDismiss = React.useCallback((id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
const iconForTone: Record<NotificationTone, React.ReactNode> = React.useMemo(
() => ({
info: <Clock className="h-4 w-4 text-slate-400" />,
warning: <AlertTriangle className="h-4 w-4 text-amber-500" />,
success: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
}),
[]
);
return (
<DropdownMenu open={open} onOpenChange={(next) => {
setOpen(next);
if (next) {
refresh();
}
}}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full border border-transparent text-slate-600 hover:text-rose-600 dark:text-slate-200"
aria-label={t('notifications.trigger')}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<Badge className="absolute -right-1 -top-1 rounded-full bg-rose-600 px-1.5 text-[10px] font-semibold text-white">
{unreadCount}
</Badge>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 space-y-1 p-0">
<DropdownMenuLabel className="flex items-center justify-between py-2">
<span>{t('notifications.title')}</span>
{!loading && unreadCount === 0 ? (
<Badge variant="outline">{t('notifications.empty')}</Badge>
) : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{loading ? (
<div className="space-y-2 p-3">
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-12 w-full rounded-xl" />
</div>
) : (
<div className="max-h-80 space-y-1 overflow-y-auto p-1">
{visibleNotifications.length === 0 ? (
<p className="px-3 py-4 text-sm text-slate-500">
{t('notifications.empty.message')}
</p>
) : (
visibleNotifications.map((item) => (
<DropdownMenuItem key={item.id} className="flex flex-col gap-1 py-3" onSelect={(event) => event.preventDefault()}>
<div className="flex items-start gap-3">
<span className="mt-0.5">{iconForTone[item.tone]}</span>
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{item.title}</p>
{item.description ? (
<p className="text-xs text-slate-600 dark:text-slate-300">{item.description}</p>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
{item.action ? (
<Button
size="sm"
variant="outline"
className="h-7 rounded-full px-3 text-xs"
onClick={() => {
item.action?.onSelect();
setOpen(false);
}}
>
{item.action.label}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
className="h-7 rounded-full px-3 text-xs text-slate-500 hover:text-rose-600"
onClick={() => handleDismiss(item.id)}
>
{t('notifications.action.dismiss')}
</Button>
</div>
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(event) => {
event.preventDefault();
setDismissed(new Set());
refresh();
}}
>
<Plus className="h-3.5 w-3.5" />
{t('notifications.action.refresh')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function buildNotifications({
events,
summary,
navigate,
t,
}: {
events: TenantEvent[];
summary: DashboardSummary | null;
navigate: ReturnType<typeof useNavigate>;
t: (key: string, options?: Record<string, unknown>) => string;
}): TenantNotification[] {
const items: TenantNotification[] = [];
const primary = events[0] ?? null;
const now = Date.now();
if (events.length === 0) {
items.push({
id: 'no-events',
title: t('notifications.noEvents.title'),
description: t('notifications.noEvents.description'),
tone: 'warning',
action: {
label: t('notifications.noEvents.cta'),
onSelect: () => navigate(ADMIN_EVENT_CREATE_PATH),
},
});
return items;
}
events.forEach((event) => {
if (event.status !== 'published') {
items.push({
id: `draft-${event.id}`,
title: t('notifications.draftEvent.title'),
description: t('notifications.draftEvent.description'),
tone: 'info',
action: event.slug
? {
label: t('notifications.draftEvent.cta'),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
}
: undefined,
});
}
const eventDate = event.event_date ? new Date(event.event_date).getTime() : null;
if (eventDate && eventDate > now) {
const days = Math.round((eventDate - now) / (1000 * 60 * 60 * 24));
if (days <= 7) {
items.push({
id: `upcoming-${event.id}`,
title: t('notifications.upcomingEvent.title'),
description: days === 0
? t('notifications.upcomingEvent.description_today')
: t('notifications.upcomingEvent.description_days', { count: days }),
tone: 'info',
action: event.slug
? {
label: t('notifications.upcomingEvent.cta'),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(event.slug!)),
}
: undefined,
});
}
}
const pendingUploads = Number(event.pending_photo_count ?? 0);
if (pendingUploads > 0) {
items.push({
id: `pending-uploads-${event.id}`,
title: t('notifications.pendingUploads.title'),
description: t('notifications.pendingUploads.description', { count: pendingUploads }),
tone: 'warning',
action: event.slug
? {
label: t('notifications.pendingUploads.cta'),
onSelect: () => navigate(`${ADMIN_EVENT_VIEW_PATH(event.slug!)}#photos`),
}
: undefined,
});
}
});
if ((summary?.new_photos ?? 0) > 0) {
items.push({
id: 'summary-new-photos',
title: t('notifications.newPhotos.title'),
description: t('notifications.newPhotos.description', { count: summary?.new_photos ?? 0 }),
tone: 'success',
action: primary?.slug
? {
label: t('notifications.newPhotos.cta'),
onSelect: () => navigate(ADMIN_EVENT_VIEW_PATH(primary.slug!)),
}
: {
label: t('notifications.newPhotos.ctaFallback'),
onSelect: () => navigate(ADMIN_EVENTS_PATH),
},
});
}
return items;
}

View File

@@ -1,167 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { HelpCircle, LogOut, Monitor, Moon, Settings, Sun, User, Languages, CreditCard } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuth } from '../auth/context';
import { ADMIN_FAQ_PATH, ADMIN_PROFILE_PATH, ADMIN_SETTINGS_PATH, ADMIN_BILLING_PATH } from '../constants';
import { useAppearance } from '@/hooks/use-appearance';
import { SUPPORTED_LANGUAGES, getCurrentLocale, switchLocale, type SupportedLocale } from '../lib/locale';
export function UserMenu() {
const { user, logout } = useAuth();
const { appearance, updateAppearance } = useAppearance();
const navigate = useNavigate();
const { t } = useTranslation('common');
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale();
const isMember = user?.role === 'member';
const initials = React.useMemo(() => {
if (user?.name) {
return user.name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
if (user?.email) {
return user.email.charAt(0).toUpperCase();
}
return 'TU';
}, [user?.name, user?.email]);
const changeLanguage = React.useCallback(async (locale: SupportedLocale) => {
if (locale === currentLocale) {
return;
}
setPendingLocale(locale);
try {
await switchLocale(locale);
} finally {
setPendingLocale(null);
}
}, [currentLocale]);
const changeAppearance = React.useCallback(
(mode: 'light' | 'dark' | 'system') => {
updateAppearance(mode);
},
[updateAppearance]
);
const goTo = React.useCallback((path: string) => navigate(path), [navigate]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 rounded-full border border-transparent px-2">
<Avatar className="h-8 w-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold sm:inline">
{user?.name || user?.email || t('app.userMenu')}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<p className="text-sm font-semibold">{user?.name ?? t('user.unknown')}</p>
{user?.email ? <p className="text-xs text-slate-500">{user.email}</p> : null}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => goTo(ADMIN_PROFILE_PATH)}>
<User className="h-4 w-4" />
{t('navigation.profile', { defaultValue: 'Profil' })}
</DropdownMenuItem>
{!isMember && (
<>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
<CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages className="h-4 w-4" />
<span>{t('app.languageSwitch')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SUPPORTED_LANGUAGES.map(({ code, labelKey }) => (
<DropdownMenuItem
key={code}
className="flex items-center justify-between"
onSelect={(event) => {
event.preventDefault();
changeLanguage(code);
}}
disabled={pendingLocale === code}
>
<span>{t(labelKey)}</span>
{currentLocale === code ? <span className="text-xs text-rose-500">{t('app.languageActive', { defaultValue: 'Aktiv' })}</span> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{appearance === 'dark' ? <Moon className="h-4 w-4" /> : appearance === 'light' ? <Sun className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t('app.theme', { defaultValue: 'Darstellung' })}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(['light', 'dark', 'system'] as const).map((mode) => (
<DropdownMenuItem key={mode} onSelect={() => changeAppearance(mode)}>
{mode === 'light' ? <Sun className="h-4 w-4" /> : mode === 'dark' ? <Moon className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
<span>{t(`app.theme_${mode}`, { defaultValue: mode })}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => goTo(ADMIN_FAQ_PATH)}>
<HelpCircle className="h-4 w-4" />
{t('app.help', { defaultValue: 'FAQ & Hilfe' })}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
logout();
}}
>
<LogOut className="h-4 w-4" />
{t('app.logout', { defaultValue: 'Abmelden' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,205 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertTriangle,
Camera,
ClipboardList,
PlugZap,
QrCode,
Sparkles,
CalendarDays,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import type { TenantEvent, DashboardSummary } from '../../api';
import type { LimitWarning } from '../../lib/limitWarnings';
import { resolveEventDisplayName, formatEventDate, formatEventStatusLabel, resolveEngagementMode } from '../../lib/events';
type DashboardEventFocusCardProps = {
event: TenantEvent | null;
limitWarnings: LimitWarning[];
summary: DashboardSummary | null;
dateLocale: string;
onCreateEvent: () => void;
onOpenEvent: () => void;
onOpenPhotos: () => void;
onOpenInvites: () => void;
onOpenTasks: () => void;
onOpenPhotobooth: () => void;
};
export function DashboardEventFocusCard({
event,
limitWarnings,
summary,
dateLocale,
onCreateEvent,
onOpenEvent,
onOpenPhotos,
onOpenInvites,
onOpenTasks,
onOpenPhotobooth,
}: DashboardEventFocusCardProps) {
const { t } = useTranslation('dashboard', { keyPrefix: 'dashboard.eventFocus' });
const { t: tc } = useTranslation('common');
if (!event) {
return (
<Card className="border border-dashed border-rose-200/80 bg-white/80 shadow-sm shadow-rose-100/40 dark:border-white/20 dark:bg-white/5">
<CardHeader>
<div className="flex items-center gap-2 text-sm font-semibold text-rose-600 dark:text-rose-200">
<Sparkles className="h-4 w-4" />
{t('empty.eyebrow', 'Noch kein Event aktiv')}
</div>
<CardTitle className="text-lg text-slate-900 dark:text-white">
{t('empty.title', 'Leg mit deinem ersten Event los')}
</CardTitle>
<CardDescription className="text-sm text-slate-600 dark:text-slate-300">
{t('empty.description', 'Importiere ein Aufgaben-Set, lege Branding fest und teile sofort den Gästelink.')}
</CardDescription>
</CardHeader>
<CardContent>
<Button className="rounded-full bg-brand-rose px-6 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]" onClick={onCreateEvent}>
{t('empty.cta', 'Event anlegen')}
</Button>
</CardContent>
</Card>
);
}
const eventName = resolveEventDisplayName(event);
const dateLabel = formatEventDate(event.event_date, dateLocale) ?? t('noDate', 'Kein Datum gesetzt');
const statusLabel = formatEventStatusLabel(event.status ?? null, tc);
const isLive = Boolean(event.is_active || event.status === 'published');
const engagementMode = resolveEngagementMode(event);
const overviewStats = [
{
key: 'uploads',
label: t('stats.uploads', 'Uploads gesamt'),
value: Number(event.photo_count ?? 0).toLocaleString(),
},
{
key: 'likes',
label: t('stats.likes', 'Likes'),
value: Number(event.like_count ?? 0).toLocaleString(),
},
{
key: 'tasks',
label: t('stats.tasks', 'Aktive Aufgaben'),
value: Number(event.tasks_count ?? 0).toLocaleString(),
},
{
key: 'invites',
label: t('stats.invites', 'QR-Codes live'),
value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(),
},
];
const quickActions = [
{
key: 'photos',
label: t('actions.photos', 'Uploads prüfen'),
description: t('actions.photosHint', 'Neueste Uploads ansehen und verstecken.'),
icon: Camera,
handler: onOpenPhotos,
disabled: !isLive,
},
{
key: 'invites',
label: t('actions.invites', 'QR-Codes'),
description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'),
icon: QrCode,
handler: onOpenInvites,
},
{
key: 'tasks',
label: t('actions.tasks', 'Aufgaben-Sets & Emotionen'),
description: t('actions.tasksHint', 'Kollektionen importieren und Emotionen aktivieren.'),
icon: ClipboardList,
handler: onOpenTasks,
},
{
key: 'photobooth',
label: t('actions.photobooth', 'Photobooth binden'),
description: t('actions.photoboothHint', 'FTP-Daten freigeben und Rate-Limit prüfen.'),
icon: PlugZap,
handler: onOpenPhotobooth,
},
];
return (
<div className="space-y-4">
<Card className="border border-slate-200 bg-white/90 shadow-lg shadow-rose-100/40 dark:border-white/10 dark:bg-white/5">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('eyebrow', 'Aktuelles Event')}
</div>
<CardTitle className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">
{eventName}
</CardTitle>
<CardDescription className="mt-1 text-sm text-slate-600 dark:text-slate-300">
{t('dateLabel', { defaultValue: 'Eventdatum: {{date}}', date: dateLabel })}
</CardDescription>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge className={isLive ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-800'}>
{statusLabel}
</Badge>
<Badge variant="outline" className="text-xs font-semibold">
{isLive ? t('badges.live', 'Live für Gäste') : t('badges.hidden', 'Noch versteckt')}
</Badge>
{engagementMode === 'photo_only' ? (
<Badge variant="outline" className="text-xs font-semibold">
{t('badges.photoOnly', 'Nur Foto-Modus')}
</Badge>
) : (
<Badge variant="outline" className="text-xs font-semibold">
{t('badges.missionMode', 'Mission Cards aktiv')}
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="rounded-full border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40" onClick={onOpenEvent}>
<CalendarDays className="mr-2 h-4 w-4" />
{t('viewEvent', 'Event öffnen')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{overviewStats.map((stat) => (
<div key={stat.key} className="rounded-2xl border border-slate-200 bg-white/80 p-3 text-xs text-slate-600 dark:border-white/10 dark:bg-white/5">
<p className="text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-300">{stat.label}</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{stat.value}</p>
</div>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
{quickActions.map((action) => (
<button
key={action.key}
type="button"
onClick={action.handler}
disabled={action.disabled}
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left transition hover:border-rose-200 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-white/5"
>
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{action.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-300">{action.description}</p>
</div>
</button>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
export interface ActionItem {
key: string;
label: string;
description: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface ActionGridProps {
items: ActionItem[];
columns?: 1 | 2;
}
export function ActionGrid({ items, columns = 2 }: ActionGridProps) {
return (
<div className={cn('grid gap-3', columns === 2 ? 'sm:grid-cols-2' : '')}>
{items.map((item) => (
<button
key={item.key}
type="button"
onClick={item.onClick}
disabled={item.disabled}
className="group flex flex-col gap-1.5 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-rose-300 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
{item.icon ? <span className="text-rose-500 dark:text-rose-200">{item.icon}</span> : null}
{item.label}
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{item.description}</p>
</button>
))}
</div>
);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { CheckCircle2, Circle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type ChecklistAction = {
label: string;
onClick: () => void;
disabled?: boolean;
};
type ChecklistRowProps = {
icon: React.ReactNode;
label: string;
hint?: string;
completed: boolean;
status: { complete: string; pending: string };
action?: ChecklistAction;
className?: string;
};
export function ChecklistRow({ icon, label, hint, completed, status, action, className }: ChecklistRowProps) {
return (
<div
className={cn(
'flex flex-col gap-3 rounded-2xl border border-white/20 bg-white/90 p-4 text-slate-900 transition duration-200 ease-out hover:-translate-y-0.5 hover:shadow-md hover:shadow-rose-300/20 md:flex-row md:items-center md:justify-between',
'dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100',
className
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'flex h-11 w-11 items-center justify-center rounded-full text-base transition-colors duration-200',
completed ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300' : 'bg-slate-200/80 text-slate-500 dark:bg-slate-800/50'
)}
>
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-semibold leading-tight">{label}</p>
{hint ? <p className="text-xs text-slate-600 dark:text-slate-400">{hint}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1 text-xs font-medium',
completed ? 'text-emerald-600 dark:text-emerald-300' : 'text-slate-500 dark:text-slate-400'
)}
>
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
{completed ? status.complete : status.pending}
</span>
{action ? (
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
{action.label}
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export const frostedCardClass = cn(
'border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/30 backdrop-blur',
'dark:border-slate-800/70 dark:bg-slate-950/85 dark:text-slate-100'
);
type FrostedCardProps = React.ComponentProps<typeof Card>;
export function FrostedCard({ className, ...props }: FrostedCardProps) {
return <Card className={cn(frostedCardClass, className)} {...props} />;
}
type FrostedSurfaceProps = React.HTMLAttributes<HTMLDivElement> & {
asChild?: boolean;
};
export function FrostedSurface({ className, ...props }: FrostedSurfaceProps) {
return (
<div
className={cn(
'rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/30 backdrop-blur',
'dark:border-slate-800/70 dark:bg-slate-950/80 dark:text-slate-100',
className
)}
{...props}
/>
);
}

View File

@@ -1,87 +0,0 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
type TenantHeroCardProps = {
badge?: string;
title: string;
description?: string;
supporting?: string[];
primaryAction?: React.ReactNode;
secondaryAction?: React.ReactNode;
aside?: React.ReactNode;
children?: React.ReactNode;
className?: string;
};
export function TenantHeroCard({
badge,
title,
description,
supporting,
primaryAction,
secondaryAction,
aside,
children,
className,
}: TenantHeroCardProps) {
return (
<Card
className={cn(
'relative overflow-hidden border border-slate-200 bg-white text-slate-900 shadow-lg shadow-rose-100/50 backdrop-blur dark:border-white/10 dark:bg-slate-900/80 dark:text-slate-100',
className
)}
>
<CardContent className="relative z-10 flex flex-col gap-6 px-5 py-6 sm:px-6 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-4">
{badge ? (
<span className="inline-flex items-center gap-2 rounded-full bg-rose-100/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-rose-600 dark:bg-white/15 dark:text-white">
{badge}
</span>
) : null}
<div className="space-y-2 text-slate-700 dark:text-slate-100">
<h1 className="font-display text-2xl font-semibold leading-tight text-slate-900 dark:text-white sm:text-3xl">
{title}
</h1>
{description ? (
<p className="text-sm text-slate-600 dark:text-white/80">{description}</p>
) : null}
{supporting?.map((paragraph) => (
<p key={paragraph} className="text-sm text-slate-600 dark:text-white/70">
{paragraph}
</p>
))}
{children}
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap gap-2">
{primaryAction}
{secondaryAction}
</div>
)}
</div>
{aside ? (
<div className="rounded-2xl border border-slate-200/70 bg-white/90 p-4 text-sm dark:border-white/10 dark:bg-white/5">
{aside}
</div>
) : null}
</CardContent>
</Card>
);
}
export const tenantHeroPrimaryButtonClass = cn(
'rounded-full bg-rose-600 px-6 text-sm font-semibold text-white shadow-md shadow-rose-400/30 transition-colors',
'hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
'dark:focus-visible:ring-offset-slate-950'
);
export const tenantHeroSecondaryButtonClass = cn(
'rounded-full border border-slate-200/80 bg-white/95 px-6 text-sm font-semibold text-slate-700 shadow-sm transition-colors',
'hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
'dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:focus-visible:ring-offset-slate-950'
);

View File

@@ -1,8 +0,0 @@
export { TenantHeroCard, tenantHeroPrimaryButtonClass, tenantHeroSecondaryButtonClass } from './hero-card';
export { FrostedCard, FrostedSurface, frostedCardClass } from './frosted-surface';
export { ChecklistRow } from './checklist-row';
export type { ChecklistStep } from './onboarding-checklist-card';
export { TenantOnboardingChecklistCard } from './onboarding-checklist-card';
export { SectionCard, SectionHeader } from './section-card';
export { StatCarousel } from './stat-carousel';
export { ActionGrid } from './action-grid';

View File

@@ -1,94 +0,0 @@
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { FrostedCard } from './frosted-surface';
import { ChecklistRow } from './checklist-row';
export type ChecklistStep = {
key: string;
title: string;
description: string;
done: boolean;
ctaLabel?: string | null;
onAction?: () => void;
icon: LucideIcon;
};
type TenantOnboardingChecklistCardProps = {
title: string;
description?: string;
steps: ChecklistStep[];
completedLabel: string;
pendingLabel: string;
completionPercent: number;
completedCount: number;
totalCount: number;
emptyCopy?: string;
fallbackActionLabel?: string;
};
export function TenantOnboardingChecklistCard({
title,
description,
steps,
completedLabel,
pendingLabel,
completionPercent,
completedCount,
totalCount,
emptyCopy,
fallbackActionLabel,
}: TenantOnboardingChecklistCardProps) {
return (
<FrostedCard className="relative overflow-hidden">
<CardHeader className="relative flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div
aria-hidden
className="pointer-events-none absolute -top-12 -right-16 h-32 w-32 rounded-full bg-rose-200/40 blur-3xl"
/>
<div className="relative">
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{completionPercent}% · {completedCount}/{totalCount}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={completionPercent} className="h-2 bg-rose-100 motion-safe:animate-pulse" />
<div className="space-y-3">
{steps.map((step) => {
const Icon = step.icon;
return (
<ChecklistRow
key={step.key}
icon={<Icon className="h-5 w-5" />}
label={step.title}
hint={step.description}
completed={step.done}
status={{ complete: completedLabel, pending: pendingLabel }}
action={
step.done || (!step.ctaLabel && !fallbackActionLabel)
? undefined
: {
label: step.ctaLabel ?? fallbackActionLabel ?? '',
onClick: () => step.onAction?.(),
disabled: !step.onAction,
}
}
/>
);
})}
</div>
{steps.length === 0 && emptyCopy ? (
<p className="text-sm text-slate-600 dark:text-slate-300">{emptyCopy}</p>
) : null}
</CardContent>
</FrostedCard>
);
}

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
export interface SectionCardProps extends React.HTMLAttributes<HTMLElement> {
as?: 'section' | 'div';
}
export function SectionCard({ className, as: Tag = 'section', ...props }: SectionCardProps) {
return (
<Tag
className={cn(
'rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner',
className,
)}
{...props}
/>
);
}
interface SectionHeaderProps {
eyebrow?: string;
title: string;
description?: string;
endSlot?: React.ReactNode;
className?: string;
}
export function SectionHeader({ eyebrow, title, description, endSlot, className }: SectionHeaderProps) {
return (
<div className={cn('flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between', className)}>
<div>
{eyebrow ? (
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{eyebrow}</p>
) : null}
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{title}</h2>
{description ? <p className="text-sm text-slate-600 dark:text-slate-300">{description}</p> : null}
</div>
{endSlot}
</div>
);
}

View File

@@ -1,34 +0,0 @@
import React from 'react';
interface StatItem {
key: string;
label: string;
value: string | number;
icon?: React.ReactNode;
hint?: string;
}
interface StatCarouselProps {
items: StatItem[];
}
export function StatCarousel({ items }: StatCarouselProps) {
return (
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-2 sm:mx-0 sm:grid sm:snap-none sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 lg:grid-cols-4">
{items.map((item) => (
<div key={item.key} className="min-w-[70%] snap-center sm:min-w-0">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{item.label}</span>
{item.icon ? <span className="text-rose-500 dark:text-rose-200">{item.icon}</span> : null}
</div>
<div className="mt-3 text-2xl font-semibold text-slate-900 dark:text-white">{item.value}</div>
{item.hint ? (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.hint}</p>
) : null}
</div>
</div>
))}
</div>
);
}

View File

@@ -2,34 +2,31 @@ export const ADMIN_BASE_PATH = '/event-admin';
export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`; export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
export const ADMIN_HOME_PATH = adminPath('/dashboard');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = adminPath('/dashboard');
export const ADMIN_LOGIN_PATH = adminPath('/login'); export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_LOGIN_PATH;
export const ADMIN_HOME_PATH = adminPath('/mobile/dashboard');
export const ADMIN_DEFAULT_AFTER_LOGIN_PATH = ADMIN_HOME_PATH;
export const ADMIN_LOGIN_START_PATH = adminPath('/start'); export const ADMIN_LOGIN_START_PATH = adminPath('/start');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback'); export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events'); export const ADMIN_EVENTS_PATH = adminPath('/mobile/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings'); export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile'); export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
export const ADMIN_FAQ_PATH = adminPath('/faq'); export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement'); export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string => export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`; export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
export const ADMIN_BILLING_PATH = adminPath('/billing'); export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome');
export const ADMIN_PHOTOS_PATH = adminPath('/photos'); export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/mobile/welcome/packages');
export const ADMIN_LIVE_PATH = adminPath('/live'); export const ADMIN_WELCOME_SUMMARY_PATH = adminPath('/mobile/welcome/summary');
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome'); export const ADMIN_WELCOME_EVENT_PATH = adminPath('/mobile/welcome/event');
export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/welcome/packages'); export const ADMIN_EVENT_CREATE_PATH = adminPath('/mobile/events/new');
export const ADMIN_WELCOME_SUMMARY_PATH = adminPath('/welcome/summary');
export const ADMIN_WELCOME_EVENT_PATH = adminPath('/welcome/event');
export const ADMIN_EVENT_CREATE_PATH = adminPath('/events/new');
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/edit`); export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/edit`);
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`); export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photos`);
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`); export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`); export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/qr`);
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`); export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photobooth`);
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`); export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/branding`); export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);

View File

@@ -49,7 +49,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
console.warn('[DevAuth] Failed to persist PAT to sessionStorage', error); console.warn('[DevAuth] Failed to persist PAT to sessionStorage', error);
} }
window.location.assign('/event-admin/dashboard'); window.location.assign('/event-admin/mobile/dashboard');
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error('[DevAuth] Demo login failed', message); console.error('[DevAuth] Demo login failed', message);

View File

@@ -12,13 +12,12 @@ import '../../css/app.css';
import './i18n'; import './i18n';
import './dev-tools'; import './dev-tools';
import { AppearanceProvider, useAppearance, initializeTheme } from '@/hooks/use-appearance'; import { AppearanceProvider, useAppearance, initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
import { EventProvider } from './context/EventContext'; import { EventProvider } from './context/EventContext';
import MatomoTracker from '@/components/analytics/MatomoTracker'; import MatomoTracker from '@/components/analytics/MatomoTracker';
import { ConsentProvider } from '@/contexts/consent'; import { ConsentProvider } from '@/contexts/consent';
import CookieBanner from '@/components/consent/CookieBanner'; import CookieBanner from '@/components/consent/CookieBanner';
const DevTenantSwitcher = React.lazy(() => import('./components/DevTenantSwitcher')); const DevTenantSwitcher = React.lazy(() => import('./DevTenantSwitcher'));
const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true'; const enableDevSwitcher = import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
@@ -63,20 +62,18 @@ function AdminApp() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<EventProvider> <EventProvider>
<OnboardingProgressProvider> <MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} /> <Suspense
<Suspense fallback={(
fallback={( <div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground"> Oberfläche wird geladen
Oberfläche wird geladen
</div>
)}
>
<div className="font-[Montserrat] text-[13px] font-normal leading-[1.5] text-slate-700">
<RouterProvider router={router} />
</div> </div>
</Suspense> )}
</OnboardingProgressProvider> >
<div className="font-[Montserrat] text-[13px] font-normal leading-[1.5] text-slate-700">
<RouterProvider router={router} />
</div>
</Suspense>
</EventProvider> </EventProvider>
</AuthProvider> </AuthProvider>
<CookieBanner /> <CookieBanner />

View File

@@ -14,6 +14,7 @@ import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { isPastEvent } from './eventDate';
export default function MobileEventDetailPage() { export default function MobileEventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -250,6 +251,14 @@ export default function MobileEventDetailPage() {
color="#38bdf8" color="#38bdf8"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))} onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
/> />
{isPastEvent(event?.event_date) ? (
<ActionTile
icon={Sparkles}
label={t('events.quick.recap', 'Recap & Archive')}
color="#f59e0b"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
/>
) : null}
</XStack> </XStack>
</YStack> </YStack>
</MobileShell> </MobileShell>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react'; import { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
@@ -7,13 +7,28 @@ import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite'; import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton } from './components/Primitives'; import { MobileCard, PillBadge, CTAButton } from './components/Primitives';
import { getEventPhotos, updatePhotoVisibility, featurePhoto, unfeaturePhoto, TenantPhoto } from '../api'; import {
getEventPhotos,
updatePhotoVisibility,
featurePhoto,
unfeaturePhoto,
TenantPhoto,
EventAddonCatalogItem,
createEventAddonCheckout,
getAddonCatalog,
EventAddonSummary,
EventLimitSummary,
getEvent,
} from '../api';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons';
type FilterKey = 'all' | 'featured' | 'hidden'; type FilterKey = 'all' | 'featured' | 'hidden';
@@ -22,6 +37,7 @@ export default function MobileEventPhotosPage() {
const { activeEvent, selectEvent } = useEventContext(); const { activeEvent, selectEvent } = useEventContext();
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]); const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
@@ -38,6 +54,10 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null); const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color?.val ?? '#111827'); const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563'); const muted = String(theme.gray?.val ?? '#4b5563');
@@ -82,8 +102,15 @@ export default function MobileEventPhotosPage() {
}); });
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos])); setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
setTotalCount(result.meta?.total ?? result.photos.length); setTotalCount(result.meta?.total ?? result.photos.length);
setLimits(result.limits ?? null);
const lastPage = result.meta?.last_page ?? 1; const lastPage = result.meta?.last_page ?? 1;
setHasMore(page < lastPage); setHasMore(page < lastPage);
const [addons, event] = await Promise.all([
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
getEvent(slug).catch(() => null),
]);
setCatalogAddons(addons ?? []);
setEventAddons(event?.addons ?? []);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.'))); setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
@@ -91,12 +118,24 @@ export default function MobileEventPhotosPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [slug, filter, t, page]); }, [slug, filter, t, page, onlyFeatured, onlyHidden, search]);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
React.useEffect(() => {
if (!location.search || !slug) return;
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.'));
setPage(1);
void load();
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
}
}, [location.search, slug, load, navigate, t, location.pathname]);
React.useEffect(() => { React.useEffect(() => {
setPage(1); setPage(1);
}, [filter, slug]); }, [filter, slug]);
@@ -215,6 +254,15 @@ export default function MobileEventPhotosPage() {
</MobileCard> </MobileCard>
) : ( ) : (
<YStack space="$3"> <YStack space="$3">
<LimitWarnings
limits={limits}
addons={catalogAddons}
onCheckout={(scopeOrKey) => { void handleCheckout(scopeOrKey, slug, catalogAddons, setBusyScope, t); }}
busyScope={busyScope}
translate={translateLimits(t)}
textColor={text}
borderColor={border}
/>
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })} {t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
</Text> </Text>
@@ -358,6 +406,15 @@ export default function MobileEventPhotosPage() {
/> />
</YStack> </YStack>
</MobileSheet> </MobileSheet>
{eventAddons.length ? (
<YStack marginTop="$3" space="$2">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
</Text>
<EventAddonList addons={eventAddons} textColor={text} mutedColor={muted} />
</YStack>
) : null}
</MobileShell> </MobileShell>
); );
} }
@@ -372,3 +429,197 @@ function Field({ label, color, children }: { label: string; color: string; child
</YStack> </YStack>
); );
} }
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
const defaults: Record<string, string> = {
photosBlocked: 'Upload limit reached. Buy more photos to continue.',
photosWarning: '{{remaining}} of {{limit}} photos remaining.',
guestsBlocked: 'Guest limit reached.',
guestsWarning: '{{remaining}} of {{limit}} guests remaining.',
galleryExpired: 'Gallery expired. Extend to keep it online.',
galleryWarningDay: 'Gallery expires in {{days}} day.',
galleryWarningDays: 'Gallery expires in {{days}} days.',
buyMorePhotos: 'Buy more photos',
extendGallery: 'Extend gallery',
};
return (key, options) => t(`limits.${key}`, defaults[key] ?? key, options);
}
function LimitWarnings({
limits,
addons,
onCheckout,
busyScope,
translate,
textColor,
borderColor,
}: {
limits: EventLimitSummary | null;
addons: EventAddonCatalogItem[];
onCheckout: (scopeOrKey: 'photos' | 'gallery' | string) => void;
busyScope: string | null;
translate: LimitTranslator;
textColor: string;
borderColor: string;
}) {
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
if (!warnings.length) {
return null;
}
return (
<YStack space="$2">
{warnings.map((warning) => (
<MobileCard key={warning.id} borderColor={borderColor} space="$2">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{warning.message}
</Text>
{(warning.scope === 'photos' || warning.scope === 'gallery') && addons.length ? (
<MobileAddonsPicker
scope={warning.scope}
addons={addons}
busy={busyScope === warning.scope}
onCheckout={onCheckout}
translate={translate}
/>
) : null}
<CTAButton
label={
warning.scope === 'photos'
? translate('buyMorePhotos')
: warning.scope === 'gallery'
? translate('extendGallery')
: translate('buyMorePhotos')
}
onPress={() => onCheckout(warning.scope)}
loading={busyScope === warning.scope}
/>
</MobileCard>
))}
</YStack>
);
}
function MobileAddonsPicker({
scope,
addons,
busy,
onCheckout,
translate,
}: {
scope: 'photos' | 'gallery';
addons: EventAddonCatalogItem[];
busy: boolean;
onCheckout: (addonKey: string) => void;
translate: LimitTranslator;
}) {
const options = React.useMemo(() => {
const whitelist = scopeDefaults[scope];
const filtered = addons.filter((addon) => addon.price_id && whitelist.includes(addon.key));
return filtered.length ? filtered : addons.filter((addon) => addon.price_id);
}, [addons, scope]);
const [selected, setSelected] = React.useState<string>(() => options[0]?.key ?? selectAddonKeyForScope(addons, scope));
React.useEffect(() => {
if (options[0]?.key) {
setSelected(options[0].key);
}
}, [options]);
if (!options.length) {
return null;
}
return (
<XStack space="$2" alignItems="center">
<select
value={selected}
onChange={(event) => setSelected(event.target.value)}
style={{
flex: 1,
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: '#fff',
}}
>
{options.map((addon) => (
<option key={addon.key} value={addon.key}>
{addon.label ?? addon.key}
</option>
))}
</select>
<CTAButton
label={scope === 'gallery' ? translate('extendGallery') : translate('buyMorePhotos')}
disabled={!selected || busy}
onPress={() => selected && onCheckout(selected)}
loading={busy}
/>
</XStack>
);
}
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
return (
<YStack space="$2">
{addons.map((addon) => (
<MobileCard key={addon.id} borderColor="#e5e7eb" space="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{addon.label ?? addon.key}
</Text>
<PillBadge tone={addon.status === 'completed' ? 'success' : addon.status === 'pending' ? 'warning' : 'muted'}>
{addon.status}
</PillBadge>
</XStack>
<Text fontSize="$xs" color={mutedColor}>
{addon.purchased_at ? new Date(addon.purchased_at).toLocaleString() : '—'}
</Text>
<XStack space="$2" marginTop="$1" flexWrap="wrap">
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
</XStack>
</MobileCard>
))}
</YStack>
);
}
async function handleCheckout(
scopeOrKey: 'photos' | 'gallery' | string,
slug: string | null,
addons: EventAddonCatalogItem[],
setBusyScope: (scope: string | null) => void,
t: (key: string, defaultValue?: string) => string,
): Promise<void> {
if (!slug) return;
const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? scopeOrKey : scopeOrKey.includes('gallery') ? 'gallery' : 'photos';
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' ? selectAddonKeyForScope(addons, scope) : scopeOrKey;
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/photos`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setBusyScope(scope);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('mobileBilling.checkoutUnavailable', 'Checkout unavailable right now.'));
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('mobileBilling.checkoutFailed', 'Checkout failed.')));
} finally {
setBusyScope(null);
}
}

View File

@@ -0,0 +1,442 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Sparkles, Camera, Link2, QrCode, RefreshCcw, Shield, Archive, ShoppingCart, Clock3, Share2 } from 'lucide-react';
import toast from 'react-hot-toast';
import {
getEvent,
getEventStats,
getEventQrInvites,
toggleEvent,
updateEvent,
createEventAddonCheckout,
getAddonCatalog,
submitTenantFeedback,
type TenantEvent,
type EventStats,
type EventQrInvite,
type EventAddonCatalogItem,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { adminPath } from '../constants';
import { selectAddonKeyForScope } from './addons';
export default function MobileEventRecapPage() {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [invites, setInvites] = React.useState<EventQrInvite[]>([]);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [checkoutBusy, setCheckoutBusy] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) return;
setLoading(true);
try {
const [eventData, statsData, inviteData, addonData] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setEvent(eventData);
setStats(statsData);
setInvites(inviteData ?? []);
setAddons(addonData ?? []);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!location.search) return;
const params = new URLSearchParams(location.search);
if (params.get('addon_success')) {
toast.success(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
params.delete('addon_success');
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true });
void load();
}
}, [location.search, location.pathname, t, navigate, load]);
if (!slug) {
return (
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={() => navigate(-1)}>
<MobileCard>
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
</MobileCard>
</MobileShell>
);
}
const activeInvite = invites.find((invite) => invite.is_active);
const guestLink = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
const galleryCounts = {
photos: stats?.uploads_total ?? stats?.total ?? 0,
pending: stats?.pending_photos ?? 0,
likes: stats?.likes_total ?? stats?.likes ?? 0,
};
async function toggleGallery() {
if (!slug) return;
setBusy(true);
try {
const updated = await toggleEvent(slug);
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
}
} finally {
setBusy(false);
}
}
async function archiveEvent() {
if (!slug || !event) return;
setArchiveBusy(true);
try {
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
setEvent(updated);
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
}
} finally {
setArchiveBusy(false);
}
}
async function checkoutAddon() {
if (!slug) return;
const addonKey = selectAddonKeyForScope(addons, 'gallery');
const currentUrl = typeof window !== 'undefined' ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/recap`)}` : '';
const successUrl = `${currentUrl}?addon_success=1`;
setCheckoutBusy(true);
try {
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
if (!isAuthError(err)) {
toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
}
} finally {
setCheckoutBusy(false);
}
}
async function submitFeedback(sentiment: 'positive' | 'neutral' | 'negative') {
if (!event) return;
try {
await submitTenantFeedback({
category: 'event_workspace_after_event',
event_slug: event.slug,
sentiment,
metadata: {
event_name: resolveName(event.name),
guest_link: guestLink,
},
});
toast.success(t('events.feedback.submitted', 'Danke!'));
} catch (err) {
toast.error(getApiErrorMessage(err, t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.')));
}
}
return (
<MobileShell
activeTab="home"
title={event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event')}
subtitle={event?.event_date ? formatDate(event.event_date) : undefined}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text color="#b91c1c">{error}</Text>
</MobileCard>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={90} opacity={0.5} />
))}
</YStack>
) : event && stats ? (
<YStack space="$3">
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$xs" color="#6b7280" fontWeight="700" letterSpacing={1.2}>
{t('events.recap.badge', 'Nachbereitung')}
</Text>
<Text fontSize="$lg" fontWeight="800" color="#0f172a">{resolveName(event.name)}</Text>
<Text fontSize="$sm" color="#6b7280">
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
</Text>
</YStack>
<PillBadge tone={event.is_active ? 'success' : 'muted'}>
{event.is_active ? t('events.recap.galleryOpen', 'Galerie geöffnet') : t('events.recap.galleryClosed', 'Galerie geschlossen')}
</PillBadge>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton
label={event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
onPress={toggleGallery}
loading={busy}
/>
<CTAButton label={t('events.recap.moderate', 'Uploads ansehen')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/photos`))} />
<CTAButton label={t('events.actions.edit', 'Bearbeiten')} tone="ghost" onPress={() => navigate(adminPath(`/mobile/events/${slug}/edit`))} />
</XStack>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.recap.galleryTitle', 'Galerie-Status')}
</Text>
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<Stat pill label={t('events.stats.uploads', 'Uploads')} value={String(galleryCounts.photos)} />
<Stat pill label={t('events.stats.pending', 'Offen')} value={String(galleryCounts.pending)} />
<Stat pill label={t('events.stats.likes', 'Likes')} value={String(galleryCounts.likes)} />
</XStack>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Link2 size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.recap.shareLink', 'Gäste-Link')}
</Text>
</XStack>
{guestLink ? (
<Text fontSize="$sm" color="#111827" selectable>
{guestLink}
</Text>
) : (
<Text fontSize="$sm" color="#6b7280">
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</Text>
)}
<XStack space="$2" marginTop="$2">
<CTAButton label={t('events.recap.copy', 'Kopieren')} tone="ghost" onPress={() => guestLink && copyToClipboard(guestLink, t)} />
{guestLink ? (
<CTAButton label={t('events.recap.share', 'Teilen')} tone="ghost" onPress={() => shareLink(guestLink, event, t)} />
) : null}
</XStack>
{activeInvite?.qr_code_data_url ? (
<XStack space="$2" alignItems="center" marginTop="$2">
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 96, height: 96 }} />
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
</XStack>
) : null}
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<ShoppingCart size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
</Text>
</XStack>
<Text fontSize="$sm" color="#6b7280">
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
</Text>
<CTAButton
label={t('events.recap.extendGallery', 'Galerie verlängern')}
onPress={() => {
void checkoutAddon();
}}
loading={checkoutBusy}
/>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Shield size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
</Text>
</XStack>
<ToggleRow
label={t('events.recap.downloads', 'Downloads erlauben')}
value={Boolean(event.settings?.guest_downloads_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_downloads_enabled', value, setError, t)}
/>
<ToggleRow
label={t('events.recap.sharing', 'Sharing erlauben')}
value={Boolean(event.settings?.guest_sharing_enabled)}
onToggle={(value) => updateSetting(event, setEvent, slug, 'guest_sharing_enabled', value, setError, t)}
/>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Archive size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.recap.archiveTitle', 'Event archivieren')}
</Text>
</XStack>
<Text fontSize="$sm" color="#6b7280">
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
</Text>
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" space="$2">
<Sparkles size={16} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
</Text>
</XStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton label={t('events.feedback.positive', 'War super')} tone="ghost" onPress={() => void submitFeedback('positive')} />
<CTAButton label={t('events.feedback.neutral', 'In Ordnung')} tone="ghost" onPress={() => void submitFeedback('neutral')} />
<CTAButton label={t('events.feedback.negative', 'Brauch(t)e Unterstützung')} tone="ghost" onPress={() => void submitFeedback('negative')} />
</XStack>
</MobileCard>
</YStack>
) : null}
</MobileShell>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<MobileCard borderColor="#e5e7eb" space="$1.5">
<Text fontSize="$xs" color="#6b7280">
{label}
</Text>
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{value}
</Text>
</MobileCard>
);
}
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
return (
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
<Text fontSize="$sm" color="#0f172a">
{label}
</Text>
<input
type="checkbox"
checked={value}
onChange={(e) => onToggle(e.target.checked)}
style={{ width: 20, height: 20 }}
/>
</XStack>
);
}
async function updateSetting(
event: TenantEvent,
setEvent: (event: TenantEvent) => void,
slug: string | undefined,
key: 'guest_downloads_enabled' | 'guest_sharing_enabled',
value: boolean,
setError: (message: string | null) => void,
t: (key: string, fallback?: string) => string,
): Promise<void> {
if (!slug) return;
try {
const updated = await updateEvent(slug, {
settings: {
...(event.settings ?? {}),
[key]: value,
},
});
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
}
}
}
function copyToClipboard(value: string, t: (key: string, fallback?: string) => string) {
navigator.clipboard
.writeText(value)
.then(() => toast.success(t('events.recap.copySuccess', 'Link kopiert')))
.catch(() => toast.error(t('events.recap.copyError', 'Link konnte nicht kopiert werden.')));
}
async function shareLink(value: string, event: TenantEvent, t: (key: string, fallback?: string) => string) {
if (navigator.share) {
try {
await navigator.share({
title: resolveName(event.name),
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
url: value,
});
return;
} catch {
// ignore
}
}
copyToClipboard(value, t);
}
function downloadQr(dataUrl: string) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = 'guest-gallery-qr.png';
link.click();
}
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function formatDate(iso?: string | null): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

@@ -25,8 +25,9 @@ import {
generatePngDataUrl, generatePngDataUrl,
triggerDownloadFromBlob, triggerDownloadFromBlob,
triggerDownloadFromDataUrl, triggerDownloadFromDataUrl,
} from '../pages/components/invite-layout/export-utils'; } from './invite-layout/export-utils';
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from '../pages/components/invite-layout/schema'; import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema';
import { buildInitialTextFields } from './qr/utils';
type Step = 'background' | 'text' | 'preview'; type Step = 'background' | 'text' | 'preview';

View File

@@ -19,38 +19,7 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ADMIN_BASE_PATH } from '../constants'; import { ADMIN_BASE_PATH } from '../constants';
import { resolveLayoutForFormat } from './qr/utils';
export function resolveLayoutForFormat(format: 'a4-poster' | 'a5-foldable', layouts: EventQrInviteLayout[]): string | null {
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
if (byHint?.id) {
return byHint.id;
}
const match = layouts.find((layout) => {
const paper = (layout.paper || '').toLowerCase();
const orientation = (layout.orientation || '').toLowerCase();
const panel = (layout.panel_mode || '').toLowerCase();
if (format === 'a4-poster') {
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
}
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
});
if (match?.id) {
return match.id;
}
if (format === 'a5-foldable') {
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
return fallback?.id ?? layouts[0]?.id ?? null;
}
return layouts[0]?.id ?? null;
}
export default function MobileQrPrintPage() { export default function MobileQrPrintPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { selectAddonKeyForScope } from '../addons';
import type { EventAddonCatalogItem } from '../../api';
const sampleAddons: EventAddonCatalogItem[] = [
{ key: 'extra_photos_500', label: '500 photos', price_id: 'price_1' },
{ key: 'extend_gallery_30d', label: '30 days', price_id: 'price_2' },
{ key: 'custom_photos', label: 'Custom photos', price_id: 'price_3' },
];
describe('selectAddonKeyForScope', () => {
it('prefers scoped default with price id', () => {
expect(selectAddonKeyForScope(sampleAddons, 'photos')).toBe('extra_photos_500');
expect(selectAddonKeyForScope(sampleAddons, 'gallery')).toBe('extend_gallery_30d');
});
it('falls back to first scoped addon when defaults missing', () => {
const addons: EventAddonCatalogItem[] = [{ key: 'custom_gallery', label: 'Gallery', price_id: 'price_9' }];
expect(selectAddonKeyForScope(addons, 'gallery')).toBe('custom_gallery');
});
it('returns fallback key when no priced addons exist', () => {
const addons: EventAddonCatalogItem[] = [{ key: 'extra_photos_500', label: '500 photos', price_id: null }];
expect(selectAddonKeyForScope(addons, 'photos')).toBe('extra_photos_500');
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { isPastEvent } from '../eventDate';
describe('isPastEvent', () => {
it('detects past dates', () => {
const past = new Date();
past.setDate(past.getDate() - 2);
expect(isPastEvent(past.toISOString())).toBe(true);
});
it('detects future dates', () => {
const future = new Date();
future.setDate(future.getDate() + 2);
expect(isPastEvent(future.toISOString())).toBe(false);
});
it('handles invalid input', () => {
expect(isPastEvent(undefined)).toBe(false);
expect(isPastEvent('not-a-date')).toBe(false);
});
});

View File

@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { EventQrInviteLayout } from '../../api'; import type { EventQrInviteLayout } from '../../api';
import { buildInitialTextFields } from '../QrLayoutCustomizePage'; import { buildInitialTextFields, resolveLayoutForFormat } from '../qr/utils';
import { resolveLayoutForFormat } from '../QrPrintPage';
describe('buildInitialTextFields', () => { describe('buildInitialTextFields', () => {
it('prefers event name for headline and default copy when customization is missing', () => { it('prefers event name for headline and default copy when customization is missing', () => {
@@ -18,7 +17,7 @@ describe('buildInitialTextFields', () => {
}); });
expect(fields.headline).toBe('Sommerfest 2025'); expect(fields.headline).toBe('Sommerfest 2025');
expect(fields.description).toBe('Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.'); expect(fields.description).toBe('Old description');
expect(fields.instructions).toHaveLength(3); expect(fields.instructions).toHaveLength(3);
}); });

View File

@@ -0,0 +1,16 @@
import type { EventAddonCatalogItem } from '../api';
export const scopeDefaults: Record<'photos' | 'gallery', string[]> = {
photos: ['extra_photos_500', 'extra_photos_2000'],
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
};
export function selectAddonKeyForScope(addons: EventAddonCatalogItem[], scope: 'photos' | 'gallery'): string {
const fallback = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
const filtered = addons.filter((addon) => addon.price_id && scopeDefaults[scope].includes(addon.key));
if (filtered.length) {
return filtered[0].key;
}
const scoped = addons.find((addon) => addon.price_id && addon.key.includes(scope));
return scoped?.key ?? fallback;
}

View File

@@ -15,7 +15,7 @@ import { MobileCard, PillBadge } from './Primitives';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge'; import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events'; import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api'; import { TenantEvent, getEvents } from '../../api';
const DevTenantSwitcher = React.lazy(() => import('../../components/DevTenantSwitcher')); const DevTenantSwitcher = React.lazy(() => import('../../DevTenantSwitcher'));
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -184,6 +184,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
</Pressable> </Pressable>
) : null} ) : null}
{headerActions ?? null} {headerActions ?? null}
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher variant="inline" />
</Suspense>
) : null}
</XStack> </XStack>
</XStack> </XStack>
</XStack> </XStack>
@@ -193,12 +198,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{children} {children}
</YStack> </YStack>
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher bottomOffset={64} />
</Suspense>
) : null}
<BottomNav active={activeTab} onNavigate={go} /> <BottomNav active={activeTab} onNavigate={go} />
<MobileSheet <MobileSheet

View File

@@ -0,0 +1,7 @@
export function isPastEvent(dateIso?: string | null): boolean {
if (!dateIso) return false;
const date = new Date(dateIso);
if (Number.isNaN(date.getTime())) return false;
const now = new Date();
return date.getTime() < now.getTime();
}

View File

@@ -0,0 +1,73 @@
import type { EventQrInviteLayout } from '../../api';
export type BuildInitialTextFieldsParams = {
customization: Record<string, unknown> | null | undefined;
layoutDefaults: EventQrInviteLayout | null | undefined;
eventName?: string | null;
};
export function buildInitialTextFields({
customization,
layoutDefaults,
eventName,
}: BuildInitialTextFieldsParams): { headline: string; subtitle: string; description: string; instructions: string[] } {
const initialInstructions =
Array.isArray(customization?.instructions) && customization.instructions.length
? customization.instructions
.map((item: unknown) => String(item ?? ''))
.filter((item: string) => item.length > 0)
: [
'QR-Code scannen\nKamera-App öffnen und auf den Code richten.',
'Webseite öffnen\nDer Link öffnet direkt das gemeinsame Jubiläumsalbum.',
'Fotos hochladen\nZeigt eure Lieblingsmomente oder erfüllt kleine Fotoaufgaben, um besondere Erinnerungen beizusteuern.',
];
return {
headline:
(typeof customization?.headline === 'string' && customization.headline.length ? customization.headline : null) ??
(typeof eventName === 'string' && eventName.length ? eventName : null) ??
(typeof layoutDefaults?.name === 'string' ? layoutDefaults.name : '') ??
'',
subtitle: typeof customization?.subtitle === 'string' ? customization.subtitle : '',
description:
(typeof customization?.description === 'string' && customization.description.length ? customization.description : null) ??
(typeof layoutDefaults?.description === 'string' ? layoutDefaults.description : null) ??
'Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.',
instructions: initialInstructions,
};
}
export function resolveLayoutForFormat(
format: 'a4-poster' | 'a5-foldable',
layouts: EventQrInviteLayout[],
): string | null {
const formatKey = format === 'a4-poster' ? 'poster-a4' : 'foldable-a5';
const byHint = layouts.find((layout) => (layout as any)?.format_hint === formatKey);
if (byHint?.id) {
return byHint.id;
}
const match = layouts.find((layout) => {
const paper = (layout.paper || '').toLowerCase();
const orientation = (layout.orientation || '').toLowerCase();
const panel = (layout.panel_mode || '').toLowerCase();
if (format === 'a4-poster') {
return paper === 'a4' && orientation === 'portrait' && panel !== 'double-mirror';
}
return paper === 'a4' && orientation === 'landscape' && panel === 'double-mirror';
});
if (match?.id) {
return match.id;
}
if (format === 'a5-foldable') {
const fallback = layouts.find((layout) => (layout.id || '').includes('foldable'));
return fallback?.id ?? layouts[0]?.id ?? null;
}
return layouts[0]?.id ?? null;
}

View File

@@ -1,79 +0,0 @@
import React from 'react';
import { describe, expect, beforeEach, afterEach, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import WelcomeLandingPage from '../pages/WelcomeLandingPage';
import { OnboardingProgressProvider } from '..';
import {
ADMIN_EVENTS_PATH,
ADMIN_WELCOME_PACKAGES_PATH,
} from '../../constants';
const navigateMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }),
}));
vi.mock('../../api', () => ({
fetchOnboardingStatus: vi.fn().mockResolvedValue(null),
trackOnboarding: vi.fn(),
}));
describe('WelcomeLandingPage', () => {
beforeEach(() => {
localStorage.clear();
navigateMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
function renderPage() {
return render(
<OnboardingProgressProvider>
<WelcomeLandingPage />
</OnboardingProgressProvider>
);
}
it('marks the welcome step as seen on mount', () => {
renderPage();
const stored = localStorage.getItem('tenant-admin:onboarding-progress');
expect(stored).toBeTruthy();
expect(stored).toContain('"welcomeSeen":true');
expect(stored).toContain('"lastStep":"landing"');
});
it('navigates to package selection when the primary CTA is clicked', async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /hero.primary.label/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_WELCOME_PACKAGES_PATH);
});
it('navigates to events when secondary CTA in hero or footer is used', async () => {
renderPage();
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /hero.secondary.label/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH);
navigateMock.mockClear();
await user.click(screen.getByRole('button', { name: /layout.jumpToDashboard/i }));
expect(navigateMock).toHaveBeenCalledWith(ADMIN_EVENTS_PATH);
});
});

View File

@@ -1,73 +0,0 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { TFunction } from 'i18next';
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'authenticated', user: { name: 'Test User' } }),
}));
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
createPaddleCheckoutMock: vi.fn(),
}));
vi.mock('../../api', () => ({
assignFreeTenantPackage: vi.fn(),
createTenantPaddleCheckout: createPaddleCheckoutMock,
}));
describe('PaddleCheckout', () => {
beforeEach(() => {
createPaddleCheckoutMock.mockReset();
});
it('opens Paddle checkout when created successfully', async () => {
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={onSuccess}
t={tMock}
/>
);
await act(async () => {
screen.getByRole('button').click();
});
await waitFor(() => {
expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99);
expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener');
expect(onSuccess).toHaveBeenCalled();
});
openSpy.mockRestore();
});
it('shows an error message on failure', async () => {
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
const tMock = ((key: string) => key) as unknown as TFunction;
render(
<PaddleCheckout
packageId={99}
onSuccess={vi.fn()}
t={tMock}
/>
);
await act(async () => {
screen.getByRole('button').click();
});
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('boom');
});
});
});

View File

@@ -1,47 +0,0 @@
import React from 'react';
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { OnboardingProgressProvider, useOnboardingProgress } from '..';
const fetchStatusMock = vi.fn();
const trackMock = vi.fn();
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'authenticated', user: { id: 1, role: 'owner' } }),
}));
vi.mock('../../api', () => ({
fetchOnboardingStatus: () => fetchStatusMock(),
trackOnboarding: () => trackMock(),
}));
function ProgressProbe() {
const { progress } = useOnboardingProgress();
return (
<div>
<span data-testid="admin-opened">{progress.adminAppOpenedAt ?? 'null'}</span>
<span data-testid="invite-created">{String(progress.inviteCreated)}</span>
</div>
);
}
describe('OnboardingProgressProvider', () => {
beforeEach(() => {
fetchStatusMock.mockResolvedValue({ steps: undefined });
trackMock.mockResolvedValue(undefined);
window.localStorage.clear();
});
it('handles onboarding status responses without steps', async () => {
render(
<OnboardingProgressProvider>
<ProgressProbe />
</OnboardingProgressProvider>
);
await waitFor(() => expect(fetchStatusMock).toHaveBeenCalled());
expect(screen.getByTestId('admin-opened').textContent).toBeTruthy();
expect(screen.getByTestId('invite-created').textContent).toBe('false');
});
});

View File

@@ -1,71 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
import { FrostedSurface } from '../../components/tenant';
export interface OnboardingAction {
id: string;
label: string;
description?: string;
href?: string;
onClick?: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary';
disabled?: boolean;
buttonLabel?: string;
}
interface OnboardingCTAListProps {
actions: OnboardingAction[];
className?: string;
}
export function OnboardingCTAList({ actions, className }: OnboardingCTAListProps) {
if (!actions.length) {
return null;
}
return (
<div className={cn('grid gap-4 md:grid-cols-2', className)}>
{actions.map(({ id, label, description, href, onClick, icon: Icon, variant = 'primary', disabled, buttonLabel }) => (
<FrostedSurface
key={id}
className="flex flex-col gap-3 border border-white/20 p-5 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<div className="flex items-center gap-3">
{Icon ? (
<span className="flex size-10 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-5" />
</span>
) : null}
<span className="text-base font-semibold text-slate-900 dark:text-slate-100">{label}</span>
</div>
{description ? (
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
) : null}
<div>
<Button
variant="default"
size="lg"
className={cn(
'w-full rounded-full transition-colors',
variant === 'secondary'
? 'bg-slate-900/80 text-white shadow-md shadow-slate-900/30 hover:bg-slate-900 dark:bg-slate-800'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
disabled={disabled}
onClick={onClick}
{...(href ? { asChild: true } : {})}
>
{href ? <a href={href}>{buttonLabel ?? label}</a> : buttonLabel ?? label}
</Button>
</div>
</FrostedSurface>
))}
</div>
);
}
OnboardingCTAList.displayName = 'OnboardingCTAList';

View File

@@ -1,55 +0,0 @@
import React from 'react';
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
export interface HighlightItem {
id: string;
icon: LucideIcon;
title: string;
description: string;
badge?: string;
}
interface OnboardingHighlightsGridProps {
items: HighlightItem[];
className?: string;
}
export function OnboardingHighlightsGrid({ items, className }: OnboardingHighlightsGridProps) {
if (!items.length) {
return null;
}
return (
<div className={cn('grid gap-4 md:grid-cols-3', className)}>
{items.map(({ id, icon: Icon, title, description, badge }) => (
<FrostedSurface
key={id}
className="relative overflow-hidden rounded-3xl border border-white/20 p-6 text-slate-900 shadow-md shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/80 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-6" />
</span>
{badge && (
<span className="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-rose-500 dark:bg-rose-500/20 dark:text-rose-200">
{badge}
</span>
)}
</div>
<CardTitle className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</CardContent>
</FrostedSurface>
))}
</div>
);
}
OnboardingHighlightsGrid.displayName = 'OnboardingHighlightsGrid';

View File

@@ -1,75 +0,0 @@
import React from 'react';
import { LanguageSwitcher } from '../../components/LanguageSwitcher';
import { FrostedSurface } from '../../components/tenant';
export interface TenantWelcomeLayoutProps {
eyebrow?: string;
title?: string;
subtitle?: string;
headerAction?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
export function TenantWelcomeLayout({
eyebrow,
title,
subtitle,
headerAction,
footer,
children,
}: TenantWelcomeLayoutProps) {
React.useEffect(() => {
document.body.classList.add('tenant-admin-theme', 'tenant-admin-welcome-theme');
return () => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
};
}, []);
return (
<div className="relative min-h-svh w-full overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.28),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.25),_transparent_65%)] motion-safe:animate-[aurora_20s_ease-in-out_infinite]"
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900/70 to-[#1d1130]" />
<div className="relative z-10 mx-auto flex min-h-svh w-full max-w-5xl flex-col gap-10 px-6 py-12 sm:px-8 md:py-16 lg:px-12">
<FrostedSurface className="flex w-full flex-1 flex-col gap-10 rounded-[36px] border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 md:gap-14 md:p-14">
<header className="flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
<div className="max-w-xl space-y-4">
{eyebrow ? (
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200">{eyebrow}</p>
) : null}
{title ? (
<h1 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-5xl">
{title}
</h1>
) : null}
{subtitle ? (
<p className="text-base text-slate-600 dark:text-slate-300 md:text-lg">{subtitle}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<LanguageSwitcher />
{headerAction}
</div>
</header>
<main className="flex flex-1 flex-col gap-8">
{children}
</main>
{footer ? (
<footer className="flex flex-col items-center gap-4 text-sm text-slate-600 dark:text-slate-400 md:flex-row md:justify-between">
{footer}
</footer>
) : null}
</FrostedSurface>
</div>
</div>
);
}
TenantWelcomeLayout.displayName = 'TenantWelcomeLayout';

View File

@@ -1,98 +0,0 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedSurface } from '../../components/tenant';
interface ActionProps {
label: string;
onClick?: () => void;
href?: string;
icon?: LucideIcon;
variant?: 'default' | 'outline';
}
export interface WelcomeHeroProps {
eyebrow?: string;
title: string;
scriptTitle?: string;
description?: string;
actions?: ActionProps[];
className?: string;
}
export function WelcomeHero({
eyebrow,
title,
scriptTitle,
description,
actions = [],
className,
}: WelcomeHeroProps) {
return (
<FrostedSurface
className={cn(
'relative overflow-hidden rounded-3xl border border-white/15 p-8 text-slate-900 shadow-2xl shadow-rose-300/20 backdrop-blur-2xl transition-colors duration-200 dark:text-slate-100 md:p-12',
className
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-[-30%] h-[220px] bg-[radial-gradient(circle,_rgba(255,137,170,0.35),_transparent_60%)]"
/>
<div className="relative space-y-4 text-center md:space-y-6">
{eyebrow && (
<p className="text-xs uppercase tracking-[0.35em] text-rose-300 dark:text-rose-200 md:text-sm">
{eyebrow}
</p>
)}
<h2 className="font-display text-3xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 md:text-4xl">
{title}
</h2>
{scriptTitle && (
<p className="font-script text-2xl text-rose-300 md:text-3xl">
{scriptTitle}
</p>
)}
{description && (
<p className="mx-auto max-w-2xl text-base text-slate-600 dark:text-slate-300 md:text-lg">
{description}
</p>
)}
{actions.length > 0 && (
<div className="flex flex-col items-center gap-3 pt-4 md:flex-row md:justify-center">
{actions.map(({ label, onClick, href, icon: Icon, variant = 'default' }) => (
<Button
key={label}
size="lg"
variant={variant === 'outline' ? 'outline' : 'default'}
className={cn(
'min-w-[220px] rounded-full px-6 transition-colors',
variant === 'outline'
? 'border-white/60 bg-white/20 text-slate-900 hover:bg-white/40 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-100'
: 'bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-lg shadow-rose-300/30 hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]'
)}
onClick={onClick}
{...(href ? { asChild: true } : {})}
>
{href ? (
<a href={href} className="flex items-center justify-center gap-2">
{Icon && <Icon className="h-4 w-4" />}
<span>{label}</span>
</a>
) : (
<span className="flex items-center justify-center gap-2">
{Icon && <Icon className="h-4 w-4" />}
<span>{label}</span>
</span>
)}
</Button>
))}
</div>
)}
</div>
</FrostedSurface>
);
}
WelcomeHero.displayName = 'WelcomeHero';

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FrostedCard } from '../../components/tenant';
export interface WelcomeStepCardProps {
step: number;
totalSteps: number;
title: string;
description?: string;
icon?: LucideIcon;
children?: React.ReactNode;
className?: string;
}
export function WelcomeStepCard({
step,
totalSteps,
title,
description,
icon: Icon,
children,
className,
}: WelcomeStepCardProps) {
const progress = Math.min(Math.max(step, 1), totalSteps);
const percent = totalSteps <= 1 ? 100 : Math.round((progress / totalSteps) * 100);
return (
<FrostedCard
className={cn(
'relative overflow-hidden rounded-3xl border border-white/20 text-slate-900 shadow-lg shadow-rose-200/20 dark:text-slate-100',
className
)}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1]" />
<CardHeader className="space-y-4 pt-8">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.4em] text-rose-300 dark:text-rose-200">
Step {progress} / {totalSteps}
</span>
<div className="w-28">
<Progress value={percent} />
</div>
</div>
<div className="flex items-start gap-4">
{Icon && (
<span className="flex size-12 items-center justify-center rounded-full bg-rose-100/90 text-rose-500 shadow-inner shadow-rose-200/60 dark:bg-rose-500/20 dark:text-rose-200">
<Icon className="size-5" />
</span>
)}
<div className="space-y-2">
<CardTitle className="font-display text-2xl font-semibold text-slate-900 dark:text-slate-100 md:text-3xl">{title}</CardTitle>
{description && (
<CardDescription className="text-base text-slate-600 dark:text-slate-400">{description}</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-6 pb-10 text-slate-700 dark:text-slate-300">{children}</CardContent>
</FrostedCard>
);
}
WelcomeStepCard.displayName = 'WelcomeStepCard';

View File

@@ -1,48 +0,0 @@
import React from 'react';
import { getTenantPackagesOverview, getPackages, Package, TenantPackageSummary } from '../../api';
export type TenantPackagesState =
| { status: 'loading' }
| { status: 'error'; message: string }
| {
status: 'success';
catalog: Package[];
activePackage: TenantPackageSummary | null;
purchasedPackages: TenantPackageSummary[];
};
export function useTenantPackages(): TenantPackagesState {
const [state, setState] = React.useState<TenantPackagesState>({ status: 'loading' });
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const [tenantPackages, catalog] = await Promise.all([
getTenantPackagesOverview(),
getPackages('endcustomer'),
]);
if (cancelled) return;
setState({
status: 'success',
catalog,
activePackage: tenantPackages.activePackage,
purchasedPackages: tenantPackages.packages,
});
} catch (error) {
console.error('[useTenantPackages] Failed to fetch', error);
if (cancelled) return;
setState({
status: 'error',
message: 'Pakete konnten nicht geladen werden. Bitte später erneut versuchen.',
});
}
})();
return () => {
cancelled = true;
};
}, []);
return state;
}

View File

@@ -1,7 +0,0 @@
export * from './store';
export * from './components/TenantWelcomeLayout';
export * from './components/WelcomeHero';
export * from './components/WelcomeStepCard';
export * from './components/OnboardingCTAList';
export * from './components/OnboardingHighlightsGrid';
export * from './hooks/useTenantPackages';

View File

@@ -1,55 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ADMIN_EVENTS_PATH, ADMIN_WELCOME_PACKAGES_PATH } from '../../constants';
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
export default function WelcomeLandingPage() {
const navigate = useNavigate();
React.useEffect(() => {
try {
const prev = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
const next = { ...prev, welcomeSeen: true, lastStep: 'landing' };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (error) {
console.warn('Failed to persist onboarding progress', error);
}
}, []);
return (
<div className="flex flex-col gap-4 p-6" data-testid="welcome-landing">
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<h1 className="text-lg font-semibold">Tenant Admin Onboarding</h1>
<p className="text-sm text-slate-600">Starte mit der Einrichtung deines Workspaces.</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-md bg-slate-900 px-4 py-2 text-white"
onClick={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)}
>
hero.primary.label
</button>
<button
type="button"
className="rounded-md border border-slate-300 px-4 py-2 text-slate-700"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
hero.secondary.label
</button>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-sm text-slate-600 underline"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
layout.jumpToDashboard
</button>
</div>
</div>
);
}

View File

@@ -1,60 +0,0 @@
import React from 'react';
import type { TFunction } from 'i18next';
import { createTenantPaddleCheckout } from '../../api';
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: TFunction;
};
export function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
const checkout = await createTenantPaddleCheckout(packageId);
if (checkout?.checkout_url) {
window.open(checkout.checkout_url, '_blank', 'noopener');
}
onSuccess();
} catch (err) {
const message = err instanceof Error ? err.message : t('errors.generic', 'Fehler');
setError(message);
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col gap-2">
{error ? (
<div role="alert" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
{error}
</div>
) : null}
<button
type="button"
onClick={handleClick}
disabled={busy}
className="inline-flex items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-white disabled:opacity-70"
>
{busy ? t('welcome.packages.loading', 'Lädt…') : t('welcome.packages.purchase', 'Jetzt kaufen')}
</button>
</div>
);
}
export default function WelcomeOrderSummaryPage() {
return (
<div className="p-6">
<p>Order summary placeholder</p>
</div>
);
}

View File

@@ -1,221 +0,0 @@
import React from 'react';
import { fetchOnboardingStatus, trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
export type OnboardingProgress = {
welcomeSeen: boolean;
packageSelected: boolean;
eventCreated: boolean;
lastStep?: string | null;
adminAppOpenedAt?: string | null;
selectedPackage?: {
id: number;
name: string;
priceText?: string | null;
isSubscription?: boolean;
} | null;
inviteCreated: boolean;
brandingConfigured: boolean;
};
type OnboardingUpdate = Partial<OnboardingProgress> & {
serverStep?: string;
meta?: Record<string, unknown>;
};
type OnboardingContextValue = {
progress: OnboardingProgress;
setProgress: (updater: (prev: OnboardingProgress) => OnboardingProgress) => void;
markStep: (step: OnboardingUpdate) => void;
reset: () => void;
};
const DEFAULT_PROGRESS: OnboardingProgress = {
welcomeSeen: false,
packageSelected: false,
eventCreated: false,
lastStep: null,
adminAppOpenedAt: null,
selectedPackage: null,
inviteCreated: false,
brandingConfigured: false,
};
const STORAGE_KEY = 'tenant-admin:onboarding-progress';
const OnboardingProgressContext = React.createContext<OnboardingContextValue | undefined>(undefined);
function readStoredProgress(): OnboardingProgress {
if (typeof window === 'undefined') {
return DEFAULT_PROGRESS;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return DEFAULT_PROGRESS;
}
const parsed = JSON.parse(raw) as Partial<OnboardingProgress>;
return {
...DEFAULT_PROGRESS,
...parsed,
};
} catch (error) {
console.warn('[OnboardingProgress] Failed to parse stored value', error);
return DEFAULT_PROGRESS;
}
}
function writeStoredProgress(progress: OnboardingProgress) {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch (error) {
console.warn('[OnboardingProgress] Failed to persist value', error);
}
}
export function OnboardingProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgressState] = React.useState<OnboardingProgress>(() => readStoredProgress());
const [synced, setSynced] = React.useState(false);
const { status } = useAuth();
React.useEffect(() => {
if (status !== 'authenticated') {
if (synced) {
setSynced(false);
}
return;
}
if (synced) {
return;
}
let cancelled = false;
fetchOnboardingStatus().then((status) => {
if (cancelled) {
return;
}
if (!status) {
setSynced(true);
return;
}
const steps = status.steps ?? {};
setProgressState((prev) => {
const next: OnboardingProgress = {
...prev,
adminAppOpenedAt: steps.admin_app_opened_at ?? prev.adminAppOpenedAt ?? null,
eventCreated: Boolean(steps.event_created ?? prev.eventCreated),
packageSelected: Boolean(steps.selected_packages ?? prev.packageSelected),
inviteCreated: Boolean(steps.invite_created ?? prev.inviteCreated),
brandingConfigured: Boolean(steps.branding_completed ?? prev.brandingConfigured),
};
writeStoredProgress(next);
return next;
});
if (!steps.admin_app_opened_at) {
const timestamp = new Date().toISOString();
trackOnboarding('admin_app_opened').catch(() => {});
setProgressState((prev) => {
const next = { ...prev, adminAppOpenedAt: timestamp };
writeStoredProgress(next);
return next;
});
}
setSynced(true);
}).catch(() => {
if (!cancelled) {
setSynced(true);
}
});
return () => {
cancelled = true;
};
}, [status, synced]);
const setProgress = React.useCallback((updater: (prev: OnboardingProgress) => OnboardingProgress) => {
setProgressState((prev) => {
const next = updater(prev);
writeStoredProgress(next);
return next;
});
}, []);
const markStep = React.useCallback((step: OnboardingUpdate) => {
const { serverStep, meta, ...rest } = step;
setProgress((prev) => {
const derived: Partial<OnboardingProgress> = {};
switch (serverStep) {
case 'package_selected':
derived.packageSelected = true;
break;
case 'event_created':
derived.eventCreated = true;
break;
case 'invite_created':
derived.inviteCreated = true;
break;
case 'branding_configured':
derived.brandingConfigured = true;
break;
default:
break;
}
const next: OnboardingProgress = {
...prev,
...rest,
...derived,
lastStep: typeof rest.lastStep === 'undefined' ? prev.lastStep : rest.lastStep,
};
if (serverStep === 'admin_app_opened' && !next.adminAppOpenedAt) {
next.adminAppOpenedAt = new Date().toISOString();
}
return next;
});
if (serverStep) {
trackOnboarding(serverStep, meta).catch(() => {});
}
}, [setProgress]);
const reset = React.useCallback(() => {
setProgress(() => DEFAULT_PROGRESS);
}, [setProgress]);
const value = React.useMemo<OnboardingContextValue>(() => ({
progress,
setProgress,
markStep,
reset,
}), [progress, setProgress, markStep, reset]);
return (
<OnboardingProgressContext.Provider value={value}>
{children}
</OnboardingProgressContext.Provider>
);
}
export function useOnboardingProgress() {
const context = React.useContext(OnboardingProgressContext);
if (!context) {
throw new Error('useOnboardingProgress must be used within OnboardingProgressProvider');
}
return context;
}

View File

@@ -1,827 +0,0 @@
// @ts-nocheck
import React from 'react';
import { AlertTriangle, Loader2, RefreshCw, Sparkles, ArrowUpRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout';
import {
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantAddonHistory,
PaddleTransactionSummary,
TenantAddonHistoryEntry,
TenantPackageSummary,
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { FrostedSurface, SectionCard, SectionHeader } from '../components/tenant';
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
export default function BillingPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
() => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'),
[i18n.language]
);
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
const [transactionsLoading, setTransactionsLoading] = React.useState(false);
const [addonHistory, setAddonHistory] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonMeta, setAddonMeta] = React.useState<PaginationMeta | null>(null);
const [addonsLoading, setAddonsLoading] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
},
[locale]
);
const formatCurrency = React.useCallback(
(value: number | null | undefined, currency = 'EUR') => {
if (value === null || value === undefined) return '--';
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value);
},
[locale]
);
const resolveEventName = React.useCallback(
(event: TenantAddonHistoryEntry['event']) => {
const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed');
if (!event) {
return fallback;
}
if (typeof event.name === 'string' && event.name.trim().length > 0) {
return event.name;
}
if (event.name && typeof event.name === 'object') {
const lang = i18n.language?.split('-')[0] ?? 'de';
return (
event.name[lang] ??
event.name.de ??
event.name.en ??
Object.values(event.name)[0] ??
fallback
);
}
return fallback;
},
[i18n.language, t]
);
const packageLabels = React.useMemo(
() => ({
statusActive: t('billing.sections.packages.card.statusActive'),
statusInactive: t('billing.sections.packages.card.statusInactive'),
used: t('billing.sections.packages.card.used'),
available: t('billing.sections.packages.card.available'),
expires: t('billing.sections.packages.card.expires'),
}),
[t]
);
const loadAll = React.useCallback(async (force = false) => {
setLoading(true);
setError(null);
try {
const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([
getTenantPackagesOverview(force ? { force: true } : undefined),
getTenantPaddleTransactions().catch((err) => {
console.warn('Failed to load Paddle transactions', err);
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
}),
getTenantAddonHistory().catch((err) => {
console.warn('Failed to load add-on history', err);
return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } };
}),
]);
setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage);
setTransactions(paddleTransactions.data);
setTransactionCursor(paddleTransactions.nextCursor);
setTransactionsHasMore(paddleTransactions.hasMore);
setAddonHistory(addonHistoryResult.data);
setAddonMeta(addonHistoryResult.meta);
} catch (err) {
if (!isAuthError(err)) {
setError(t('billing.errors.load'));
}
} finally {
setLoading(false);
}
}, [t]);
const loadMoreTransactions = React.useCallback(async () => {
if (!transactionsHasMore || transactionsLoading || !transactionCursor) {
return;
}
setTransactionsLoading(true);
try {
const result = await getTenantPaddleTransactions(transactionCursor);
setTransactions((current) => [...current, ...result.data]);
setTransactionCursor(result.nextCursor);
setTransactionsHasMore(result.hasMore && Boolean(result.nextCursor));
} catch (error) {
console.warn('Failed to load additional Paddle transactions', error);
setTransactionsHasMore(false);
} finally {
setTransactionsLoading(false);
}
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
const loadMoreAddons = React.useCallback(async () => {
if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) {
return;
}
setAddonsLoading(true);
try {
const nextPage = addonMeta.current_page + 1;
const result = await getTenantAddonHistory(nextPage);
setAddonHistory((current) => [...current, ...result.data]);
setAddonMeta(result.meta);
} catch (error) {
console.warn('Failed to load additional add-on history', error);
} finally {
setAddonsLoading(false);
}
}, [addonMeta, addonsLoading]);
React.useEffect(() => {
void loadAll();
}, [loadAll]);
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
}
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
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 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: used,
helper: t('billing.stats.events.helper', { count: remaining }),
tone: 'amber' as const,
},
];
}, [activePackage, computedRemainingEvents, t]);
return (
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<BillingSkeleton />
) : (
<>
<BillingWarningBanner warnings={activeWarnings} t={t} />
<SectionCard className="mt-6 space-y-5">
<SectionHeader
eyebrow={t('billing.sections.overview.badge', 'Aktuelles Paket')}
title={t('billing.sections.overview.title')}
description={t('billing.sections.overview.description')}
endSlot={(
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700 dark:bg-pink-500/20 dark:text-pink-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{activePackage ? activePackage.package_name : t('billing.sections.overview.emptyBadge')}
</Badge>
)}
/>
{activePackage ? (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<InfoCard
label={t('billing.sections.overview.cards.package.label')}
value={activePackage.package_name}
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: computedRemainingEvents ?? 0,
})}
/>
<InfoCard
label={t('billing.sections.overview.cards.price.label')}
value={formatCurrency(activePackage.price ?? null, activePackage.currency ?? 'EUR')}
tone="sky"
helper={activePackage.currency ?? 'EUR'}
/>
<InfoCard
label={t('billing.sections.overview.cards.expires.label')}
value={formatDate(activePackage.expires_at)}
tone="emerald"
helper={t('billing.sections.overview.cards.expires.helper')}
/>
</div>
</div>
) : (
<EmptyState message={t('billing.sections.overview.empty')} />
)}
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.packages.badge', 'Pakete')}
title={t('billing.sections.packages.title')}
description={t('billing.sections.packages.description')}
/>
<div className="space-y-3">
{packages.length === 0 ? (
<EmptyState message={t('billing.sections.packages.empty')} />
) : (
packages.map((pkg) => {
const warnings = buildPackageWarnings(pkg, t, formatDate, 'billing.sections.packages.card.warnings');
return (
<PackageCard
key={pkg.id}
pkg={pkg}
isActive={Boolean(pkg.active)}
labels={packageLabels}
formatDate={formatDate}
formatCurrency={formatCurrency}
warnings={warnings}
/>
);
})
)}
</div>
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.addOns.badge', 'Add-ons')}
title={t('billing.sections.addOns.title')}
description={t('billing.sections.addOns.description')}
/>
{addonHistory.length === 0 ? (
<EmptyState message={t('billing.sections.addOns.empty')} />
) : (
<AddonHistoryTable
items={addonHistory}
formatCurrency={formatCurrency}
formatDate={formatDate}
resolveEventName={resolveEventName}
locale={locale}
t={t}
/>
)}
{hasMoreAddons && (
<Button variant="outline" onClick={() => void loadMoreAddons()} disabled={addonsLoading}>
{addonsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.addOns.loadingMore', 'Loading add-ons...')}
</>
) : (
t('billing.sections.addOns.loadMore', 'Load more add-ons')
)}
</Button>
)}
</SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.transactions.badge', 'Transaktionen')}
title={t('billing.sections.transactions.title')}
description={t('billing.sections.transactions.description')}
/>
<div className="space-y-3">
{transactions.length === 0 ? (
<EmptyState message={t('billing.sections.transactions.empty')} />
) : (
<TransactionsTable
items={transactions}
formatCurrency={formatCurrency}
formatDate={formatDate}
locale={locale}
t={t}
/>
)}
{transactionsHasMore && (
<Button
variant="outline"
onClick={() => void loadMoreTransactions()}
disabled={transactionsLoading}
>
{transactionsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.transactions.loadingMore')}
</>
) : (
t('billing.sections.transactions.loadMore')
)}
</Button>
)}
</div>
</SectionCard>
</>
)}
</AdminLayout>
);
}
function AddonHistoryTable({
items,
formatCurrency,
formatDate,
resolveEventName,
locale,
t,
}: {
items: TenantAddonHistoryEntry[];
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
resolveEventName: (event: TenantAddonHistoryEntry['event']) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const extrasLabel = (key: 'photos' | 'guests' | 'gallery', count: number) =>
t(`billing.sections.addOns.extras.${key}`, { count });
return (
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
<tr>
<th className="px-4 py-3">{t('billing.sections.addOns.table.addon')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.event')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.amount')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.status')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.purchased')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
{items.map((item) => {
const extras: string[] = [];
if (item.extra_photos > 0) {
extras.push(extrasLabel('photos', item.extra_photos));
}
if (item.extra_guests > 0) {
extras.push(extrasLabel('guests', item.extra_guests));
}
if (item.extra_gallery_days > 0) {
extras.push(extrasLabel('gallery', item.extra_gallery_days));
}
const purchasedLabel = item.purchased_at
? new Date(item.purchased_at).toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(item.purchased_at);
const statusKey = `billing.sections.addOns.status.${item.status}`;
const statusLabel = t(statusKey, { defaultValue: item.status });
const statusTone: Record<string, string> = {
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
pending: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
};
return (
<tr key={item.id} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
<span className="font-semibold">{item.label ?? item.addon_key}</span>
{item.quantity > 1 ? (
<Badge variant="outline" className="border-slate-200/70 text-[11px] font-medium dark:border-slate-700">
×{item.quantity}
</Badge>
) : null}
</div>
{extras.length > 0 ? (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{extras.join(' · ')}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-medium text-slate-800 dark:text-slate-200">{resolveEventName(item.event)}</p>
{item.event?.slug ? (
<p className="text-xs text-slate-500 dark:text-slate-500">{item.event.slug}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(item.amount, item.currency ?? 'EUR')}
</p>
{item.receipt_url ? (
<a
href={item.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
) : null}
</td>
<td className="px-4 py-3 align-top">
<Badge className={statusTone[item.status] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{statusLabel}
</Badge>
</td>
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{purchasedLabel}</td>
</tr>
);
})}
</tbody>
</table>
</FrostedSurface>
);
}
function TransactionsTable({
items,
formatCurrency,
formatDate,
locale,
t,
}: {
items: PaddleTransactionSummary[];
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const statusTone: Record<string, string> = {
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
processing: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
cancelled: 'bg-slate-200 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200',
};
return (
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
<tr>
<th className="px-4 py-3">{t('billing.sections.transactions.table.transaction', 'Transaktion')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.amount', 'Betrag')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.status', 'Status')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.date', 'Datum')}</th>
<th className="px-4 py-3">{t('billing.sections.transactions.table.origin', 'Herkunft')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
{items.map((transaction) => {
const amount = transaction.grand_total ?? transaction.amount ?? null;
const statusKey = transaction.status ? `billing.sections.transactions.status.${transaction.status}` : 'billing.sections.transactions.status.unknown';
const statusLabel = t(statusKey, { defaultValue: transaction.status ?? 'Unknown' });
const createdAt = transaction.created_at
? new Date(transaction.created_at).toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(transaction.created_at);
return (
<tr key={transaction.id ?? Math.random().toString(36).slice(2)} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
</p>
{transaction.checkout_id ? (
<p className="text-xs text-slate-500 dark:text-slate-500">
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(amount, transaction.currency ?? 'EUR')}</p>
{transaction.tax ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, transaction.currency ?? 'EUR') })}
</p>
) : null}
{transaction.receipt_url ? (
<a
href={transaction.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt', 'Beleg ansehen')}
</a>
) : null}
</td>
<td className="px-4 py-3 align-top">
<Badge className={statusTone[transaction.status ?? ''] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{statusLabel}
</Badge>
</td>
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{createdAt}</td>
<td className="px-4 py-3 align-top">
<p className="text-sm text-slate-700 dark:text-slate-200">{transaction.origin ?? '—'}</p>
</td>
</tr>
);
})}
</tbody>
</table>
</FrostedSurface>
);
}
function BillingStatGrid({
stats,
}: {
stats: Array<{ key: string; label: string; value: string | number | null | undefined; helper?: string; tone: 'pink' | 'amber' | 'sky' | 'emerald' }>;
}) {
return (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<InfoCard key={stat.key} label={stat.label} value={stat.value} helper={stat.helper} tone={stat.tone} />
))}
</div>
);
}
function BillingWarningBanner({ warnings, t }: { warnings: PackageWarning[]; t: (key: string, options?: Record<string, unknown>) => string }) {
if (!warnings.length) {
return null;
}
return (
<Alert className="mt-6 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/20 dark:text-amber-100">
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" /> {t('billingWarning.title', 'Handlungsbedarf')}
</AlertTitle>
<AlertDescription className="mt-2 space-y-2 text-sm">
<p>{t('billingWarning.description', 'Paketwarnungen und Limits, die du im Blick behalten solltest.')}</p>
<ul className="list-disc space-y-1 pl-4 text-xs">
{warnings.map((warning) => (
<li key={warning.id}>{warning.message}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
function InfoCard({
label,
value,
helper,
tone,
}: {
label: string;
value: string | number | null | undefined;
helper?: string;
tone: 'pink' | 'amber' | 'sky' | 'emerald';
}) {
const toneBorders: Record<'pink' | 'amber' | 'sky' | 'emerald', string> = {
pink: 'border-pink-200/60 shadow-rose-200/30',
amber: 'border-amber-200/60 shadow-amber-200/30',
sky: 'border-sky-200/60 shadow-sky-200/30',
emerald: 'border-emerald-200/60 shadow-emerald-200/30',
} as const;
return (
<FrostedSurface className={`border ${toneBorders[tone]} p-5 text-slate-900 shadow-md transition-colors duration-200 dark:text-slate-100`}>
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
<div className="mt-3 text-xl font-semibold">{value ?? '--'}</div>
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
</FrostedSurface>
);
}
function PackageCard({
pkg,
isActive,
labels,
formatDate,
formatCurrency,
warnings = [],
}: {
pkg: TenantPackageSummary;
isActive: boolean;
labels: {
statusActive: string;
statusInactive: string;
used: string;
available: string;
expires: string;
};
formatDate: (value: string | null | undefined) => string;
formatCurrency: (value: number | null | undefined, currency?: string) => string;
warnings?: PackageWarning[];
}) {
return (
<FrostedSurface className={`space-y-4 border ${isActive ? 'border-amber-200/60 shadow-amber-200/20' : 'border-slate-200/60 shadow-slate-200/20'} p-5 text-slate-900 dark:text-slate-100`}>
{warnings.length > 0 && (
<div className="mb-3 space-y-2">
{warnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200' : 'dark:border-slate-800/70 dark:bg-slate-950/80'}
>
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{pkg.package_name}</h3>
<p className="text-xs text-slate-600 dark:text-slate-400">
{formatDate(pkg.purchased_at)} · {formatCurrency(pkg.price, pkg.currency ?? 'EUR')}
</p>
</div>
<Badge className={isActive ? 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{isActive ? labels.statusActive : labels.statusInactive}
</Badge>
</div>
<Separator className="my-3 dark:border-slate-800/70" />
<div className="grid gap-2 text-xs text-slate-600 dark:text-slate-400 sm:grid-cols-3">
<span>
{labels.used}: {pkg.used_events}
</span>
<span>
{labels.available}: {pkg.remaining_events ?? '--'}
</span>
<span>
{labels.expires}: {formatDate(pkg.expires_at)}
</span>
</div>
</FrostedSurface>
);
}
function EmptyState({ message }: { message: string }) {
return (
<FrostedSurface className="flex flex-col items-center justify-center gap-3 border border-dashed border-slate-200/70 p-8 text-center shadow-inner dark:border-slate-700/60">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Sparkles className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
</FrostedSurface>
);
}
function buildPackageWarnings(
pkg: TenantPackageSummary | null | undefined,
translate: (key: string, options?: Record<string, unknown>) => string,
formatDate: (value: string | null | undefined) => string,
keyPrefix: string,
): PackageWarning[] {
if (!pkg) {
return [];
}
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);
// 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`,
tone: 'danger',
message: translate(`${keyPrefix}.noEvents`),
});
} else if (remaining <= 2) {
warnings.push({
id: `${pkg.id}-low-events`,
tone: 'warning',
message: translate(`${keyPrefix}.lowEvents`, { remaining }),
});
}
}
const expiresAt = pkg.expires_at ? new Date(pkg.expires_at) : null;
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
const now = new Date();
const diffMillis = expiresAt.getTime() - now.getTime();
const diffDays = Math.ceil(diffMillis / (1000 * 60 * 60 * 24));
const formatted = formatDate(pkg.expires_at);
if (diffDays < 0) {
warnings.push({
id: `${pkg.id}-expired`,
tone: 'danger',
message: translate(`${keyPrefix}.expired`, { date: formatted }),
});
} else if (diffDays <= 14) {
warnings.push({
id: `${pkg.id}-expires`,
tone: 'warning',
message: translate(`${keyPrefix}.expiresSoon`, { date: formatted }),
});
}
}
return warnings;
}
function BillingSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<FrostedSurface
key={index}
className="space-y-4 border border-white/20 p-6 shadow-md shadow-rose-200/10 dark:border-slate-800/70 dark:bg-slate-950/80"
>
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/30 via-white/60 to-white/30 dark:from-slate-700 dark:via-slate-600 dark:to-slate-700" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((__, placeholderIndex) => (
<div
key={placeholderIndex}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/30 via-white/55 to-white/30 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
</FrostedSurface>
))}
</div>
);
}

View File

@@ -1,968 +0,0 @@
// @ts-nocheck
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Camera,
AlertTriangle,
Sparkles,
CalendarDays,
Plus,
Settings,
QrCode,
ClipboardList,
Package as PackageIcon,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
TenantOnboardingChecklistCard,
SectionCard,
SectionHeader,
ActionGrid,
} from '../components/tenant';
import type { ChecklistStep } from '../components/tenant';
import { AdminLayout } from '../components/AdminLayout';
import {
DashboardSummary,
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
TenantEvent,
TenantPackageSummary,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { useAuth } from '../auth/context';
import { useEventContext } from '../context/EventContext';
import {
adminPath,
ADMIN_HOME_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_CREATE_PATH,
buildEngagementTabPath,
} from '../constants';
import { useOnboardingProgress } from '../onboarding';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { DashboardEventFocusCard } from '../components/dashboard/DashboardEventFocusCard';
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
interface DashboardState {
summary: DashboardSummary | null;
events: TenantEvent[];
activePackage: TenantPackageSummary | null;
loading: boolean;
errorKey: string | null;
}
type ReadinessState = {
hasEvent: boolean;
hasTasks: boolean;
hasQrInvites: boolean;
hasPackage: boolean;
primaryEventSlug: string | null;
primaryEventName: string | null;
loading: boolean;
};
export default function DashboardPage() {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { events: ctxEvents, activeEvent: ctxActiveEvent, selectEvent } = useEventContext();
const { progress, markStep } = useOnboardingProgress();
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
const { t: tc } = useTranslation('common');
const translate = React.useCallback(
(key: string, optionsOrFallback?: Record<string, unknown> | string, explicitFallback?: string) => {
const hasOptions = typeof optionsOrFallback === 'object' && optionsOrFallback !== null;
const options = hasOptions ? (optionsOrFallback as Record<string, unknown>) : undefined;
const fallback = typeof optionsOrFallback === 'string' ? optionsOrFallback : explicitFallback;
const value = t(key, { defaultValue: fallback, ...(options ?? {}) });
if (value === `dashboard.${key}`) {
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
if (fallbackValue !== `dashboard:${key}`) {
return fallbackValue;
}
if (fallback !== undefined) {
return fallback;
}
}
return value;
},
[t, i18n],
);
const [state, setState] = React.useState<DashboardState>({
summary: null,
events: [],
activePackage: null,
loading: true,
errorKey: null,
});
const [readiness, setReadiness] = React.useState<ReadinessState>({
hasEvent: false,
hasTasks: false,
hasQrInvites: false,
hasPackage: false,
primaryEventSlug: null,
primaryEventName: null,
loading: false,
});
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const [summary, events, packages] = await Promise.all([
getDashboardSummary().catch(() => null),
getEvents({ force: true }).catch(() => [] as TenantEvent[]),
getTenantPackagesOverview().catch(() => ({ packages: [], activePackage: null })),
]);
if (cancelled) {
return;
}
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
const eventPool = events.length ? events : ctxEvents;
const primaryEvent = ctxActiveEvent ?? eventPool[0] ?? null;
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
setReadiness({
hasEvent: eventPool.length > 0,
hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false,
hasQrInvites: primaryEvent
? Number(
primaryEvent.active_invites_count ??
primaryEvent.active_join_tokens_count ??
0
) > 0
: false,
hasPackage: Boolean(packages.activePackage),
primaryEventSlug: primaryEvent?.slug ?? null,
primaryEventName,
loading: false,
});
setState({
summary: summary ?? fallbackSummary,
events: eventPool,
activePackage: packages.activePackage,
loading: false,
errorKey: null,
});
if (!primaryEvent && !cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: false,
hasQrInvites: false,
loading: false,
}));
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
errorKey: 'loadFailed',
loading: false,
}));
}
}
})();
return () => {
cancelled = true;
};
}, []);
const { summary, events, activePackage, loading, errorKey } = state;
React.useEffect(() => {
if (loading) {
return;
}
if (events.length > 0 && !progress.eventCreated) {
const primary = events[0];
markStep({
eventCreated: true,
serverStep: 'event_created',
meta: primary ? { event_id: primary.id } : undefined,
});
}
}, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
const greetingName = user?.name ?? translate('welcome.fallbackName');
const greetingTitle = translate('welcome.greeting', { name: greetingName });
const subtitle = translate('welcome.subtitle');
const errorMessage = errorKey ? translate(`errors.${errorKey}`) : null;
const dateLocale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const canCreateEvent = React.useMemo(() => {
if (!activePackage) {
return true;
}
if (activePackage.remaining_events === null || activePackage.remaining_events === undefined) {
return true;
}
return activePackage.remaining_events > 0;
}, [activePackage]);
const eventOptions = ctxEvents.length ? ctxEvents : events;
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(() => ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
React.useEffect(() => {
setSelectedSlug(ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
}, [ctxActiveEvent?.slug, eventOptions]);
const upcomingEvents = getUpcomingEvents(eventOptions);
const publishedEvents = eventOptions.filter((event) => event.status === 'published');
const primaryEvent = React.useMemo(
() => eventOptions.find((event) => event.slug === selectedSlug) ?? eventOptions[0] ?? null,
[eventOptions, selectedSlug],
);
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
const singleEvent = eventOptions.length === 1 ? eventOptions[0] : null;
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
const primaryEventLimits = primaryEvent?.limits ?? null;
const limitTranslate = React.useCallback(
(key: string, options?: Record<string, unknown>) => tc(`limits.${key}`, options),
[tc],
);
const limitWarnings = React.useMemo(
() => buildLimitWarnings(primaryEventLimits, limitTranslate),
[primaryEventLimits, limitTranslate],
);
const shownToastsRef = React.useRef<Set<string>>(new Set());
// Limit warnings werden ausschließlich in der Limits-Karte angezeigt; keine Toasts mehr
const limitScopeLabels = React.useMemo(
() => ({
photos: tc('limits.photosTitle'),
guests: tc('limits.guestsTitle'),
gallery: tc('limits.galleryTitle'),
}),
[tc],
);
const hasPhotos = React.useMemo(() => {
if ((summary?.new_photos ?? 0) > 0) {
return true;
}
return events.some((event) => Number(event.photo_count ?? 0) > 0 || Number(event.pending_photo_count ?? 0) > 0);
}, [summary, events]);
const primaryEventSlug = readiness.primaryEventSlug;
const liveEvents = React.useMemo(() => {
const now = Date.now();
const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day
return events.filter((event) => {
if (!event.slug) {
return false;
}
const isActivated = Boolean(event.is_active || event.status === 'published');
if (!isActivated) {
return false;
}
if (!event.event_date) {
return true;
}
const eventStart = new Date(event.event_date).getTime();
if (Number.isNaN(eventStart)) {
return true;
}
return now >= eventStart && now <= eventStart + windowLengthMs;
});
}, [events]);
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
const steps: ChecklistStep[] = [
{
key: 'admin_app',
title: translate('onboarding.admin_app.title', 'Admin-App öffnen'),
description: translate(
'onboarding.admin_app.description',
'Verwalte Events, Uploads und Gäste direkt in der Admin-App.'
),
done: Boolean(progress.adminAppOpenedAt),
ctaLabel: translate('onboarding.admin_app.cta', 'Admin-App starten'),
onAction: () => navigate(ADMIN_HOME_PATH),
icon: Sparkles,
},
{
key: 'event_setup',
title: translate('onboarding.event_setup.title', 'Erstes Event vorbereiten'),
description: translate(
'onboarding.event_setup.description',
'Lege in der Admin-App Name, Datum und Aufgaben fest.'
),
done: readiness.hasEvent,
ctaLabel: translate('onboarding.event_setup.cta', 'Event anlegen'),
onAction: () => navigate(ADMIN_EVENT_CREATE_PATH),
icon: CalendarDays,
},
{
key: 'invite_guests',
title: translate('onboarding.invite_guests.title', 'Gäste einladen'),
description: translate(
'onboarding.invite_guests.description',
'Teile QR-Codes oder Links, damit Gäste sofort starten.'
),
done: readiness.hasQrInvites || progress.inviteCreated,
ctaLabel: translate('onboarding.invite_guests.cta', 'QR-Links öffnen'),
onAction: () => {
if (primaryEventSlug) {
navigate(`${ADMIN_EVENT_VIEW_PATH(primaryEventSlug)}#qr-invites`);
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: QrCode,
},
{
key: 'collect_photos',
title: translate('onboarding.collect_photos.title', 'Erste Fotos einsammeln'),
description: translate(
'onboarding.collect_photos.description',
'Sobald Uploads eintreffen, moderierst du sie in der Admin-App.'
),
done: hasPhotos,
ctaLabel: translate('onboarding.collect_photos.cta', 'Uploads prüfen'),
onAction: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: Camera,
},
{
key: 'branding',
title: translate('onboarding.branding.title', 'Branding & Aufgaben verfeinern'),
description: translate(
'onboarding.branding.description',
'Passt Farbwelt und Aufgabenpakete an euren Anlass an.'
),
done: (progress.brandingConfigured || readiness.hasTasks) && (readiness.hasPackage || progress.packageSelected),
ctaLabel: translate('onboarding.branding.cta', 'Branding öffnen'),
onAction: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
icon: ClipboardList,
},
];
return steps;
}, [
translate,
progress.adminAppOpenedAt,
progress.inviteCreated,
progress.brandingConfigured,
progress.packageSelected,
readiness.hasEvent,
readiness.hasQrInvites,
readiness.hasTasks,
readiness.hasPackage,
hasPhotos,
navigate,
primaryEventSlug,
]);
const completedOnboardingSteps = React.useMemo(
() => onboardingChecklist.filter((step) => step.done).length,
[onboardingChecklist]
);
const onboardingCompletion = React.useMemo(() => {
if (onboardingChecklist.length === 0) {
return 0;
}
return Math.round((completedOnboardingSteps / onboardingChecklist.length) * 100);
}, [completedOnboardingSteps, onboardingChecklist]);
const onboardingCardTitle = translate('onboarding.card.title', 'Dein Start in fünf Schritten');
const onboardingCardDescription = translate(
'onboarding.card.description',
'Bearbeite die Schritte in der Admin-App das Dashboard zeigt dir den Status.'
);
const onboardingCompletedCopy = translate(
'onboarding.card.completed',
'Alle Schritte abgeschlossen großartig! Du kannst jederzeit zur Admin-App wechseln.'
);
const onboardingFallbackCta = translate('onboarding.card.cta_fallback', 'Jetzt starten');
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
const hasEventContext = readiness.hasEvent;
const quickActionItems = React.useMemo(
() => [
{
key: 'create',
label: translate('quickActions.createEvent.label'),
description: translate('quickActions.createEvent.description'),
icon: <Plus className="h-5 w-5" />,
onClick: () => {
if (!canCreateEvent) {
toast.error(tc('errors.eventLimit', 'Dein aktuelles Paket enthält keine freien Event-Slots mehr.'));
navigate(ADMIN_BILLING_PATH);
return;
}
navigate(ADMIN_EVENT_CREATE_PATH);
},
disabled: !canCreateEvent,
},
{
key: 'photos',
label: translate('quickActions.moderatePhotos.label'),
description: translate('quickActions.moderatePhotos.description'),
icon: <Camera className="h-5 w-5" />,
onClick: () => navigate(ADMIN_EVENTS_PATH),
disabled: !hasEventContext,
},
{
key: 'tasks',
label: translate('quickActions.organiseTasks.label'),
description: translate('quickActions.organiseTasks.description'),
icon: <ClipboardList className="h-5 w-5" />,
onClick: () => navigate(buildEngagementTabPath('tasks')),
disabled: !hasEventContext,
},
{
key: 'packages',
label: translate('quickActions.managePackages.label'),
description: translate('quickActions.managePackages.description'),
icon: <Sparkles className="h-5 w-5" />,
onClick: () => navigate(ADMIN_BILLING_PATH),
},
],
[translate, navigate, hasEventContext],
);
const adminTitle = singleEventName ?? greetingTitle;
const adminSubtitle = singleEvent
? translate('overview.eventHero.subtitle', {
defaultValue: 'Alle Funktionen konzentrieren sich auf dieses Event.',
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
})
: subtitle;
const focusActions = React.useMemo(
() => ({
createEvent: () => navigate(ADMIN_EVENT_CREATE_PATH),
openEvent: () => {
if (primaryEvent?.slug) {
navigate(ADMIN_EVENT_VIEW_PATH(primaryEvent.slug));
} else {
navigate(ADMIN_EVENTS_PATH);
}
},
openPhotos: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openInvites: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_INVITES_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openTasks: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_TASKS_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
openPhotobooth: () => {
if (primaryEventSlug) {
navigate(ADMIN_EVENT_PHOTOBOOTH_PATH(primaryEventSlug));
return;
}
navigate(ADMIN_EVENTS_PATH);
},
}),
[navigate, primaryEvent, primaryEventSlug],
);
return (
<AdminLayout title={adminTitle} subtitle={adminSubtitle}>
{loading ? (
<DashboardSkeleton />
) : (
<>
<div id="overview" className="space-y-6 scroll-mt-32">
{eventOptions.length > 1 ? (
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('overview.eventSwitcherEyebrow', 'Events')}
title={translate('overview.eventSwitcherTitle', 'Event auswählen')}
description={translate('overview.eventSwitcherDescription', 'Wechsle das Event, für das das Dashboard Daten anzeigt.')}
/>
<Select
value={selectedSlug ?? ''}
onValueChange={(value) => {
setSelectedSlug(value);
selectEvent(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={translate('overview.eventSwitcherPlaceholder', 'Event auswählen')} />
</SelectTrigger>
<SelectContent>
{eventOptions.map((event) => (
<SelectItem key={event.slug} value={event.slug}>
{resolveEventName(event.name, event.slug)}
{event.event_date ? `${formatDate(event.event_date, dateLocale) ?? ''}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</SectionCard>
) : null}
<DashboardEventFocusCard
event={primaryEvent}
limitWarnings={limitWarnings}
summary={summary}
dateLocale={dateLocale}
onCreateEvent={focusActions.createEvent}
onOpenEvent={focusActions.openEvent}
onOpenPhotos={focusActions.openPhotos}
onOpenInvites={focusActions.openInvites}
onOpenTasks={focusActions.openTasks}
onOpenPhotobooth={focusActions.openPhotobooth}
/>
</div>
<div id="live" className="space-y-6 scroll-mt-32">
{primaryEventLimits ? (
<Card className="border-0 bg-brand-card shadow-brand-primary">
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<PackageIcon className="h-5 w-5 text-brand-rose" />
{translate('limitsCard.title')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{primaryEventName
? translate('limitsCard.description', { name: primaryEventName })
: translate('limitsCard.descriptionFallback')}
</CardDescription>
</div>
<Badge className="bg-brand-rose-soft text-brand-rose">
{primaryEventName ?? translate('limitsCard.descriptionFallback')}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{limitWarnings.length > 0 && (
<div className="space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<LimitUsageRow
label={translate('limitsCard.photosLabel')}
summary={primaryEventLimits.photos}
unlimitedLabel={tc('limits.unlimited')}
usageLabel={translate('limitsCard.usageLabel')}
remainingLabel={translate('limitsCard.remainingLabel')}
/>
<LimitUsageRow
label={translate('limitsCard.guestsLabel')}
summary={primaryEventLimits.guests}
unlimitedLabel={tc('limits.unlimited')}
usageLabel={translate('limitsCard.usageLabel')}
remainingLabel={translate('limitsCard.remainingLabel')}
/>
</div>
<GalleryStatusRow
label={translate('limitsCard.galleryLabel')}
summary={primaryEventLimits.gallery}
locale={dateLocale}
messages={{
expired: tc('limits.galleryExpired'),
noExpiry: translate('limitsCard.galleryNoExpiry'),
expires: translate('limitsCard.galleryExpires'),
}}
/>
</CardContent>
</Card>
) : null}
</div>
<div id="setup" className="space-y-6 scroll-mt-32">
<SectionCard className="space-y-3">
<SectionHeader
eyebrow={translate('quickActions.title')}
title={translate('quickActions.title')}
description={translate('quickActions.description')}
/>
<ActionGrid items={quickActionItems} />
</SectionCard>
<TenantOnboardingChecklistCard
title={onboardingCardTitle}
description={onboardingCardDescription}
steps={onboardingChecklist}
completedLabel={readinessCompleteLabel}
pendingLabel={readinessPendingLabel}
completionPercent={onboardingCompletion}
completedCount={completedOnboardingSteps}
totalCount={onboardingChecklist.length}
emptyCopy={onboardingCompletedCopy}
fallbackActionLabel={onboardingFallbackCta}
/>
</div>
<div id="recap" className="space-y-6 scroll-mt-32">
<section className="space-y-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5 dark:shadow-inner">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{translate('upcoming.title')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">{translate('upcoming.description')}</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{translate('upcoming.settings')}
</Button>
</div>
<div className="space-y-3">
{upcomingEvents.length === 0 ? (
<EmptyState
message={translate('upcoming.empty.message')}
ctaLabel={translate('upcoming.empty.cta')}
onCta={() => navigate(adminPath('/events/new'))}
/>
) : (
upcomingEvents.map((event) => (
<UpcomingEventRow
key={event.id}
event={event}
onView={() => navigate(ADMIN_EVENT_VIEW_PATH(event.slug))}
locale={dateLocale}
labels={{
live: translate('upcoming.status.live'),
planning: translate('upcoming.status.planning'),
open: tc('actions.open'),
noDate: translate('upcoming.status.noDate'),
}}
/>
))
)}
</div>
</section>
</div>
</>
)}
</AdminLayout>
);
}
function formatDate(value: string | null, locale: string): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name;
}
if (name && typeof name === 'object') {
if (typeof name.de === 'string' && name.de.trim().length > 0) {
return name.de;
}
if (typeof name.en === 'string' && name.en.trim().length > 0) {
return name.en;
}
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
if (typeof first === 'string') {
return first;
}
}
return fallbackSlug || 'Event';
}
function buildSummaryFallback(
events: TenantEvent[],
activePackage: TenantPackageSummary | null
): DashboardSummary {
const activeEvents = events.filter((event) => Boolean(event.is_active || event.status === 'published'));
const totalPhotos = events.reduce((sum, event) => sum + Number(event.photo_count ?? 0), 0);
return {
active_events: activeEvents.length,
new_photos: totalPhotos,
task_progress: 0,
upcoming_events: activeEvents.length,
active_package: activePackage
? {
name: activePackage.package_name,
remaining_events: activePackage.remaining_events,
expires_at: activePackage.expires_at,
}
: null,
};
}
function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
const now = new Date();
return events
.filter((event) => {
if (!event.event_date) return false;
const date = new Date(event.event_date);
return !Number.isNaN(date.getTime()) && date >= now;
})
.sort((a, b) => {
const dateA = a.event_date ? new Date(a.event_date).getTime() : 0;
const dateB = b.event_date ? new Date(b.event_date).getTime() : 0;
return dateA - dateB;
})
.slice(0, 4);
}
function LimitUsageRow({
label,
summary,
unlimitedLabel,
usageLabel,
remainingLabel,
}: {
label: string;
summary: LimitUsageSummary | null;
unlimitedLabel: string;
usageLabel: string;
remainingLabel: string;
}) {
if (!summary) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4">
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
<span>{label}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</span>
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
</div>
);
}
const limit = typeof summary.limit === 'number' && summary.limit > 0 ? summary.limit : null;
const percent = limit ? Math.min(100, Math.round((summary.used / limit) * 100)) : 0;
const remaining = typeof summary.remaining === 'number' ? summary.remaining : null;
const barClass = summary.state === 'limit_reached'
? 'bg-rose-500'
: summary.state === 'warning'
? 'bg-amber-500'
: 'bg-emerald-500';
return (
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
<span>{label}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{limit ? usageLabel.replace('{{used}}', `${summary.used}`).replace('{{limit}}', `${limit}`) : unlimitedLabel}
</span>
</div>
{limit ? (
<>
<div className="mt-3 h-2 rounded-full bg-slate-200 dark:bg-slate-800">
<div
className={`h-2 rounded-full transition-all ${barClass}`}
style={{ width: `${Math.max(6, percent)}%` }}
/>
</div>
{remaining !== null ? (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{remainingLabel
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
.replace('{{limit}}', `${limit}`)}
</p>
) : null}
</>
) : (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
)}
</div>
);
}
function GalleryStatusRow({
label,
summary,
locale,
messages,
}: {
label: string;
summary: GallerySummary | null;
locale: string;
messages: { expired: string; noExpiry: string; expires: string };
}) {
const expiresAt = summary?.expires_at ? formatDate(summary.expires_at, locale) : null;
let statusLabel = messages.noExpiry;
let badgeClass = 'bg-emerald-500/20 text-emerald-700';
if (summary?.state === 'expired') {
statusLabel = messages.expired;
badgeClass = 'bg-rose-500/20 text-rose-700';
} else if (summary?.state === 'warning') {
const days = Math.max(0, summary.days_remaining ?? 0);
statusLabel = `${messages.expires.replace('{{date}}', expiresAt ?? '')} (${days}d)`;
badgeClass = 'bg-amber-500/20 text-amber-700';
} else if (summary?.state === 'ok' && expiresAt) {
statusLabel = messages.expires.replace('{{date}}', expiresAt);
}
return (
<div className="rounded-2xl border border-slate-200 bg-white/70 p-4 dark:border-slate-800/70 dark:bg-slate-950/80">
<div className="flex items-center justify-between text-sm font-medium text-slate-700 dark:text-slate-200">
<span>{label}</span>
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{statusLabel}</span>
</div>
</div>
);
}
function UpcomingEventRow({
event,
onView,
locale,
labels,
}: {
event: TenantEvent;
onView: () => void;
locale: string;
labels: {
live: string;
planning: string;
open: string;
noDate: string;
};
}) {
const date = event.event_date ? new Date(event.event_date) : null;
const formattedDate = date
? date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' })
: labels.noDate;
return (
<div className="flex flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-white/30 dark:border-white/10 dark:bg-white/5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">{formattedDate}</span>
<div className="flex items-center gap-2">
<Badge className={event.status === 'published' ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-600'}>
{event.status === 'published' ? labels.live : labels.planning}
</Badge>
<Button
size="sm"
variant="outline"
onClick={onView}
className="rounded-full border-brand-rose-soft text-brand-rose hover:bg-brand-rose-soft/40"
>
{labels.open}
</Button>
</div>
</div>
</div>
);
}
function EmptyState({ message, ctaLabel, onCta }: { message: string; ctaLabel: string; onCta: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-brand-rose-soft p-3 text-brand-rose shadow-inner shadow-pink-200/80">
<Sparkles className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600">{message}</p>
<Button onClick={onCta} className="bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]">
{ctaLabel}
</Button>
</div>
);
}
function DashboardSkeleton() {
return (
<div className="grid gap-6">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="space-y-4 rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((__ , cardIndex) => (
<div
key={cardIndex}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -1,429 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import type { Locale } from 'date-fns';
import { Loader2, Palette, Plus, Power, Smile } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout';
import {
getEmotions,
createEmotion,
updateEmotion,
deleteEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
type EmotionFormState = {
name: string;
description: string;
icon: string;
color: string;
is_active: boolean;
sort_order: number;
};
const DEFAULT_COLOR = '#6366f1';
const INITIAL_FORM_STATE: EmotionFormState = {
name: '',
description: '',
icon: 'lucide-smile',
color: DEFAULT_COLOR,
is_active: true,
sort_order: 0,
};
export type EmotionsSectionProps = {
embedded?: boolean;
};
export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
const { t, i18n } = useTranslation('management');
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<TenantEmotion | null>(null);
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
React.useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const data = await getEmotions();
if (!cancelled) {
setEmotions(data);
}
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.load'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [t]);
const openCreateDialog = React.useCallback(() => {
setForm(INITIAL_FORM_STATE);
setDialogOpen(true);
}, []);
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form.name.trim()) {
setError(t('emotions.errors.nameRequired'));
return;
}
setSaving(true);
setError(null);
const payload: EmotionPayload = {
name: form.name.trim(),
description: form.description.trim() || null,
icon: form.icon.trim() || 'lucide-smile',
color: form.color.trim() || DEFAULT_COLOR,
is_active: form.is_active,
sort_order: form.sort_order,
};
try {
const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]);
setDialogOpen(false);
toast.success(t('emotions.toast.created', 'Emotion erstellt.'));
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.create'));
toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.'));
}
} finally {
setSaving(false);
}
}
async function toggleEmotion(emotion: TenantEmotion) {
try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
toast.success(
updated.is_active
? t('emotions.toast.activated', 'Emotion aktiviert.')
: t('emotions.toast.deactivated', 'Emotion deaktiviert.')
);
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.toggle'));
toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.'));
}
}
}
async function handleDeleteEmotion(emotion: TenantEmotion) {
setSaving(true);
try {
await deleteEmotion(emotion.id);
setEmotions((prev) => prev.filter((item) => item.id !== emotion.id));
toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.'));
}
} finally {
setSaving(false);
setDeleteTarget(null);
}
}
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded
? t('emotions.subtitle')
: t('emotions.subtitle');
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Palette className="h-5 w-5 text-pink-500" />
{title}
</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
{t('emotions.actions.create')}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{loading ? (
<EmotionSkeleton />
) : emotions.length === 0 ? (
<EmptyEmotionsState onCreate={openCreateDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
onDelete={() => setDeleteTarget(emotion)}
locale={locale}
/>
))}
</div>
)}
</CardContent>
</Card>
<EmotionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
form={form}
setForm={setForm}
saving={saving}
onSubmit={handleCreate}
/>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.delete.title', 'Emotion löschen?')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-slate-600">
{t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t('actions.cancel', 'Abbrechen')}
</Button>
<Button
variant="destructive"
onClick={() => deleteTarget && void handleDeleteEmotion(deleteTarget)}
disabled={saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('actions.delete', 'Löschen')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default function EmotionsPage() {
const { t } = useTranslation('management');
return (
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
<EmotionsSection />
</AdminLayout>
);
}
function EmotionCard({
emotion,
onToggle,
onDelete,
locale,
}: {
emotion: TenantEmotion;
onToggle: () => void;
onDelete: () => void;
locale: Locale;
}) {
const { t } = useTranslation('management');
const updated = emotion.updated_at ? format(new Date(emotion.updated_at), 'Pp', { locale }) : null;
return (
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
<CardHeader className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="flex h-9 w-9 items-center justify-center rounded-full"
style={{ backgroundColor: `${emotion.color}20`, color: emotion.color ?? DEFAULT_COLOR }}
>
<Smile className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base text-slate-900">{emotion.name}</CardTitle>
{emotion.description ? (
<CardDescription className="text-xs text-slate-500">{emotion.description}</CardDescription>
) : null}
</div>
</div>
<Badge variant={emotion.is_active ? 'default' : 'secondary'}>
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-600">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">#{emotion.icon}</Badge>
{emotion.event_types?.length ? (
emotion.event_types.map((eventType) => (
<Badge key={eventType.id} variant="outline">
{eventType.name}
</Badge>
))
) : (
<Badge variant="outline">{t('emotions.labels.noEventType')}</Badge>
)}
</div>
{updated ? <p className="text-xs text-slate-400">{t('emotions.labels.updated', { date: updated })}</p> : null}
</CardContent>
<CardFooter className="flex justify-between gap-2">
<Button variant="ghost" onClick={onToggle} className="text-slate-500 hover:text-emerald-600">
<Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
{!emotion.is_global ? (
<Button variant="ghost" size="sm" className="text-rose-600 hover:bg-rose-50" onClick={onDelete}>
{t('actions.delete', 'Löschen')}
</Button>
) : (
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
)}
</CardFooter>
</Card>
);
}
function EmotionDialog({
open,
onOpenChange,
form,
setForm,
saving,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
form: EmotionFormState;
setForm: React.Dispatch<React.SetStateAction<EmotionFormState>>;
saving: boolean;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
}) {
const { t } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
<Input
id="emotion-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
<Input
id="emotion-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
<Input
id="emotion-icon"
value={form.icon}
onChange={(event) => setForm((prev) => ({ ...prev, icon: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
<Input
id="emotion-color"
type="color"
value={form.color}
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div>
<p className="text-sm font-medium text-slate-700">{t('emotions.dialogs.activeLabel')}</p>
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
</div>
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
</div>
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('emotions.dialogs.cancel')}
</Button>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('emotions.dialogs.submit')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function EmotionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={`emotion-skeleton-${index}`} className="h-36 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyEmotionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
<p className="text-sm text-slate-500">{t('emotions.empty.description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
{t('emotions.actions.create')}
</Button>
</div>
);
}

View File

@@ -1,200 +0,0 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { AdminLayout } from '../components/AdminLayout';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
StatCarousel,
ActionGrid,
} from '../components/tenant';
import { getDashboardSummary } from '../api';
import { TasksSection } from './TasksPage';
import { TaskCollectionsSection } from './TaskCollectionsPage';
import { EmotionsSection } from './EmotionsPage';
const TAB_KEYS = ['tasks', 'collections', 'emotions'] as const;
type EngagementTab = (typeof TAB_KEYS)[number];
function ensureValidTab(value: string | null): EngagementTab {
if (value && (TAB_KEYS as readonly string[]).includes(value)) {
return value as EngagementTab;
}
return 'tasks';
}
export default function EngagementPage() {
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common');
const [searchParams, setSearchParams] = useSearchParams();
const [engagementStats, setEngagementStats] = React.useState<{ tasks?: number; collections?: number; emotions?: number } | null>(null);
const initialTab = React.useMemo(() => ensureValidTab(searchParams.get('tab')), [searchParams]);
const [activeTab, setActiveTab] = React.useState<EngagementTab>(initialTab);
const handleTabChange = React.useCallback(
(next: string) => {
const valid = ensureValidTab(next);
setActiveTab(valid);
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('tab', valid);
return params;
});
},
[setSearchParams]
);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const summary = await getDashboardSummary();
if (!cancelled && summary?.engagement_totals) {
setEngagementStats(summary.engagement_totals);
}
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, []);
const heading = tc('navigation.engagement');
const heroDescription = t('engagement.hero.description', {
defaultValue: 'Kuratiere Aufgaben, Moderationskollektionen und Emotionen als kreative Toolbox für jedes Event.'
});
const heroSupporting = [
t('engagement.hero.summary.tasks', { defaultValue: 'Plane Aufgaben, die Gäste motivieren von Upload-Regeln bis zu Story-Prompts.' }),
t('engagement.hero.summary.collections', { defaultValue: 'Sammle Vorlagen und kollektive Inhalte, um Events im Handumdrehen neu zu starten.' })
];
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => handleTabChange('tasks')}
>
{t('engagement.hero.actions.tasks', 'Zu Aufgaben wechseln')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => handleTabChange('collections')}
>
{t('engagement.hero.actions.collections', 'Kollektionen ansehen')}
</Button>
);
const heroAside = (
<FrostedSurface className="space-y-4 border-slate-200 p-5 text-slate-900 shadow-md shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">{t('engagement.hero.activeTab', { defaultValue: 'Aktiver Bereich' })}</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{t(`engagement.tabs.${activeTab}.title`, { defaultValue: tc(`navigation.${activeTab}`) })}</p>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">
{t('engagement.hero.tip', 'Wechsle Tabs, um Aufgaben, Kollektionen oder Emotionen zu bearbeiten und direkt in Events einzubinden.')}
</p>
</FrostedSurface>
);
return (
<AdminLayout
title={heading}
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
>
<TenantHeroCard
badge={t('engagement.hero.badge', 'Engagement')}
title={heading}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<SectionCard className="mt-6 space-y-6">
<SectionHeader
eyebrow={t('engagement.sections.active.badge', 'Active Toolkit')}
title={t('engagement.sections.active.title', 'Aufgaben & Co.')}
description={t('engagement.sections.active.description', 'Verwalte Aufgaben, Kollektionen und Emotionen an einem Ort.')}
/>
<StatCarousel
items={[
{
key: 'tasks',
label: t('engagement.stats.tasks', 'Aufgaben'),
value: engagementStats?.tasks ?? '—',
},
{
key: 'collections',
label: t('engagement.stats.collections', 'Kollektionen'),
value: engagementStats?.collections ?? '—',
},
{
key: 'emotions',
label: t('engagement.stats.emotions', 'Emotionen'),
value: engagementStats?.emotions ?? '—',
},
]}
/>
<ActionGrid
items={[
{
key: 'newTask',
label: t('engagement.actions.newTask', 'Neue Aufgabe'),
description: t('engagement.actions.newTask.description', 'Starte mit einer neuen Idee oder Vorlage.'),
onClick: () => handleTabChange('tasks'),
},
{
key: 'collection',
label: t('engagement.actions.collection', 'Kollektion bauen'),
description: t('engagement.actions.collection.description', 'Fasse Aufgaben zu Events zusammen.'),
onClick: () => handleTabChange('collections'),
},
{
key: 'emotion',
label: t('engagement.actions.emotion', 'Emotion hinzufügen'),
description: t('engagement.actions.emotion.description', 'Markiere Momente zur Moderation.'),
onClick: () => handleTabChange('emotions'),
},
]}
/>
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full grid-cols-3 rounded-2xl border border-white/25 bg-white/80 p-1 shadow-inner shadow-rose-200/20 dark:border-slate-800/70 dark:bg-slate-900/70">
{(['tasks', 'collections', 'emotions'] as const).map((tab) => (
<TabsTrigger
key={tab}
value={tab}
className="rounded-xl text-sm font-medium transition data-[state=active]:bg-gradient-to-r data-[state=active]:from-[#ff5f87] data-[state=active]:via-[#ec4899] data-[state=active]:to-[#6366f1] data-[state=active]:text-white"
>
{tc(`navigation.${tab}`)}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="tasks" className="space-y-6">
<TasksSection embedded onNavigateToCollections={() => handleTabChange('collections')} />
</TabsContent>
<TabsContent value="collections" className="space-y-6">
<TaskCollectionsSection embedded onNavigateToTasks={() => handleTabChange('tasks')} />
</TabsContent>
<TabsContent value="emotions" className="space-y-6">
<EmotionsSection embedded />
</TabsContent>
</Tabs>
</SectionCard>
</AdminLayout>
);
}

View File

@@ -1,325 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Save, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { AppCard, PrimaryCTA, BottomNav } from '../tamagui/primitives';
import { AdminLayout } from '../components/AdminLayout';
import { TenantEvent, getEvent, updateEvent } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import { adminPath } from '../constants';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
};
export default function EventBrandingPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [form, setForm] = React.useState<BrandingForm>({
primary: '#007AFF',
accent: '#5AD2F4',
headingFont: '',
bodyFont: '',
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!slug) return;
setLoading(true);
(async () => {
try {
const data = await getEvent(slug);
setEvent(data);
const branding = extractBranding(data);
setForm(branding);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Branding konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [slug, t]);
async function handleSave() {
if (!event?.slug) return;
setSaving(true);
setError(null);
try {
const nextSettings = { ...(event.settings ?? {}) };
nextSettings.branding = {
...(typeof nextSettings.branding === 'object' ? (nextSettings.branding as Record<string, unknown>) : {}),
primary_color: form.primary,
accent_color: form.accent,
heading_font: form.headingFont,
body_font: form.bodyFont,
};
const updated = await updateEvent(event.slug, { settings: nextSettings });
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
}
} finally {
setSaving(false);
}
}
function handleReset() {
if (event) {
setForm(extractBranding(event));
}
}
const previewTitle = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
return (
<AdminLayout title={t('events.branding.title', 'Branding & Customization')} subtitle={t('events.branding.subtitle', 'Gib der Gäste-App dein Branding.')} disableCommandShelf>
<YStack space="$3" maxWidth={640} marginHorizontal="auto" paddingBottom="$9">
<XStack alignItems="center" justifyContent="space-between" space="$2">
<Button backgroundColor="white" borderColor="$muted" borderWidth={1} onPress={() => navigate(-1)}>
<XStack alignItems="center" space="$1.5">
<RefreshCcw size={16} />
<Text>{t('events.actions.back', 'Zurück')}</Text>
</XStack>
</Button>
<Button backgroundColor="white" borderColor="$muted" borderWidth={1} onPress={() => navigate(adminPath('/settings'))}>
<XStack alignItems="center" space="$1.5">
<Sparkles size={16} />
<Text>{t('events.branding.settings', 'Einstellungen')}</Text>
</XStack>
</Button>
</XStack>
{error ? (
<AppCard>
<Text fontWeight="700" color="#b91c1c">
{error}
</Text>
</AppCard>
) : null}
<AppCard space="$3">
<Text fontSize="$sm" fontWeight="700">
{t('events.branding.previewTitle', 'Guest App Preview')}
</Text>
<YStack
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
space="$2"
>
<YStack width="100%" borderRadius="$card" overflow="hidden" backgroundColor="white" borderWidth={1} borderColor="$muted">
<YStack backgroundColor={form.primary} padding="$3" />
<YStack padding="$3" space="$2">
<Text fontSize="$md" fontWeight="800">
{previewTitle}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.branding.previewSubtitle', 'Aktuelle Branding-Farben und Schriften')}
</Text>
<XStack space="$2">
<ColorSwatch color={form.primary} label={t('events.branding.primary', 'Primary')} />
<ColorSwatch color={form.accent} label={t('events.branding.accent', 'Accent')} />
</XStack>
</YStack>
</YStack>
</YStack>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.colors', 'Colors')}
</Text>
<ColorField
label={t('events.branding.primary', 'Primary Color')}
value={form.primary}
onChange={(value) => setForm((prev) => ({ ...prev, primary: value }))}
/>
<ColorField
label={t('events.branding.accent', 'Accent Color')}
value={form.accent}
onChange={(value) => setForm((prev) => ({ ...prev, accent: value }))}
/>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.fonts', 'Fonts')}
</Text>
<InputField
label={t('events.branding.headingFont', 'Headline Font')}
value={form.headingFont}
placeholder="SF Pro Display"
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
/>
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
/>
</AppCard>
<AppCard space="$3">
<Text fontSize="$md" fontWeight="800">
{t('events.branding.logo', 'Logo')}
</Text>
<YStack
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="#f8fafc"
padding="$3"
alignItems="center"
justifyContent="center"
space="$2"
>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="$color">
{t('events.branding.logoHint', 'Logo Upload folgt nutze aktuelle Farben.')}
</Text>
</YStack>
</AppCard>
<YStack space="$2">
<PrimaryCTA label={t('events.branding.save', 'Save Branding')} onPress={() => handleSave()} />
<Button
height={52}
borderRadius="$card"
backgroundColor="white"
borderWidth={1}
borderColor="$muted"
color="$color"
onPress={handleReset}
disabled={loading || saving}
>
<XStack alignItems="center" justifyContent="center" space="$2">
<RefreshCcw size={16} />
<Text>{t('events.branding.reset', 'Reset to Defaults')}</Text>
</XStack>
</Button>
</YStack>
</YStack>
<BottomNav
active="settings"
onNavigate={(key) => {
if (key === 'events') navigate(adminPath('/events'));
if (key === 'analytics') navigate(adminPath('/dashboard'));
if (key === 'settings') navigate(adminPath('/settings'));
}}
/>
</AdminLayout>
);
}
function extractBranding(event: TenantEvent): BrandingForm {
const source = (event.settings as Record<string, unknown>) ?? {};
const branding = (source.branding as Record<string, unknown>) ?? source;
const readColor = (key: string, fallback: string) => {
const value = branding[key];
return typeof value === 'string' && value.startsWith('#') ? value : fallback;
};
const readText = (key: string) => {
const value = branding[key];
return typeof value === 'string' ? value : '';
};
return {
primary: readColor('primary_color', '#007AFF'),
accent: readColor('accent_color', '#5AD2F4'),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
};
}
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}
return '';
}
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700">
{label}
</Text>
<XStack alignItems="center" space="$2">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
/>
<Text fontSize="$sm" color="$color">
{value}
</Text>
</XStack>
</YStack>
);
}
function ColorSwatch({ color, label }: { color: string; label: string }) {
return (
<YStack alignItems="center" space="$1">
<YStack width={44} height={44} borderRadius="$pill" borderWidth={1} borderColor="$muted" backgroundColor={color} />
<Text fontSize="$xs" color="$color">
{label}
</Text>
</YStack>
);
}
function InputField({
label,
value,
placeholder,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (next: string) => void;
}) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700">
{label}
</Text>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
style={{
width: '100%',
height: 48,
borderRadius: 14,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 14,
}}
/>
</YStack>
);
}

View File

@@ -1,426 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, BarChart2, Camera, CheckCircle2, ChevronLeft, Circle, QrCode, Settings, Sparkles, Users } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Pressable } from '@tamagui/react-native-web-lite';
import { AdminLayout } from '../components/AdminLayout';
import { AppCard, PrimaryCTA, StatusPill, BottomNav } from '../tamagui/primitives';
import {
TenantEvent,
EventStats,
EventToolkit,
getEvent,
getEventStats,
getEventToolkit,
toggleEvent,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { isAuthError } from '../auth/tokens';
import {
adminPath,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_BRANDING_PATH,
ADMIN_EVENT_INVITES_PATH,
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_PHOTOBOOTH_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
} from '../constants';
type DetailState = {
event: TenantEvent | null;
stats: EventStats | null;
toolkit: EventToolkit | null;
loading: boolean;
busy: boolean;
error: string | null;
};
export default function EventDetailPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const slug = slugParam ?? null;
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const [state, setState] = React.useState<DetailState>({
event: null,
stats: null,
toolkit: null,
loading: true,
busy: false,
error: null,
});
const load = React.useCallback(async () => {
if (!slug) {
setState({ event: null, stats: null, toolkit: null, loading: false, busy: false, error: t('events.errors.missingSlug', 'Kein Event ausgewählt.') });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData, toolkitData] = await Promise.all([getEvent(slug), getEventStats(slug), getEventToolkit(slug)]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
toolkit: toolkitData,
loading: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
loading: false,
error: getApiErrorMessage(error, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')),
}));
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
const { event, stats, toolkit, loading, busy, error } = state;
const eventName = resolveName(event?.name) ?? t('events.placeholders.untitled', 'Unbenanntes Event');
const tasksSummary = toolkit?.tasks?.summary;
const inviteSummary = toolkit?.invites?.summary;
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Tasks Completed'),
value: tasksSummary ? `${tasksSummary.completed}/${tasksSummary.total}` : '—',
icon: Sparkles,
tone: '#22c55e',
},
{
label: t('events.detail.kpi.guests', 'Guests Registered'),
value: inviteSummary?.total ?? event?.active_invites_count ?? '—',
icon: Users,
tone: '#2563eb',
},
{
label: t('events.detail.kpi.photos', 'Images Uploaded'),
value: stats?.uploads_total ?? event?.photo_count ?? '—',
icon: Camera,
tone: '#8b5cf6',
},
];
const actions = [
{
key: 'tasks',
label: t('events.quick.tasks', 'Tasks & Checklists'),
icon: Sparkles,
to: ADMIN_EVENT_TASKS_PATH(event?.slug ?? ''),
color: '#60a5fa',
},
{
key: 'qr',
label: t('events.quick.qr', 'QR Code Layouts'),
icon: QrCode,
to: `${ADMIN_EVENT_INVITES_PATH(event?.slug ?? '')}?tab=layout`,
color: '#fbbf24',
},
{
key: 'images',
label: t('events.quick.images', 'Image Management'),
icon: Camera,
to: ADMIN_EVENT_PHOTOS_PATH(event?.slug ?? ''),
color: '#a855f7',
},
{
key: 'guests',
label: t('events.quick.guests', 'Guest Management'),
icon: Users,
to: ADMIN_EVENT_MEMBERS_PATH(event?.slug ?? ''),
color: '#4ade80',
},
{
key: 'branding',
label: t('events.quick.branding', 'Branding & Theme'),
icon: Sparkles,
to: ADMIN_EVENT_BRANDING_PATH(event?.slug ?? ''),
color: '#fb7185',
},
{
key: 'photobooth',
label: t('events.quick.moderation', 'Photo Moderation'),
icon: BarChart2,
to: ADMIN_EVENT_PHOTOBOOTH_PATH(event?.slug ?? ''),
color: '#38bdf8',
},
];
async function handleToggle() {
if (!event?.slug) {
return;
}
setState((prev) => ({ ...prev, busy: true }));
try {
const updated = await toggleEvent(event.slug);
setState((prev) => ({ ...prev, busy: false, event: updated }));
} catch (err) {
setState((prev) => ({
...prev,
busy: false,
error: isAuthError(err) ? prev.error : getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')),
}));
}
}
if (!slug) {
return (
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}>
<AppCard>
<Text fontSize="$sm" color="$color">
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
</Text>
<PrimaryCTA label={t('events.actions.backToList', 'Zurück zur Liste')} onPress={() => navigate(ADMIN_EVENTS_PATH)} />
</AppCard>
</AdminLayout>
);
}
return (
<AdminLayout title={eventName} subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')} disableCommandShelf>
<YStack space="$3" maxWidth={640} marginHorizontal="auto" paddingBottom="$9">
<XStack alignItems="center" space="$3">
<Pressable onPress={() => navigate(ADMIN_EVENTS_PATH)}>
<XStack alignItems="center" space="$2">
<ChevronLeft size={20} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF">
{t('events.actions.back', 'Back')}
</Text>
</XStack>
</Pressable>
<Text fontSize="$lg" fontWeight="700" flex={1}>
{eventName}
</Text>
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Settings size={20} color="#0f172a" />
</Pressable>
</XStack>
{error ? (
<AppCard>
<Text fontWeight="700" color="#b91c1c">
{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}
</Text>
<Text color="$color">{error}</Text>
</AppCard>
) : null}
<AppCard space="$3">
<Text fontSize="$xs" letterSpacing={2} textTransform="uppercase" color="$color">
{t('events.workspace.hero.badge', 'Event')}
</Text>
<Text fontSize="$xl" fontWeight="800" color="$color">
{eventName}
</Text>
<Text fontSize="$sm" color="$color">
{formatDate(event?.event_date) ?? t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und QR-Code für dieses Event.')}
</Text>
<XStack alignItems="center" space="$2">
<StatusPill tone={event?.status === 'published' ? 'success' : 'warning'}>
{statusLabel(event, tCommon)}
</StatusPill>
<StatusPill tone="muted">{resolveLocation(event)}</StatusPill>
</XStack>
<XStack space="$2">
<PrimaryCTA label={t('events.actions.openGallery', 'Event öffnen')} onPress={() => navigate(ADMIN_EVENT_VIEW_PATH(event?.slug ?? ''))} />
<Button
flex={1}
height={56}
borderRadius="$card"
backgroundColor="white"
color="$primary"
borderColor="$muted"
borderWidth={1}
onPress={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event?.slug ?? ''))}
>
{t('events.list.actions.photos', 'Fotos moderieren')}
</Button>
</XStack>
<Button
height={48}
borderRadius="$pill"
backgroundColor="white"
borderColor="$muted"
borderWidth={1}
color="$color"
onPress={() => handleToggle()}
disabled={busy}
pressStyle={{ opacity: 0.8 }}
>
<XStack alignItems="center" justifyContent="center" space="$2">
{event?.is_active ? <CheckCircle2 size={18} color="#16a34a" /> : <Circle size={18} color="#9ca3af" />}
<Text fontWeight="700" color="$color">
{event?.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
</Text>
</XStack>
</Button>
</AppCard>
{loading ? (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<AppCard key={`kpi-skeleton-${idx}`} height={80} opacity={0.5} />
))}
</YStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiCard key={kpi.label} label={kpi.label} value={kpi.value} tone={kpi.tone} icon={kpi.icon} />
))}
</XStack>
)}
<AppCard>
<Text fontSize="$md" fontWeight="800" color="$color">
{t('events.detail.managementTitle', 'Event Management')}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.detail.managementSubtitle', 'Schnelle Aktionen für Aufgaben, Gäste und Layouts.')}
</Text>
<XStack flexWrap="wrap" space="$2" marginTop="$3">
{actions.map((action) => (
<Pressable key={action.key} style={{ width: '48%' }} onPress={() => navigate(action.to)}>
<YStack
borderRadius="$card"
padding="$3"
backgroundColor="#f8fafc"
borderWidth={1}
borderColor="$muted"
space="$2"
minHeight={110}
justifyContent="space-between"
>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius="$pill"
backgroundColor={action.color}
alignItems="center"
justifyContent="center"
>
<action.icon size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color="$color">
{action.label}
</Text>
</XStack>
<Text fontSize="$xs" color="$color">
{t('events.detail.open', 'Open')}
</Text>
</YStack>
</Pressable>
))}
</XStack>
</AppCard>
</YStack>
<BottomNav
active="events"
onNavigate={(key) => {
if (key === 'analytics') {
navigate(adminPath('/dashboard'));
} else if (key === 'settings') {
navigate(adminPath('/settings'));
} else {
navigate(ADMIN_EVENTS_PATH);
}
}}
/>
</AdminLayout>
);
}
function resolveName(name: TenantEvent['name'] | undefined | null): string | null {
if (!name) return null;
if (typeof name === 'string') return name;
if (typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? null;
}
return null;
}
function resolveLocation(event: TenantEvent | null): string {
if (!event?.settings) return 'Location tbd';
const maybeAddress =
(event.settings as Record<string, unknown>).location ??
(event.settings as Record<string, unknown>).address ??
(event.settings as Record<string, unknown>).city;
if (typeof maybeAddress === 'string' && maybeAddress.trim()) {
return maybeAddress;
}
return 'Location tbd';
}
function formatDate(value?: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}
function statusLabel(event: TenantEvent | null, t: ReturnType<typeof useTranslation>['t']): string {
if (!event) return t('events.status.draft', 'Entwurf');
if (event.status === 'published') {
return t('events.status.published', 'Live');
}
if (event.status === 'archived') {
return t('events.status.archived', 'Archiviert');
}
return t('events.status.draft', 'Entwurf');
}
function KpiCard({
label,
value,
tone,
icon: IconCmp,
}: {
label: string;
value: string | number;
tone: string;
icon: React.ComponentType<{ size?: number; color?: string }>;
}) {
return (
<YStack
width="31%"
minWidth={180}
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
backgroundColor="white"
padding="$3"
space="$1.5"
shadowColor="#0f172a"
shadowOpacity={0.05}
shadowRadius={12}
shadowOffset={{ width: 0, height: 6 }}
>
<XStack alignItems="center" space="$2">
<XStack width={32} height={32} borderRadius="$pill" backgroundColor={`${tone}22`} alignItems="center" justifyContent="center">
<IconCmp size={16} color={tone} />
</XStack>
<Text fontSize="$xs" color="$color">
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color="$color">
{value}
</Text>
</YStack>
);
}

View File

@@ -1,779 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { FloatingActionBar } from '../components/FloatingActionBar';
import {
createEvent,
getEvent,
getTenantPackagesOverview,
updateEvent,
getPackages,
getEventTypes,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { ADMIN_BILLING_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENTS_PATH } from '../constants';
interface EventFormState {
name: string;
slug: string;
date: string;
eventTypeId: number | null;
package_id: number;
isPublished: boolean;
}
type PackageHighlight = {
label: string;
value: string;
};
const FEATURE_LABELS: Record<string, string> = {
basic_uploads: 'Uploads inklusive',
unlimited_sharing: 'Unbegrenztes Teilen',
no_watermark: 'Kein Wasserzeichen',
custom_branding: 'Eigenes Branding',
custom_tasks: 'Eigene Aufgaben',
watermark_allowed: 'Wasserzeichen erlaubt',
branding_allowed: 'Branding-Optionen',
};
type EventPackageMeta = {
id: number;
name: string;
purchasedAt: string | null;
expiresAt: string | null;
};
export default function EventFormPage() {
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slugParam = params.slug ?? searchParams.get('slug') ?? undefined;
const isEdit = Boolean(slugParam);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t: tErrors } = useTranslation('common', { keyPrefix: 'errors' });
const { t: tForm } = useTranslation('management', { keyPrefix: 'eventForm' });
const { t: tLimits } = useTranslation('common', { keyPrefix: 'limits' });
const [form, setForm] = React.useState<EventFormState>({
name: '',
slug: '',
date: '',
eventTypeId: null,
package_id: 0,
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
const slugSuffixRef = React.useRef<string | null>(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [showUpgradeHint, setShowUpgradeHint] = React.useState(false);
const [readOnlyPackageName, setReadOnlyPackageName] = React.useState<string | null>(null);
const [eventPackageMeta, setEventPackageMeta] = React.useState<EventPackageMeta | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);
const { data: packages, isLoading: packagesLoading } = useQuery({
queryKey: ['packages', 'endcustomer'],
queryFn: () => getPackages('endcustomer'),
});
const { data: eventTypes, isLoading: eventTypesLoading } = useQuery({
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'],
queryFn: () => getTenantPackagesOverview(),
});
const activePackage = packageOverview?.activePackage ?? null;
React.useEffect(() => {
if (isEdit || !activePackage?.package_id) {
return;
}
setForm((prev) => {
if (prev.package_id === activePackage.package_id) {
return prev;
}
return {
...prev,
package_id: activePackage.package_id,
};
});
setReadOnlyPackageName((prev) => prev ?? activePackage.package_name);
}, [isEdit, activePackage]);
const {
data: loadedEvent,
isLoading: eventLoading,
error: eventLoadError,
} = useQuery<TenantEvent>({
queryKey: ['tenant', 'events', slugParam],
queryFn: () => getEvent(slugParam!),
enabled: Boolean(isEdit && slugParam),
staleTime: 0,
});
React.useEffect(() => {
if (isEdit) {
return;
}
if (!eventTypes || eventTypes.length === 0) {
return;
}
setForm((prev) => {
if (prev.eventTypeId) {
return prev;
}
return {
...prev,
eventTypeId: eventTypes[0]!.id,
};
});
}, [eventTypes, isEdit]);
React.useEffect(() => {
if (!isEdit || !loadedEvent) {
return;
}
const name = normalizeName(loadedEvent.name);
setForm((prev) => ({
...prev,
name,
slug: loadedEvent.slug,
date: loadedEvent.event_date ? loadedEvent.event_date.slice(0, 10) : '',
eventTypeId: loadedEvent.event_type_id ?? prev.eventTypeId,
isPublished: loadedEvent.status === 'published',
package_id: loadedEvent.package?.id ? Number(loadedEvent.package.id) : prev.package_id,
}));
setOriginalSlug(loadedEvent.slug);
setReadOnlyPackageName(loadedEvent.package?.name ?? null);
setEventPackageMeta(loadedEvent.package
? {
id: Number(loadedEvent.package.id),
name: loadedEvent.package.name ?? (typeof loadedEvent.package === 'string' ? loadedEvent.package : ''),
purchasedAt: loadedEvent.package.purchased_at ?? null,
expiresAt: loadedEvent.package.expires_at ?? null,
}
: null);
setAutoSlug(false);
slugSuffixRef.current = null;
}, [isEdit, loadedEvent]);
React.useEffect(() => {
if (!isEdit || !loadedEvent || !eventTypes || eventTypes.length === 0) {
return;
}
if (loadedEvent.event_type_id) {
return;
}
setForm((prev) => ({
...prev,
eventTypeId: prev.eventTypeId ?? eventTypes[0]!.id,
}));
}, [eventTypes, isEdit, loadedEvent]);
React.useEffect(() => {
if (!isEdit || !eventLoadError) {
return;
}
if (!isAuthError(eventLoadError)) {
setError('Event konnte nicht geladen werden.');
}
}, [isEdit, eventLoadError]);
const loading = isEdit ? eventLoading : false;
const limitWarnings = React.useMemo(() => {
if (!isEdit) {
return [];
}
return buildLimitWarnings(loadedEvent?.limits, tLimits);
}, [isEdit, loadedEvent?.limits, tLimits]);
const shownToastRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
limitWarnings.forEach((warning) => {
const key = `${warning.id}-${warning.message}`;
if (shownToastRef.current.has(key)) {
return;
}
shownToastRef.current.add(key);
toast(warning.message, {
icon: warning.tone === 'danger' ? '🚨' : '⚠️',
id: key,
});
});
}, [limitWarnings]);
const limitScopeLabels = React.useMemo(() => ({
photos: tLimits('photosTitle'),
guests: tLimits('guestsTitle'),
gallery: tLimits('galleryTitle'),
}), [tLimits]);
function ensureSlugSuffix(): string {
if (!slugSuffixRef.current) {
slugSuffixRef.current = Math.random().toString(36).slice(2, 7);
}
return slugSuffixRef.current;
}
function buildAutoSlug(value: string): string {
const base = slugify(value).replace(/^-+|-+$/g, '');
const suffix = ensureSlugSuffix();
const safeBase = base || 'event';
return `${safeBase}-${suffix}`;
}
function handleNameChange(value: string) {
setForm((prev) => ({ ...prev, name: value }));
if (autoSlug) {
setForm((prev) => ({ ...prev, slug: buildAutoSlug(value) }));
}
}
const handleSubmitClick = React.useCallback(() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}, []);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmedName = form.name.trim();
if (!trimmedName) {
setError(tForm('errors.nameRequired', 'Bitte gib einen Eventnamen ein.'));
return;
}
let finalSlug = form.slug.trim();
if (!finalSlug || autoSlug) {
finalSlug = buildAutoSlug(trimmedName);
}
if (!form.eventTypeId) {
setError(tForm('errors.typeRequired', 'Bitte wähle einen Event-Typ aus.'));
return;
}
setSaving(true);
setError(null);
setShowUpgradeHint(false);
const status: 'draft' | 'published' | 'archived' = form.isPublished ? 'published' : 'draft';
const packageIdForSubmit = form.package_id || activePackage?.package_id || null;
const shouldIncludePackage = !isEdit
&& packageIdForSubmit
&& (!activePackage?.package_id || packageIdForSubmit !== activePackage.package_id);
const payload = {
name: trimmedName,
slug: finalSlug,
event_type_id: form.eventTypeId,
event_date: form.date || undefined,
status,
...(packageIdForSubmit ? { package_id: Number(packageIdForSubmit) } : {}),
};
try {
if (isEdit) {
const targetSlug = originalSlug ?? slugParam!;
const updated = await updateEvent(targetSlug, payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'events', targetSlug] });
queryClient.invalidateQueries({ queryKey: ['tenant', 'dashboard'] });
setOriginalSlug(updated.slug);
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(updated.slug));
} else {
const { event: created } = await createEvent(payload);
queryClient.invalidateQueries({ queryKey: ['tenant', 'events'] });
setShowUpgradeHint(false);
setError(null);
toast.success(tForm('actions.saved', 'Event gespeichert'));
navigate(ADMIN_EVENT_VIEW_PATH(created.slug));
}
} catch (err) {
if (!isAuthError(err)) {
if (isApiError(err)) {
switch (err.code) {
case 'event_limit_exceeded': {
const limit = Number(err.meta?.limit ?? 0);
const used = Number(err.meta?.used ?? 0);
const remaining = Number(err.meta?.remaining ?? Math.max(0, limit - used));
const detail = limit > 0 ? tErrors('eventLimitDetails', { used, limit, remaining }) : '';
setError(`${tErrors('eventLimit')}${detail ? `\n${detail}` : ''}`);
setShowUpgradeHint(true);
break;
}
default: {
const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null;
setError(metaErrors || err.message || tErrors('generic'));
setShowUpgradeHint(false);
}
}
} else {
setError(getApiErrorMessage(err, tErrors('generic')));
setShowUpgradeHint(false);
}
}
} finally {
setSaving(false);
}
}
const effectivePackageId = form.package_id || activePackage?.package_id || null;
const selectedPackage = React.useMemo(() => {
if (!packages || !packages.length) {
return null;
}
if (effectivePackageId) {
return packages.find((pkg) => pkg.id === effectivePackageId) ?? null;
}
return null;
}, [packages, effectivePackageId]);
React.useEffect(() => {
if (!readOnlyPackageName && selectedPackage?.name) {
setReadOnlyPackageName(selectedPackage.name);
}
}, [readOnlyPackageName, selectedPackage]);
const packageNameDisplay = readOnlyPackageName
?? selectedPackage?.name
?? ((overviewLoading || packagesLoading) ? 'Paket wird geladen…' : 'Kein aktives Paket gefunden');
const packagePriceLabel = selectedPackage?.price !== undefined && selectedPackage?.price !== null
? formatCurrency(selectedPackage.price)
: null;
const packageHighlights = React.useMemo<PackageHighlight[]>(() => {
const highlights: PackageHighlight[] = [];
if (selectedPackage?.max_photos) {
highlights.push({
label: 'Fotos',
value: `${selectedPackage.max_photos.toLocaleString('de-DE')} Bilder`,
});
}
if (selectedPackage?.max_guests) {
highlights.push({
label: 'Gäste',
value: `${selectedPackage.max_guests.toLocaleString('de-DE')} Personen`,
});
}
if (selectedPackage?.gallery_days) {
highlights.push({
label: 'Galerie',
value: `${selectedPackage.gallery_days} Tage online`,
});
}
return highlights;
}, [selectedPackage]);
const featureTags = React.useMemo(() => {
if (!selectedPackage?.features) {
return [];
}
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);
const remainingEventsLabel = typeof activePackage?.remaining_events === 'number'
? `Noch ${activePackage.remaining_events} Event${activePackage.remaining_events === 1 ? '' : 's'} in deinem Paket`
: null;
const actions = (
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> {tForm('actions.backToList', 'Zurück zur Liste')}
</Button>
);
const fabActions = [
{
key: 'save',
label: saving ? tForm('actions.saving', 'Speichert') : tForm('actions.save', 'Speichern'),
icon: Save,
onClick: handleSubmitClick,
loading: saving,
disabled: loading || !form.name.trim() || !form.slug.trim() || !form.eventTypeId,
tone: 'primary' as const,
},
{
key: 'cancel',
label: tForm('actions.cancel', 'Abbrechen'),
icon: ArrowLeft,
onClick: () => navigate(-1),
disabled: saving,
tone: 'secondary' as const,
},
];
return (
<AdminLayout
title={isEdit ? tForm('titles.edit', 'Event bearbeiten') : tForm('titles.create', 'Neues Event erstellen')}
subtitle={tForm('subtitle', 'Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{tForm('errors.notice', 'Hinweis')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{error.split('\n').map((line, index) => (
<span key={index}>{line}</span>
))}
{showUpgradeHint && (
<div>
<Button size="sm" variant="outline" onClick={() => navigate(ADMIN_BILLING_PATH)}>
{tErrors('goToBilling', 'Zum Billing')}
</Button>
</div>
)}
</AlertDescription>
</Alert>
)}
{limitWarnings.length > 0 && (
<div className="mb-6 space-y-2">
{limitWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]}
</AlertTitle>
<AlertDescription className="text-sm">
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60 pb-28">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> {tForm('sections.details.title', 'Eventdetails')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{tForm('sections.details.description', 'Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.')}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<FormSkeleton />
) : (
<form className="space-y-6" onSubmit={handleSubmit} ref={formRef}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">{tForm('fields.name.label', 'Eventname')}</Label>
<Input
id="event-name"
placeholder={tForm('fields.name.placeholder', 'z. B. Sommerfest 2025')}
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
autoFocus
/>
<p className="text-xs text-slate-500">
{tForm('fields.name.help', 'Die Kennung und Event-URL werden automatisch aus dem Namen generiert.')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">{tForm('fields.date.label', 'Datum')}</Label>
<Input
id="event-date"
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-type">{tForm('fields.type.label', 'Event-Typ')}</Label>
<Select
value={form.eventTypeId ? String(form.eventTypeId) : undefined}
onValueChange={(value) => setForm((prev) => ({ ...prev, eventTypeId: Number(value) }))}
disabled={eventTypesLoading || !sortedEventTypes.length}
>
<SelectTrigger id="event-type">
<SelectValue
placeholder={
eventTypesLoading
? tForm('fields.type.loading', 'Event-Typ wird geladen…')
: tForm('fields.type.placeholder', 'Event-Typ auswählen')
}
/>
</SelectTrigger>
<SelectContent>
{sortedEventTypes.map((eventType) => (
<SelectItem key={eventType.id} value={String(eventType.id)}>
{eventType.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!eventTypesLoading && (!sortedEventTypes || sortedEventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
{tForm('fields.type.empty', 'Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.')}
</p>
) : null}
</div>
</div>
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
<Checkbox
id="event-published"
checked={form.isPublished}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))}
/>
<div>
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
{tForm('fields.publish.label', 'Event sofort veröffentlichen')}
</Label>
<p className="text-xs text-slate-600">
{tForm('fields.publish.help', 'Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.')}
</p>
</div>
</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>
</Card>
<FloatingActionBar actions={fabActions} />
</AdminLayout>
);
}
function FormSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-12 animate-pulse rounded-lg bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function formatCurrency(value: number | null | undefined): string | null {
if (value === null || value === undefined) {
return null;
}
try {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: value % 1 === 0 ? 0 : 2,
}).format(value);
} catch {
return `${value}`;
}
}
function formatDate(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}
function slugify(value: string): string {
return value
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
.slice(0, 60);
}
function normalizeName(name: string | Record<string, string>): string {
if (typeof name === 'string') {
return name;
}
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,356 +0,0 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Loader2, Mail, Sparkles, Trash2, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AdminLayout } from '../components/AdminLayout';
import {
EventMember,
getEvent,
getEventMembers,
inviteEventMember,
removeEventMember,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH } from '../constants';
type InviteForm = {
email: string;
name: string;
role: EventMember['role'];
};
const emptyInvite: InviteForm = {
email: '',
name: '',
role: 'member',
};
export default function EventMembersPage() {
const { t, i18n } = useTranslation(['management', 'dashboard']);
const locale = React.useMemo(
() => (i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'),
[i18n.language]
);
const params = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const slug = params.slug ?? searchParams.get('slug') ?? null;
const navigate = useNavigate();
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [members, setMembers] = React.useState<EventMember[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [invite, setInvite] = React.useState<InviteForm>(emptyInvite);
const [inviting, setInviting] = React.useState(false);
const [membersUnavailable, setMembersUnavailable] = React.useState(false);
const formatDate = React.useCallback(
(value: string | null | undefined) => {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString(locale, { day: '2-digit', month: 'short', year: 'numeric' });
},
[locale]
);
const roleLabels = React.useMemo(
() => ({
tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'),
member: t('management.members.roles.member', 'Mitglied'),
}),
[t]
);
const statusLabels = React.useMemo(
() => ({
published: t('management.members.statuses.published', 'Veröffentlicht'),
draft: t('management.members.statuses.draft', 'Entwurf'),
active: t('management.members.statuses.active', 'Aktiv'),
}),
[t]
);
const resolveRole = React.useCallback(
(role: string) => roleLabels[role as keyof typeof roleLabels] ?? role,
[roleLabels]
);
const resolveMemberStatus = React.useCallback(
(status?: string | null) => {
if (!status) {
return statusLabels.active;
}
return statusLabels[status as keyof typeof statusLabels] ?? status;
},
[statusLabels]
);
React.useEffect(() => {
if (!slug) {
setError(t('management.members.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
return;
}
let cancelled = false;
(async () => {
try {
setLoading(true);
const eventData = await getEvent(slug);
if (cancelled) return;
setEvent(eventData);
const response = await getEventMembers(slug);
if (cancelled) return;
setMembers(response.data);
setMembersUnavailable(false);
} catch (err) {
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
setMembersUnavailable(true);
} else if (!isAuthError(err)) {
setError(t('management.members.errors.load', 'Mitglieder konnten nicht geladen werden.'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [slug, t]);
async function handleInvite(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!slug) return;
if (!invite.email.trim()) {
setError(t('management.members.errors.emailRequired', 'Bitte gib eine E-Mail-Adresse ein.'));
return;
}
setInviting(true);
try {
const member = await inviteEventMember(slug, {
email: invite.email.trim(),
name: invite.name.trim() || undefined,
role: invite.role,
});
setMembers((prev) => [member, ...prev]);
setInvite(emptyInvite);
setMembersUnavailable(false);
} catch (err) {
if (err instanceof Error && err.message.includes('Mitgliederverwaltung')) {
setMembersUnavailable(true);
} else if (!isAuthError(err)) {
setError(t('management.members.errors.invite', 'Einladung konnte nicht verschickt werden.'));
}
} finally {
setInviting(false);
}
}
async function handleRemove(member: EventMember) {
if (!slug) return;
if (!window.confirm(`${member.name} wirklich entfernen?`)) return;
try {
await removeEventMember(slug, member.id);
setMembers((prev) => prev.filter((entry) => entry.id !== member.id));
} catch (err) {
if (!isAuthError(err)) {
setError(t('management.members.errors.remove', 'Mitglied konnte nicht entfernt werden.'));
}
}
}
const actions = (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
<ArrowLeft className="h-4 w-4" />
{t('management.members.actions.back', 'Zurück zur Übersicht')}
</Button>
);
return (
<AdminLayout
title={t('management.members.title', 'Event-Mitglieder')}
subtitle={t('management.members.subtitle', 'Verwalte Moderatoren, Admins und Helfer für dieses Event.')}
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('dashboard.alerts.errorTitle', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<MembersSkeleton />
) : !event ? (
<Alert variant="destructive">
<AlertTitle>{t('management.members.alerts.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('management.members.alerts.notFoundDescription', 'Bitte kehre zur Eventliste zurück.')}</AlertDescription>
</Alert>
) : (
<>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{renderName(event.name, t)}</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('management.members.eventStatus', {
status: event.status === 'published' ? statusLabels.published : statusLabels.draft,
})}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-2">
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Sparkles className="h-4 w-4 text-pink-500" />
{t('management.members.sections.list.title', 'Mitglieder')}
</h3>
{membersUnavailable ? (
<Alert>
<AlertTitle>{t('management.members.alerts.lockedTitle', 'Feature noch nicht aktiviert')}</AlertTitle>
<AlertDescription>
{t(
'management.members.alerts.lockedDescription',
'Die Mitgliederverwaltung ist für dieses Event noch nicht verfügbar. Bitte kontaktiere den Support, um das Feature freizuschalten.'
)}
</AlertDescription>
</Alert>
) : members.length === 0 ? (
<EmptyState message={t('management.members.sections.list.empty', 'Noch keine Mitglieder eingeladen.')} />
) : (
<div className="space-y-2">
{members.map((member) => (
<div
key={member.id}
className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-3 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-semibold text-slate-900">{member.name}</p>
<p className="text-xs text-slate-600">{member.email}</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span>
{t('management.members.labels.status', {
status: resolveMemberStatus(member.status),
})}
</span>
{member.joined_at && (
<span>
{t('management.members.labels.joined', {
date: formatDate(member.joined_at),
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-pink-200 text-pink-600">
{resolveRole(member.role)}
</Badge>
<Button variant="outline" size="sm" onClick={() => void handleRemove(member)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</section>
<section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Users className="h-4 w-4 text-emerald-500" />
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
</h3>
<p className="text-xs text-slate-500">
{t(
'management.members.sections.invite.helper',
'Mitglieder erhalten Zugriff auf Fotomoderation, Aufgaben und QR-Einladungen. Admins steuern zusätzlich Pakete, Abrechnung und Events.'
)}
</p>
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
<div className="space-y-2">
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
<Input
id="invite-email"
type="email"
placeholder={t('management.members.form.emailPlaceholder', 'person@example.com')}
value={invite.email}
onChange={(event) => setInvite((prev) => ({ ...prev, email: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">{t('management.members.form.nameLabel', 'Name (optional)')}</Label>
<Input
id="invite-name"
placeholder={t('management.members.form.namePlaceholder', 'Name')}
value={invite.name}
onChange={(event) => setInvite((prev) => ({ ...prev, name: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">{t('management.members.form.roleLabel', 'Rolle')}</Label>
<Select
value={invite.role}
onValueChange={(value) => setInvite((prev) => ({ ...prev, role: value as InviteForm['role'] }))}
>
<SelectTrigger id="invite-role">
<SelectValue placeholder={t('management.members.form.rolePlaceholder', 'Rolle wählen')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
<SelectItem value="member">{roleLabels.member}</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" className="w-full" disabled={inviting}>
{inviting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mail className="h-4 w-4" />}
{' '}{t('management.members.form.submit', 'Einladung senden')}
</Button>
</form>
</section>
</CardContent>
</Card>
</>
)}
</AdminLayout>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-6 text-center">
<p className="text-xs text-slate-600">{message}</p>
</div>
);
}
function MembersSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function renderName(name: TenantEvent['name'], translate: (key: string, defaultValue: string) => string): string {
if (typeof name === 'string') {
return name;
}
return name?.de ?? name?.en ?? Object.values(name ?? {})[0] ?? translate('management.members.events.untitled', 'Unbenanntes Event');
}

View File

@@ -1,876 +0,0 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertCircle, ArrowLeft, CheckCircle2, Circle, Clock3, Loader2, PlugZap, Power, RefreshCw, ShieldCheck, Copy } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
PhotoboothStatus,
TenantEvent,
type EventToolkit,
type TenantPhoto,
disableEventPhotobooth,
enableEventPhotobooth,
getEvent,
getEventPhotoboothStatus,
getEventToolkit,
rotateEventPhotobooth,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants';
import { buildEventTabs } from '../lib/eventTabs';
type State = {
event: TenantEvent | null;
status: PhotoboothStatus | null;
toolkit: EventToolkit | null;
loading: boolean;
updating: boolean;
error: string | null;
};
export default function EventPhotoboothPage() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const { t } = useTranslation(['management', 'common']);
const [mode, setMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
const [state, setState] = React.useState<State>({
event: null,
status: null,
toolkit: null,
loading: true,
updating: false,
error: null,
});
const load = React.useCallback(async () => {
if (!slug) {
setState((prev) => ({
...prev,
loading: false,
error: t('management.photobooth.errors.missingSlug', 'Kein Event ausgewählt.'),
}));
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const toolkitPromise = getEventToolkit(slug)
.then((data) => data)
.catch((toolkitError) => {
if (!isAuthError(toolkitError)) {
console.warn('[Photobooth] Toolkit konnte nicht geladen werden', toolkitError);
}
return null;
});
const [eventData, statusData, toolkitData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug), toolkitPromise]);
setState({
event: eventData,
status: statusData,
toolkit: toolkitData,
loading: false,
updating: false,
error: null,
});
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
loading: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')),
}));
} else {
setState((prev) => ({ ...prev, loading: false }));
}
}
}, [slug, t]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (state.status?.mode) {
setMode(state.status.mode);
}
}, [state.status?.mode]);
async function handleEnable(targetMode?: 'ftp' | 'sparkbooth'): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const selectedMode = targetMode ?? mode;
const result = await enableEventPhotobooth(slug, { mode: selectedMode });
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
if (result.mode) {
setMode(result.mode);
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.enableFailed', 'Zugang konnte nicht aktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleRotate(): Promise<void> {
if (!slug) return;
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await rotateEventPhotobooth(slug, { mode });
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.rotateFailed', 'Zugangsdaten konnten nicht neu generiert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
async function handleDisable(options?: { skipConfirm?: boolean }): Promise<void> {
if (!slug) return;
if (!options?.skipConfirm && !window.confirm(t('management.photobooth.confirm.disable', 'Photobooth-Zugang deaktivieren?'))) {
return;
}
setState((prev) => ({ ...prev, updating: true, error: null }));
try {
const result = await disableEventPhotobooth(slug, { mode });
setState((prev) => ({
...prev,
status: result,
updating: false,
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({
...prev,
updating: false,
error: getApiErrorMessage(error, t('management.photobooth.errors.disableFailed', 'Zugang konnte nicht deaktiviert werden.')),
}));
} else {
setState((prev) => ({ ...prev, updating: false }));
}
}
}
const { event, status, toolkit, loading, updating, error } = state;
const title = event
? t('management.photobooth.titleForEvent', { defaultValue: 'Fotobox-Uploads verwalten', event: resolveEventName(event.name) })
: t('management.photobooth.title', 'Fotobox-Uploads');
const subtitle = t(
'management.photobooth.subtitle',
'Erstelle einen einfachen Photobooth-Link per FTP oder Sparkbooth-Upload. Rate-Limit: 20 Fotos/Minute.'
);
const eventTabs = React.useMemo(() => {
if (!event || !slug) {
return [];
}
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return buildEventTabs(event, translateMenu, {
photos: event.photo_count ?? event.pending_photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
});
}, [event, slug, t]);
const recentPhotos = React.useMemo(() => toolkit?.photos?.recent ?? [], [toolkit?.photos?.recent]);
const photoboothRecent = React.useMemo(
() => recentPhotos.filter((photo) => photo.ingest_source === 'photobooth' || photo.ingest_source === 'sparkbooth'),
[recentPhotos]
);
const effectiveRecentPhotos = React.useMemo(
() => (photoboothRecent.length > 0 ? photoboothRecent : recentPhotos),
[photoboothRecent, recentPhotos],
);
const uploads24h = React.useMemo(
() => countUploadsInWindow(effectiveRecentPhotos, 24 * 60 * 60 * 1000),
[effectiveRecentPhotos],
);
const recentShare = React.useMemo(() => {
if (recentPhotos.length === 0) {
return null;
}
const ratio = photoboothRecent.length / recentPhotos.length;
return Math.round(ratio * 100);
}, [photoboothRecent.length, recentPhotos.length]);
const lastUploadAt = React.useMemo(() => {
const latestPhoto = selectLatestUpload(effectiveRecentPhotos);
if (latestPhoto?.uploaded_at) {
return latestPhoto.uploaded_at;
}
return status?.metrics?.last_upload_at ?? null;
}, [effectiveRecentPhotos, status?.metrics?.last_upload_at]);
const lastUploadSource: 'photobooth' | 'event' | null = photoboothRecent.length > 0
? 'photobooth'
: recentPhotos.length > 0
? 'event'
: null;
const eventUploadsTotal = toolkit?.metrics.uploads_total ?? event?.photo_count ?? 0;
const uploadStats = React.useMemo(
() => ({
uploads24h,
shareRecent: recentShare,
totalEventUploads: eventUploadsTotal ?? 0,
lastUploadAt,
source: lastUploadSource,
sampleSize: recentPhotos.length,
}),
[uploads24h, recentShare, eventUploadsTotal, lastUploadAt, lastUploadSource, recentPhotos.length],
);
const actions = (
<div className="flex gap-2">
{slug ? (
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('management.photobooth.actions.backToEvent', 'Zur Detailansicht')}
</Button>
) : null}
<Button variant="ghost" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
{t('management.photobooth.actions.allEvents', 'Zur Eventliste')}
</Button>
</div>
);
return (
<AdminLayout title={title} subtitle={subtitle} actions={actions} tabs={eventTabs} currentTabKey="photobooth">
{error ? (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('common:messages.error', 'Fehler')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<PhotoboothSkeleton />
) : (
<div className="space-y-6">
<div className="rounded-3xl border border-slate-200/80 bg-white/70 p-5 shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
{t('management.photobooth.mode.title', 'Photobooth-Typ auswählen')}
</p>
<p className="text-xs text-slate-500">
{t(
'management.photobooth.mode.description',
'Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.'
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={mode === 'ftp' ? 'default' : 'outline'}
onClick={() => handleEnable('ftp')}
disabled={updating || mode === 'ftp'}
>
FTP (Classic)
</Button>
<Button
variant={mode === 'sparkbooth' ? 'default' : 'outline'}
onClick={() => handleEnable('sparkbooth')}
disabled={updating || mode === 'sparkbooth'}
>
Sparkbooth (HTTP)
</Button>
</div>
</div>
<p className="mt-3 text-xs text-slate-500">
{t('management.photobooth.mode.active', 'Aktuell: {{mode}}', {
mode: mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP',
})}
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<StatusCard status={status} />
<SetupChecklistCard status={status} />
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<ModePresetsCard
status={status}
updating={updating}
onEnable={handleEnable}
onDisable={() => handleDisable({ skipConfirm: true })}
onRotate={handleRotate}
/>
<UploadStatsCard stats={uploadStats} />
</div>
<CredentialsCard status={status} updating={updating} onEnable={handleEnable} onRotate={handleRotate} onDisable={handleDisable} />
<div className="grid gap-6 lg:grid-cols-2">
<StatusTimelineCard status={status} lastUploadAt={lastUploadAt} />
<RateLimitCard status={status} uploadsLastHour={status?.metrics?.uploads_last_hour ?? null} />
</div>
</div>
)}
</AdminLayout>
);
}
function resolveEventName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
return Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function PhotoboothSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="rounded-3xl border border-slate-200/80 bg-white/70 p-6 shadow-sm">
<div className="h-4 w-32 animate-pulse rounded bg-slate-200/80" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
<div className="mt-2 h-3 w-3/4 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
);
}
type ModePresetsCardProps = {
status: PhotoboothStatus | null;
updating: boolean;
onEnable: () => Promise<void>;
onDisable: () => Promise<void>;
onRotate: () => Promise<void>;
};
function ModePresetsCard({ status, updating, onEnable, onDisable, onRotate }: ModePresetsCardProps) {
const { t } = useTranslation('management');
const activePreset: 'plan' | 'live' = status?.enabled ? 'live' : 'plan';
const [selectedPreset, setSelectedPreset] = React.useState<'plan' | 'live'>(activePreset);
React.useEffect(() => {
setSelectedPreset(activePreset);
}, [activePreset]);
const presets = React.useMemo(
() => [
{
key: 'plan' as const,
title: t('photobooth.presets.planTitle', 'Planungsmodus'),
description: t('photobooth.presets.planDescription', 'Zugang bleibt deaktiviert, um Tests vorzubereiten.'),
badge: t('photobooth.presets.badgePlan', 'Planung'),
icon: <Clock3 className="h-5 w-5 text-slate-500" />,
},
{
key: 'live' as const,
title: t('photobooth.presets.liveTitle', 'Live-Modus'),
description: t('photobooth.presets.liveDescription', 'Uploads sind aktiv (FTP oder Sparkbooth) und werden direkt verarbeitet.'),
badge: t('photobooth.presets.badgeLive', 'Live'),
icon: <PlugZap className="h-5 w-5 text-emerald-500" />,
},
],
[t],
);
const handleApply = React.useCallback(() => {
if (selectedPreset === activePreset) {
return;
}
if (selectedPreset === 'live') {
void onEnable();
} else {
void onDisable();
}
}, [activePreset, onDisable, onEnable, selectedPreset]);
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.presets.title', 'Modus wählen')}</CardTitle>
<CardDescription>{t('photobooth.presets.description', 'Passe die Photobooth an Vorbereitung oder Live-Betrieb an.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
{presets.map((preset) => {
const isSelected = selectedPreset === preset.key;
const isActive = activePreset === preset.key;
return (
<button
key={preset.key}
type="button"
onClick={() => setSelectedPreset(preset.key)}
className={`flex h-full flex-col items-start gap-3 rounded-2xl border p-4 text-left transition ${
isSelected ? 'border-emerald-400 bg-emerald-50/70 shadow-inner' : 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
{preset.icon}
<div>
<p className="text-sm font-semibold text-slate-900">{preset.title}</p>
<p className="text-xs text-slate-500">{preset.description}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<Badge variant="outline" className="border-slate-200 text-slate-600">
{preset.badge}
</Badge>
{isActive ? (
<Badge className="bg-emerald-500/20 text-emerald-700">
{t('photobooth.presets.current', 'Aktiv')}
</Badge>
) : null}
</div>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-slate-200 pt-4">
<Button
onClick={handleApply}
disabled={selectedPreset === activePreset || updating}
className="min-w-[160px]"
>
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
{t('photobooth.presets.actions.apply', 'Modus übernehmen')}
</Button>
<Button variant="outline" onClick={onRotate} disabled={updating}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('photobooth.presets.actions.rotate', 'Zugang zurücksetzen')}
</Button>
</div>
</CardContent>
</Card>
);
}
type UploadStatsCardProps = {
stats: {
uploads24h: number;
shareRecent: number | null;
totalEventUploads: number;
lastUploadAt: string | null;
source: 'photobooth' | 'event' | null;
sampleSize: number;
};
};
function UploadStatsCard({ stats }: UploadStatsCardProps) {
const { t } = useTranslation('management');
const lastUploadLabel = stats.lastUploadAt ? formatPhotoboothDate(stats.lastUploadAt) : t('photobooth.stats.none', 'Noch keine Uploads');
const shareLabel = stats.shareRecent != null ? `${stats.shareRecent}%` : '—';
const sourceLabel = stats.source === 'photobooth'
? t('photobooth.stats.sourcePhotobooth', 'Quelle: Photobooth')
: stats.source === 'event'
? t('photobooth.stats.sourceEvent', 'Quelle: Event')
: null;
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.stats.title', 'Upload-Status')}</CardTitle>
<CardDescription>{t('photobooth.stats.description', 'Fokussiere deine Photobooth-Uploads der letzten Stunden.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.lastUpload', 'Letzter Upload')}</span>
<span className="font-semibold text-slate-900">{lastUploadLabel}</span>
</div>
{sourceLabel ? <p className="text-xs text-slate-500">{sourceLabel}</p> : null}
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.uploads24h', 'Uploads (24h)')}</span>
<span className="font-medium text-slate-900">{stats.uploads24h}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.share', 'Anteil Photobooth (letzte Uploads)')}</span>
<span className="font-medium text-slate-900">{shareLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-500">{t('photobooth.stats.totalEvent', 'Uploads gesamt (Event)')}</span>
<span className="font-medium text-slate-900">{stats.totalEventUploads}</span>
</div>
<div className="flex items-center justify-between border-t border-dashed border-slate-200 pt-3 text-xs text-slate-500">
<span>{t('photobooth.stats.sample', 'Analysierte Uploads')}</span>
<span>{stats.sampleSize}</span>
</div>
</CardContent>
</Card>
);
}
function SetupChecklistCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
const steps = [
{
key: 'enable',
label: t('photobooth.checklist.enable', 'Zugang aktivieren'),
description: t('photobooth.checklist.enableCopy', 'Aktiviere den FTP-Account für eure Photobooth-Software.'),
done: Boolean(status?.enabled),
},
{
key: 'share',
label: t('photobooth.checklist.share', 'Zugang teilen'),
description: t('photobooth.checklist.shareCopy', 'Übergib Host, Benutzer & Passwort an den Betreiber.'),
done: Boolean(status?.username && status?.password),
},
{
key: 'monitor',
label: t('photobooth.checklist.monitor', 'Uploads beobachten'),
description: t('photobooth.checklist.monitorCopy', 'Verfolge Uploads & Limits direkt im Dashboard.'),
done: Boolean(status?.status === 'active'),
},
];
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.checklist.title', 'Setup-Checkliste')}</CardTitle>
<CardDescription>{t('photobooth.checklist.description', 'Erledige jeden Schritt, bevor Gäste Zugang erhalten.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{steps.map((step) => (
<div key={step.key} className="flex items-start gap-3 rounded-2xl border border-slate-200/70 p-3">
{step.done ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
) : (
<Circle className="mt-0.5 h-4 w-4 text-slate-300" />
)}
<div>
<p className={`text-sm font-semibold ${step.done ? 'text-emerald-700' : 'text-slate-800'}`}>{step.label}</p>
<p className="text-xs text-slate-500">{step.description}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function StatusCard({ status }: { status: PhotoboothStatus | null }) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
const badgeColor = isActive ? 'bg-emerald-600 text-white' : 'bg-slate-300 text-slate-800';
const icon = isActive ? <PlugZap className="h-5 w-5 text-emerald-500" /> : <Power className="h-5 w-5 text-slate-400" />;
const modeLabel = status?.mode === 'sparkbooth' ? 'Sparkbooth / HTTP' : 'FTP';
return (
<Card className="rounded-3xl border border-slate-200/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle>{t('photobooth.status.heading', 'Status')}</CardTitle>
<CardDescription>
{isActive
? t('photobooth.status.active', 'Photobooth-Link ist aktiv.')
: t('photobooth.status.inactive', 'Noch keine Photobooth-Uploads angebunden.')}
</CardDescription>
</div>
<div className="flex items-center gap-3">
{icon}
<Badge className={badgeColor}>
{isActive ? t('photobooth.status.badgeActive', 'AKTIV') : t('photobooth.status.badgeInactive', 'INAKTIV')}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm text-slate-600">
<p className="text-xs uppercase tracking-wide text-slate-500">
{t('photobooth.status.mode', 'Modus')}: {modeLabel}
</p>
{status?.expires_at ? (
<p>
{t('photobooth.status.expiresAt', 'Automatisches Abschalten am {{date}}', {
date: new Date(status.expires_at).toLocaleString(),
})}
</p>
) : null}
</CardContent>
</Card>
);
}
type CredentialCardProps = {
status: PhotoboothStatus | null;
updating: boolean;
onEnable: () => Promise<void>;
onRotate: () => Promise<void>;
onDisable: () => Promise<void>;
};
function CredentialsCard({ status, updating, onEnable, onRotate, onDisable }: CredentialCardProps) {
const { t } = useTranslation('management');
const isActive = Boolean(status?.enabled);
const isSparkbooth = status?.mode === 'sparkbooth';
return (
<Card className="rounded-3xl border border-rose-100/80 shadow-lg shadow-rose-100/40">
<CardHeader>
<CardTitle>
{isSparkbooth ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth-Upload (HTTP)') : t('photobooth.credentials.heading', 'FTP-Zugangsdaten')}
</CardTitle>
<CardDescription>
{isSparkbooth
? t(
'photobooth.credentials.sparkboothDescription',
'Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten erfolgen als JSON (optional XML).'
)
: t(
'photobooth.credentials.description',
'Teile die Zugangsdaten mit der Photobooth-Software. Passwörter werden max. 8 Zeichen lang generiert.'
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isSparkbooth ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.postUrl', 'Upload-URL')} value={status?.upload_url ?? '—'} copyable className="md:col-span-2" />
<Field
label={t('photobooth.credentials.responseFormat', 'Antwort-Format')}
value={status?.sparkbooth?.response_format === 'xml' ? 'XML' : 'JSON'}
className="md:col-span-2"
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('photobooth.credentials.host', 'Host')} value={status?.ftp.host ?? '—'} />
<Field label={t('photobooth.credentials.port', 'Port')} value={String(status?.ftp.port ?? 2121)} />
<Field label={t('photobooth.credentials.username', 'Benutzername')} value={status?.username ?? '—'} copyable />
<Field label={t('photobooth.credentials.password', 'Passwort')} value={status?.password ?? '—'} copyable sensitive />
<Field label={t('photobooth.credentials.path', 'Upload-Pfad')} value={status?.path ?? '/photobooth/...'} copyable />
<Field label="FTP-Link" value={status?.ftp_url ?? '—'} copyable className="md:col-span-2" />
</div>
)}
<div className="flex flex-wrap gap-3">
{isActive ? (
<>
<Button onClick={onRotate} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{t('photobooth.actions.rotate', 'Zugang neu generieren')}
</Button>
<Button variant="outline" onClick={onDisable} disabled={updating}>
<Power className="mr-2 h-4 w-4" />
{t('photobooth.actions.disable', 'Deaktivieren')}
</Button>
</>
) : (
<Button onClick={onEnable} disabled={updating} className="bg-rose-600 text-white hover:bg-rose-500">
{updating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlugZap className="mr-2 h-4 w-4" />}
{t('photobooth.actions.enable', 'Photobooth aktivieren')}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
function StatusTimelineCard({ status, lastUploadAt }: { status: PhotoboothStatus | null; lastUploadAt?: string | null }) {
const { t } = useTranslation('management');
const entries = [
{
title: t('photobooth.timeline.activation', 'Freischaltung'),
body: status?.enabled
? t('photobooth.timeline.activationReady', 'Zugang ist aktiv.')
: t('photobooth.timeline.activationPending', 'Noch nicht aktiviert.'),
},
{
title: t('photobooth.timeline.credentials', 'Zugangsdaten'),
body: status?.username
? t('photobooth.timeline.credentialsReady', { defaultValue: 'Benutzer {{username}} ist bereit.', username: status.username })
: t('photobooth.timeline.credentialsPending', 'Noch keine Logindaten generiert.'),
},
{
title: t('photobooth.timeline.expiry', 'Ablauf'),
body: status?.expires_at
? t('photobooth.timeline.expiryHint', { defaultValue: 'Automatisches Abschalten am {{date}}', date: formatPhotoboothDate(status.expires_at) })
: t('photobooth.timeline.noExpiry', 'Noch kein Ablaufdatum gesetzt.'),
},
{
title: t('photobooth.timeline.lastUpload', 'Letzter Upload'),
body: lastUploadAt
? t('photobooth.timeline.lastUploadAt', { defaultValue: 'Zuletzt am {{date}}', date: formatPhotoboothDate(lastUploadAt) })
: t('photobooth.timeline.lastUploadPending', 'Noch keine Uploads registriert.'),
},
];
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader>
<CardTitle>{t('photobooth.timeline.title', 'Status-Timeline')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{entries.map((entry, index) => (
<div key={entry.title} className="flex gap-3">
<div className="flex flex-col items-center">
<Clock3 className="h-4 w-4 text-slate-400" />
{index < entries.length - 1 ? <span className="mt-1 h-10 w-px bg-slate-200" /> : null}
</div>
<div>
<p className="text-sm font-semibold text-slate-800">{entry.title}</p>
<p className="text-xs text-slate-500">{entry.body}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function RateLimitCard({ status, uploadsLastHour }: { status: PhotoboothStatus | null; uploadsLastHour: number | null }) {
const { t } = useTranslation('management');
const rateLimit = status?.rate_limit_per_minute ?? 20;
const usage = uploadsLastHour != null ? Math.max(0, uploadsLastHour) : null;
const usageRatio = usage !== null && rateLimit > 0 ? usage / rateLimit : null;
const showWarning = usageRatio !== null && usageRatio >= 0.8;
return (
<Card className="rounded-3xl border border-slate-200/80 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center gap-3">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
<div>
<CardTitle>{t('photobooth.rateLimit.heading', 'Sicherheit & Limits')}</CardTitle>
<CardDescription>
{t('photobooth.rateLimit.description', 'Uploads werden strikt auf {{count}} Fotos pro Minute begrenzt.', {
count: rateLimit,
})}
</CardDescription>
</div>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-slate-600">
<p>
{t(
'photobooth.rateLimit.body',
'Bei Überschreitung wird die Verbindung hart geblockt. Nach 60 Sekunden wird der Zugang automatisch wieder freigegeben.'
)}
</p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50/70 p-3">
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-slate-500">
<span>{t('photobooth.rateLimit.usage', 'Uploads letzte Stunde')}</span>
<span className="text-slate-900">{usage !== null ? `${usage}/${rateLimit}` : `${rateLimit}`}</span>
</div>
{showWarning ? (
<p className="mt-2 text-xs text-amber-600">
{t('photobooth.rateLimit.warning', 'Kurz vor dem Limit bitte Upload-Takt reduzieren oder Support informieren.')}
</p>
) : null}
</div>
<p className="mt-3 text-xs text-slate-500">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{t(
'photobooth.rateLimit.hint',
'Ablaufzeit stimmt mit dem Event-Ende überein. Nach Ablauf wird der Account automatisch entfernt.'
)}
</p>
</CardContent>
</Card>
);
}
type FieldProps = {
label: string;
value: string;
copyable?: boolean;
sensitive?: boolean;
className?: string;
};
function Field({ label, value, copyable, sensitive, className }: FieldProps) {
const [copied, setCopied] = React.useState(false);
const showValue = sensitive && value && value !== '—' ? '•'.repeat(Math.min(6, value.length)) : value;
async function handleCopy() {
if (!copyable || !value || value === '—') return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className={`rounded-2xl border border-slate-200/80 bg-white/70 p-4 shadow-inner ${className ?? ''}`}>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="truncate text-base font-medium text-slate-900">{showValue}</span>
{copyable ? (
<Button variant="ghost" size="icon" onClick={handleCopy} aria-label="Copy" disabled={!value || value === '—'}>
{copied ? <ShieldCheck className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4 text-slate-500" />}
</Button>
) : null}
</div>
</div>
);
}
function formatPhotoboothDate(iso: string | null): string {
if (!iso) {
return '—';
}
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function countUploadsInWindow(photos: TenantPhoto[], windowMs: number): number {
if (!photos.length) {
return 0;
}
const now = Date.now();
return photos.reduce((total, photo) => {
if (!photo.uploaded_at) {
return total;
}
const timestamp = Date.parse(photo.uploaded_at);
if (Number.isNaN(timestamp)) {
return total;
}
return now - timestamp <= windowMs ? total + 1 : total;
}, 0);
}
function selectLatestUpload(photos: TenantPhoto[]): TenantPhoto | null {
let latest: TenantPhoto | null = null;
let bestTime = -Infinity;
photos.forEach((photo) => {
if (!photo.uploaded_at) {
return;
}
const timestamp = Date.parse(photo.uploaded_at);
if (!Number.isNaN(timestamp) && timestamp > bestTime) {
latest = photo;
bestTime = timestamp;
}
});
return latest;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,930 +0,0 @@
// @ts-nocheck
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Camera, Clock3, Loader2, MessageSquare, Printer, ShoppingCart, Sparkles } from 'lucide-react';
import toast from 'react-hot-toast';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { AdminLayout } from '../components/AdminLayout';
import {
createEventAddonCheckout,
EventQrInvite,
EventStats,
getEvent,
getEventStats,
getEventQrInvites,
getAddonCatalog,
type EventAddonCatalogItem,
TenantEvent,
toggleEvent,
submitTenantFeedback,
} from '../api';
import { updateEvent } from '../api';
import { buildEventTabs } from '../lib/eventTabs';
import { formatEventDate } from '../lib/events';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
ADMIN_EVENT_EDIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENTS_PATH,
} from '../constants';
export default function EventRecapPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [searchParams, setSearchParams] = useSearchParams();
const slug = slugParam ?? null;
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [stats, setStats] = React.useState<EventStats | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [settingsBusy, setSettingsBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [joinTokens, setJoinTokens] = React.useState<EventQrInvite[]>([]);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [addonBusyKey, setAddonBusyKey] = React.useState<string | null>(null);
const loadEventData = React.useCallback(async () => {
if (!slug) {
setLoading(false);
setError(t('events.errors.missingSlug', 'Kein Event ausgewählt.'));
return;
}
setLoading(true);
setError(null);
try {
const [eventData, statsData, invites, addons] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setEvent(eventData);
setStats(statsData);
setJoinTokens(invites);
setAddonsCatalog(addons);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
}, [slug, t]);
React.useEffect(() => {
void loadEventData();
}, [loadEventData]);
React.useEffect(() => {
if (!searchParams.get('addon_success')) {
return;
}
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void loadEventData();
const next = new URLSearchParams(searchParams);
next.delete('addon_success');
setSearchParams(next, { replace: true });
}, [loadEventData, searchParams, setSearchParams, t]);
const handleToggleEvent = React.useCallback(async () => {
if (!slug) return;
setBusy(true);
setError(null);
try {
const updated = await toggleEvent(slug);
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Status konnte nicht angepasst werden.')));
}
} finally {
setBusy(false);
}
}, [slug, t]);
const handleAddonCheckout = React.useCallback(async (addonKey: string) => {
if (!slug) return;
setAddonBusyKey(addonKey);
setError(null);
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
if (!isAuthError(err)) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
}
} finally {
setAddonBusyKey(null);
}
}, [slug, t]);
const handleArchive = React.useCallback(async () => {
if (!slug || !event) return;
setArchiveBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, { status: 'archived', is_active: false });
setEvent(updated);
setArchiveOpen(false);
toast.success(t('events.recap.archivedSuccess', 'Event archiviert. Galerie ist geschlossen.'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.archiveFailed', 'Archivierung fehlgeschlagen.')));
}
} finally {
setArchiveBusy(false);
}
}, [event, slug, t]);
const handleToggleSetting = React.useCallback(async (key: 'guest_downloads_enabled' | 'guest_sharing_enabled', value: boolean) => {
if (!slug || !event) return;
setSettingsBusy(true);
setError(null);
try {
const updated = await updateEvent(slug, {
settings: {
...(event.settings ?? {}),
[key]: value,
},
});
setEvent(updated);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.toggleFailed', 'Einstellung konnte nicht gespeichert werden.')));
}
} finally {
setSettingsBusy(false);
}
}, [event, slug, t]);
if (!slug) {
return (
<AdminLayout
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
>
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
</AdminLayout>
);
}
const eventName = event ? resolveName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const activeInvite = joinTokens.find((token) => token.is_active);
const guestLinkRaw = event?.public_url ?? activeInvite?.url ?? (activeInvite?.token ? `/g/${activeInvite.token}` : null);
const guestLink = buildAbsoluteGuestLink(guestLinkRaw);
const guestQrCodeDataUrl = activeInvite?.qr_code_data_url ?? null;
const eventTabs = event
? buildEventTabs(event, (key, fallback) => t(key, { defaultValue: fallback }), {
photos: stats?.uploads_total ?? event.photo_count ?? undefined,
tasks: event.tasks_count ?? undefined,
})
: [];
return (
<AdminLayout
title={eventName}
subtitle={t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und QR-Codes deines Events im Blick.')}
tabs={eventTabs}
currentTabKey="recap"
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<WorkspaceSkeleton />
) : event && stats ? (
<RecapContent
event={event}
stats={stats}
busy={busy}
onToggleEvent={handleToggleEvent}
guestLink={guestLink}
guestQrCodeDataUrl={guestQrCodeDataUrl}
addonsCatalog={addonsCatalog}
addonBusyKey={addonBusyKey}
onCheckoutAddon={handleAddonCheckout}
onArchive={handleArchive}
onCopyLink={guestLink ? () => {
navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} : undefined}
onOpenPhotos={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
onEditEvent={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))}
onBack={() => navigate(ADMIN_EVENTS_PATH)}
settingsBusy={settingsBusy}
onToggleDownloads={(value) => handleToggleSetting('guest_downloads_enabled', value)}
onToggleSharing={(value) => handleToggleSetting('guest_sharing_enabled', value)}
/>
) : (
<Alert variant="destructive">
<AlertTitle>{t('events.errors.notFoundTitle', 'Event nicht gefunden')}</AlertTitle>
<AlertDescription>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</AlertDescription>
</Alert>
)}
</AdminLayout>
);
}
type RecapContentProps = {
event: TenantEvent;
stats: EventStats;
busy: boolean;
onToggleEvent: () => void;
guestLink: string | null;
guestQrCodeDataUrl: string | null;
addonsCatalog: EventAddonCatalogItem[];
addonBusyKey: string | null;
onCheckoutAddon: (addonKey: string) => void;
onArchive: () => void;
onCopyLink?: () => void;
onOpenPhotos: () => void;
onEditEvent: () => void;
onBack: () => void;
settingsBusy: boolean;
onToggleDownloads: (value: boolean) => void;
onToggleSharing: (value: boolean) => void;
};
function RecapContent({
event,
stats,
busy,
onToggleEvent,
guestLink,
guestQrCodeDataUrl,
addonsCatalog,
addonBusyKey,
onCheckoutAddon,
onArchive,
onCopyLink,
onOpenPhotos,
onEditEvent,
onBack,
settingsBusy,
onToggleDownloads,
onToggleSharing,
}: RecapContentProps) {
const { t } = useTranslation('management');
const galleryExpiresAt = event.package?.expires_at ?? event.limits?.gallery?.expires_at ?? null;
const galleryStatusLabel = event.is_active
? t('events.recap.galleryOpen', 'Galerie geöffnet')
: t('events.recap.galleryClosed', 'Galerie geschlossen');
const counts = {
photos: stats.uploads_total ?? stats.total ?? 0,
pending: stats.pending_photos ?? 0,
likes: stats.likes_total ?? stats.likes ?? 0,
};
const [sentiment, setSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
const [feedbackMessage, setFeedbackMessage] = React.useState('');
const [feedbackBusy, setFeedbackBusy] = React.useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
const [feedbackError, setFeedbackError] = React.useState<string | undefined>(undefined);
const [archiveOpen, setArchiveOpen] = React.useState(false);
const [archiveBusy, setArchiveBusy] = React.useState(false);
const [archiveConfirmed, setArchiveConfirmed] = React.useState(false);
const [feedbackOpen, setFeedbackOpen] = React.useState(false);
const [feedbackBestArea, setFeedbackBestArea] = React.useState<string | null>(null);
const [feedbackNeedsSupport, setFeedbackNeedsSupport] = React.useState(false);
const galleryAddons = React.useMemo(
() => addonsCatalog.filter((addon) => addon.key.includes('gallery') || addon.key.includes('boost')),
[addonsCatalog],
);
const addonsToShow = galleryAddons.length ? galleryAddons : addonsCatalog;
const defaultAddon = addonsToShow[0] ?? null;
const describeAddon = React.useCallback((addon: EventAddonCatalogItem): string | null => {
const increments = addon.increments ?? {};
const photos = (increments as Record<string, number | undefined>).photos ?? (increments as Record<string, number | undefined>).extra_photos;
const guests = (increments as Record<string, number | undefined>).guests ?? (increments as Record<string, number | undefined>).extra_guests;
const galleryDays = (increments as Record<string, number | undefined>).gallery_days
?? (increments as Record<string, number | undefined>).extra_gallery_days;
const parts: string[] = [];
if (typeof photos === 'number' && photos > 0) {
parts.push(t('events.sections.addons.summary.photos', `+${photos} Fotos`, { count: photos.toLocaleString() }));
}
if (typeof guests === 'number' && guests > 0) {
parts.push(t('events.sections.addons.summary.guests', `+${guests} Gäste`, { count: guests.toLocaleString() }));
}
if (typeof galleryDays === 'number' && galleryDays > 0) {
parts.push(t('events.sections.addons.summary.gallery', `+${galleryDays} Tage`, { count: galleryDays }));
}
return parts.length ? parts.join(' · ') : null;
}, [t]);
const copy = {
positive: t('events.feedback.positive', 'War super'),
neutral: t('events.feedback.neutral', 'In Ordnung'),
negative: t('events.feedback.negative', 'Brauch(t)e Unterstützung'),
};
const bestAreaOptions = [
{ key: 'uploads', label: t('events.feedback.best.uploads', 'Uploads & Geschwindigkeit') },
{ key: 'invites', label: t('events.feedback.best.invites', 'QR-Einladungen & Layouts') },
{ key: 'moderation', label: t('events.feedback.best.moderation', 'Moderation & Export') },
{ key: 'experience', label: t('events.feedback.best.experience', 'Allgemeine App-Erfahrung') },
];
const handleQrDownload = React.useCallback(() => {
if (!guestQrCodeDataUrl) return;
const link = document.createElement('a');
link.href = guestQrCodeDataUrl;
link.download = 'guest-gallery-qr.png';
link.click();
}, [guestQrCodeDataUrl]);
const handleQrShare = React.useCallback(async () => {
if (!guestLink) return;
if (navigator.share) {
try {
await navigator.share({
title: resolveName(event.name),
text: t('events.recap.shareGuests', 'Gäste-Galerie teilen'),
url: guestLink,
});
return;
} catch {
// Ignore share cancellation and fall back to copy.
}
}
try {
await navigator.clipboard.writeText(guestLink);
toast.success(t('events.recap.copySuccess', 'Link kopiert'));
} catch {
toast.error(t('events.recap.copyError', 'Link konnte nicht geteilt werden.'));
}
}, [event.name, guestLink, t]);
const handleFeedbackSubmit = React.useCallback(async () => {
if (feedbackBusy) return;
setFeedbackBusy(true);
setFeedbackError(undefined);
try {
await submitTenantFeedback({
category: 'event_workspace_after_event',
event_slug: event.slug,
sentiment: sentiment ?? undefined,
message: feedbackMessage.trim() ? feedbackMessage.trim() : undefined,
metadata: {
best_area: feedbackBestArea,
needs_support: feedbackNeedsSupport,
event_name: resolveName(event.name),
guest_link: guestLink,
},
});
setFeedbackSubmitted(true);
setFeedbackOpen(false);
setFeedbackMessage('');
setFeedbackNeedsSupport(false);
toast.success(t('events.feedback.submitted', 'Danke!'));
} catch (err) {
setFeedbackError(isAuthError(err)
? t('events.feedback.authError', 'Deine Session ist abgelaufen. Bitte melde dich erneut an.')
: t('events.feedback.genericError', 'Feedback konnte nicht gesendet werden.'));
} finally {
setFeedbackBusy(false);
}
}, [event.slug, event.name, feedbackBestArea, feedbackBusy, feedbackMessage, feedbackNeedsSupport, feedbackSubmitted, guestLink, sentiment, t]);
return (
<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-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{t('events.recap.badge', 'Nachbereitung')}
</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.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
</p>
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
<Sparkles className="h-3.5 w-3.5" />
{event.status === 'published' ? t('events.status.published', 'Veröffentlicht') : t('events.status.draft', 'Entwurf')}
</Badge>
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
<Clock3 className="h-3.5 w-3.5" />
{event.event_date ? formatEventDate(event.event_date, undefined) : t('events.workspace.noDate', 'Kein Datum')}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack} 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={onEditEvent} 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={onToggleEvent}
disabled={busy}
className="rounded-full border-slate-200"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-500">
{t('events.recap.galleryTitle', 'Galerie-Status')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{galleryStatusLabel}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', counts)}
</p>
</div>
<Badge variant={event.is_active ? 'default' : 'outline'} className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-100">
{event.is_active ? t('events.recap.open', 'Offen') : t('events.recap.closed', 'Geschlossen')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant={event.is_active ? 'secondary' : 'default'} disabled={busy} onClick={onToggleEvent}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
{event.is_active ? t('events.recap.closeGallery', 'Galerie schließen') : t('events.recap.openGallery', 'Galerie öffnen')}
</Button>
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
<Camera className="mr-2 h-4 w-4" />
{t('events.recap.moderate', 'Uploads ansehen')}
</Button>
</div>
<div className="mt-4 space-y-3 rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 p-3 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-emerald-600 dark:text-emerald-200">
{t('events.recap.shareLink', 'Gäste-Link')}
</p>
{guestLink ? (
<span className="block truncate text-emerald-900" title={guestLink}>{guestLink}</span>
) : (
<p className="text-xs text-emerald-800/80 dark:text-emerald-100">
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
</p>
)}
</div>
{guestLink && onCopyLink ? (
<Button size="sm" variant="secondary" className="rounded-full bg-emerald-600 text-white hover:bg-emerald-700" onClick={onCopyLink}>
{t('events.recap.copyLink', 'Link kopieren')}
</Button>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowDownloads', 'Downloads erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowDownloadsHint', 'Gäste dürfen Fotos speichern')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_downloads_enabled ?? true)}
onCheckedChange={(checked) => onToggleDownloads(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-emerald-100/70 bg-white/80 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900/40 dark:bg-white/5 dark:text-emerald-50">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.allowSharing', 'Teilen erlauben')}</p>
<p className="text-[11px] text-emerald-600 dark:text-emerald-200">{t('events.recap.allowSharingHint', 'Gäste dürfen Links teilen')}</p>
</div>
<Switch
checked={Boolean((event.settings as any)?.guest_sharing_enabled ?? true)}
onCheckedChange={(checked) => onToggleSharing(Boolean(checked))}
disabled={settingsBusy}
/>
</div>
</div>
{guestQrCodeDataUrl ? (
<div className="mt-2 grid gap-3 rounded-2xl border border-emerald-100/80 bg-white/90 p-3 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-white/10 dark:text-emerald-50 sm:grid-cols-[auto,1fr]">
<div className="flex items-center justify-center rounded-xl border border-emerald-100/70 bg-white/70 p-2 dark:border-emerald-900/50 dark:bg-emerald-900/30">
<img src={guestQrCodeDataUrl} alt={t('events.recap.qrAlt', 'QR-Code zur Gäste-Galerie')} className="h-28 w-28 rounded-lg" />
</div>
<div className="flex min-w-0 flex-col gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-500">{t('events.recap.qrTitle', 'QR-Code teilen')}</p>
{guestLink ? (
<p className="truncate text-sm text-emerald-800 dark:text-emerald-100" title={guestLink}>{guestLink}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={handleQrDownload}>
{t('events.recap.qrDownload', 'QR-Code herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={handleQrShare}>
{t('events.recap.qrShare', 'Link/QR teilen')}
</Button>
</div>
</div>
</div>
) : null}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-500">
{t('events.recap.exportTitle', 'Export & Backup')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{t('events.recap.exportCopy', 'Alle Assets sichern')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.exportHint', 'Zip/CSV Export und Backup anstoßen.')}
</p>
</div>
<Badge variant="outline" className="border-indigo-200 text-indigo-700 dark:border-indigo-800 dark:text-indigo-200">
{t('events.recap.backup', 'Backup')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" onClick={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadAll', 'Alles herunterladen')}
</Button>
<Button size="sm" variant="outline" onClick={onOpenPhotos}>
<Printer className="mr-2 h-4 w-4" />
{t('events.recap.downloadHighlights', 'Highlights herunterladen')}
</Button>
</div>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{t('events.recap.highlightsHint', '“Highlights” = als Highlight markierte Fotos in der Galerie.')}
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">
{t('events.recap.retentionTitle', 'Verlängerung / Archivierung')}
</p>
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
{galleryExpiresAt
? t('events.recap.expiresAt', 'Läuft ab am {{date}}', { date: formatEventDate(galleryExpiresAt, undefined) })
: t('events.recap.noExpiry', 'Ablaufdatum nicht gesetzt')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.recap.retentionHint', 'Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.')}
</p>
</div>
<Badge variant="outline" className="border-amber-200 text-amber-700 dark:border-amber-800 dark:text-amber-100">
{t('events.recap.expiry', 'Ablauf')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="default"
disabled={!defaultAddon || addonBusyKey === defaultAddon?.key}
onClick={() => defaultAddon && onCheckoutAddon(defaultAddon.key)}
>
{addonBusyKey === defaultAddon?.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Clock3 className="mr-2 h-4 w-4" />}
{defaultAddon?.label ?? t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
<Button size="sm" variant="outline" onClick={() => setArchiveOpen(true)}>
{t('events.recap.archive', 'Archivieren/Löschen')}
</Button>
</div>
{addonsToShow.length ? (
<div className="mt-4 space-y-3 rounded-2xl border border-amber-100/80 bg-white/80 p-3 text-sm text-slate-700 shadow-sm dark:border-amber-900/40 dark:bg-white/10 dark:text-slate-200">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-600 dark:text-amber-200">
{t('events.recap.extendOptions', 'Alle Add-ons für dieses Event')}
</p>
<div className="space-y-2">
{addonsToShow.map((addon) => (
<div
key={addon.key}
className="flex flex-col gap-2 rounded-xl border border-amber-100/60 bg-white/80 p-3 shadow-sm dark:border-amber-900/40 dark:bg-amber-900/20 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<p className="truncate font-semibold text-slate-900 dark:text-white" title={addon.label}>
{addon.label}
</p>
<p className="text-xs text-slate-500 dark:text-slate-300">
{describeAddon(addon) ?? t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={!addon.price_id || addonBusyKey === addon.key}
onClick={() => onCheckoutAddon(addon.key)}
>
{addonBusyKey === addon.key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShoppingCart className="mr-2 h-4 w-4" />}
{addon.price_id ? t('addons.buyNow', 'Jetzt freischalten') : t('events.recap.priceMissing', 'Preis nicht verknüpft')}
</Button>
</div>
))}
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{t('events.recap.extendHint', 'Laufzeitverlängerungen addieren sich. Checkout öffnet in einem neuen Tab.')}
</p>
</div>
) : (
<p className="mt-4 text-xs text-slate-500 dark:text-slate-300">
{t('events.recap.noAddons', 'Aktuell keine Add-ons verfügbar.')}
</p>
)}
</div>
<div className="rounded-3xl border border-slate-200 bg-white/85 p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-rose-500">
{t('events.feedback.badge', 'Feedback')}
</p>
<p className="text-lg font-semibold text-slate-900 dark:text-white">
{t('events.feedback.afterEventTitle', 'Event beendet kurzes Feedback?')}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{t('events.feedback.afterEventCopy', 'Hat alles geklappt? Deine Antwort hilft uns für kommende Events.')}<br />
<span className="text-[11px] text-slate-500">{t('events.feedback.privacyHint', 'Nur Admin-Feedback, keine Gastdaten')}</span>
</p>
</div>
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-800 dark:text-rose-100">
{t('events.feedback.badgeShort', 'Feedback')}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-slate-600 dark:text-slate-300">
<Badge variant="secondary" className="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-50">
{resolveName(event.name)}
</Badge>
{event.event_date ? (
<Badge variant="outline" className="rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
{formatEventDate(event.event_date, undefined)}
</Badge>
) : null}
</div>
{feedbackSubmitted ? (
<div className="mt-4 rounded-2xl border border-emerald-200/60 bg-emerald-50/80 p-4 text-emerald-900 shadow-sm dark:border-emerald-900/40 dark:bg-emerald-900/20 dark:text-emerald-50">
<p className="font-semibold">{t('events.feedback.submitted', 'Danke!')}</p>
<p className="text-sm">{t('events.feedback.afterEventThanks', 'Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.')}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => { setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.sendAnother', 'Weiteres Feedback senden')}
</Button>
<Button size="sm" variant="secondary" className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => { setFeedbackNeedsSupport(true); setFeedbackSubmitted(false); setFeedbackOpen(true); }}>
{t('events.feedback.supportFollowup', 'Support anfragen')}
</Button>
</div>
</div>
) : (
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button size="sm" onClick={() => setFeedbackOpen(true)} disabled={feedbackBusy}>
<MessageSquare className="mr-2 h-4 w-4" />
{t('events.feedback.cta', 'Feedback geben')}
</Button>
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<span>{t('events.feedback.quickSentiment', 'Stimmung auswählbar (positiv/neutral/Support).')}</span>
</div>
</div>
)}
{feedbackError ? (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('events.feedback.errorTitle', 'Feedback konnte nicht gesendet werden.')}</AlertTitle>
<AlertDescription>{feedbackError}</AlertDescription>
</Alert>
) : null}
</div>
</div>
<Dialog open={feedbackOpen} onOpenChange={(open) => { setFeedbackOpen(open); setFeedbackError(undefined); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.feedback.dialogTitle', 'Kurzes After-Event Feedback')}</DialogTitle>
<DialogDescription>
{t('events.feedback.dialogCopy', 'Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.sentiment', 'Stimmung')}</p>
<div className="mt-2 flex flex-wrap gap-2">
{(Object.keys(copy) as Array<'positive' | 'neutral' | 'negative'>).map((key) => (
<Button
key={key}
type="button"
size="sm"
variant={sentiment === key ? 'default' : 'outline'}
className={sentiment === key ? 'bg-slate-900 text-white' : 'border-slate-300 text-slate-700'}
onClick={() => setSentiment(key)}
>
{copy[key]}
</Button>
))}
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.bestQuestion', 'Was lief am besten?')}</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{bestAreaOptions.map((option) => (
<Button
key={option.key}
type="button"
variant={feedbackBestArea === option.key ? 'secondary' : 'outline'}
className={feedbackBestArea === option.key ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 text-slate-700'}
onClick={() => setFeedbackBestArea(option.key)}
>
{option.label}
</Button>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{t('events.feedback.improve', 'Was sollen wir verbessern?')}</p>
<textarea
value={feedbackMessage}
onChange={(event) => setFeedbackMessage(event.target.value)}
placeholder={t('events.feedback.placeholder', 'Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.')}
className="min-h-[120px] w-full rounded-lg border border-slate-200 bg-white/90 p-3 text-sm text-slate-700 outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300 dark:border-white/10 dark:bg-white/5 dark:text-slate-100"
/>
</div>
<div className="flex items-start gap-2 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="needs-support"
checked={feedbackNeedsSupport}
onCheckedChange={(checked) => setFeedbackNeedsSupport(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="needs-support" className="cursor-pointer text-sm leading-5">
{t('events.feedback.supportHelp', 'Ich hätte gern ein kurzes Follow-up (Support).')}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFeedbackOpen(false)} disabled={feedbackBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button onClick={() => { void handleFeedbackSubmit(); }} disabled={feedbackBusy}>
{feedbackBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <MessageSquare className="mr-2 h-4 w-4" />}
{t('events.feedback.submit', 'Feedback senden')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={archiveOpen} onOpenChange={(open) => {
setArchiveOpen(open);
if (!open) {
setArchiveConfirmed(false);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('events.recap.archiveTitle', 'Event archivieren')}</DialogTitle>
<DialogDescription>
{t('events.recap.archiveDesc', 'Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte solltest du vorher abschließen.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 rounded-lg border border-amber-200/60 bg-amber-50/70 p-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-50">
<p className="font-semibold">{t('events.recap.archiveImpact', 'Was passiert?')}</p>
<ul className="list-disc space-y-1 pl-4 text-amber-900 dark:text-amber-50">
<li>{t('events.recap.archiveImpactClose', 'Gäste-Zugriff wird beendet, Uploads/Downloads werden deaktiviert.')}</li>
<li>{t('events.recap.archiveImpactLinks', 'Öffentliche Links und QR-Codes werden ungültig, bestehende Sessions laufen aus.')}</li>
<li>{t('events.recap.archiveImpactData', 'Daten bleiben intern für Compliance & Support sichtbar, können aber auf Anfrage gelöscht werden (DSGVO).')}</li>
</ul>
</div>
<div className="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-sm text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-100">
<Checkbox
id="archive-confirm"
checked={archiveConfirmed}
onCheckedChange={(checked) => setArchiveConfirmed(Boolean(checked))}
className="mt-0.5"
/>
<Label htmlFor="archive-confirm" className="cursor-pointer text-sm leading-5">
{t('events.recap.archiveConfirm', 'Ich habe Exporte abgeschlossen und möchte die Galerie jetzt archivieren.')}
</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setArchiveOpen(false)} disabled={archiveBusy}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button variant="destructive" onClick={onArchive} disabled={!archiveConfirmed || archiveBusy}>
{archiveBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('events.recap.archiveConfirmCta', 'Archivierung starten')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function resolveName(name: TenantEvent['name']): string {
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function buildAbsoluteGuestLink(link: string | null): string | null {
if (!link) return null;
try {
const base = typeof window !== 'undefined' ? window.location.origin : undefined;
return base ? new URL(link, base).toString() : new URL(link).toString();
} catch {
return link;
}
}
function WorkspaceSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<SkeletonCard key={`recap-metric-skeleton-${index}`} />
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
<SkeletonCard />
<SkeletonCard />
</div>
</div>
);
}
function SkeletonCard() {
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import React from 'react';
import EventDetailPage from './EventDetailPage';
export default function EventToolkitPage() {
return <EventDetailPage />;
}

View File

@@ -1,382 +0,0 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AlertTriangle, ArrowRight, CalendarDays, Camera, Heart, Plus } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AppCard, PrimaryCTA, Segmented, StatusPill, MetaRow, BottomNav } from '../tamagui/primitives';
import { AdminLayout } from '../components/AdminLayout';
import { getEvents, TenantEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import {
adminPath,
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_PHOTOBOOTH_PATH,
ADMIN_EVENT_BRANDING_PATH,
} from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings';
import { useTranslation } from 'react-i18next';
export default function EventsPage() {
const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common');
const [rows, setRows] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const navigate = useNavigate();
React.useEffect(() => {
(async () => {
try {
setRows(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Event konnte nicht geladen werden.')));
}
} finally {
setLoading(false);
}
})();
}, [t]);
const translateManagement = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
const translateCommon = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
[tCommon],
);
const totalEvents = rows.length;
const publishedEvents = React.useMemo(
() => rows.filter((event) => event.status === 'published').length,
[rows],
);
const pageTitle = translateManagement('events.list.title', 'Deine Events');
const draftEvents = totalEvents - publishedEvents;
const [statusFilter, setStatusFilter] = React.useState<'all' | 'published' | 'draft'>('all');
const filteredRows = React.useMemo(() => {
if (statusFilter === 'published') {
return rows.filter((event) => event.status === 'published');
}
if (statusFilter === 'draft') {
return rows.filter((event) => event.status !== 'published');
}
return rows;
}, [rows, statusFilter]);
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 },
{ key: 'draft', label: t('events.list.filters.drafts', 'Entwürfe'), count: Math.max(0, draftEvents) },
];
return (
<AdminLayout title={pageTitle} disableCommandShelf>
<YStack space="$3" maxWidth={560} marginHorizontal="auto" paddingBottom="$8">
{error ? (
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppCard>
<YStack space="$1">
<Text fontSize="$lg" fontWeight="700" color="$color">
{t('events.list.dashboardTitle', 'All Events Dashboard')}
</Text>
<Text fontSize="$sm" color="$color">
{t('events.list.dashboardSubtitle', 'Schneller Überblick über deine Events')}
</Text>
</YStack>
<Segmented
options={filterOptions.map((opt) => ({ key: opt.key, label: `${opt.label} (${opt.count})` }))}
value={statusFilter}
onChange={(key) => setStatusFilter(key as typeof statusFilter)}
/>
<PrimaryCTA label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</AppCard>
{loading ? (
<LoadingState />
) : filteredRows.length === 0 ? (
<EmptyState
title={t('events.list.empty.title', 'Noch kein Event angelegt')}
description={t(
'events.list.empty.description',
'Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.'
)}
onCreate={() => navigate(adminPath('/events/new'))}
/>
) : (
<YStack space="$3">
{filteredRows.map((event) => (
<EventCard
key={event.id}
event={event}
translate={translateManagement}
translateCommon={translateCommon}
/>
))}
</YStack>
)}
</YStack>
<BottomNav
active="events"
onNavigate={(key) => {
if (key === 'analytics') {
navigate(adminPath('/dashboard'));
} else if (key === 'settings') {
navigate(adminPath('/settings'));
} else {
navigate(adminPath('/events'));
}
}}
/>
</AdminLayout>
);
}
function EventCard({
event,
translate,
translateCommon,
}: {
event: TenantEvent;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
translateCommon: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const slug = event.slug;
const isPublished = event.status === 'published';
const photoCount = event.photo_count ?? 0;
const likeCount = event.like_count ?? 0;
const limitWarnings = React.useMemo(
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
[event.limits, translateCommon],
);
const statusLabel = translateCommon(
event.status === 'published'
? 'events.status.published'
: event.status === 'archived'
? 'events.status.archived'
: 'events.status.draft',
event.status === 'published' ? 'Live' : event.status === 'archived' ? 'Archiviert' : 'Entwurf',
);
const metaItems = [
{
key: 'date',
label: translate('events.list.meta.date', 'Eventdatum'),
value: formatDate(event.event_date),
icon: <CalendarDays className="h-4 w-4 text-rose-500" />,
},
{
key: 'photos',
label: translate('events.list.meta.photos', 'Uploads'),
value: photoCount,
icon: <Camera className="h-4 w-4 text-fuchsia-500" />,
},
{
key: 'likes',
label: translate('events.list.meta.likes', 'Likes'),
value: likeCount,
icon: <Heart className="h-4 w-4 text-amber-500" />,
},
];
const secondaryLinks = [
{ key: 'edit', label: translateCommon('actions.edit', 'Bearbeiten'), to: ADMIN_EVENT_EDIT_PATH(slug) },
{ key: 'members', label: translate('events.list.actions.members', 'Mitglieder'), to: ADMIN_EVENT_MEMBERS_PATH(slug) },
{ 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: 'branding', label: translate('events.list.actions.branding', 'Branding'), to: ADMIN_EVENT_BRANDING_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_VIEW_PATH(slug) },
];
return (
<AppCard>
<XStack justifyContent="space-between" alignItems="flex-start" space="$3">
<YStack space="$1">
<Text fontSize="$xs" letterSpacing={2.6} textTransform="uppercase" color="$color">
{translate('events.list.item.label', 'Event')}
</Text>
<Text fontSize="$lg" fontWeight="700" color="$color">
{renderName(event.name)}
</Text>
<MetaRow date={formatDate(event.event_date)} location={resolveLocation(event, translate)} status={statusLabel} />
</YStack>
<StatusPill tone={isPublished ? 'success' : 'warning'}>{statusLabel}</StatusPill>
</XStack>
<XStack space="$2" flexWrap="wrap">
{metaItems.map((item) => (
<MetaChip key={item.key} icon={item.icon} label={item.label} value={item.value} />
))}
</XStack>
{limitWarnings.length > 0 ? (
<YStack space="$2">
{limitWarnings.map((warning) => (
<XStack
key={warning.id}
space="$2"
alignItems="flex-start"
borderWidth={1}
borderRadius="$tile"
padding="$3"
backgroundColor={warning.tone === 'danger' ? '#fff1f2' : '#fffbeb'}
borderColor={warning.tone === 'danger' ? '#fecdd3' : '#fef3c7'}
>
<AlertTriangle className="h-4 w-4" />
<Text fontSize="$xs" color="$color">
{warning.message}
</Text>
</XStack>
))}
</YStack>
) : null}
<XStack space="$2">
<Link
to={ADMIN_EVENT_VIEW_PATH(slug)}
className="flex-1 rounded-xl bg-[#007AFF] px-4 py-3 text-center text-sm font-semibold text-white shadow-sm transition hover:opacity-90"
>
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="inline h-4 w-4" />
</Link>
<Link
to={ADMIN_EVENT_PHOTOS_PATH(slug)}
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-center text-sm font-semibold text-[#007AFF] transition hover:bg-slate-50"
>
{translate('events.list.actions.photos', 'Fotos moderieren')}
</Link>
</XStack>
<XStack flexWrap="wrap" space="$2">
{secondaryLinks.map((action) => (
<ActionChip key={action.key} to={action.to}>
{action.label}
</ActionChip>
))}
</XStack>
</AppCard>
);
}
function MetaChip({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
}) {
return (
<YStack borderWidth={1} borderColor="$muted" borderRadius="$tile" padding="$3" minWidth="45%">
<XStack alignItems="center" space="$2">
{icon}
<Text fontSize="$xs" color="$color">
{label}
</Text>
</XStack>
<Text fontSize="$md" fontWeight="700" color="$color" marginTop="$1">
{value}
</Text>
</YStack>
);
}
function ActionChip({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link to={to} className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">
{children}
</Link>
);
}
function LoadingState() {
return (
<YStack space="$2">
{Array.from({ length: 3 }).map((_, index) => (
<AppCard key={index} height={96} opacity={0.6} />
))}
</YStack>
);
}
function EmptyState({
title,
description,
onCreate,
}: {
title: string;
description: string;
onCreate: () => void;
}) {
return (
<AppCard alignItems="center" justifyContent="center" space="$3" borderStyle="dashed" borderColor="$muted">
<YStack bg="$muted" padding="$3" borderRadius="$pill">
<Plus className="h-5 w-5 text-[#007AFF]" />
</YStack>
<YStack space="$1" alignItems="center">
<Text fontSize="$lg" fontWeight="700" color="$color">
{title}
</Text>
<Text fontSize="$sm" color="$color" textAlign="center">
{description}
</Text>
</YStack>
<PrimaryCTA label="Event erstellen" onPress={onCreate} />
</AppCard>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'Unbekanntes Datum';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') return name;
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event';
}
return 'Unbenanntes Event';
}
function resolveLocation(
event: TenantEvent,
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string,
): string {
const settings = (event.settings ?? {}) as Record<string, unknown>;
const candidate =
(settings.location as string | undefined) ??
(settings.address as string | undefined) ??
(settings.city as string | undefined);
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
return translate('events.list.meta.locationFallback', 'Ort folgt');
}

View File

@@ -1,245 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, RefreshCcw } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { HelpCenterArticleSummary, HelpCenterArticle } from '../api';
import { fetchHelpCenterArticles, fetchHelpCenterArticle } from '../api';
function normalizeLocale(language: string | undefined): 'de' | 'en' {
const normalized = (language ?? 'de').toLowerCase().split('-')[0];
return normalized === 'en' ? 'en' : 'de';
}
export default function FaqPage() {
const { t, i18n } = useTranslation('dashboard');
const helpLocale = normalizeLocale(i18n.language);
const [query, setQuery] = React.useState('');
const [articles, setArticles] = React.useState<HelpCenterArticleSummary[]>([]);
const [listState, setListState] = React.useState<'loading' | 'ready' | 'error'>('loading');
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(null);
const [detailState, setDetailState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [articleCache, setArticleCache] = React.useState<Record<string, HelpCenterArticle>>({});
const loadArticles = React.useCallback(async () => {
setListState('loading');
try {
const data = await fetchHelpCenterArticles(helpLocale);
setArticles(data);
setListState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load list', error);
setListState('error');
}
}, [helpLocale]);
React.useEffect(() => {
setArticles([]);
setArticleCache({});
setSelectedSlug(null);
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (!selectedSlug && articles.length > 0) {
setSelectedSlug(articles[0].slug);
} else if (selectedSlug && !articles.some((article) => article.slug === selectedSlug)) {
setSelectedSlug(articles[0]?.slug ?? null);
}
}, [articles, selectedSlug]);
const loadArticle = React.useCallback(async (slug: string, options?: { bypassCache?: boolean }) => {
if (!slug) {
return;
}
const bypassCache = options?.bypassCache ?? false;
if (!bypassCache && articleCache[slug]) {
setDetailState('ready');
return;
}
setDetailState('loading');
try {
const article = await fetchHelpCenterArticle(slug, helpLocale);
setArticleCache((prev) => ({ ...prev, [slug]: article }));
setDetailState('ready');
} catch (error) {
console.error('[HelpCenter] Failed to load article', error);
setDetailState('error');
}
}, [articleCache, helpLocale]);
React.useEffect(() => {
if (selectedSlug) {
loadArticle(selectedSlug);
}
}, [selectedSlug, loadArticle]);
const filteredArticles = React.useMemo(() => {
if (!query.trim()) {
return articles;
}
const needle = query.trim().toLowerCase();
return articles.filter((article) => `${article.title} ${article.summary}`.toLowerCase().includes(needle));
}, [articles, query]);
const activeArticle = selectedSlug ? articleCache[selectedSlug] : null;
return (
<AdminLayout
title={t('helpCenter.title', 'Hilfe & Dokumentation')}
subtitle={t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}
>
<div className="grid gap-6 lg:grid-cols-[320px,1fr]">
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{t('helpCenter.title', 'Hilfe & Dokumentation')}</CardTitle>
<CardDescription>{t('helpCenter.subtitle', 'Aktuelle Playbooks für dein Team.')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
placeholder={t('helpCenter.search.placeholder', 'Suche nach Thema')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div>
{listState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading', 'Lädt...')}
</div>
)}
{listState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-3 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.list.error')}</p>
<Button variant="secondary" size="sm" onClick={loadArticles}>
<span className="flex items-center gap-2">
<RefreshCcw className="h-4 w-4" />
{t('helpCenter.list.retry')}
</span>
</Button>
</div>
)}
{listState === 'ready' && filteredArticles.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-200/80 p-4 text-sm text-muted-foreground dark:border-white/10">
{t('helpCenter.list.empty')}
</div>
)}
{listState === 'ready' && filteredArticles.length > 0 && (
<div className="space-y-2">
{filteredArticles.map((article) => {
const isActive = selectedSlug === article.slug;
return (
<button
key={article.slug}
type="button"
onClick={() => setSelectedSlug(article.slug)}
className={`w-full rounded-xl border p-3 text-left transition-colors ${
isActive
? 'border-sky-500 bg-sky-500/5 shadow-sm'
: 'border-slate-200/80 hover:border-sky-400/70'
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-slate-900 dark:text-white">{article.title}</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-300 line-clamp-2">{article.summary}</p>
</div>
{article.updated_at && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{t('helpCenter.list.updated', { date: formatDate(article.updated_at, i18n.language) })}
</span>
)}
</div>
</button>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-white/95 shadow-sm dark:border-white/10 dark:bg-white/5">
<CardHeader>
<CardTitle>{activeArticle?.title ?? t('helpCenter.article.placeholder')}</CardTitle>
<CardDescription className="text-sm text-slate-500 dark:text-slate-300">
{activeArticle?.summary ?? ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!selectedSlug && (
<p className="text-sm text-muted-foreground">{t('helpCenter.article.placeholder')}</p>
)}
{selectedSlug && detailState === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('helpCenter.article.loading')}
</div>
)}
{selectedSlug && detailState === 'error' && (
<div className="space-y-3 rounded-lg border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-900 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
<p>{t('helpCenter.article.error')}</p>
<Button size="sm" variant="secondary" onClick={() => loadArticle(selectedSlug, { bypassCache: true })}>
{t('helpCenter.list.retry')}
</Button>
</div>
)}
{detailState === 'ready' && activeArticle && (
<div className="space-y-6">
{activeArticle.updated_at && (
<Badge variant="outline" className="border-slate-200 text-xs font-normal text-slate-600 dark:border-white/20 dark:text-slate-300">
{t('helpCenter.article.updated', { date: formatDate(activeArticle.updated_at, i18n.language) })}
</Badge>
)}
<div
className="prose prose-sm max-w-none text-slate-700 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: activeArticle.body_html ?? activeArticle.body_markdown ?? '' }}
/>
{activeArticle.related && activeArticle.related.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
{t('helpCenter.article.related')}
</h3>
<div className="flex flex-wrap gap-2">
{activeArticle.related.map((rel) => (
<Button
key={rel.slug}
variant="outline"
size="sm"
onClick={() => setSelectedSlug(rel.slug)}
>
{rel.slug}
</Button>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</AdminLayout>
);
}
function formatDate(value: string, language: string | undefined): string {
try {
return new Date(value).toLocaleDateString(language ?? 'de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return value;
}
}

View File

@@ -1,37 +0,0 @@
// @ts-nocheck
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useEventContext } from '../context/EventContext';
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_PHOTOS_PATH } from '../constants';
export default function LiveRedirectPage(): React.ReactElement {
const navigate = useNavigate();
const { activeEvent, events, isLoading, refetch } = useEventContext();
React.useEffect(() => {
if (!isLoading && !events.length) {
void refetch();
}
}, [isLoading, events.length, refetch]);
React.useEffect(() => {
if (isLoading) {
return;
}
const targetEvent = activeEvent ?? events[0] ?? null;
if (targetEvent) {
navigate(ADMIN_EVENT_PHOTOS_PATH(targetEvent.slug), { replace: true });
} else {
navigate(ADMIN_EVENTS_PATH, { replace: true });
}
}, [isLoading, activeEvent, events, navigate]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Lade Events ...
</div>
);
}
return null;
}

View File

@@ -1,212 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { resolveReturnTarget } from '../lib/returnTo';
import { useMutation } from '@tanstack/react-query';
type LoginResponse = {
token: string;
token_type: string;
abilities: string[];
};
async function performLogin(payload: { login: string; password: string; return_to?: string | null }): Promise<LoginResponse> {
const response = await fetch('/api/v1/tenant-auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
...payload,
remember: true,
}),
});
if (response.status === 422) {
const data = await response.json();
const errors = data.errors ?? {};
const flattened = Object.values(errors).flat();
throw new Error(flattened.join(' ') || 'Validation failed');
}
if (!response.ok) {
throw new Error('Login failed.');
}
return (await response.json()) as LoginResponse;
}
export default function LoginPage(): React.ReactElement {
const { status, applyToken, abilities } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const computeDefaultAfterLogin = React.useCallback(
(abilityList?: string[]) => {
const source = abilityList ?? abilities;
return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH;
},
[abilities],
);
const fallbackTarget = computeDefaultAfterLogin();
const { finalTarget } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
[rawReturnTo, fallbackTarget]
);
React.useEffect(() => {
if (status === 'authenticated') {
navigate(finalTarget, { replace: true });
}
}, [finalTarget, navigate, status]);
const [login, setLogin] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const mutation = useMutation({
mutationKey: ['tenantAdminLogin'],
mutationFn: performLogin,
onError: (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
setError(message);
},
onSuccess: async (data) => {
setError(null);
await applyToken(data.token, data.abilities ?? []);
const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []);
const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback);
navigate(successTarget, { replace: true });
},
});
const isSubmitting = (mutation as { isPending?: boolean; isLoading?: boolean }).isPending
?? (mutation as { isPending?: boolean; isLoading?: boolean }).isLoading
?? false;
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
mutation.mutate({
login,
password,
return_to: rawReturnTo,
});
};
return (
<div className="relative min-h-svh overflow-hidden bg-slate-950 text-white">
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(255,137,170,0.35),_transparent_60%),radial-gradient(ellipse_at_bottom,_rgba(99,102,241,0.35),_transparent_55%)] blur-3xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-gray-950 via-gray-950/70 to-[#1a0f1f]" aria-hidden />
<div className="relative z-10 mx-auto flex w-full max-w-md flex-col gap-10 px-6 py-16">
<header className="flex flex-col items-center gap-3 text-center">
<span className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 shadow-lg shadow-black/20">
<img
src="/logo-transparent-md.png"
alt={t('login.brand_alt', 'Fotospiel Logo')}
className="h-12 w-12 object-contain"
loading="lazy"
/>
</span>
<h1 className="text-2xl font-semibold tracking-tight">
{t('login.panel_title', t('login.title', 'Team Login für Fotospiel'))}
</h1>
<p className="max-w-sm text-sm text-white/70">
{t(
'login.panel_copy',
'Melde dich an, um Events zu planen, Fotos zu moderieren und Aufgaben im Fotospiel-Team zu koordinieren.'
)}
</p>
</header>
<form onSubmit={handleSubmit} className="flex flex-col gap-5 rounded-3xl border border-white/10 bg-white/95 p-8 text-slate-900 shadow-2xl shadow-rose-400/10 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/95 dark:text-slate-50">
<div className="grid gap-2">
<Label htmlFor="login" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('login.username_or_email', 'E-Mail oder Benutzername')}
</Label>
<Input
id="login"
name="login"
type="text"
autoComplete="username"
required
value={login}
onChange={(event) => setLogin(event.target.value)}
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
placeholder={t('login.username_or_email_placeholder', 'name@example.com')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password" className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('login.password', 'Passwort')}
</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="h-12 rounded-xl border-slate-200/60 bg-white/90 px-4 text-base shadow-inner shadow-slate-200/40 focus-visible:ring-[#ff8bb1]/40 dark:border-slate-800/70 dark:bg-slate-900/70 dark:text-slate-100"
placeholder={t('login.password_placeholder', '••••••••')}
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('login.remember_hint', 'Wir halten dich automatisch angemeldet, solange du dieses Gerät nutzt.')}
</p>
</div>
{error ? (
<Alert variant="destructive" className="rounded-2xl border-rose-400/50 bg-rose-50/90 text-rose-700 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button
type="submit"
className={`mt-2 h-12 w-full justify-center rounded-xl bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-base font-semibold shadow-[0_18px_35px_-18px_rgba(236,72,153,0.7)] transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5] ${
isSubmitting ? 'cursor-wait opacity-90 saturate-75 shadow-none' : ''
}`}
disabled={isSubmitting}
aria-live="polite"
aria-busy={isSubmitting}
data-loading={isSubmitting ? 'true' : undefined}
>
<span className="flex w-full items-center justify-center gap-2">
<Loader2
className={`h-5 w-5 text-white transition-opacity ${isSubmitting ? 'animate-spin opacity-100' : 'opacity-0'}`}
aria-hidden={!isSubmitting}
/>
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
</span>
</Button>
</form>
<footer className="text-center text-xs text-white/50">
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
</footer>
</div>
</div>
);
}

View File

@@ -1,421 +0,0 @@
import React from 'react';
import { Loader2, ShieldCheck, ShieldX, Mail, User as UserIcon, Globe, Lock } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { AdminLayout } from '../components/AdminLayout';
import { useAuth } from '../auth/context';
import {
fetchTenantProfile,
updateTenantProfile,
type TenantAccountProfile,
type UpdateTenantProfilePayload,
} from '../api';
import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
type FieldErrors = Record<string, string>;
function extractFieldErrors(error: unknown): FieldErrors {
if (isApiError(error) && error.meta && typeof error.meta.errors === 'object') {
const entries = error.meta.errors as Record<string, unknown>;
const mapped: FieldErrors = {};
Object.entries(entries).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') {
mapped[key] = value[0];
}
});
return mapped;
}
return {};
}
const DEFAULT_LOCALES = ['de', 'en'];
const AUTO_LOCALE_OPTION = '__auto__';
export default function ProfilePage() {
const { t } = useTranslation(['settings', 'common']);
const { refreshProfile } = useAuth();
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [loading, setLoading] = React.useState(true);
const [infoForm, setInfoForm] = React.useState({
name: '',
email: '',
preferred_locale: '',
});
const [passwordForm, setPasswordForm] = React.useState({
current_password: '',
password: '',
password_confirmation: '',
});
const [infoErrors, setInfoErrors] = React.useState<FieldErrors>({});
const [passwordErrors, setPasswordErrors] = React.useState<FieldErrors>({});
const [savingInfo, setSavingInfo] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const availableLocales = React.useMemo(() => {
const candidates = new Set(DEFAULT_LOCALES);
if (typeof document !== 'undefined') {
const lang = document.documentElement.lang;
if (lang) {
const short = lang.toLowerCase().split('-')[0];
candidates.add(short);
}
}
if (profile?.preferred_locale) {
candidates.add(profile.preferred_locale.toLowerCase());
}
return Array.from(candidates).sort();
}, [profile?.preferred_locale]);
const selectedLocale = infoForm.preferred_locale && infoForm.preferred_locale !== '' ? infoForm.preferred_locale : AUTO_LOCALE_OPTION;
React.useEffect(() => {
let cancelled = false;
async function loadProfile(): Promise<void> {
setLoading(true);
try {
const data = await fetchTenantProfile();
if (cancelled) {
return;
}
setProfile(data);
setInfoForm({
name: data.name ?? '',
email: data.email ?? '',
preferred_locale: data.preferred_locale ?? '',
});
} catch (error) {
if (!cancelled) {
toast.error(getApiErrorMessage(error, t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadProfile();
return () => {
cancelled = true;
};
}, [t]);
const handleInfoSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setInfoErrors({});
setSavingInfo(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.updated', 'Profil wurde aktualisiert.'));
setInfoForm({
name: updated.name ?? '',
email: updated.email ?? '',
preferred_locale: updated.preferred_locale ?? '',
});
setPasswordForm((prev) => ({ ...prev, current_password: '' }));
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setInfoErrors(fieldErrors);
}
} finally {
setSavingInfo(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, refreshProfile, t]
);
const handlePasswordSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPasswordErrors({});
setSavingPassword(true);
const payload: UpdateTenantProfilePayload = {
name: infoForm.name,
email: infoForm.email,
preferred_locale: infoForm.preferred_locale || null,
current_password: passwordForm.current_password || undefined,
password: passwordForm.password || undefined,
password_confirmation: passwordForm.password_confirmation || undefined,
};
try {
const updated = await updateTenantProfile(payload);
setProfile(updated);
toast.success(t('settings:profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
await refreshProfile();
} catch (error) {
const message = getApiErrorMessage(error, t('settings:profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
toast.error(message);
const fieldErrors = extractFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
setPasswordErrors(fieldErrors);
}
} finally {
setSavingPassword(false);
}
},
[infoForm.email, infoForm.name, infoForm.preferred_locale, passwordForm, refreshProfile, t]
);
if (loading) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="flex min-h-[320px] items-center justify-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
{t('common:loading', 'Wird geladen …')}
</div>
</div>
</AdminLayout>
);
}
if (!profile) {
return (
<AdminLayout title={t('settings:profile.title', 'Profil')} subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}>
<div className="rounded-3xl border border-rose-200/60 bg-rose-50/70 p-8 text-center text-sm text-rose-600">
{t('settings:profile.errors.load', 'Profil konnte nicht geladen werden.')}
</div>
</AdminLayout>
);
}
return (
<AdminLayout
title={t('settings:profile.title', 'Profil')}
subtitle={t('settings:profile.subtitle', 'Verwalte Accountangaben und Zugangsdaten.')}
>
<Card className="border-0 bg-white/90 shadow-xl shadow-rose-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<UserIcon className="h-5 w-5 text-rose-500" />
{t('settings:profile.sections.account.heading', 'Account-Informationen')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.account.description', 'Passe Name, E-Mail und Sprache deiner Admin-Oberfläche an.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInfoSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-name" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<UserIcon className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.name', 'Anzeigename')}
</Label>
<Input
id="profile-name"
value={infoForm.name}
onChange={(event) => setInfoForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('settings:profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
/>
{infoErrors.name && <p className="text-sm text-rose-500">{infoErrors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-email" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Mail className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.email', 'E-Mail-Adresse')}
</Label>
<Input
id="profile-email"
type="email"
value={infoForm.email}
onChange={(event) => setInfoForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="admin@example.com"
/>
{infoErrors.email && <p className="text-sm text-rose-500">{infoErrors.email}</p>}
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="profile-locale" className="flex items-center gap-2 text-sm font-semibold text-slate-800">
<Globe className="h-4 w-4 text-rose-500" />
{t('settings:profile.fields.locale', 'Bevorzugte Sprache')}
</Label>
<Select
value={selectedLocale}
onValueChange={(value) =>
setInfoForm((prev) => ({ ...prev, preferred_locale: value === AUTO_LOCALE_OPTION ? '' : value }))
}
>
<SelectTrigger id="profile-locale" aria-label={t('settings:profile.fields.locale', 'Bevorzugte Sprache')}>
<SelectValue placeholder={t('settings:profile.placeholders.locale', 'Systemsprache verwenden')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_LOCALE_OPTION}>{t('settings:profile.locale.auto', 'Automatisch')}</SelectItem>
{availableLocales.map((locale) => (
<SelectItem key={locale} value={locale} className="capitalize">
{locale}
</SelectItem>
))}
</SelectContent>
</Select>
{infoErrors.preferred_locale && <p className="text-sm text-rose-500">{infoErrors.preferred_locale}</p>}
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-semibold text-slate-800">
{profile.email_verified ? (
<>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
{t('settings:profile.status.emailVerified', 'E-Mail bestätigt')}
</>
) : (
<>
<ShieldX className="h-4 w-4 text-rose-500" />
{t('settings:profile.status.emailNotVerified', 'Bestätigung erforderlich')}
</>
)}
</Label>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-3 text-sm text-slate-600">
{profile.email_verified
? t('settings:profile.status.verifiedHint', 'Bestätigt am {{date}}.', {
date: profile.email_verified_at ? new Date(profile.email_verified_at).toLocaleString() : '',
})
: t('settings:profile.status.unverifiedHint', 'Wir senden dir eine neue Bestätigung, sobald du die E-Mail änderst.')}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={savingInfo} className="flex items-center gap-2">
{savingInfo && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.save', 'Speichern')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
if (!profile) {
return;
}
setInfoForm({
name: profile.name ?? '',
email: profile.email ?? '',
preferred_locale: profile.preferred_locale ?? '',
});
setInfoErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-indigo-100/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Lock className="h-5 w-5 text-indigo-500" />
{t('settings:profile.sections.password.heading', 'Passwort ändern')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('settings:profile.sections.password.description', 'Wähle ein sicheres Passwort, um dein Admin-Konto zu schützen.')}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="profile-current-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.currentPassword', 'Aktuelles Passwort')}
</Label>
<Input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={passwordForm.current_password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, current_password: event.target.value }))}
/>
{passwordErrors.current_password && <p className="text-sm text-rose-500">{passwordErrors.current_password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-new-password" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.newPassword', 'Neues Passwort')}
</Label>
<Input
id="profile-new-password"
type="password"
autoComplete="new-password"
value={passwordForm.password}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password: event.target.value }))}
/>
{passwordErrors.password && <p className="text-sm text-rose-500">{passwordErrors.password}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="profile-password-confirmation" className="text-sm font-semibold text-slate-800">
{t('settings:profile.fields.passwordConfirmation', 'Passwort bestätigen')}
</Label>
<Input
id="profile-password-confirmation"
type="password"
autoComplete="new-password"
value={passwordForm.password_confirmation}
onChange={(event) => setPasswordForm((prev) => ({ ...prev, password_confirmation: event.target.value }))}
/>
{passwordErrors.password_confirmation && <p className="text-sm text-rose-500">{passwordErrors.password_confirmation}</p>}
</div>
</div>
<div className="rounded-2xl border border-slate-200/70 bg-slate-50/70 p-4 text-xs text-slate-600">
{t('settings:profile.sections.password.hint', 'Dein Passwort muss mindestens 8 Zeichen lang sein und eine Mischung aus Buchstaben und Zahlen enthalten.')}
</div>
<div className="flex flex-wrap gap-3">
<Button type="submit" variant="secondary" disabled={savingPassword} className="flex items-center gap-2">
{savingPassword && <Loader2 className="h-4 w-4 animate-spin" />}
{t('settings:profile.actions.updatePassword', 'Passwort aktualisieren')}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setPasswordForm({ current_password: '', password: '', password_confirmation: '' });
setPasswordErrors({});
}}
>
{t('common:actions.reset', 'Zurücksetzen')}
</Button>
</div>
</form>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -1,407 +0,0 @@
import React from 'react';
import { AlertTriangle, Loader2, Lock, LogOut, Mail, Moon, ShieldCheck, SunMedium, UserCog } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { AdminLayout } from '../components/AdminLayout';
import {
TenantHeroCard,
FrostedSurface,
tenantHeroPrimaryButtonClass,
tenantHeroSecondaryButtonClass,
SectionCard,
SectionHeader,
} from '../components/tenant';
import { useAuth } from '../auth/context';
import { ADMIN_EVENTS_PATH, ADMIN_LOGIN_PATH, ADMIN_PROFILE_PATH } from '../constants';
import { encodeReturnTo } from '../lib/returnTo';
import {
getNotificationPreferences,
updateNotificationPreferences,
NotificationPreferences,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { useTranslation } from 'react-i18next';
export default function SettingsPage() {
const navigate = useNavigate();
const { user, logout } = useAuth();
const { t } = useTranslation('management');
const [preferences, setPreferences] = React.useState<NotificationPreferences | null>(null);
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
const [loadingNotifications, setLoadingNotifications] = React.useState(true);
const [savingNotifications, setSavingNotifications] = React.useState(false);
const [notificationError, setNotificationError] = React.useState<string | null>(null);
const heroDescription = t('settings.hero.description', { defaultValue: 'Gestalte das Erlebnis für dein Admin-Team Darstellung, Benachrichtigungen und Session-Sicherheit.' });
const heroSupporting = [
t('settings.hero.summary.appearance', { defaultValue: 'Synchronisiere den Look & Feel mit dem Gästeportal oder schalte den Dark Mode frei.' }),
t('settings.hero.summary.notifications', { defaultValue: 'Stimme Benachrichtigungen auf Aufgaben, Pakete und Live-Events ab.' })
];
const accountName = user?.name ?? user?.email ?? 'Customer Admin';
const heroPrimaryAction = (
<Button
size="sm"
className={tenantHeroPrimaryButtonClass}
onClick={() => navigate(ADMIN_PROFILE_PATH)}
>
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
</Button>
);
const heroSecondaryAction = (
<Button
size="sm"
className={tenantHeroSecondaryButtonClass}
onClick={() => navigate(ADMIN_EVENTS_PATH)}
>
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
</Button>
);
const heroAside = (
<FrostedSurface className="space-y-3 border-slate-200 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">{t('settings.hero.accountLabel', { defaultValue: 'Angemeldeter Account' })}</p>
<p className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{accountName}</p>
{user?.tenant_id ? (
<p className="text-xs text-slate-600 dark:text-slate-400">Tenant #{user.tenant_id}</p>
) : null}
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{t('settings.hero.support', { defaultValue: 'Passe Einstellungen für dich und dein Team an Änderungen wirken sofort im Admin.' })}</p>
</FrostedSurface>
);
const translateNotification = React.useCallback(
(key: string, fallback?: string, options?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(options ?? {}) }),
[t],
);
function handleLogout() {
const target = new URL(ADMIN_LOGIN_PATH, window.location.origin);
target.searchParams.set('reset-auth', '1');
target.searchParams.set('return_to', encodeReturnTo(ADMIN_EVENTS_PATH));
logout({ redirect: `${target.pathname}${target.search}` });
}
React.useEffect(() => {
(async () => {
try {
const result = await getNotificationPreferences();
setPreferences(result.preferences);
setDefaults(result.defaults);
} catch (error) {
setNotificationError(getApiErrorMessage(error, t('settings.notifications.errorLoad', 'Benachrichtigungseinstellungen konnten nicht geladen werden.')));
} finally {
setLoadingNotifications(false);
}
})();
}, [t]);
return (
<AdminLayout
title="Einstellungen"
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
>
<TenantHeroCard
badge={t('settings.hero.badge', { defaultValue: 'Administration' })}
title={t('settings.title', { defaultValue: 'Einstellungen' })}
description={heroDescription}
supporting={heroSupporting}
primaryAction={heroPrimaryAction}
secondaryAction={heroSecondaryAction}
aside={heroAside}
/>
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-6">
<SectionCard className="space-y-6">
<SectionHeader
eyebrow={t('settings.appearance.badge', 'Darstellung')}
title={t('settings.appearance.title', 'Darstellung & Branding')}
description={t('settings.appearance.description', 'Passe den Admin an eure Markenfarben oder synchronisiere das System-Theme.')}
/>
<div className="grid gap-3 md:grid-cols-2">
<FrostedSurface className="flex items-start gap-3 border border-white/20 bg-white/70 p-4 text-slate-900 shadow-sm">
<SunMedium className="mt-0.5 h-5 w-5 text-amber-500" />
<div>
<p className="text-sm font-semibold">{t('settings.appearance.lightTitle', 'Heller Modus')}</p>
<p className="text-xs text-slate-600">{t('settings.appearance.lightCopy', 'Perfekt für Büros und klare Kontraste.')}</p>
</div>
</FrostedSurface>
<FrostedSurface className="flex items-start gap-3 border border-white/20 bg-slate-900/80 p-4 text-white shadow-sm">
<Moon className="mt-0.5 h-5 w-5 text-indigo-200" />
<div>
<p className="text-sm font-semibold">{t('settings.appearance.darkTitle', 'Dunkler Modus')}</p>
<p className="text-xs text-slate-200">{t('settings.appearance.darkCopy', 'Schonend für Nachtproduktionen oder OLED-Displays.')}</p>
</div>
</FrostedSurface>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900 dark:text-white">{t('settings.appearance.themeLabel', 'Theme wählen')}</p>
<p className="text-xs text-slate-600 dark:text-slate-400">
{t('settings.appearance.themeHint', 'Nutze automatische Anpassung oder überschreibe das Theme manuell.')}
</p>
</div>
<AppearanceToggleDropdown />
</div>
</div>
</SectionCard>
<SectionCard className="space-y-6">
<SectionHeader
eyebrow={t('settings.session.badge', 'Account & Sicherheit')}
title={t('settings.session.title', 'Angemeldeter Account')}
description={t('settings.session.description', 'Verwalte deine Sitzung oder wechsel schnell zu deinem Profil.')}
/>
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-inner dark:border-slate-700 dark:bg-slate-900/40">
<p className="text-sm text-slate-700 dark:text-slate-200">
{user ? (
<>
{t('settings.session.loggedInAs', 'Eingeloggt als')} <span className="font-semibold text-slate-900 dark:text-white">{user.name ?? user.email ?? 'Customer Admin'}</span>
{user.tenant_id ? <span className="text-xs text-slate-500 dark:text-slate-400"> Tenant #{user.tenant_id}</span> : null}
</>
) : (
t('settings.session.unknown', 'Aktuell kein Benutzer geladen.')
)}
</p>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
<Badge variant="outline" className="border-emerald-200 text-emerald-700">
<ShieldCheck className="mr-1 h-3 w-3" /> {t('settings.session.security', 'SSO & 2FA aktivierbar')}
</Badge>
<Badge variant="outline" className="border-slate-200 text-slate-600">
<Lock className="mr-1 h-3 w-3" /> {t('settings.session.session', 'Session 12h gültig')}
</Badge>
</div>
</div>
<Alert className="border-amber-200 bg-amber-50 text-amber-900">
<AlertDescription className="flex items-center gap-2 text-xs">
<AlertTriangle className="h-4 w-4" />
{t('settings.session.hint', 'Bei Gerätewechsel solltest du dich kurz ab- und wieder anmelden, um Berechtigungen zu synchronisieren.')}
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-3">
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
<LogOut className="h-4 w-4" /> {t('settings.session.logout', 'Abmelden')}
</Button>
<Button variant="secondary" onClick={() => navigate(ADMIN_PROFILE_PATH)} className="flex items-center gap-2">
<UserCog className="h-4 w-4" /> {t('settings.profile.actions.openProfile', 'Profil bearbeiten')}
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
{t('settings.session.cancel', 'Zurück')}
</Button>
</div>
</SectionCard>
<SectionCard className="space-y-6">
<SectionHeader
eyebrow={t('settings.notifications.badge', 'Benachrichtigungen')}
title={t('settings.notifications.title', 'Benachrichtigungen')}
description={t('settings.notifications.description', 'Lege fest, für welche Ereignisse wir dich per E-Mail informieren.')}
/>
{notificationError ? (
<Alert variant="destructive">
<AlertDescription>{notificationError}</AlertDescription>
</Alert>
) : null}
{loadingNotifications ? (
<NotificationSkeleton />
) : preferences ? (
<NotificationPreferencesForm
preferences={preferences}
defaults={defaults}
onChange={(next) => setPreferences(next)}
onReset={() => setPreferences(defaults)}
onSave={async () => {
if (!preferences) {
return;
}
try {
setSavingNotifications(true);
const updated = await updateNotificationPreferences(preferences);
setPreferences(updated.preferences);
if (updated.defaults && Object.keys(updated.defaults).length > 0) {
setDefaults(updated.defaults);
}
setNotificationError(null);
} catch (error) {
setNotificationError(
getApiErrorMessage(error, t('settings.notifications.errorSave', 'Speichern fehlgeschlagen. Bitte versuche es erneut.')),
);
} finally {
setSavingNotifications(false);
}
}}
saving={savingNotifications}
translate={translateNotification}
/>
) : null}
</SectionCard>
</div>
<div className="space-y-6">
<SupportCard />
</div>
</div>
</AdminLayout>
);
}
function NotificationPreferencesForm({
preferences,
defaults,
onChange,
onReset,
onSave,
saving,
translate,
}: {
preferences: NotificationPreferences;
defaults: NotificationPreferences;
onChange: (next: NotificationPreferences) => void;
onReset: () => void;
onSave: () => Promise<void>;
saving: boolean;
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
}) {
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
return (
<div className="relative space-y-4 pb-16">
<div className="space-y-3">
{items.map((item) => {
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
return (
<FrostedSurface key={item.key} className="flex items-start justify-between gap-4 border border-slate-200 p-4 text-slate-900 shadow-sm shadow-rose-200/20 dark:border-pink-500/20 dark:bg-white/10">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{item.label}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={(value) => onChange({ ...preferences, [item.key]: Boolean(value) })}
/>
</FrostedSurface>
);
})}
</div>
<div className="sticky bottom-4 z-[1] flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-lg shadow-rose-200/40 backdrop-blur dark:border-slate-700 dark:bg-slate-900/80">
<Button onClick={onSave} disabled={saving} className="flex items-center gap-2 bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-md shadow-rose-400/30">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{saving ? translate('settings.notifications.actions.save', 'Speichern') : translate('settings.notifications.actions.save', 'Speichern')}
</Button>
<Button variant="ghost" onClick={onReset} disabled={saving}>
{translate('settings.notifications.actions.reset', 'Auf Standard setzen')}
</Button>
<span className="text-xs text-slate-500 dark:text-slate-400">
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
</span>
</div>
</div>
);
}
function buildPreferenceMeta(
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
const map = [
{
key: 'photo_thresholds',
label: translate('settings.notifications.items.photoThresholds.label', 'Warnung bei Foto-Schwellen'),
description: translate('settings.notifications.items.photoThresholds.description', 'Sende Warnungen bei 80 % und 95 % Foto-Auslastung.'),
},
{
key: 'photo_limits',
label: translate('settings.notifications.items.photoLimits.label', 'Sperre bei Foto-Limit'),
description: translate('settings.notifications.items.photoLimits.description', 'Informiere mich, sobald keine Foto-Uploads mehr möglich sind.'),
},
{
key: 'guest_thresholds',
label: translate('settings.notifications.items.guestThresholds.label', 'Warnung bei Gästekontingent'),
description: translate('settings.notifications.items.guestThresholds.description', 'Warnung kurz bevor alle Gästelinks vergeben sind.'),
},
{
key: 'guest_limits',
label: translate('settings.notifications.items.guestLimits.label', 'Sperre bei Gästelimit'),
description: translate('settings.notifications.items.guestLimits.description', 'Hinweis, wenn keine neuen Gästelinks mehr erzeugt werden können.'),
},
{
key: 'gallery_warnings',
label: translate('settings.notifications.items.galleryWarnings.label', 'Galerie läuft bald ab'),
description: translate('settings.notifications.items.galleryWarnings.description', 'Erhalte 7 und 1 Tag vor Ablauf eine Erinnerung.'),
},
{
key: 'gallery_expired',
label: translate('settings.notifications.items.galleryExpired.label', 'Galerie ist abgelaufen'),
description: translate('settings.notifications.items.galleryExpired.description', 'Informiere mich, sobald Gäste die Galerie nicht mehr sehen können.'),
},
{
key: 'event_thresholds',
label: translate('settings.notifications.items.eventThresholds.label', 'Warnung bei Event-Kontingent'),
description: translate('settings.notifications.items.eventThresholds.description', 'Hinweis, wenn das Reseller-Paket fast ausgeschöpft ist.'),
},
{
key: 'event_limits',
label: translate('settings.notifications.items.eventLimits.label', 'Sperre bei Event-Kontingent'),
description: translate('settings.notifications.items.eventLimits.description', 'Nachricht, sobald keine weiteren Events erstellt werden können.'),
},
{
key: 'package_expiring',
label: translate('settings.notifications.items.packageExpiring.label', 'Paket läuft bald ab'),
description: translate('settings.notifications.items.packageExpiring.description', 'Erinnerungen bei 30, 7 und 1 Tag vor Paketablauf.'),
},
{
key: 'package_expired',
label: translate('settings.notifications.items.packageExpired.label', 'Paket ist abgelaufen'),
description: translate('settings.notifications.items.packageExpired.description', 'Benachrichtige mich, wenn das Paket abgelaufen ist.'),
},
];
return map as Array<{ key: keyof NotificationPreferences; label: string; description: string }>;
}
function NotificationSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<FrostedSurface
key={`notification-skeleton-${index}`}
className="h-14 animate-pulse border border-white/20 bg-gradient-to-r from-white/30 via-white/60 to-white/30 shadow-inner dark:border-slate-800/60 dark:from-slate-800 dark:via-slate-700 dark:to-slate-800"
/>
))}
</div>
);
}
function SupportCard() {
const { t } = useTranslation('management');
return (
<Card className="border border-slate-200 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
<CardContent className="space-y-4 p-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('settings.support.badge', 'Hilfe & Support')}</p>
<p className="mt-1 text-base font-semibold text-slate-900 dark:text-white">{t('settings.support.title', 'Team informieren')}</p>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('settings.support.copy', 'Benötigst du sofortige Hilfe? Unser Support reagiert in der Regel innerhalb weniger Stunden.')}</p>
</div>
<Button
variant="secondary"
className="w-full"
onClick={() => {
window.location.href = 'mailto:support@fotospiel.app';
}}
>
<Mail className="mr-2 h-4 w-4" /> {t('settings.support.cta', 'Support kontaktieren')}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -1,445 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import type { Locale } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import { Layers, Library, Loader2, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AdminLayout } from '../components/AdminLayout';
import {
getTaskCollections,
importTaskCollection,
getEvents,
PaginationMeta,
TenantEvent,
TenantTaskCollection,
} from '../api';
import { buildEngagementTabPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import toast from 'react-hot-toast';
const DEFAULT_PAGE_SIZE = 12;
type ScopeFilter = 'all' | 'global' | 'tenant';
type CollectionsState = {
items: TenantTaskCollection[];
meta: PaginationMeta | null;
};
export type TaskCollectionsSectionProps = {
embedded?: boolean;
onNavigateToTasks?: () => void;
};
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps) {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const [collectionsState, setCollectionsState] = React.useState<CollectionsState>({ items: [], meta: null });
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState('');
const [scope, setScope] = React.useState<ScopeFilter>('all');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [selectedEventSlug, setSelectedEventSlug] = React.useState('');
const [importing, setImporting] = React.useState(false);
const [eventsLoading, setEventsLoading] = React.useState(false);
const [eventError, setEventError] = React.useState<string | null>(null);
const [reloadToken, setReloadToken] = React.useState(0);
const scopeParam = React.useMemo(() => {
if (scope === 'global') return 'global';
if (scope === 'tenant') return 'tenant';
return undefined;
}, [scope]);
React.useEffect(() => {
let cancelled = false;
async function loadCollections() {
setLoading(true);
setError(null);
try {
const result = await getTaskCollections({
page,
per_page: DEFAULT_PAGE_SIZE,
search: search.trim() || undefined,
scope: scopeParam,
});
if (cancelled) return;
setCollectionsState({ items: result.data, meta: result.meta });
} catch (err) {
if (cancelled) return;
if (!isAuthError(err)) {
setError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadCollections();
return () => {
cancelled = true;
};
}, [page, search, scopeParam, reloadToken, t]);
async function ensureEventsLoaded() {
if (events.length > 0 || eventsLoading) {
return;
}
setEventsLoading(true);
setEventError(null);
try {
const result = await getEvents();
setEvents(result);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.errors.eventsLoad'));
}
} finally {
setEventsLoading(false);
}
}
function openImportDialog(collection: TenantTaskCollection) {
setSelectedCollection(collection);
setSelectedEventSlug('');
setDialogOpen(true);
void ensureEventsLoaded();
}
async function handleImport(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedCollection || !selectedEventSlug) {
setEventError(t('collections.errors.selectEvent'));
return;
}
setImporting(true);
setEventError(null);
try {
await importTaskCollection(selectedCollection.id, selectedEventSlug);
toast.success(t('collections.notifications.imported'));
setDialogOpen(false);
setReloadToken((token) => token + 1);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.notifications.error'));
toast.error(t('collections.notifications.error'));
}
} finally {
setImporting(false);
}
}
const showEmpty = !loading && collectionsState.items.length === 0;
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = t('collections.title') ?? 'Task Collections';
const subtitle = embedded
? t('collections.subtitle') ?? ''
: t('collections.subtitle') ?? '';
const navigateToTasks = React.useCallback(() => {
if (onNavigateToTasks) {
onNavigateToTasks();
return;
}
navigate(buildEngagementTabPath('tasks'));
}, [navigate, onNavigateToTasks]);
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('collections.notifications.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Layers className="h-5 w-5 text-pink-500" />
{title}
</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={navigateToTasks}>
<Library className="mr-2 h-4 w-4" />
{t('collections.actions.openTasks')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => setScope('tenant')}
>
{t('collections.scope.tenant')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),220px]">
<Input
placeholder={t('collections.filters.search') ?? 'Nach Vorlagen suchen'}
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
/>
<Select value={scope} onValueChange={(value: ScopeFilter) => { setPage(1); setScope(value); }}>
<SelectTrigger>
<SelectValue placeholder={t('collections.filters.allScopes')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<CollectionsSkeleton />
) : showEmpty ? (
<EmptyCollectionsState onCreate={navigateToTasks} />
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{collectionsState.items.map((collection) => (
<TaskCollectionCard
key={collection.id}
collection={collection}
locale={locale}
onImport={() => openImportDialog(collection)}
onNavigateToTasks={navigateToTasks}
/>
))}
</div>
)}
{collectionsState.meta && collectionsState.meta.last_page > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<div className="text-slate-500">
{t('collections.pagination.page', {
current: collectionsState.meta.current_page ?? 1,
total: collectionsState.meta.last_page ?? 1,
})}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((value) => Math.max(value - 1, 1))}
disabled={(collectionsState.meta.current_page ?? 1) <= 1}
>
{t('collections.pagination.prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((value) => Math.min(value + 1, collectionsState.meta?.last_page ?? value + 1))}
disabled={(collectionsState.meta.current_page ?? 1) >= (collectionsState.meta.last_page ?? 1)}
>
{t('collections.pagination.next')}
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<ImportCollectionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
collection={selectedCollection}
events={events}
eventsLoading={eventsLoading}
selectedEventSlug={selectedEventSlug}
onSelectedEventChange={setSelectedEventSlug}
onSubmit={handleImport}
importing={importing}
error={eventError}
locale={locale}
/>
</div>
);
}
export default function TaskCollectionsPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
return (
<AdminLayout
title={t('collections.title') ?? 'Task Collections'}
subtitle={t('collections.subtitle') ?? ''}
>
<TaskCollectionsSection onNavigateToTasks={() => navigate(buildEngagementTabPath('tasks'))} />
</AdminLayout>
);
}
function TaskCollectionCard({
collection,
locale,
onImport,
onNavigateToTasks,
}: {
collection: TenantTaskCollection;
locale: Locale;
onImport: () => void;
onNavigateToTasks: () => void;
}) {
const { t } = useTranslation('management');
const updatedAt = collection.updated_at ? format(new Date(collection.updated_at), 'Pp', { locale }) : null;
return (
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
<CardHeader>
<CardTitle className="text-base text-slate-900">{collection.name}</CardTitle>
{collection.description ? (
<CardDescription className="text-xs text-slate-500">{collection.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-600">
<div className="flex flex-wrap gap-2">
<Badge variant={collection.is_global ? 'secondary' : 'default'}>
{collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant')}
</Badge>
<Badge variant="outline">{t('collections.labels.taskCount', { count: collection.tasks_count })}</Badge>
{collection.event_type ? (
<Badge variant="outline">{collection.event_type.name}</Badge>
) : null}
</div>
{updatedAt ? (
<p className="text-xs text-slate-400">{t('collections.labels.updated', { date: updatedAt })}</p>
) : null}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white" onClick={onImport}>
<Plus className="mr-1 h-4 w-4" />
{t('collections.actions.import')}
</Button>
<Button variant="outline" onClick={onNavigateToTasks}>
{t('collections.actions.openTasks')}
</Button>
</CardFooter>
</Card>
);
}
function ImportCollectionDialog({
open,
onOpenChange,
collection,
events,
eventsLoading,
selectedEventSlug,
onSelectedEventChange,
onSubmit,
importing,
error,
locale,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
collection: TenantTaskCollection | null;
events: TenantEvent[];
eventsLoading: boolean;
selectedEventSlug: string;
onSelectedEventChange: (slug: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
importing: boolean;
error: string | null;
locale: Locale;
}) {
const { t } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('collections.dialogs.importTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1">
<Label>{t('collections.dialogs.collectionLabel')}</Label>
<p className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
{collection?.name ?? 'Unbekannte Sammlung'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="collection-event">{t('collections.dialogs.selectEvent')}</Label>
<Select value={selectedEventSlug} onValueChange={onSelectedEventChange} disabled={eventsLoading || !events.length}>
<SelectTrigger id="collection-event">
<SelectValue placeholder={eventsLoading ? t('collections.errors.eventsLoad') : t('collections.dialogs.selectEvent')} />
</SelectTrigger>
<SelectContent>
{events.map((event) => {
const eventDate = event.event_date ? format(new Date(event.event_date), 'PPP', { locale }) : null;
return (
<SelectItem key={event.slug} value={event.slug}>
{event.name && typeof event.name === 'string' ? event.name : event.slug}
{eventDate ? ` · ${eventDate}` : ''}
</SelectItem>
);
})}
</SelectContent>
</Select>
{error ? <p className="text-xs text-rose-600">{error}</p> : null}
</div>
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('collections.dialogs.cancel')}
</Button>
<Button type="submit" disabled={importing || !selectedEventSlug}>
{importing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('collections.dialogs.submit')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CollectionsSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={`collection-skeleton-${index}`} className="h-40 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('collections.empty.title')}</h3>
<p className="text-sm text-slate-500">{t('collections.empty.description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
);
}

View File

@@ -1,552 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Circle, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { AdminLayout } from '../components/AdminLayout';
import {
createTask,
deleteTask,
getTasks,
getEmotions,
PaginationMeta,
TenantTask,
TenantEmotion,
TaskPayload,
updateTask,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { buildEngagementTabPath } from '../constants';
import { filterEmotionsByEventType } from '../lib/emotions';
type TaskFormState = {
title: string;
description: string;
priority: TaskPayload['priority'];
due_date: string;
is_completed: boolean;
};
const INITIAL_FORM: TaskFormState = {
title: '',
description: '',
priority: 'medium',
due_date: '',
is_completed: false,
};
export type TasksSectionProps = {
embedded?: boolean;
onNavigateToCollections?: () => void;
};
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
const navigate = useNavigate();
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
const { t: tc } = useTranslation('common');
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState('');
const [eventTypeFilter, setEventTypeFilter] = React.useState<string>('all');
const [ownershipFilter, setOwnershipFilter] = React.useState<'all' | 'custom' | 'global'>('all');
const [emotionFilter, setEmotionFilter] = React.useState<number | null>(null);
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editingTask, setEditingTask] = React.useState<TenantTask | null>(null);
const [form, setForm] = React.useState<TaskFormState>(INITIAL_FORM);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getTasks({ page, per_page: 200, search: search.trim() || undefined })
.then((result) => {
if (cancelled) return;
setTasks(result.data);
setMeta(result.meta);
})
.catch((err) => {
if (!isAuthError(err)) {
setError(t('errors.load'));
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [page, search, t]);
React.useEffect(() => {
let cancelled = false;
getEmotions()
.then((list) => {
if (!cancelled) {
setEmotions(list);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
const eventTypeOptions = React.useMemo(() => {
const options = new Map<string, string>();
tasks.forEach((task) => {
const slug = task.event_type?.slug ?? 'none';
const name = task.event_type?.name ?? 'Allgemein';
options.set(slug, name);
});
return Array.from(options.entries());
}, [tasks]);
const filteredTasks = React.useMemo(() => {
return tasks.filter((task) => {
if (eventTypeFilter !== 'all') {
const slug = task.event_type?.slug ?? 'none';
if (slug !== eventTypeFilter) return false;
}
if (ownershipFilter === 'custom' && task.tenant_id === null) return false;
if (ownershipFilter === 'global' && task.tenant_id !== null) return false;
if (emotionFilter !== null) {
if (task.emotion_id !== emotionFilter) return false;
}
return true;
});
}, [tasks, eventTypeFilter, ownershipFilter, emotionFilter]);
const openCreate = React.useCallback(() => {
setEditingTask(null);
setForm(INITIAL_FORM);
setDialogOpen(true);
}, []);
const openEdit = React.useCallback((task: TenantTask) => {
setEditingTask(task);
setForm({
title: task.title,
description: task.description ?? '',
priority: task.priority ?? 'medium',
due_date: task.due_date ? task.due_date.slice(0, 10) : '',
is_completed: task.is_completed,
});
setDialogOpen(true);
}, []);
const handleNavigateToCollections = React.useCallback(() => {
if (onNavigateToCollections) {
onNavigateToCollections();
return;
}
navigate(buildEngagementTabPath('collections'));
}, [navigate, onNavigateToCollections]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form.title.trim()) {
setError('Bitte gib einen Titel ein.');
return;
}
setSaving(true);
setError(null);
const payload: TaskPayload = {
title: form.title.trim(),
description: form.description.trim() || null,
priority: form.priority ?? undefined,
due_date: form.due_date || undefined,
is_completed: form.is_completed,
};
try {
if (editingTask) {
const updated = await updateTask(editingTask.id, payload);
setTasks((prev) => prev.map((task) => (task.id === updated.id ? updated : task)));
} else {
const created = await createTask(payload);
setTasks((prev) => [created, ...prev]);
}
setDialogOpen(false);
} catch (err) {
if (!isAuthError(err)) {
setError('Task konnte nicht gespeichert werden.');
}
} finally {
setSaving(false);
}
}
async function handleDelete(taskId: number) {
if (!window.confirm('Task wirklich löschen?')) {
return;
}
try {
await deleteTask(taskId);
setTasks((prev) => prev.filter((task) => task.id !== taskId));
} catch (err) {
if (!isAuthError(err)) {
setError('Task konnte nicht gelöscht werden.');
}
}
}
async function toggleCompletion(task: TenantTask) {
if (task.tenant_id === null) {
return;
}
try {
const updated = await updateTask(task.id, { is_completed: !task.is_completed });
setTasks((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)));
} catch (err) {
if (!isAuthError(err)) {
setError('Status konnte nicht aktualisiert werden.');
}
}
}
const title = embedded ? t('titles.embedded') : t('titles.default');
const subtitle = embedded ? t('subtitles.embedded') : t('subtitles.default');
return (
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('errors.title')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{title}</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleNavigateToCollections}>
{tc('navigation.collections')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreate}
>
<Plus className="h-4 w-4" />
{t('actions.new')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder={t('actions.searchPlaceholder')}
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
className="max-w-sm"
/>
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700">{t('filters.eventType', 'Event-Typ')}</span>
<Select value={eventTypeFilter} onValueChange={(value) => setEventTypeFilter(value)}>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('filters.eventTypeAll', 'Alle')}</SelectItem>
{eventTypeOptions.map(([slug, name]) => (
<SelectItem key={slug} value={slug}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700">{t('filters.ownership', 'Quelle')}</span>
<div className="flex gap-1 rounded-lg border border-slate-200 p-1">
{(['all', 'custom', 'global'] as const).map((key) => (
<Button
key={key}
size="sm"
variant={ownershipFilter === key ? 'default' : 'ghost'}
className="h-8"
onClick={() => setOwnershipFilter(key)}
>
{key === 'all'
? t('filters.ownershipAll', 'Alle')
: key === 'custom'
? t('filters.ownershipCustom', 'Selbst angelegt')
: t('filters.ownershipGlobal', 'Standard')}
</Button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-700">{t('filters.emotion', 'Emotion')}</span>
<Select
value={emotionFilter ? String(emotionFilter) : 'all'}
onValueChange={(value) => setEmotionFilter(value === 'all' ? null : Number(value))}
>
<SelectTrigger className="h-8 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('filters.emotionAll', 'Alle')}</SelectItem>
{emotions.map((emotion) => (
<SelectItem key={emotion.id} value={String(emotion.id)}>
{emotion.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-xs text-slate-500">
{t('pagination.page', {
current: meta?.current_page ?? 1,
total: meta?.last_page ?? 1,
count: filteredTasks.length,
})}
</div>
</div>
</div>
{loading ? (
<TasksSkeleton />
) : tasks.length === 0 ? (
<EmptyState onCreate={openCreate} />
) : filteredTasks.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-6 text-sm text-slate-600">
{t('filters.noResults', 'Keine Aufgaben zu den Filtern gefunden.')}
</div>
) : (
<div className="grid gap-3">
{filteredTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={() => toggleCompletion(task)}
onEdit={() => openEdit(task)}
onDelete={() => handleDelete(task.id)}
/>
))}
</div>
)}
{meta && meta.last_page > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<div className="text-slate-500">
{t('pagination.summary', { count: meta.total, current: meta.current_page, total: meta.last_page })}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
{t('pagination.prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
disabled={meta.current_page >= (meta.last_page ?? 1)}
>
{t('pagination.next')}
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTask ? t('form.editTitle') : t('form.createTitle')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="task-title">{t('form.title')}</Label>
<Input
id="task-title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-description">{t('form.description')}</Label>
<Input
id="task-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
placeholder={t('form.descriptionPlaceholder')}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="task-priority">{t('form.priority')}</Label>
<Select
value={form.priority ?? 'medium'}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))
}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder={t('form.priorityPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priorities.low')}</SelectItem>
<SelectItem value="medium">{t('priorities.medium')}</SelectItem>
<SelectItem value="high">{t('priorities.high')}</SelectItem>
<SelectItem value="urgent">{t('priorities.urgent')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="task-due-date">{t('form.dueDate')}</Label>
<Input
id="task-due-date"
type="date"
value={form.due_date}
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div>
<p className="text-sm font-medium text-slate-700">{t('form.completedTitle')}</p>
<p className="text-xs text-slate-500">{t('form.completedCopy')}</p>
</div>
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t('form.cancel')}
</Button>
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('form.save')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}
export default function TasksPage() {
const navigate = useNavigate();
const { t: tc } = useTranslation('common');
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
return (
<AdminLayout
title={tc('navigation.tasks')}
subtitle={t('subtitles.default')}
>
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
</AdminLayout>
);
}
function TaskRow({
task,
onToggle,
onEdit,
onDelete,
}: {
task: TenantTask;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const isCompleted = task.is_completed;
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
return (
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<button type="button" onClick={onToggle} className="mt-1 text-slate-500 transition-colors hover:text-emerald-500">
{statusIcon}
</button>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-slate-900">{task.title}</span>
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
{task.collection_id ? <Badge variant="secondary">{t('list.template', { id: task.collection_id })}</Badge> : null}
</div>
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="mr-1 h-4 w-4" />
{t('list.edit')}
</Button>
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
<Trash2 className="mr-1 h-4 w-4" />
{t('list.delete')}
</Button>
</div>
</div>
);
}
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary' });
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
low: { label: t('priorities.low'), className: 'bg-emerald-50 text-emerald-600' },
medium: { label: t('priorities.medium'), className: 'bg-amber-50 text-amber-600' },
high: { label: t('priorities.high'), className: 'bg-rose-50 text-rose-600' },
urgent: { label: t('priorities.urgent'), className: 'bg-red-50 text-red-600' },
};
const { label, className } = mapping[priority];
return <Badge className={`border-none ${className}`}>{label}</Badge>;
}
function TasksSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={`task-skeleton-${index}`} className="h-16 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management', { keyPrefix: 'taskLibrary.empty' });
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('title')}</h3>
<p className="text-sm text-slate-500">{t('description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
{t('cta')}
</Button>
</div>
);
}

View File

@@ -1,493 +0,0 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import {
ArrowRight,
CheckCircle2,
Layers,
ListChecks,
Menu,
Moon,
Palette,
QrCode,
ShieldCheck,
Smartphone,
Sparkles,
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 { useAppearance } from '@/hooks/use-appearance';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_LOGIN_PATH } from '../constants';
import { useAuth } from '../auth/context';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { navigateToHref } from '../lib/navigation';
import { getCurrentLocale } from '../lib/locale';
type Feature = {
key: string;
title: string;
description: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
};
type Step = {
key: string;
title: string;
description: string;
accent: string;
};
type Plan = {
key: string;
title: string;
badge?: string;
highlight?: string;
points: string[];
};
export default function WelcomeTeaserPage() {
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');
return () => {
document.body.classList.remove('tenant-admin-theme', 'tenant-admin-welcome-theme');
};
}, []);
if (status === 'authenticated') {
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
}
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>
);
return (
<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="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="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 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="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 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 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="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">
<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="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-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 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>
<div className="relative">
<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="space-y-3 text-sm text-slate-700 dark:text-slate-200">
{previewBullets.map((item) => (
<div key={item} className="flex items-start gap-2 rounded-2xl border border-slate-200/70 bg-white/70 px-3 py-2 text-left dark:border-white/10 dark:bg-white/5">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
<p>{item}</p>
</div>
))}
</div>
<div className="mt-auto grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
<div className="rounded-2xl border border-dashed border-rose-200/70 bg-rose-50/70 p-4 dark:border-rose-200/30 dark:bg-rose-200/10">
<p className="text-[11px] uppercase tracking-[0.3em] text-rose-500">{t('welcome.highlight.moderation', 'Live-Moderation')}</p>
<p className="mt-2 font-semibold text-slate-900 dark:text-white">{t('welcome.highlight.moderationHint', 'Approve/Hide, Highlights, Galerie-Link')}</p>
</div>
<div className="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="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
key={feature.key}
className="group flex h-full flex-col gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-md dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<feature.icon className="h-4 w-4 text-rose-500" />
{feature.title}
</div>
<p className="text-sm text-slate-600 dark:text-slate-300">{feature.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.steps.title', "So funktioniert's")}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.steps.subtitle', 'In drei Schritten bereit')}</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{steps.map((step) => (
<div
key={step.key}
className="flex h-full flex-col gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
{step.accent}
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{step.title}</h3>
<p className="text-sm text-slate-600 dark:text-slate-300">{step.description}</p>
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-2">
<p className="text-xs uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">{t('welcome.plans.title', 'Pakete im Überblick')}</p>
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white sm:text-3xl">{t('welcome.plans.subtitle', 'Wähle das passende Kontingent')}</h2>
<p className="text-sm text-slate-600 dark:text-slate-300">{t('welcome.plans.hint', 'Starter, Standard oder Reseller alles mit Moderation & Einladungen.')}</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{plans.map((plan) => (
<div
key={plan.key}
className={cn(
'flex 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>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import DashboardPage from '../DashboardPage';
import { ADMIN_WELCOME_BASE_PATH } from '../../constants';
const navigateMock = vi.fn();
const markStepMock = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: '/event-admin', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../components/AdminLayout', () => ({
AdminLayout: ({ children }: { children: React.ReactNode }) => <div data-testid="admin-layout">{children}</div>,
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'authenticated', user: { name: 'Test Tenant' } }),
}));
vi.mock('../../onboarding', () => ({
useOnboardingProgress: () => ({
progress: {
welcomeSeen: false,
packageSelected: false,
eventCreated: false,
lastStep: null,
selectedPackage: null,
},
setProgress: vi.fn(),
markStep: markStepMock,
reset: vi.fn(),
}),
}));
vi.mock('../context/EventContext', () => ({
useEventContext: () => ({ events: [], activeEvent: null, selectEvent: vi.fn(), isLoading: false, isError: false, refetch: vi.fn() }),
}));
vi.mock('../../api', () => ({
getDashboardSummary: vi.fn().mockResolvedValue(null),
getEvents: vi.fn().mockResolvedValue([]),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
}));
describe('DashboardPage onboarding guard', () => {
beforeEach(() => {
navigateMock.mockReset();
markStepMock.mockReset();
});
it('redirects to the welcome flow when no events exist and onboarding is incomplete', async () => {
render(<DashboardPage />);
await waitFor(() => {
expect(navigateMock).not.toHaveBeenCalled();
});
expect(markStepMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,79 +0,0 @@
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();
vi.mock('../../components/LanguageSwitcher', () => ({
LanguageSwitcher: () => <div data-testid="language-switcher" />,
}));
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();
navigateMock.mockReset();
});
it('applies the tenant admin theme classes while mounted', () => {
const { unmount } = renderWithI18n();
expect(document.body.classList.contains('tenant-admin-theme')).toBe(true);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(true);
unmount();
expect(document.body.classList.contains('tenant-admin-theme')).toBe(false);
expect(document.body.classList.contains('tenant-admin-welcome-theme')).toBe(false);
});
it('shows the hero CTA and triggers the login redirect', async () => {
renderWithI18n();
const user = userEvent.setup();
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 () => {
renderWithI18n();
const user = userEvent.setup();
const toggle = screen.getByLabelText(/welcome\.theme\.aria|darstellung|appearance/i);
expect(toggle).toHaveTextContent(/hell|light/i);
await user.click(toggle);
expect(toggle).toHaveTextContent(/dunkel|dark/i);
});
});

View File

@@ -1,30 +1,21 @@
import React from 'react'; import React from 'react';
import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom'; import { createBrowserRouter, Outlet, Navigate, useLocation, useParams } from 'react-router-dom';
import RouteErrorElement from '@/components/RouteErrorElement';
import { useAuth } from './auth/context'; import { useAuth } from './auth/context';
import { import {
ADMIN_BASE_PATH, ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH, ADMIN_LOGIN_START_PATH,
ADMIN_PUBLIC_LANDING_PATH, ADMIN_PUBLIC_LANDING_PATH,
} from './constants'; } from './constants';
import RouteErrorElement from '@/components/RouteErrorElement'; const AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
const LoginPage = React.lazy(() => import('./pages/LoginPage')); const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
const DashboardPage = React.lazy(() => import('./pages/DashboardPage')); const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
const EventsPage = React.lazy(() => import('./pages/EventsPage'));
const SettingsPage = React.lazy(() => import('./pages/SettingsPage'));
const EventFormPage = React.lazy(() => import('./pages/EventFormPage'));
const EventPhotosPage = React.lazy(() => import('./pages/EventPhotosPage'));
const EventDetailPage = React.lazy(() => import('./pages/EventDetailPage'));
const EventRecapPage = React.lazy(() => import('./pages/EventRecapPage'));
const EventMembersPage = React.lazy(() => import('./pages/EventMembersPage'));
const EventTasksPage = React.lazy(() => import('./pages/EventTasksPage'));
const EventToolkitPage = React.lazy(() => import('./pages/EventToolkitPage'));
const EventInvitesPage = React.lazy(() => import('./pages/EventInvitesPage'));
const EventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
const EventBrandingPage = React.lazy(() => import('./pages/EventBrandingPage'));
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage')); const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage')); const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage')); const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage')); const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage')); const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
@@ -32,6 +23,7 @@ const MobileQrLayoutCustomizePage = React.lazy(() => import('./mobile/QrLayoutCu
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage')); const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage')); const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
@@ -40,18 +32,6 @@ const MobileLoginPage = React.lazy(() => import('./mobile/LoginPage'));
const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage')); const MobileDashboardPage = React.lazy(() => import('./mobile/DashboardPage'));
const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage')); const MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage')); const MobileUploadsTabPage = React.lazy(() => import('./mobile/UploadsTabPage'));
const EngagementPage = React.lazy(() => import('./pages/EngagementPage'));
const BillingPage = React.lazy(() => import('./pages/BillingPage'));
const TasksPage = React.lazy(() => import('./pages/TasksPage'));
const TaskCollectionsPage = React.lazy(() => import('./pages/TaskCollectionsPage'));
const EmotionsPage = React.lazy(() => import('./pages/EmotionsPage'));
const FaqPage = React.lazy(() => import('./pages/FaqPage'));
const AuthCallbackPage = React.lazy(() => import('./pages/AuthCallbackPage'));
const WelcomeTeaserPage = React.lazy(() => import('./pages/WelcomeTeaserPage'));
const LoginStartPage = React.lazy(() => import('./pages/LoginStartPage'));
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'));
const LogoutPage = React.lazy(() => import('./pages/LogoutPage'));
const LiveRedirectPage = React.lazy(() => import('./pages/LiveRedirectPage'));
function RequireAuth() { function RequireAuth() {
const { status } = useAuth(); const { status } = useAuth();
@@ -73,7 +53,7 @@ function RequireAuth() {
} }
function LandingGate() { function LandingGate() {
const { status, user } = useAuth(); const { status } = useAuth();
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -84,11 +64,10 @@ function LandingGate() {
} }
if (status === 'authenticated') { if (status === 'authenticated') {
const target = user?.role === 'member' ? ADMIN_EVENTS_PATH : ADMIN_DEFAULT_AFTER_LOGIN_PATH; return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
return <Navigate to={target} replace />;
} }
return <WelcomeTeaserPage />; return <Navigate to={ADMIN_LOGIN_PATH} replace />;
} }
function RequireAdminAccess({ children }: { children: React.ReactNode }) { function RequireAdminAccess({ children }: { children: React.ReactNode }) {
@@ -101,6 +80,14 @@ function RequireAdminAccess({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
function RedirectToMobileEvent({ buildPath }: { buildPath: (slug: string) => string }) {
const { slug } = useParams<{ slug?: string }>();
if (!slug) {
return <Navigate to={ADMIN_EVENTS_PATH} replace />;
}
return <Navigate to={buildPath(slug)} replace />;
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: ADMIN_BASE_PATH, path: ADMIN_BASE_PATH,
@@ -108,7 +95,7 @@ export const router = createBrowserRouter([
errorElement: <RouteErrorElement />, errorElement: <RouteErrorElement />,
children: [ children: [
{ index: true, element: <LandingGate /> }, { index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> }, { path: 'login', element: <MobileLoginPage /> },
{ path: 'mobile/login', element: <MobileLoginPage /> }, { path: 'mobile/login', element: <MobileLoginPage /> },
{ path: 'start', element: <LoginStartPage /> }, { path: 'start', element: <LoginStartPage /> },
{ path: 'logout', element: <LogoutPage /> }, { path: 'logout', element: <LogoutPage /> },
@@ -116,20 +103,20 @@ export const router = createBrowserRouter([
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [ children: [
{ path: 'dashboard', element: <RequireAdminAccess><DashboardPage /></RequireAdminAccess> }, { path: 'dashboard', element: <RequireAdminAccess><MobileDashboardPage /></RequireAdminAccess> },
{ path: 'live', element: <RequireAdminAccess><LiveRedirectPage /></RequireAdminAccess> }, { path: 'live', element: <RequireAdminAccess><MobileDashboardPage /></RequireAdminAccess> },
{ path: 'events', element: <EventsPage /> }, { path: 'events', element: <Navigate to={ADMIN_EVENTS_PATH} replace /> },
{ path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> }, { path: 'events/new', element: <Navigate to={`${ADMIN_EVENTS_PATH}/new`} replace /> },
{ path: 'events/:slug', element: <EventDetailPage /> }, { path: 'events/:slug', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'events/:slug/recap', element: <EventRecapPage /> }, { path: 'events/:slug/recap', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'events/:slug/edit', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> }, { path: 'events/:slug/edit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/edit`} /> },
{ path: 'events/:slug/photos', element: <EventPhotosPage /> }, { path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photos`} /> },
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> }, { path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> }, { path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> }, { path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> }, { path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> }, { path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> }, { path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
{ path: 'mobile/events', element: <MobileEventsPage /> }, { path: 'mobile/events', element: <MobileEventsPage /> },
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> }, { path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
@@ -138,8 +125,10 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> }, { path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> }, { path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> }, { path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> }, { path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
@@ -147,14 +136,6 @@ export const router = createBrowserRouter([
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> }, { path: 'mobile/dashboard', element: <MobileDashboardPage /> },
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> }, { path: 'mobile/tasks', element: <MobileTasksTabPage /> },
{ path: 'mobile/uploads', element: <MobileUploadsTabPage /> }, { path: 'mobile/uploads', element: <MobileUploadsTabPage /> },
{ path: 'engagement', element: <EngagementPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <RequireAdminAccess><BillingPage /></RequireAdminAccess> },
{ path: 'settings', element: <RequireAdminAccess><SettingsPage /></RequireAdminAccess> },
{ path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <RequireAdminAccess><ProfilePage /></RequireAdminAccess> },
], ],
}, },
], ],

View File

@@ -1,159 +0,0 @@
import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
import { Home, BarChart2, Settings } from 'lucide-react';
export function AppCard({ children, padding = '$4', ...rest }: React.ComponentProps<typeof YStack> & { padding?: keyof typeof rest }) {
return (
<YStack
bg="$surface"
borderRadius="$card"
borderWidth={1}
borderColor="$muted"
shadowColor="#0f172a"
shadowOpacity={0.05}
shadowRadius={12}
shadowOffset={{ width: 0, height: 8 }}
padding={padding as any}
space="$3"
{...rest}
>
{children}
</YStack>
);
}
export function StatusPill({ tone = 'muted', children }: { tone?: 'success' | 'warning' | 'muted'; children: React.ReactNode }) {
const colors: Record<typeof tone, { bg: string; color: string; border: string }> = {
success: { bg: '#ecfdf3', color: '#047857', border: '#bbf7d0' },
warning: { bg: '#fffbeb', color: '#92400e', border: '#fef3c7' },
muted: { bg: '#f3f4f6', color: '#374151', border: '#e5e7eb' },
};
const palette = colors[tone] ?? colors.muted;
return (
<XStack
alignItems="center"
paddingHorizontal="$2.5"
paddingVertical="$1"
borderRadius="$pill"
borderWidth={1}
backgroundColor={palette.bg}
borderColor={palette.border}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="700" color={palette.color}>
{children}
</Text>
</XStack>
);
}
export function PrimaryCTA({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Button
backgroundColor="$primary"
color="white"
height={56}
borderRadius="$card"
fontWeight="700"
onPress={onPress}
pressStyle={{ opacity: 0.9 }}
>
{label}
</Button>
);
}
export function Segmented({
options,
value,
onChange,
}: {
options: Array<{ key: string; label: string }>;
value: string;
onChange: (key: string) => void;
}) {
return (
<XStack bg="$muted" borderRadius="$pill" borderWidth={1} borderColor="$muted" padding="$1" space="$1">
{options.map((option) => {
const active = option.key === value;
return (
<Pressable key={option.key} onPress={() => onChange(option.key)} style={{ flex: 1 }}>
<YStack
bg={active ? '$primary' : 'transparent'}
borderRadius="$pill"
paddingVertical="$2"
alignItems="center"
justifyContent="center"
>
<Text color={active ? 'white' : '$color'} fontWeight="700" fontSize="$sm">
{option.label}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
);
}
export function MetaRow({ date, location, status }: { date: string; location: string; status: string }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color="$color">{date}</Text>
<Text fontSize="$sm" color="$color">{location}</Text>
<StatusPill tone="muted">{status}</StatusPill>
</YStack>
);
}
export function BottomNav({
active,
onNavigate,
}: {
active: 'events' | 'analytics' | 'settings';
onNavigate: (key: 'events' | 'analytics' | 'settings') => void;
}) {
const theme = useTheme();
const items = [
{ key: 'events', icon: Home, label: 'Events' },
{ key: 'analytics', icon: BarChart2, label: 'Analytics' },
{ key: 'settings', icon: Settings, label: 'Settings' },
];
return (
<XStack
position="fixed"
bottom={0}
left={0}
right={0}
bg="$background"
borderTopWidth={1}
borderColor="$muted"
padding="$3"
justifyContent="space-around"
shadowColor="#0f172a"
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
zIndex={50}
>
{items.map((item) => {
const activeState = item.key === active;
const IconCmp = item.icon;
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key as typeof active)}>
<YStack alignItems="center" space="$1">
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#9ca3af'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '$muted'}>
{item.label}
</Text>
</YStack>
</Pressable>
);
})}
</XStack>
);
}

View File

@@ -8,6 +8,9 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, 'resources/js'), '@': path.resolve(__dirname, 'resources/js'),
'~': path.resolve(__dirname, 'resources/css'), '~': path.resolve(__dirname, 'resources/css'),
// Tamagui's react-native-web-lite imports a directory entry; point to the ESM file explicitly for Vitest.
'@tamagui/react-native-web-lite/dist/esm/vendor/react-native/deepDiffer':
path.resolve(__dirname, 'node_modules/@tamagui/react-native-web-lite/dist/esm/vendor/react-native/deepDiffer/index.mjs'),
}, },
}, },
test: { test: {