removed the old event admin components and pages
This commit is contained in:
@@ -21,9 +21,10 @@ declare global {
|
||||
|
||||
type DevTenantSwitcherProps = {
|
||||
bottomOffset?: number;
|
||||
variant?: 'floating' | 'inline';
|
||||
};
|
||||
|
||||
export function DevTenantSwitcher({ bottomOffset = 16 }: DevTenantSwitcherProps) {
|
||||
export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: DevTenantSwitcherProps) {
|
||||
const helper = window.fotospielDemoAuth;
|
||||
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = React.useState<boolean>(() => {
|
||||
@@ -55,6 +56,62 @@ export function DevTenantSwitcher({ bottomOffset = 16 }: DevTenantSwitcherProps)
|
||||
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) {
|
||||
return (
|
||||
<button
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,34 +2,31 @@ export const ADMIN_BASE_PATH = '/event-admin';
|
||||
|
||||
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_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_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
|
||||
export const ADMIN_EVENTS_PATH = adminPath('/events');
|
||||
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
|
||||
export const ADMIN_PROFILE_PATH = adminPath('/settings/profile');
|
||||
export const ADMIN_FAQ_PATH = adminPath('/faq');
|
||||
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
|
||||
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
|
||||
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
|
||||
export const ADMIN_BILLING_PATH = adminPath('/billing');
|
||||
export const ADMIN_PHOTOS_PATH = adminPath('/photos');
|
||||
export const ADMIN_LIVE_PATH = adminPath('/live');
|
||||
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome');
|
||||
export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/welcome/packages');
|
||||
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_EVENTS_PATH = adminPath('/mobile/events');
|
||||
export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
|
||||
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
|
||||
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
|
||||
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
|
||||
export const ADMIN_PHOTOS_PATH = adminPath('/mobile/uploads');
|
||||
export const ADMIN_LIVE_PATH = adminPath('/mobile/dashboard');
|
||||
export const ADMIN_WELCOME_BASE_PATH = adminPath('/mobile/welcome');
|
||||
export const ADMIN_WELCOME_PACKAGES_PATH = adminPath('/mobile/welcome/packages');
|
||||
export const ADMIN_WELCOME_SUMMARY_PATH = adminPath('/mobile/welcome/summary');
|
||||
export const ADMIN_WELCOME_EVENT_PATH = adminPath('/mobile/welcome/event');
|
||||
export const ADMIN_EVENT_CREATE_PATH = adminPath('/mobile/events/new');
|
||||
|
||||
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}`);
|
||||
export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/edit`);
|
||||
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
|
||||
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);
|
||||
export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photobooth`);
|
||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/recap`);
|
||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/branding`);
|
||||
export const ADMIN_EVENT_VIEW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
|
||||
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(`/mobile/events/${encodeURIComponent(slug)}/photos`);
|
||||
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(`/mobile/events/${encodeURIComponent(slug)}/tasks`);
|
||||
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(`/mobile/events/${encodeURIComponent(slug)}/photobooth`);
|
||||
export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`);
|
||||
export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
window.location.assign('/event-admin/dashboard');
|
||||
window.location.assign('/event-admin/mobile/dashboard');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error('[DevAuth] Demo login failed', message);
|
||||
|
||||
@@ -12,13 +12,12 @@ import '../../css/app.css';
|
||||
import './i18n';
|
||||
import './dev-tools';
|
||||
import { AppearanceProvider, useAppearance, initializeTheme } from '@/hooks/use-appearance';
|
||||
import { OnboardingProgressProvider } from './onboarding';
|
||||
import { EventProvider } from './context/EventContext';
|
||||
import MatomoTracker from '@/components/analytics/MatomoTracker';
|
||||
import { ConsentProvider } from '@/contexts/consent';
|
||||
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';
|
||||
|
||||
@@ -63,7 +62,6 @@ function AdminApp() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<EventProvider>
|
||||
<OnboardingProgressProvider>
|
||||
<MatomoTracker config={(window as any).__MATOMO_ADMIN__} />
|
||||
<Suspense
|
||||
fallback={(
|
||||
@@ -76,7 +74,6 @@ function AdminApp() {
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</OnboardingProgressProvider>
|
||||
</EventProvider>
|
||||
</AuthProvider>
|
||||
<CookieBanner />
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { isPastEvent } from './eventDate';
|
||||
|
||||
export default function MobileEventDetailPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
@@ -250,6 +251,14 @@ export default function MobileEventDetailPage() {
|
||||
color="#38bdf8"
|
||||
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>
|
||||
</YStack>
|
||||
</MobileShell>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Image as ImageIcon, RefreshCcw, Search, Filter } from 'lucide-react';
|
||||
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 { MobileShell } from './components/MobileShell';
|
||||
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 { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { adminPath } from '../constants';
|
||||
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||||
|
||||
type FilterKey = 'all' | 'featured' | 'hidden';
|
||||
|
||||
@@ -22,6 +37,7 @@ export default function MobileEventPhotosPage() {
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||
@@ -38,6 +54,10 @@ export default function MobileEventPhotosPage() {
|
||||
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
|
||||
const [onlyHidden, setOnlyHidden] = React.useState(false);
|
||||
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 text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
@@ -82,8 +102,15 @@ export default function MobileEventPhotosPage() {
|
||||
});
|
||||
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
|
||||
setTotalCount(result.meta?.total ?? result.photos.length);
|
||||
setLimits(result.limits ?? null);
|
||||
const lastPage = result.meta?.last_page ?? 1;
|
||||
setHasMore(page < lastPage);
|
||||
const [addons, event] = await Promise.all([
|
||||
getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]),
|
||||
getEvent(slug).catch(() => null),
|
||||
]);
|
||||
setCatalogAddons(addons ?? []);
|
||||
setEventAddons(event?.addons ?? []);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
|
||||
@@ -91,12 +118,24 @@ export default function MobileEventPhotosPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, filter, t, page]);
|
||||
}, [slug, filter, t, page, onlyFeatured, onlyHidden, search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void 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(() => {
|
||||
setPage(1);
|
||||
}, [filter, slug]);
|
||||
@@ -215,6 +254,15 @@ export default function MobileEventPhotosPage() {
|
||||
</MobileCard>
|
||||
) : (
|
||||
<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}>
|
||||
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
|
||||
</Text>
|
||||
@@ -358,6 +406,15 @@ export default function MobileEventPhotosPage() {
|
||||
/>
|
||||
</YStack>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -372,3 +429,197 @@ function Field({ label, color, children }: { label: string; color: string; child
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
442
resources/js/admin/mobile/EventRecapPage.tsx
Normal file
442
resources/js/admin/mobile/EventRecapPage.tsx
Normal 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' });
|
||||
}
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
generatePngDataUrl,
|
||||
triggerDownloadFromBlob,
|
||||
triggerDownloadFromDataUrl,
|
||||
} from '../pages/components/invite-layout/export-utils';
|
||||
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from '../pages/components/invite-layout/schema';
|
||||
} from './invite-layout/export-utils';
|
||||
import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/schema';
|
||||
import { buildInitialTextFields } from './qr/utils';
|
||||
|
||||
type Step = 'background' | 'text' | 'preview';
|
||||
|
||||
|
||||
@@ -19,38 +19,7 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ADMIN_BASE_PATH } from '../constants';
|
||||
|
||||
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;
|
||||
}
|
||||
import { resolveLayoutForFormat } from './qr/utils';
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
|
||||
26
resources/js/admin/mobile/__tests__/addons.test.ts
Normal file
26
resources/js/admin/mobile/__tests__/addons.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
21
resources/js/admin/mobile/__tests__/eventDetail.test.ts
Normal file
21
resources/js/admin/mobile/__tests__/eventDetail.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { EventQrInviteLayout } from '../../api';
|
||||
import { buildInitialTextFields } from '../QrLayoutCustomizePage';
|
||||
import { resolveLayoutForFormat } from '../QrPrintPage';
|
||||
import { buildInitialTextFields, resolveLayoutForFormat } from '../qr/utils';
|
||||
|
||||
describe('buildInitialTextFields', () => {
|
||||
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.description).toBe('Helft uns, diesen besonderen Tag mit euren schönen Momenten festzuhalten.');
|
||||
expect(fields.description).toBe('Old description');
|
||||
expect(fields.instructions).toHaveLength(3);
|
||||
});
|
||||
|
||||
|
||||
16
resources/js/admin/mobile/addons.ts
Normal file
16
resources/js/admin/mobile/addons.ts
Normal 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;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { MobileCard, PillBadge } from './Primitives';
|
||||
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
|
||||
import { TenantEvent, getEvents } from '../../api';
|
||||
const DevTenantSwitcher = React.lazy(() => import('../../components/DevTenantSwitcher'));
|
||||
const DevTenantSwitcher = React.lazy(() => import('../../DevTenantSwitcher'));
|
||||
|
||||
type MobileShellProps = {
|
||||
title?: string;
|
||||
@@ -184,6 +184,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</Pressable>
|
||||
) : null}
|
||||
{headerActions ?? null}
|
||||
{showDevTenantSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher variant="inline" />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
@@ -193,12 +198,6 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
{children}
|
||||
</YStack>
|
||||
|
||||
{showDevTenantSwitcher ? (
|
||||
<Suspense fallback={null}>
|
||||
<DevTenantSwitcher bottomOffset={64} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
<BottomNav active={activeTab} onNavigate={go} />
|
||||
|
||||
<MobileSheet
|
||||
|
||||
7
resources/js/admin/mobile/eventDate.ts
Normal file
7
resources/js/admin/mobile/eventDate.ts
Normal 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();
|
||||
}
|
||||
73
resources/js/admin/mobile/qr/utils.ts
Normal file
73
resources/js/admin/mobile/qr/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import EventDetailPage from './EventDetailPage';
|
||||
|
||||
export default function EventToolkitPage() {
|
||||
return <EventDetailPage />;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,21 @@
|
||||
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 {
|
||||
ADMIN_BASE_PATH,
|
||||
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_LOGIN_PATH,
|
||||
ADMIN_LOGIN_START_PATH,
|
||||
ADMIN_PUBLIC_LANDING_PATH,
|
||||
} from './constants';
|
||||
import RouteErrorElement from '@/components/RouteErrorElement';
|
||||
const LoginPage = React.lazy(() => import('./pages/LoginPage'));
|
||||
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
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 AuthCallbackPage = React.lazy(() => import('./mobile/AuthCallbackPage'));
|
||||
const LoginStartPage = React.lazy(() => import('./mobile/LoginStartPage'));
|
||||
const LogoutPage = React.lazy(() => import('./mobile/LogoutPage'));
|
||||
const MobileEventsPage = React.lazy(() => import('./mobile/EventsPage'));
|
||||
const MobileEventDetailPage = React.lazy(() => import('./mobile/EventDetailPage'));
|
||||
const MobileEventPhotoboothPage = React.lazy(() => import('./mobile/EventPhotoboothPage'));
|
||||
const MobileBrandingPage = React.lazy(() => import('./mobile/BrandingPage'));
|
||||
const MobileEventFormPage = React.lazy(() => import('./mobile/EventFormPage'));
|
||||
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 MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
|
||||
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
|
||||
const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'));
|
||||
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
|
||||
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
|
||||
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 MobileTasksTabPage = React.lazy(() => import('./mobile/TasksTabPage'));
|
||||
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() {
|
||||
const { status } = useAuth();
|
||||
@@ -73,7 +53,7 @@ function RequireAuth() {
|
||||
}
|
||||
|
||||
function LandingGate() {
|
||||
const { status, user } = useAuth();
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
@@ -84,11 +64,10 @@ function LandingGate() {
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
const target = user?.role === 'member' ? ADMIN_EVENTS_PATH : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
return <Navigate to={target} replace />;
|
||||
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
|
||||
}
|
||||
|
||||
return <WelcomeTeaserPage />;
|
||||
return <Navigate to={ADMIN_LOGIN_PATH} replace />;
|
||||
}
|
||||
|
||||
function RequireAdminAccess({ children }: { children: React.ReactNode }) {
|
||||
@@ -101,6 +80,14 @@ function RequireAdminAccess({ children }: { children: React.ReactNode }) {
|
||||
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([
|
||||
{
|
||||
path: ADMIN_BASE_PATH,
|
||||
@@ -108,7 +95,7 @@ export const router = createBrowserRouter([
|
||||
errorElement: <RouteErrorElement />,
|
||||
children: [
|
||||
{ index: true, element: <LandingGate /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'login', element: <MobileLoginPage /> },
|
||||
{ path: 'mobile/login', element: <MobileLoginPage /> },
|
||||
{ path: 'start', element: <LoginStartPage /> },
|
||||
{ path: 'logout', element: <LogoutPage /> },
|
||||
@@ -116,20 +103,20 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ path: 'dashboard', element: <RequireAdminAccess><DashboardPage /></RequireAdminAccess> },
|
||||
{ path: 'live', element: <RequireAdminAccess><LiveRedirectPage /></RequireAdminAccess> },
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug', element: <EventDetailPage /> },
|
||||
{ path: 'events/:slug/recap', element: <EventRecapPage /> },
|
||||
{ path: 'events/:slug/edit', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
|
||||
{ path: 'events/:slug/branding', element: <RequireAdminAccess><EventBrandingPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/photobooth', element: <RequireAdminAccess><EventPhotoboothPage /></RequireAdminAccess> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'dashboard', element: <RequireAdminAccess><MobileDashboardPage /></RequireAdminAccess> },
|
||||
{ path: 'live', element: <RequireAdminAccess><MobileDashboardPage /></RequireAdminAccess> },
|
||||
{ path: 'events', element: <Navigate to={ADMIN_EVENTS_PATH} replace /> },
|
||||
{ path: 'events/new', element: <Navigate to={`${ADMIN_EVENTS_PATH}/new`} replace /> },
|
||||
{ path: 'events/:slug', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||
{ path: 'events/:slug/recap', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||
{ path: 'events/:slug/edit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/edit`} /> },
|
||||
{ path: 'events/:slug/photos', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photos`} /> },
|
||||
{ path: 'events/:slug/members', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
|
||||
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
|
||||
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
|
||||
{ path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
|
||||
{ path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
|
||||
{ path: 'events/:slug/toolkit', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}`} /> },
|
||||
{ path: 'mobile/events', element: <MobileEventsPage /> },
|
||||
{ path: 'mobile/events/:slug', element: <MobileEventDetailPage /> },
|
||||
{ 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/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> },
|
||||
{ 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/tasks', element: <MobileEventTasksPage /> },
|
||||
{ path: 'mobile/events/:slug/photobooth', element: <RequireAdminAccess><MobileEventPhotoboothPage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
|
||||
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
|
||||
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
|
||||
@@ -147,14 +136,6 @@ export const router = createBrowserRouter([
|
||||
{ path: 'mobile/dashboard', element: <MobileDashboardPage /> },
|
||||
{ path: 'mobile/tasks', element: <MobileTasksTabPage /> },
|
||||
{ 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> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'resources/js'),
|
||||
'~': 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: {
|
||||
|
||||
Reference in New Issue
Block a user