der tenant admin hat eine neue, mobil unterstützende UI, login redirect funktioniert, typescript fehler wurden bereinigt. Neue Blog Posts von ChatGPT eingebaut, übersetzt von Gemini 2.5
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Loader2, RefreshCw, Sparkles } from 'lucide-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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
|
||||
type PackageWarning = { id: string; tone: 'warning' | 'danger'; message: string };
|
||||
|
||||
@@ -107,24 +108,78 @@ export default function BillingPage() {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => void loadAll()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('billing.actions.refresh')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const activeWarnings = React.useMemo(
|
||||
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||
[activePackage, t, formatDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('billing.title')}
|
||||
subtitle={t('billing.subtitle')}
|
||||
actions={actions}
|
||||
const heroBadge = t('billing.hero.badge', 'Abrechnung');
|
||||
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
|
||||
const heroSupporting: string[] = [
|
||||
activePackage
|
||||
? t('billing.hero.summary.active', 'Aktives Paket: {{name}}', { name: activePackage.package_name })
|
||||
: t('billing.hero.summary.inactive', 'Noch kein aktives Paket – wählt ein Kontingent, das zu euch passt.'),
|
||||
t('billing.hero.summary.transactions', '{{count}} Zahlungen synchronisiert', { count: transactions.length })
|
||||
];
|
||||
const packagesHref = `/${i18n.language?.split('-')[0] ?? 'de'}/packages`;
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => void loadAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{t('billing.actions.refresh')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
onClick={() => window.location.assign(packagesHref)}
|
||||
>
|
||||
{t('billing.actions.explorePackages', 'Pakete vergleichen')}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
const nextRenewalLabel = t('billing.hero.nextRenewal', 'Verlängerung am');
|
||||
const topWarning = activeWarnings[0];
|
||||
const heroAside = (
|
||||
<FrostedSurface className="space-y-4 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/85">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{t('billing.hero.activePackage', 'Aktuelles Paket')}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{activePackage?.package_name ?? t('billing.hero.activeFallback', 'Noch nicht ausgewählt')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{nextRenewalLabel}</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-200">{formatDate(activePackage?.expires_at)}</p>
|
||||
</div>
|
||||
{topWarning ? (
|
||||
<div className="rounded-xl border border-amber-200/60 bg-amber-50/80 p-3 text-xs text-amber-800 shadow-inner shadow-amber-200/40 dark:border-amber-500/50 dark:bg-amber-500/15 dark:text-amber-200">
|
||||
{topWarning.message}
|
||||
</div>
|
||||
) : null}
|
||||
</FrostedSurface>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout title={t('billing.title')} subtitle={t('billing.subtitle')}>
|
||||
<TenantHeroCard
|
||||
badge={heroBadge}
|
||||
title={t('billing.title')}
|
||||
description={heroDescription}
|
||||
supporting={heroSupporting}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard:alerts.errorTitle')}</AlertTitle>
|
||||
@@ -136,7 +191,7 @@ export default function BillingPage() {
|
||||
<BillingSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<FrostedCard className="mt-6 border border-white/20">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
@@ -147,7 +202,7 @@ export default function BillingPage() {
|
||||
{t('billing.sections.overview.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={activePackage ? 'bg-pink-500/10 text-pink-700' : 'bg-slate-200 text-slate-700'}>
|
||||
<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>
|
||||
</CardHeader>
|
||||
@@ -160,7 +215,7 @@ export default function BillingPage() {
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900 dark:border-amber-500/60 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-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
@@ -204,9 +259,9 @@ export default function BillingPage() {
|
||||
<EmptyState message={t('billing.sections.overview.empty')} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-amber-100/60">
|
||||
<FrostedCard className="border border-white/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
@@ -236,9 +291,9 @@ export default function BillingPage() {
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
|
||||
<FrostedCard className="border border-white/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-sky-500" />
|
||||
@@ -282,7 +337,7 @@ export default function BillingPage() {
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -322,47 +377,47 @@ function TransactionCard({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<FrostedSurface className="flex flex-col gap-3 border border-slate-200/60 p-4 text-slate-900 shadow-md shadow-slate-200/10 transition-colors duration-200 dark:border-slate-800/70 dark:bg-slate-950/80 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
{t('billing.sections.transactions.labels.transactionId', { id: transaction.id ?? '—' })}
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{createdLabel}</p>
|
||||
{transaction.checkout_id && (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{createdLabel}</p>
|
||||
{transaction.checkout_id ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('billing.sections.transactions.labels.checkoutId', { id: transaction.checkout_id })}
|
||||
</p>
|
||||
)}
|
||||
{transaction.origin && (
|
||||
<p className="text-xs text-slate-500">
|
||||
) : null}
|
||||
{transaction.origin ? (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('billing.sections.transactions.labels.origin', { origin: transaction.origin })}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 md:flex-row md:items-center md:gap-4">
|
||||
<Badge className="bg-sky-100 text-sky-700">
|
||||
<div className="flex flex-col items-start gap-2 text-sm font-medium text-slate-700 dark:text-slate-300 md:flex-row md:items-center md:gap-4">
|
||||
<Badge className="bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
|
||||
{statusText}
|
||||
</Badge>
|
||||
<div className="text-base font-semibold text-slate-900">
|
||||
<div className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(amount, currency)}
|
||||
</div>
|
||||
{transaction.tax !== undefined && transaction.tax !== null && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{transaction.tax !== undefined && transaction.tax !== null ? (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatCurrency(transaction.tax, currency) })}
|
||||
</span>
|
||||
)}
|
||||
{transaction.receipt_url && (
|
||||
) : null}
|
||||
{transaction.receipt_url ? (
|
||||
<a
|
||||
href={transaction.receipt_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs font-medium text-sky-600 hover:text-sky-700"
|
||||
className="text-xs font-medium text-sky-600 transition hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
>
|
||||
{t('billing.sections.transactions.labels.receipt')}
|
||||
</a>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,19 +432,19 @@ function InfoCard({
|
||||
helper?: string;
|
||||
tone: 'pink' | 'amber' | 'sky' | 'emerald';
|
||||
}) {
|
||||
const toneClass = {
|
||||
pink: 'from-pink-50 to-rose-100 text-pink-700',
|
||||
amber: 'from-amber-50 to-yellow-100 text-amber-700',
|
||||
sky: 'from-sky-50 to-blue-100 text-sky-700',
|
||||
emerald: 'from-emerald-50 to-green-100 text-emerald-700',
|
||||
}[tone];
|
||||
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 (
|
||||
<div className={`rounded-2xl border border-white/60 bg-gradient-to-br ${toneClass} p-5 shadow-sm`}>
|
||||
<span className="text-xs uppercase tracking-wide text-slate-600/90">{label}</span>
|
||||
<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/80">{helper}</p>}
|
||||
</div>
|
||||
{helper ? <p className="mt-2 text-xs text-slate-600 dark:text-slate-400">{helper}</p> : null}
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,14 +470,14 @@ function PackageCard({
|
||||
warnings?: PackageWarning[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-sm">
|
||||
<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' : undefined}
|
||||
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" />
|
||||
@@ -434,17 +489,17 @@ function PackageCard({
|
||||
)}
|
||||
<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">{pkg.package_name}</h3>
|
||||
<p className="text-xs text-slate-600">
|
||||
<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/10 text-amber-700' : 'bg-slate-200 text-slate-700'}>
|
||||
<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" />
|
||||
<div className="grid gap-2 text-xs text-slate-600 sm:grid-cols-3">
|
||||
<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>
|
||||
@@ -455,18 +510,18 @@ function PackageCard({
|
||||
{labels.expires}: {formatDate(pkg.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-8 text-center">
|
||||
<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">{message}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{message}</p>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,17 +583,20 @@ function BillingSkeleton() {
|
||||
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" />
|
||||
<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/40 via-white/60 to-white/40"
|
||||
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>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,10 @@ import {
|
||||
Users,
|
||||
Plus,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
Loader2,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -22,6 +20,9 @@ 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 { Progress } from '@/components/ui/progress';
|
||||
import { TenantHeroCard, TenantOnboardingChecklistCard, FrostedSurface } from '../components/tenant';
|
||||
import type { ChecklistStep } from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -36,9 +37,11 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { useAuth } from '../auth/context';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
@@ -47,6 +50,7 @@ import {
|
||||
} from '../constants';
|
||||
import { useOnboardingProgress } from '../onboarding';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import type { LimitUsageSummary, GallerySummary } from '../lib/limitWarnings';
|
||||
|
||||
interface DashboardState {
|
||||
summary: DashboardSummary | null;
|
||||
@@ -75,12 +79,23 @@ export default function DashboardPage() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
|
||||
const translate = React.useCallback(
|
||||
(key: string, options?: Record<string, unknown>) => {
|
||||
const value = t(key, options);
|
||||
(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 fallback = i18n.t(`dashboard:${key}`, options);
|
||||
return fallback === `dashboard:${key}` ? value : fallback;
|
||||
const fallbackValue = i18n.t(`dashboard:${key}`, { defaultValue: fallback, ...(options ?? {}) });
|
||||
if (fallbackValue !== `dashboard:${key}`) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
[t, i18n],
|
||||
@@ -237,6 +252,191 @@ export default function DashboardPage() {
|
||||
[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 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 heroBadge = translate('overview.title', 'Kurzer Überblick');
|
||||
const heroDescription = translate(
|
||||
'overview.description',
|
||||
'Wichtigste Kennzahlen deines Tenants auf einen Blick.'
|
||||
);
|
||||
const marketingDashboardLabel = translate('onboarding.back_to_marketing', 'Marketing-Dashboard ansehen');
|
||||
const marketingDashboardDescription = translate(
|
||||
'onboarding.back_to_marketing_description',
|
||||
'Zur Zusammenfassung im Kundenportal wechseln.'
|
||||
);
|
||||
const heroSupportingCopy = onboardingCompletion === 100 ? onboardingCompletedCopy : onboardingCardDescription;
|
||||
const heroPrimaryCtaLabel = readiness.hasEvent
|
||||
? translate('quickActions.moderatePhotos.label', 'Fotos moderieren')
|
||||
: translate('actions.newEvent');
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => {
|
||||
if (readiness.hasEvent) {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
} else {
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{heroPrimaryCtaLabel}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
>
|
||||
{marketingDashboardLabel}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/90 p-5 text-slate-900 shadow-lg shadow-rose-300/20 backdrop-blur">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{onboardingCardTitle}</span>
|
||||
<span>
|
||||
{completedOnboardingSteps}/{onboardingChecklist.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
|
||||
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
|
||||
</FrostedSurface>
|
||||
);
|
||||
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
|
||||
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button
|
||||
@@ -273,6 +473,16 @@ export default function DashboardPage() {
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<TenantHeroCard
|
||||
badge={heroBadge}
|
||||
title={greetingTitle}
|
||||
description={subtitle}
|
||||
supporting={[heroDescription, heroSupportingCopy]}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
{events.length === 0 && (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="space-y-3">
|
||||
@@ -446,53 +656,26 @@ export default function DashboardPage() {
|
||||
description={translate('quickActions.managePackages.description')}
|
||||
onClick={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={<ArrowUpRight className="h-5 w-5" />}
|
||||
label={marketingDashboardLabel}
|
||||
description={marketingDashboardDescription}
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReadinessCard
|
||||
readiness={readiness}
|
||||
labels={{
|
||||
title: translate('readiness.title'),
|
||||
description: translate('readiness.description'),
|
||||
pending: translate('readiness.pending'),
|
||||
complete: translate('readiness.complete'),
|
||||
items: {
|
||||
event: {
|
||||
title: translate('readiness.items.event.title'),
|
||||
hint: translate('readiness.items.event.hint'),
|
||||
},
|
||||
tasks: {
|
||||
title: translate('readiness.items.tasks.title'),
|
||||
hint: translate('readiness.items.tasks.hint'),
|
||||
},
|
||||
qr: {
|
||||
title: translate('readiness.items.qr.title'),
|
||||
hint: translate('readiness.items.qr.hint'),
|
||||
},
|
||||
package: {
|
||||
title: translate('readiness.items.package.title'),
|
||||
hint: translate('readiness.items.package.hint'),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
createEvent: translate('readiness.actions.createEvent'),
|
||||
openTasks: translate('readiness.actions.openTasks'),
|
||||
openQr: translate('readiness.actions.openQr'),
|
||||
openPackages: translate('readiness.actions.openPackages'),
|
||||
},
|
||||
}}
|
||||
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
onOpenTasks={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
|
||||
: navigate(buildEngagementTabPath('tasks'))
|
||||
}
|
||||
onOpenQr={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
|
||||
: navigate(ADMIN_EVENTS_PATH)
|
||||
}
|
||||
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
|
||||
<TenantOnboardingChecklistCard
|
||||
title={onboardingCardTitle}
|
||||
description={onboardingCardDescription}
|
||||
steps={onboardingChecklist}
|
||||
completedLabel={readinessCompleteLabel}
|
||||
pendingLabel={readinessPendingLabel}
|
||||
completionPercent={onboardingCompletion}
|
||||
completedCount={completedOnboardingSteps}
|
||||
totalCount={onboardingChecklist.length}
|
||||
emptyCopy={onboardingCompletedCopy}
|
||||
fallbackActionLabel={onboardingFallbackCta}
|
||||
/>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
@@ -619,25 +802,6 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
type ReadinessLabels = {
|
||||
title: string;
|
||||
description: string;
|
||||
pending: string;
|
||||
complete: string;
|
||||
items: {
|
||||
event: { title: string; hint: string };
|
||||
tasks: { title: string; hint: string };
|
||||
qr: { title: string; hint: string };
|
||||
package: { title: string; hint: string };
|
||||
};
|
||||
actions: {
|
||||
createEvent: string;
|
||||
openTasks: string;
|
||||
openQr: string;
|
||||
openPackages: string;
|
||||
};
|
||||
};
|
||||
|
||||
function LimitUsageRow({
|
||||
label,
|
||||
summary,
|
||||
@@ -656,9 +820,9 @@ function LimitUsageRow({
|
||||
<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">{unlimitedLabel}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -674,23 +838,23 @@ function LimitUsageRow({
|
||||
: 'bg-emerald-500';
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
{remainingLabel
|
||||
.replace('{{remaining}}', `${Math.max(0, remaining)}`)
|
||||
.replace('{{limit}}', `${limit}`)}
|
||||
@@ -698,7 +862,7 @@ function LimitUsageRow({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">{unlimitedLabel}</p>
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{unlimitedLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -732,155 +896,10 @@ function GalleryStatusRow({
|
||||
}
|
||||
|
||||
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">
|
||||
<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}`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessCard({
|
||||
readiness,
|
||||
labels,
|
||||
onCreateEvent,
|
||||
onOpenTasks,
|
||||
onOpenQr,
|
||||
onOpenPackages,
|
||||
}: {
|
||||
readiness: ReadinessState;
|
||||
labels: ReadinessLabels;
|
||||
onCreateEvent: () => void;
|
||||
onOpenTasks: () => void;
|
||||
onOpenQr: () => void;
|
||||
onOpenPackages: () => void;
|
||||
}) {
|
||||
const checklistItems = [
|
||||
{
|
||||
key: 'event',
|
||||
icon: <CalendarDays className="h-5 w-5" />,
|
||||
completed: readiness.hasEvent,
|
||||
label: labels.items.event.title,
|
||||
hint: labels.items.event.hint,
|
||||
actionLabel: labels.actions.createEvent,
|
||||
onAction: onCreateEvent,
|
||||
showAction: !readiness.hasEvent,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
completed: readiness.hasTasks,
|
||||
label: labels.items.tasks.title,
|
||||
hint: labels.items.tasks.hint,
|
||||
actionLabel: labels.actions.openTasks,
|
||||
onAction: onOpenTasks,
|
||||
showAction: readiness.hasEvent && !readiness.hasTasks,
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
icon: <QrCode className="h-5 w-5" />,
|
||||
completed: readiness.hasQrInvites,
|
||||
label: labels.items.qr.title,
|
||||
hint: labels.items.qr.hint,
|
||||
actionLabel: labels.actions.openQr,
|
||||
onAction: onOpenQr,
|
||||
showAction: readiness.hasEvent && !readiness.hasQrInvites,
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
icon: <PackageIcon className="h-5 w-5" />,
|
||||
completed: readiness.hasPackage,
|
||||
label: labels.items.package.title,
|
||||
hint: labels.items.package.hint,
|
||||
actionLabel: labels.actions.openPackages,
|
||||
onAction: onOpenPackages,
|
||||
showAction: !readiness.hasPackage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const activeEventName = readiness.primaryEventName;
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
|
||||
{activeEventName ? (
|
||||
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
|
||||
{activeEventName}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{readiness.loading ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{labels.pending}
|
||||
</div>
|
||||
) : (
|
||||
checklistItems.map((item) => (
|
||||
<ChecklistRow
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
hint={item.hint}
|
||||
completed={item.completed}
|
||||
status={{ complete: labels.complete, pending: labels.pending }}
|
||||
action={
|
||||
item.showAction
|
||||
? {
|
||||
label: item.actionLabel,
|
||||
onClick: item.onAction,
|
||||
disabled:
|
||||
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistRow({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
completed,
|
||||
status,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
completed: boolean;
|
||||
status: { complete: string; pending: string };
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{label}</p>
|
||||
<p className="text-xs text-slate-600">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{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}
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${badgeClass} dark:text-slate-100`}>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -898,14 +917,14 @@ function StatCard({
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-brand-rose-soft bg-brand-card p-5 shadow-md shadow-pink-100/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<FrostedSurface className="border-brand-rose-soft/40 p-5 shadow-md shadow-pink-100/30 transition-transform duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg hover:shadow-rose-300/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">{label}</span>
|
||||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-semibold text-slate-900">{value}</div>
|
||||
{hint && <p className="mt-2 text-xs text-slate-500">{hint}</p>}
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-semibold text-slate-900 dark:text-slate-100">{value}</div>
|
||||
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,11 +943,11 @@ function QuickAction({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40"
|
||||
className="group flex flex-col items-start gap-2 rounded-2xl border border-slate-100 bg-white/85 p-4 text-left shadow-sm transition duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-rose-soft hover:shadow-md focus:outline-none focus:ring-2 focus:ring-brand-rose/40 dark:border-slate-800/70 dark:bg-slate-950/80"
|
||||
>
|
||||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose">{icon}</span>
|
||||
<span className="text-sm font-semibold text-slate-900">{label}</span>
|
||||
<span className="text-xs text-slate-600">{description}</span>
|
||||
<span className="rounded-full bg-brand-rose-soft p-2 text-brand-rose shadow-sm shadow-rose-200/60 transition-transform duration-200 group-hover:scale-105">{icon}</span>
|
||||
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{label}</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1009,4 +1028,3 @@ function DashboardSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export type EmotionsSectionProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX.Element {
|
||||
export function EmotionsSection({ embedded = false }: EmotionsSectionProps) {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
@@ -191,7 +191,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmotionsPage(): JSX.Element {
|
||||
export default function EmotionsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
|
||||
|
||||
@@ -3,8 +3,11 @@ 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 { CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import { TasksSection } from './TasksPage';
|
||||
import { TaskCollectionsSection } from './TaskCollectionsPage';
|
||||
import { EmotionsSection } from './EmotionsPage';
|
||||
@@ -19,7 +22,7 @@ function ensureValidTab(value: string | null): EngagementTab {
|
||||
return 'tasks';
|
||||
}
|
||||
|
||||
export default function EngagementPage(): JSX.Element {
|
||||
export default function EngagementPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -41,37 +44,91 @@ export default function EngagementPage(): JSX.Element {
|
||||
);
|
||||
|
||||
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="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => handleTabChange('tasks')}
|
||||
>
|
||||
{t('engagement.hero.actions.tasks', 'Zu Aufgaben wechseln')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
onClick={() => handleTabChange('collections')}
|
||||
>
|
||||
{t('engagement.hero.actions.collections', 'Kollektionen ansehen')}
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
<FrostedSurface className="space-y-4 border-white/20 p-5 text-slate-900 shadow-md shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/80">
|
||||
<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.')}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/60 shadow-sm">
|
||||
<TabsTrigger value="tasks">{tc('navigation.tasks')}</TabsTrigger>
|
||||
<TabsTrigger value="collections">{tc('navigation.collections')}</TabsTrigger>
|
||||
<TabsTrigger value="emotions">{tc('navigation.emotions')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TenantHeroCard
|
||||
badge={t('engagement.hero.badge', 'Engagement')}
|
||||
title={heading}
|
||||
description={heroDescription}
|
||||
supporting={heroSupporting}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
<TabsContent value="tasks" className="space-y-6">
|
||||
<TasksSection
|
||||
embedded
|
||||
onNavigateToCollections={() => handleTabChange('collections')}
|
||||
/>
|
||||
</TabsContent>
|
||||
<FrostedCard className="mt-6 border border-white/20">
|
||||
<CardHeader className="px-0 pt-0">
|
||||
<CardTitle className="sr-only">{heading}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<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="collections" className="space-y-6">
|
||||
<TaskCollectionsSection
|
||||
embedded
|
||||
onNavigateToTasks={() => handleTabChange('tasks')}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="tasks" className="space-y-6">
|
||||
<TasksSection embedded onNavigateToCollections={() => handleTabChange('collections')} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emotions" className="space-y-6">
|
||||
<EmotionsSection embedded />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="collections" className="space-y-6">
|
||||
<TaskCollectionsSection embedded onNavigateToTasks={() => handleTabChange('tasks')} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="emotions" className="space-y-6">
|
||||
<EmotionsSection embedded />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</FrostedCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
EventToolkitTask,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
TenantEventStats,
|
||||
EventStats,
|
||||
getEvent,
|
||||
getEventStats,
|
||||
getEventToolkit,
|
||||
@@ -62,13 +62,13 @@ type ToolkitState = {
|
||||
|
||||
type WorkspaceState = {
|
||||
event: TenantEvent | null;
|
||||
stats: TenantEventStats | null;
|
||||
stats: EventStats | null;
|
||||
loading: boolean;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps): JSX.Element {
|
||||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
@@ -325,7 +325,7 @@ function resolveName(name: TenantEvent['name']): string {
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: TenantEventStats | null; busy: boolean; onToggle: () => void }) {
|
||||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const statusLabel = event.status === 'published'
|
||||
@@ -355,12 +355,21 @@ function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stat
|
||||
<div className="rounded-xl border border-pink-100 bg-pink-50/60 p-4 text-xs text-pink-900">
|
||||
<p className="font-semibold text-pink-700">{t('events.workspace.fields.insights', 'Letzte Aktivität')}</p>
|
||||
<p>
|
||||
{t('events.workspace.fields.uploadsTotal', { defaultValue: '{{count}} Uploads gesamt', count: stats.uploads_total })}
|
||||
{t('events.workspace.fields.uploadsTotal', {
|
||||
defaultValue: '{{count}} Uploads gesamt',
|
||||
count: stats.uploads_total ?? stats.total ?? 0,
|
||||
})}
|
||||
{' · '}
|
||||
{t('events.workspace.fields.uploadsToday', { defaultValue: '{{count}} Uploads (24h)', count: stats.uploads_24h })}
|
||||
{t('events.workspace.fields.uploadsToday', {
|
||||
defaultValue: '{{count}} Uploads (24h)',
|
||||
count: stats.uploads_24h ?? stats.recent_uploads ?? 0,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('events.workspace.fields.likesTotal', { defaultValue: '{{count}} Likes vergeben', count: stats.likes_total })}
|
||||
{t('events.workspace.fields.likesTotal', {
|
||||
defaultValue: '{{count}} Likes vergeben',
|
||||
count: stats.likes_total ?? stats.likes ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -456,7 +465,7 @@ function QuickActionsCard({ slug, busy, onToggle, navigate }: { slug: string; bu
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: TenantEventStats | null }) {
|
||||
function MetricsGrid({ metrics, stats }: { metrics: EventToolkit['metrics'] | null | undefined; stats: EventStats | null }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const cards = [
|
||||
@@ -780,7 +789,8 @@ function resolveEventType(event: TenantEvent): string {
|
||||
if (typeof event.event_type.name === 'string') {
|
||||
return event.event_type.name;
|
||||
}
|
||||
return event.event_type.name.de ?? event.event_type.name.en ?? Object.values(event.event_type.name)[0] ?? '—';
|
||||
const translations = event.event_type.name as Record<string, string>;
|
||||
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
@@ -801,7 +811,7 @@ function AlertList({ alerts }: { alerts: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon(): JSX.Element {
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
@@ -812,7 +822,7 @@ function CalendarIcon(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSkeleton(): JSX.Element {
|
||||
function WorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
@@ -837,6 +847,6 @@ function WorkspaceSkeleton(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard(): JSX.Element {
|
||||
function SkeletonCard() {
|
||||
return <div className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-slate-100 via-white to-slate-100" />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ import React from 'react';
|
||||
|
||||
import EventDetailPage from './EventDetailPage';
|
||||
|
||||
export default function EventToolkitPage(): JSX.Element {
|
||||
export default function EventToolkitPage() {
|
||||
return <EventDetailPage mode="toolkit" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Settings, Sparkles, Share2 } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight, CalendarDays, Plus, Share2 } 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 { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
@@ -28,6 +29,7 @@ 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);
|
||||
@@ -47,28 +49,107 @@ export default function EventsPage() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={() => navigate(adminPath('/events/new'))}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> {t('events.list.actions.create', 'Neues Event')}
|
||||
</Button>
|
||||
<Link to={ADMIN_SETTINGS_PATH}>
|
||||
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<Settings className="h-4 w-4" /> {t('events.list.actions.settings', 'Einstellungen')}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
const translateManagement = React.useCallback(
|
||||
(key: string, fallback?: string, options?: Record<string, unknown>) =>
|
||||
t(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('events.list.title', 'Deine Events')}
|
||||
subtitle={t('events.list.subtitle', 'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.')}
|
||||
actions={actions}
|
||||
const translateCommon = React.useCallback(
|
||||
(key: string, fallback?: string, options?: Record<string, unknown>) =>
|
||||
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||
[tCommon],
|
||||
);
|
||||
|
||||
const pageTitle = translateManagement('events.list.title', 'Deine Events');
|
||||
const pageSubtitle = translateManagement(
|
||||
'events.list.subtitle',
|
||||
'Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.'
|
||||
);
|
||||
const totalEvents = rows.length;
|
||||
const publishedEvents = React.useMemo(() => rows.filter((event) => event.status === 'published').length, [rows]);
|
||||
const nextEvent = React.useMemo(() => {
|
||||
return rows
|
||||
.filter((event) => event.event_date)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const dateA = a.event_date ? new Date(a.event_date).getTime() : Infinity;
|
||||
const dateB = b.event_date ? new Date(b.event_date).getTime() : Infinity;
|
||||
return dateA - dateB;
|
||||
})[0] ?? null;
|
||||
}, [rows]);
|
||||
const heroDescription = t(
|
||||
'events.list.hero.description',
|
||||
'Aktiviere Storytelling, Moderation und Galerie-Workflows für jeden Anlass in wenigen Minuten.'
|
||||
);
|
||||
const heroSummaryCopy = totalEvents > 0
|
||||
? t('events.list.hero.summary', ':count Events aktiv verwaltet – halte Aufgaben und Uploads im Blick.', { count: totalEvents })
|
||||
: t('events.list.hero.summary_empty', 'Noch keine Events – starte jetzt mit deinem ersten Konzept.');
|
||||
const heroSecondaryCopy = t(
|
||||
'events.list.hero.secondary',
|
||||
'Erstelle Events im Admin, begleite Gäste live vor Ort und prüfe Kennzahlen im Marketing-Dashboard.'
|
||||
);
|
||||
const heroBadge = t('events.list.badge.dashboard', 'Tenant Dashboard');
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => navigate(adminPath('/events/new'))}
|
||||
>
|
||||
{t('events.list.actions.create', 'Neues Event')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button size="sm" variant="outline" className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white" asChild>
|
||||
<Link to={ADMIN_SETTINGS_PATH}>
|
||||
{t('events.list.actions.settings', 'Einstellungen')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
<FrostedSurface className="w-full rounded-2xl border-white/30 bg-white/95 p-5 text-slate-900 shadow-lg shadow-pink-200/20 backdrop-blur">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{t('events.list.hero.published_label', 'Veröffentlichte Events')}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">{publishedEvents}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t('events.list.hero.total_label', ':count insgesamt', { count: totalEvents })}
|
||||
</p>
|
||||
</div>
|
||||
{nextEvent ? (
|
||||
<div className="rounded-xl border border-pink-100 bg-pink-50/70 p-4 text-slate-900 shadow-inner shadow-pink-200/50">
|
||||
<p className="text-xs uppercase tracking-wide text-pink-600">
|
||||
{t('events.list.hero.next_label', 'Nächstes Event')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">{renderName(nextEvent.name)}</p>
|
||||
<p className="text-xs text-slate-600">{formatDate(nextEvent.event_date)}</p>
|
||||
{nextEvent.slug ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 h-8 justify-start px-3 text-pink-600 hover:bg-pink-100"
|
||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(nextEvent.slug))}
|
||||
>
|
||||
{t('events.list.hero.open_event', 'Event öffnen')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-dashed border-pink-200/70 bg-white/70 p-4 text-xs text-slate-600">
|
||||
{t('events.list.hero.no_upcoming', 'Plane ein Datum, um hier die nächste Station zu sehen.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<AdminLayout title={pageTitle} subtitle={pageSubtitle}>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
@@ -76,58 +157,70 @@ export default function EventsPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<TenantHeroCard
|
||||
badge={heroBadge}
|
||||
title={pageTitle}
|
||||
description={heroDescription}
|
||||
supporting={[heroSummaryCopy, heroSecondaryCopy]}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
<FrostedCard className="mt-6">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-slate-900">{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
||||
<CardDescription className="text-slate-600">
|
||||
{rows.length === 0
|
||||
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
|
||||
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: rows.length })}
|
||||
<CardTitle>{t('events.list.overview.title', 'Übersicht')}</CardTitle>
|
||||
<CardDescription>
|
||||
{loading
|
||||
? t('events.list.overview.loading', 'Wir sammeln gerade deine Event-Details …')
|
||||
: totalEvents === 0
|
||||
? t('events.list.overview.empty', 'Noch keine Events - starte jetzt und lege dein erstes Event an.')
|
||||
: t('events.list.overview.count', '{{count}} Events aktiv verwaltet.', { count: totalEvents })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-pink-600">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.list.badge.dashboard', 'Tenant Dashboard')}
|
||||
</div>
|
||||
<Badge className="bg-pink-100 text-pink-700">{t('events.list.badge.dashboard', 'Tenant Dashboard')}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : rows.length === 0 ? (
|
||||
) : totalEvents === 0 ? (
|
||||
<EmptyState onCreate={() => navigate(adminPath('/events/new'))} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{rows.map((event) => (
|
||||
<EventCard key={event.id} event={event} translateCommon={tCommon} />
|
||||
<EventCard key={event.id} event={event} translate={translateManagement} translateCommon={translateCommon} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
translate,
|
||||
translateCommon,
|
||||
}: {
|
||||
event: TenantEvent;
|
||||
translateCommon: (key: string, options?: Record<string, unknown>) => string;
|
||||
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}`, opts)),
|
||||
() => buildLimitWarnings(event.limits ?? null, (key, opts) => translateCommon(`limits.${key}`, undefined, opts)),
|
||||
[event.limits, translateCommon],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
|
||||
<FrostedSurface className="p-5 transition hover:-translate-y-0.5 hover:shadow-lg">
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="mb-4 space-y-2">
|
||||
{limitWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
@@ -143,61 +236,63 @@ function EventCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100/90 px-3 py-1 font-medium text-pink-700">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{formatDate(event.event_date)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 font-medium text-sky-700">
|
||||
Photos: {photoCount}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100/90 px-3 py-1 font-medium text-sky-700">
|
||||
{translate('events.list.badges.photos', 'Fotos')}: {photoCount}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 font-medium text-amber-700">
|
||||
Likes: {likeCount}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100/90 px-3 py-1 font-medium text-amber-700">
|
||||
{translate('events.list.badges.likes', 'Likes')}: {likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className={
|
||||
isPublished
|
||||
? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30'
|
||||
: 'bg-slate-200 text-slate-700'
|
||||
}
|
||||
>
|
||||
{isPublished ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
<Badge className={isPublished ? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30' : 'bg-slate-200 text-slate-700'}>
|
||||
{isPublished
|
||||
? translateCommon('events.status.published', 'Veröffentlicht')
|
||||
: translateCommon('events.status.draft', 'Entwurf')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
|
||||
<Link to={ADMIN_EVENT_VIEW_PATH(slug)}>
|
||||
Details <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
{translateCommon('actions.open', 'Öffnen')} <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>Bearbeiten</Link>
|
||||
<Link to={ADMIN_EVENT_EDIT_PATH(slug)}>{translateCommon('actions.edit', 'Bearbeiten')}</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>Fotos moderieren</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>Mitglieder</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
|
||||
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
|
||||
<Link to={ADMIN_EVENT_PHOTOS_PATH(slug)}>
|
||||
{translate('events.list.actions.photos', 'Fotos moderieren')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
|
||||
<Link to={ADMIN_EVENT_MEMBERS_PATH(slug)}>
|
||||
{translate('events.list.actions.members', 'Mitglieder')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>
|
||||
{translate('events.list.actions.tasks', 'Tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||
<Link to={ADMIN_EVENT_INVITES_PATH(slug)}>
|
||||
<Share2 className="h-3.5 w-3.5" /> {translate('events.list.actions.invites', 'QR-Einladungen')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>{translate('events.list.actions.toolkit', 'Toolkit')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,9 +300,9 @@ function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
<FrostedSurface
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
|
||||
className="h-24 animate-pulse bg-gradient-to-r from-white/40 via-white/70 to-white/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -216,11 +311,11 @@ function LoadingState() {
|
||||
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-pink-200 bg-white/70 p-10 text-center">
|
||||
<FrostedSurface className="flex flex-col items-center justify-center gap-4 border-dashed border-pink-200/70 p-10 text-center">
|
||||
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
|
||||
<Plus className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
|
||||
@@ -228,11 +323,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
className="rounded-full bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 px-6 text-white shadow-lg shadow-pink-500/20"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Event erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,20 @@ import AppLogoIcon from '@/components/app-logo-icon';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, encodeReturnTo, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
|
||||
import {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
encodeReturnTo,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginPage(): JSX.Element {
|
||||
export default function LoginPage() {
|
||||
const { status, login } = useAuth();
|
||||
const { t } = useTranslation('auth');
|
||||
const location = useLocation();
|
||||
@@ -25,9 +32,22 @@ export default function LoginPage(): JSX.Element {
|
||||
const oauthError = searchParams.get('error');
|
||||
const oauthErrorDescription = searchParams.get('error_description');
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const state = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const composed = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(composed)) {
|
||||
return composed;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [state]);
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
||||
[rawReturnTo]
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
);
|
||||
|
||||
const resolvedErrorMessage = React.useMemo(() => {
|
||||
@@ -60,7 +80,6 @@ export default function LoginPage(): JSX.Element {
|
||||
return finalTarget;
|
||||
}
|
||||
|
||||
const state = location.state as LocationState | null;
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
@@ -71,7 +90,7 @@ export default function LoginPage(): JSX.Element {
|
||||
}
|
||||
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [finalTarget, location.state]);
|
||||
}, [finalTarget, state]);
|
||||
|
||||
const shouldOpenAccountLogin = oauthError === 'login_required';
|
||||
const isLoading = status === 'loading';
|
||||
@@ -199,7 +218,7 @@ export default function LoginPage(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }): JSX.Element {
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl, resolveReturnTarget, storeLastDestination } from '../lib/returnTo';
|
||||
import {
|
||||
buildAdminOAuthStartPath,
|
||||
buildMarketingLoginUrl,
|
||||
isPermittedReturnTarget,
|
||||
resolveReturnTarget,
|
||||
storeLastDestination,
|
||||
} from '../lib/returnTo';
|
||||
|
||||
export default function LoginStartPage(): JSX.Element {
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginStartPage() {
|
||||
const { status, login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const locationState = location.state as LocationState | null;
|
||||
const fallbackTarget = React.useMemo(() => {
|
||||
const from = locationState?.from;
|
||||
if (from) {
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
const combined = `${from.pathname}${search}${hash}`;
|
||||
if (isPermittedReturnTarget(combined)) {
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
return ADMIN_DEFAULT_AFTER_LOGIN_PATH;
|
||||
}, [locationState]);
|
||||
|
||||
const rawReturnTo = searchParams.get('return_to');
|
||||
const { finalTarget, encodedFinal } = React.useMemo(
|
||||
() => resolveReturnTarget(rawReturnTo, ADMIN_DEFAULT_AFTER_LOGIN_PATH),
|
||||
[rawReturnTo]
|
||||
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
|
||||
[fallbackTarget, rawReturnTo]
|
||||
);
|
||||
|
||||
const [hasStarted, setHasStarted] = React.useState(false);
|
||||
|
||||
@@ -41,7 +41,7 @@ function extractFieldErrors(error: unknown): FieldErrors {
|
||||
const DEFAULT_LOCALES = ['de', 'en'];
|
||||
const AUTO_LOCALE_OPTION = '__auto__';
|
||||
|
||||
export default function ProfilePage(): JSX.Element {
|
||||
export default function ProfilePage() {
|
||||
const { t } = useTranslation(['settings', 'common']);
|
||||
const { refreshProfile } = useAuth();
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { TenantHeroCard, FrostedCard, FrostedSurface } from '../components/tenant';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_PROFILE_PATH } from '../constants';
|
||||
import { buildAdminOAuthStartPath, buildMarketingLoginUrl } from '../lib/returnTo';
|
||||
@@ -33,6 +34,50 @@ export default function SettingsPage() {
|
||||
const [notificationError, setNotificationError] = React.useState<string | null>(null);
|
||||
const [notificationMeta, setNotificationMeta] = React.useState<NotificationPreferencesMeta | 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 ?? 'Tenant Admin';
|
||||
const heroPrimaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] px-6 text-white shadow-md shadow-rose-400/30 transition hover:from-[#ff4470] hover:via-[#ec4899] hover:to-[#4f46e5]"
|
||||
onClick={() => navigate(ADMIN_PROFILE_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.profile', 'Profil bearbeiten')}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full border-white/70 bg-white/80 px-6 text-slate-900 shadow-sm hover:bg-white"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
>
|
||||
{t('settings.hero.actions.events', 'Zur Event-Übersicht')}
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
<FrostedSurface className="space-y-3 border-white/25 p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-slate-800/70 dark:bg-slate-950/80">
|
||||
<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 targetPath = buildAdminOAuthStartPath(ADMIN_EVENTS_PATH);
|
||||
let marketingUrl = buildMarketingLoginUrl(targetPath);
|
||||
@@ -55,23 +100,22 @@ export default function SettingsPage() {
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
const actions = (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENTS_PATH)}
|
||||
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Einstellungen"
|
||||
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
|
||||
actions={actions}
|
||||
>
|
||||
<Card className="max-w-2xl border-0 bg-white/85 shadow-xl shadow-amber-100/60">
|
||||
<TenantHeroCard
|
||||
badge={t('settings.hero.badge', { defaultValue: 'Administration' })}
|
||||
title={t('settings.title', { defaultValue: 'Einstellungen' })}
|
||||
description={heroDescription}
|
||||
supporting={heroSupporting}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
<FrostedCard className="mt-6 max-w-2xl border border-white/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
|
||||
@@ -114,9 +158,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
|
||||
<Card className="mt-8 max-w-3xl border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<FrostedCard className="max-w-3xl border border-white/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<AlertTriangle className="h-5 w-5 text-pink-500" />
|
||||
@@ -136,9 +180,9 @@ export default function SettingsPage() {
|
||||
{loadingNotifications ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
<FrostedSurface
|
||||
key={index}
|
||||
className="h-12 animate-pulse rounded-xl bg-gradient-to-r from-white/30 via-white/60 to-white/30"
|
||||
className="h-12 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>
|
||||
@@ -173,11 +217,11 @@ export default function SettingsPage() {
|
||||
}
|
||||
}}
|
||||
saving={savingNotifications}
|
||||
translate={t}
|
||||
translate={translateNotification}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FrostedCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -199,7 +243,7 @@ function NotificationPreferencesForm({
|
||||
onReset: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
saving: boolean;
|
||||
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]);
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
|
||||
@@ -226,37 +270,37 @@ function NotificationPreferencesForm({
|
||||
const checked = preferences[item.key] ?? defaults[item.key] ?? true;
|
||||
|
||||
return (
|
||||
<div key={item.key} className="flex items-start justify-between gap-4 rounded-xl border border-pink-100 bg-white/70 p-4 shadow-sm">
|
||||
<FrostedSurface key={item.key} className="flex items-start justify-between gap-4 border border-pink-100/60 p-4 text-slate-900 shadow-sm shadow-rose-200/20 dark:border-pink-500/20 dark:bg-slate-950/80">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-slate-900">{item.label}</h3>
|
||||
<p className="text-sm text-slate-600">{item.description}</p>
|
||||
<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) })}
|
||||
/>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
|
||||
<Button onClick={onSave} disabled={saving} className="bg-gradient-to-r from-[#ff5f87] via-[#ec4899] to-[#6366f1] text-white shadow-md shadow-rose-400/30">
|
||||
{saving ? '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">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
|
||||
</span>
|
||||
</div>
|
||||
{creditText && <p className="text-xs text-slate-500">{creditText}</p>}
|
||||
{creditText ? <p className="text-xs text-slate-500 dark:text-slate-400">{creditText}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPreferenceMeta(
|
||||
translate: (key: string, options?: Record<string, unknown>) => string
|
||||
translate: (key: string, fallback?: string, options?: Record<string, unknown>) => string
|
||||
): Array<{ key: keyof NotificationPreferences; label: string; description: string }> {
|
||||
const map = [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -40,7 +41,7 @@ export type TaskCollectionsSectionProps = {
|
||||
onNavigateToTasks?: () => void;
|
||||
};
|
||||
|
||||
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps): JSX.Element {
|
||||
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
@@ -297,7 +298,7 @@ export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }:
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCollectionsPage(): JSX.Element {
|
||||
export default function TaskCollectionsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export type TasksSectionProps = {
|
||||
onNavigateToCollections?: () => void;
|
||||
};
|
||||
|
||||
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps): JSX.Element {
|
||||
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
@@ -300,7 +300,9 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
<Label htmlFor="task-priority">Priorität</Label>
|
||||
<Select
|
||||
value={form.priority ?? 'medium'}
|
||||
onValueChange={(value: TaskPayload['priority']) => setForm((prev) => ({ ...prev, priority: value }))}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="task-priority">
|
||||
<SelectValue placeholder="Priorität wählen" />
|
||||
@@ -309,6 +311,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
<SelectItem value="urgent">Dringend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -347,7 +350,7 @@ export function TasksSection({ embedded = false, onNavigateToCollections }: Task
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasksPage(): JSX.Element {
|
||||
export default function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tc } = useTranslation('common');
|
||||
@@ -408,6 +411,7 @@ function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priori
|
||||
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
|
||||
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
|
||||
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
|
||||
urgent: { label: 'Dringend', className: 'bg-red-50 text-red-600' },
|
||||
};
|
||||
const { label, className } = mapping[priority];
|
||||
return <Badge className={`border-none ${className}`}>{label}</Badge>;
|
||||
|
||||
@@ -189,7 +189,7 @@ export function DesignerCanvas({
|
||||
return;
|
||||
}
|
||||
canvas.selection = !readOnly;
|
||||
canvas.forEachObject((object) => {
|
||||
canvas.forEachObject((object: fabric.Object) => {
|
||||
object.set({
|
||||
selectable: !readOnly,
|
||||
hoverCursor: readOnly ? 'default' : 'move',
|
||||
@@ -216,11 +216,12 @@ export function DesignerCanvas({
|
||||
onSelect(active.elementId);
|
||||
};
|
||||
|
||||
const handleSelectionCleared = (event?: fabric.IEvent<MouseEvent>) => {
|
||||
const handleSelectionCleared = (event?: unknown) => {
|
||||
const pointerEvent = event as { e?: MouseEvent } | undefined;
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const triggeredByPointer = Boolean(event?.e);
|
||||
const triggeredByPointer = Boolean(pointerEvent?.e);
|
||||
if (!triggeredByPointer && requestedSelectionRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -245,14 +246,16 @@ export function DesignerCanvas({
|
||||
};
|
||||
|
||||
// Manual collision check: Calculate overlap and push vertically
|
||||
const otherObjects = canvas.getObjects().filter(obj => obj !== target && (obj as FabricObjectWithId).elementId);
|
||||
otherObjects.forEach(other => {
|
||||
const otherObjects = canvas
|
||||
.getObjects()
|
||||
.filter((obj): obj is FabricObjectWithId => obj !== target && Boolean((obj as FabricObjectWithId).elementId));
|
||||
otherObjects.forEach((other) => {
|
||||
const otherBounds = other.getBoundingRect();
|
||||
const overlapX = Math.max(0, Math.min(bounds.left + bounds.width, otherBounds.left + otherBounds.width) - Math.max(bounds.left, otherBounds.left));
|
||||
const overlapY = Math.max(0, Math.min(bounds.top + bounds.height, otherBounds.top + otherBounds.height) - Math.max(bounds.top, otherBounds.top));
|
||||
if (overlapX > 0 && overlapY > 0) {
|
||||
// Push down by 120px if overlap (massive spacing für größeren QR-Code)
|
||||
nextPatch.y = Math.max(nextPatch.y, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
|
||||
nextPatch.y = Math.max(nextPatch.y ?? 0, (Number(otherBounds.top || 0)) + (Number(otherBounds.height || 0)) + 120);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -388,7 +391,7 @@ export function DesignerCanvas({
|
||||
|
||||
const match = canvas
|
||||
.getObjects()
|
||||
.find((object) => (object as FabricObjectWithId).elementId === selectedId);
|
||||
.find((object): object is FabricObjectWithId => (object as FabricObjectWithId).elementId === selectedId);
|
||||
|
||||
if (match) {
|
||||
canvas.setActiveObject(match);
|
||||
@@ -483,7 +486,7 @@ export async function renderFabricLayout(
|
||||
qrCodeDataUrl,
|
||||
logoDataUrl,
|
||||
readOnly,
|
||||
}, abortController.signal),
|
||||
}),
|
||||
);
|
||||
|
||||
const fabricObjects = await Promise.all(objectPromises);
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function withFabricCanvas<T>(
|
||||
await renderFabricLayout(canvas, {
|
||||
...options,
|
||||
readOnly: true,
|
||||
selectedId: null,
|
||||
});
|
||||
return await handler(canvas, canvasElement);
|
||||
} finally {
|
||||
@@ -102,7 +101,9 @@ export function triggerDownloadFromBlob(blob: Blob, filename: string): void {
|
||||
}
|
||||
|
||||
export async function openPdfInNewTab(pdfBytes: Uint8Array): Promise<void> {
|
||||
const blobUrl = URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' }));
|
||||
const arrayBuffer = pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength) as ArrayBuffer;
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const printWindow = window.open(blobUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
if (!printWindow) {
|
||||
|
||||
Reference in New Issue
Block a user