coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
TenantOnboardingChecklistCard,
|
||||
FrostedSurface,
|
||||
tenantHeroPrimaryButtonClass,
|
||||
tenantHeroSecondaryButtonClass,
|
||||
SectionCard,
|
||||
SectionHeader,
|
||||
StatCarousel,
|
||||
@@ -52,6 +50,7 @@ import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_INVITES_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
@@ -212,7 +211,7 @@ export default function DashboardPage() {
|
||||
meta: primary ? { event_id: primary.id } : undefined,
|
||||
});
|
||||
}
|
||||
}, [loading, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||||
}, [loading, events, events.length, progress.eventCreated, navigate, location.pathname, markStep]);
|
||||
|
||||
const greetingName = user?.name ?? translate('welcome.fallbackName');
|
||||
const greetingTitle = translate('welcome.greeting', { name: greetingName });
|
||||
@@ -224,6 +223,9 @@ export default function DashboardPage() {
|
||||
const publishedEvents = events.filter((event) => event.status === 'published');
|
||||
const primaryEvent = events[0] ?? null;
|
||||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||
const singleEvent = events.length === 1 ? events[0] : null;
|
||||
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
|
||||
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
|
||||
const primaryEventLimits = primaryEvent?.limits ?? null;
|
||||
|
||||
const limitTranslate = React.useCallback(
|
||||
@@ -271,6 +273,31 @@ export default function DashboardPage() {
|
||||
}, [summary, events]);
|
||||
|
||||
const primaryEventSlug = readiness.primaryEventSlug;
|
||||
const liveEvents = React.useMemo(() => {
|
||||
const now = Date.now();
|
||||
const windowLengthMs = 2 * 24 * 60 * 60 * 1000; // event day + following day
|
||||
return events.filter((event) => {
|
||||
if (!event.slug) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActivated = Boolean(event.is_active || event.status === 'published');
|
||||
if (!isActivated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!event.event_date) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const eventStart = new Date(event.event_date).getTime();
|
||||
if (Number.isNaN(eventStart)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return now >= eventStart && now <= eventStart + windowLengthMs;
|
||||
});
|
||||
}, [events]);
|
||||
const statItems = React.useMemo(
|
||||
() => ([
|
||||
{
|
||||
@@ -430,46 +457,77 @@ export default function DashboardPage() {
|
||||
'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 heroBadge = singleEvent
|
||||
? translate('overview.eventHero.badge', 'Aktives Event')
|
||||
: translate('overview.title', 'Kurzer Überblick');
|
||||
|
||||
const heroDescription = singleEvent
|
||||
? translate('overview.eventHero.description', 'Alles richtet sich nach {{event}}. Nächster Termin: {{date}}.', {
|
||||
event: singleEventName ?? '',
|
||||
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
|
||||
})
|
||||
: translate('overview.description', 'Wichtigste Kennzahlen deines Tenants auf einen Blick.');
|
||||
|
||||
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={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
if (readiness.hasEvent) {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
} else {
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{heroPrimaryCtaLabel}
|
||||
</Button>
|
||||
);
|
||||
const heroSecondaryAction = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={tenantHeroSecondaryButtonClass}
|
||||
onClick={() => window.location.assign('/dashboard')}
|
||||
>
|
||||
{marketingDashboardLabel}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
const heroAside = (
|
||||
const heroSupporting = singleEvent
|
||||
? [
|
||||
translate('overview.eventHero.supporting.status', 'Status: {{status}}', {
|
||||
status: formatEventStatus(singleEvent.status ?? null, tc),
|
||||
}),
|
||||
singleEventDateLabel
|
||||
? translate('overview.eventHero.supporting.date', 'Eventdatum: {{date}}', { date: singleEventDateLabel })
|
||||
: translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt.'),
|
||||
].filter(Boolean)
|
||||
: [heroSupportingCopy];
|
||||
|
||||
const heroPrimaryAction = (() => {
|
||||
if (onboardingCompletion < 100) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => {
|
||||
if (readiness.hasEvent) {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
} else {
|
||||
navigate(ADMIN_EVENT_CREATE_PATH);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate('onboarding.hero.cta', 'Setup fortsetzen')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (singleEvent?.slug) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={tenantHeroPrimaryButtonClass}
|
||||
onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug))}
|
||||
>
|
||||
{translate('actions.openEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (readiness.hasEvent) {
|
||||
return (
|
||||
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENTS_PATH)}>
|
||||
{translate('quickActions.moderatePhotos.label', 'Fotos moderieren')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="sm" className={tenantHeroPrimaryButtonClass} onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}>
|
||||
{translate('actions.newEvent')}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
const heroAside = onboardingCompletion < 100 ? (
|
||||
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-slate-700">
|
||||
<span>{onboardingCardTitle}</span>
|
||||
@@ -480,9 +538,44 @@ export default function DashboardPage() {
|
||||
<Progress value={onboardingCompletion} className="mt-4 h-2 bg-rose-100" />
|
||||
<p className="mt-3 text-xs text-slate-600">{onboardingCardDescription}</p>
|
||||
</FrostedSurface>
|
||||
);
|
||||
) : singleEvent ? (
|
||||
<FrostedSurface className="w-full rounded-2xl border-slate-200 bg-white p-5 text-slate-900 shadow-lg shadow-rose-300/20 dark:border-white/20 dark:bg-white/10">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-rose-500">
|
||||
{translate('overview.eventHero.stats.title', 'Momentaufnahme')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{formatEventStatus(singleEvent.status ?? null, tc)}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.date', 'Eventdatum')}</dt>
|
||||
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Nicht gesetzt')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.uploads', 'Uploads gesamt')}</dt>
|
||||
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{Number(singleEvent.photo_count ?? 0).toLocaleString(i18n.language)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-xs text-slate-500">{translate('overview.eventHero.stats.tasks', 'Offene Aufgaben')}</dt>
|
||||
<dd className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{Number(singleEvent.tasks_count ?? 0).toLocaleString(i18n.language)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</FrostedSurface>
|
||||
) : null;
|
||||
const readinessCompleteLabel = translate('readiness.complete', 'Erledigt');
|
||||
const readinessPendingLabel = translate('readiness.pending', 'Noch offen');
|
||||
const hasEventContext = readiness.hasEvent;
|
||||
|
||||
const quickActionItems = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -498,6 +591,7 @@ export default function DashboardPage() {
|
||||
description: translate('quickActions.moderatePhotos.description'),
|
||||
icon: <Camera className="h-5 w-5" />,
|
||||
onClick: () => navigate(ADMIN_EVENTS_PATH),
|
||||
disabled: !hasEventContext,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
@@ -505,6 +599,7 @@ export default function DashboardPage() {
|
||||
description: translate('quickActions.organiseTasks.description'),
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
onClick: () => navigate(buildEngagementTabPath('tasks')),
|
||||
disabled: !hasEventContext,
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
@@ -513,18 +608,24 @@ export default function DashboardPage() {
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
onClick: () => navigate(ADMIN_BILLING_PATH),
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
label: marketingDashboardLabel,
|
||||
description: marketingDashboardDescription,
|
||||
icon: <ArrowUpRight className="h-5 w-5" />,
|
||||
onClick: () => window.location.assign('/dashboard'),
|
||||
},
|
||||
],
|
||||
[translate, navigate, marketingDashboardLabel, marketingDashboardDescription],
|
||||
[translate, navigate, hasEventContext],
|
||||
);
|
||||
|
||||
const layoutActions = (
|
||||
const layoutActions = singleEvent ? (
|
||||
<Button
|
||||
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => {
|
||||
if (singleEvent.slug) {
|
||||
navigate(ADMIN_EVENT_VIEW_PATH(singleEvent.slug));
|
||||
} else {
|
||||
navigate(ADMIN_EVENTS_PATH);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate('actions.openEvent', 'Event öffnen')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full bg-brand-rose px-4 text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)]"
|
||||
onClick={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
@@ -533,8 +634,29 @@ export default function DashboardPage() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
const adminTitle = singleEventName ?? greetingTitle;
|
||||
const adminSubtitle = singleEvent
|
||||
? translate('overview.eventHero.subtitle', 'Alle Funktionen konzentrieren sich auf dieses Event.', {
|
||||
date: singleEventDateLabel ?? translate('overview.eventHero.noDate', 'Noch kein Datum festgelegt'),
|
||||
})
|
||||
: subtitle;
|
||||
|
||||
const heroTitle = adminTitle;
|
||||
const liveNowTitle = t('liveNow.title', { defaultValue: 'Während des Events' });
|
||||
const liveNowDescription = t('liveNow.description', {
|
||||
defaultValue: 'Direkter Zugriff, solange dein Event läuft.',
|
||||
count: liveEvents.length,
|
||||
});
|
||||
const liveActionLabels = React.useMemo(() => ({
|
||||
photos: t('liveNow.actions.photos', { defaultValue: 'Uploads' }),
|
||||
invites: t('liveNow.actions.invites', { defaultValue: 'QR & Einladungen' }),
|
||||
tasks: t('liveNow.actions.tasks', { defaultValue: 'Aufgaben' }),
|
||||
}), [t]);
|
||||
const liveStatusLabel = t('liveNow.status', { defaultValue: 'Live' });
|
||||
const liveNoDate = t('liveNow.noDate', { defaultValue: 'Kein Datum' });
|
||||
|
||||
return (
|
||||
<AdminLayout title={greetingTitle} subtitle={subtitle} actions={layoutActions}>
|
||||
<AdminLayout title={adminTitle} subtitle={adminSubtitle} actions={layoutActions}>
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('dashboard.alerts.errorTitle')}</AlertTitle>
|
||||
@@ -548,14 +670,74 @@ export default function DashboardPage() {
|
||||
<>
|
||||
<TenantHeroCard
|
||||
badge={heroBadge}
|
||||
title={greetingTitle}
|
||||
title={heroTitle}
|
||||
description={heroDescription}
|
||||
supporting={[heroSupportingCopy]}
|
||||
supporting={heroSupporting}
|
||||
primaryAction={heroPrimaryAction}
|
||||
secondaryAction={heroSecondaryAction}
|
||||
aside={heroAside}
|
||||
/>
|
||||
|
||||
{liveEvents.length > 0 && (
|
||||
<Card className="border border-rose-200 bg-rose-50/80 shadow-lg shadow-rose-200/40">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold text-rose-900">{liveNowTitle}</CardTitle>
|
||||
<CardDescription className="text-sm text-rose-700">{liveNowDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
{liveEvents.map((event) => {
|
||||
const name = resolveEventName(event.name, event.slug);
|
||||
const dateLabel = event.event_date ? formatDate(event.event_date, dateLocale) : liveNoDate;
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="rounded-2xl border border-white/70 bg-white/80 p-4 shadow-sm shadow-rose-100/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{name}</p>
|
||||
<p className="text-xs text-slate-500">{dateLabel}</p>
|
||||
</div>
|
||||
<Badge className="bg-rose-600/90 text-white">{liveStatusLabel}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
|
||||
onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{liveActionLabels.photos}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
|
||||
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
|
||||
>
|
||||
<QrCode className="h-4 w-4" />
|
||||
{liveActionLabels.invites}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex flex-1 items-center gap-2 border-rose-200 text-rose-700 hover:border-rose-400 hover:text-rose-800"
|
||||
onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
{liveActionLabels.tasks}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{events.length === 0 && (
|
||||
<Card className="border-none bg-white/90 shadow-lg shadow-rose-100/50">
|
||||
<CardHeader className="space-y-2">
|
||||
@@ -752,6 +934,17 @@ function formatDate(value: string | null, locale: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventStatus(status: TenantEvent['status'] | null, translateFn: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
const map: Record<string, { key: string; fallback: string }> = {
|
||||
published: { key: 'events.status.published', fallback: 'Veröffentlicht' },
|
||||
draft: { key: 'events.status.draft', fallback: 'Entwurf' },
|
||||
archived: { key: 'events.status.archived', fallback: 'Archiviert' },
|
||||
};
|
||||
|
||||
const target = map[status ?? 'draft'] ?? map.draft;
|
||||
return translateFn(target.key, { defaultValue: target.fallback });
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name;
|
||||
@@ -914,29 +1107,6 @@ function GalleryStatusRow({
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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 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 dark:text-slate-100">{value}</div>
|
||||
{hint && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{hint}</p>}
|
||||
</FrostedSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingEventRow({
|
||||
event,
|
||||
onView,
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Download,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Printer,
|
||||
@@ -23,8 +21,6 @@ import toast from 'react-hot-toast';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
EventToolkit,
|
||||
@@ -54,6 +50,7 @@ import {
|
||||
SectionCard,
|
||||
SectionHeader,
|
||||
ActionGrid,
|
||||
TenantHeroCard,
|
||||
} from '../components/tenant';
|
||||
|
||||
type EventDetailPageProps = {
|
||||
@@ -175,48 +172,6 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
|
||||
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
|
||||
</Button>
|
||||
{event && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_MEMBERS_PATH(event.slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
|
||||
<Users className="h-4 w-4" /> {t('events.actions.members', 'Team & Rollen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.tasks', 'Aufgaben verwalten')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||||
<QrCode className="h-4 w-4" /> {t('events.actions.invites', 'Einladungen & Layouts')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Camera className="h-4 w-4" /> {t('events.actions.photos', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('events.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')} actions={actions}>
|
||||
<SectionCard>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const limitWarnings = React.useMemo(
|
||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||
@@ -240,8 +195,23 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('events.errors.notFoundTitle', 'Event nicht gefunden')}
|
||||
subtitle={t('events.errors.notFoundCopy', 'Bitte wähle ein Event aus der Übersicht.')}
|
||||
>
|
||||
<SectionCard>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{t('events.errors.notFoundBody', 'Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.')}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={eventName} subtitle={subtitle} actions={actions}>
|
||||
<AdminLayout title={eventName} subtitle={subtitle}>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
@@ -276,6 +246,14 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
<WorkspaceSkeleton />
|
||||
) : event ? (
|
||||
<div className="space-y-6">
|
||||
<EventHeroCardSection
|
||||
event={event}
|
||||
stats={stats}
|
||||
onRefresh={() => { void load(); }}
|
||||
loading={state.busy}
|
||||
navigate={navigate}
|
||||
/>
|
||||
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
@@ -332,14 +310,82 @@ function resolveName(name: TenantEvent['name']): string {
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function EventHeroCardSection({ event, stats, onRefresh, loading, navigate }: {
|
||||
event: TenantEvent;
|
||||
stats: EventStats | null;
|
||||
onRefresh: () => void;
|
||||
loading: boolean;
|
||||
navigate: ReturnType<typeof useNavigate>;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const statusLabel = getStatusLabel(event, t);
|
||||
const supporting = [
|
||||
t('events.workspace.hero.status', { defaultValue: 'Status: {{status}}', status: statusLabel }),
|
||||
t('events.workspace.hero.date', { defaultValue: 'Eventdatum: {{date}}', date: formatDate(event.event_date) }),
|
||||
t('events.workspace.hero.metrics', {
|
||||
defaultValue: 'Uploads gesamt: {{count}} · Likes: {{likes}}',
|
||||
count: stats?.uploads_total ?? stats?.total ?? 0,
|
||||
likes: stats?.likes_total ?? stats?.likes ?? 0,
|
||||
}),
|
||||
];
|
||||
|
||||
const aside = (
|
||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-200">
|
||||
<InfoRow
|
||||
icon={<Sparkles className="h-4 w-4 text-pink-500" />}
|
||||
label={t('events.workspace.fields.status', 'Status')}
|
||||
value={statusLabel}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<CalendarIcon />}
|
||||
label={t('events.workspace.fields.date', 'Eventdatum')}
|
||||
value={formatDate(event.event_date)}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={<Users className="h-4 w-4 text-sky-500" />}
|
||||
label={t('events.workspace.fields.active', 'Aktiv für Gäste')}
|
||||
value={event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<TenantHeroCard
|
||||
badge={t('events.workspace.hero.badge', 'Event')}
|
||||
title={resolveName(event.name)}
|
||||
description={t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
|
||||
supporting={supporting}
|
||||
primaryAction={(
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<ArrowLeft className="h-4 w-4" /> {t('events.actions.backToList', 'Zurück zur Liste')}
|
||||
</Button>
|
||||
)}
|
||||
secondaryAction={(
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_EDIT_PATH(event.slug))} className="rounded-full border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
|
||||
<Sparkles className="h-4 w-4" /> {t('events.actions.edit', 'Bearbeiten')}
|
||||
</Button>
|
||||
)}
|
||||
aside={aside}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="rounded-full border-slate-200"
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{t('events.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</TenantHeroCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const statusLabel = event.status === 'published'
|
||||
? t('events.status.published', 'Veröffentlicht')
|
||||
: event.status === 'draft'
|
||||
? t('events.status.draft', 'Entwurf')
|
||||
: t('events.status.archived', 'Archiviert');
|
||||
const statusLabel = getStatusLabel(event, t);
|
||||
|
||||
return (
|
||||
<SectionCard className="space-y-4">
|
||||
@@ -839,6 +885,16 @@ function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string;
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
if (event.status === 'published') {
|
||||
return t('events.status.published', 'Veröffentlicht');
|
||||
}
|
||||
if (event.status === 'archived') {
|
||||
return t('events.status.archived', 'Archiviert');
|
||||
}
|
||||
return t('events.status.draft', 'Entwurf');
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -67,7 +67,17 @@ export default function EventTasksPage() {
|
||||
setEvent(eventData);
|
||||
const assignedIds = new Set(eventTasksResponse.data.map((task) => task.id));
|
||||
setAssignedTasks(eventTasksResponse.data);
|
||||
setAvailableTasks(libraryTasks.data.filter((task) => !assignedIds.has(task.id)));
|
||||
const eventTypeId = eventData.event_type_id ?? null;
|
||||
const filteredLibraryTasks = libraryTasks.data.filter((task) => {
|
||||
if (assignedIds.has(task.id)) {
|
||||
return false;
|
||||
}
|
||||
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setAvailableTasks(filteredLibraryTasks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -104,6 +114,10 @@ export default function EventTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelected((current) => current.filter((taskId) => availableTasks.some((task) => task.id === taskId)));
|
||||
}, [availableTasks]);
|
||||
|
||||
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import {
|
||||
adminPath,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
ADMIN_WELCOME_BASE_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENT_EDIT_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
@@ -69,6 +70,25 @@ export default function EventsPage() {
|
||||
tCommon(key, { defaultValue: fallback, ...(options ?? {}) }),
|
||||
[tCommon],
|
||||
);
|
||||
|
||||
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 statItems = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -125,18 +145,6 @@ export default function EventsPage() {
|
||||
'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.'
|
||||
|
||||
80
resources/js/admin/pages/FaqPage.tsx
Normal file
80
resources/js/admin/pages/FaqPage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function FaqPage() {
|
||||
const { t } = useTranslation('dashboard');
|
||||
|
||||
const entries = [
|
||||
{
|
||||
question: t('faq.events.question', 'Wie arbeite ich mit Events?'),
|
||||
answer: t(
|
||||
'faq.events.answer',
|
||||
'Wähle dein aktives Event, passe Aufgaben an und lade Gäste über die Einladungsseite ein. Weitere Dokumentation folgt bald.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.uploads.question', 'Wie moderiere ich Uploads?'),
|
||||
answer: t(
|
||||
'faq.uploads.answer',
|
||||
'Sobald Fotos eintreffen, findest du sie in der Galerie-Ansicht deines Events. Von dort kannst du sie freigeben oder zurückweisen.'
|
||||
),
|
||||
},
|
||||
{
|
||||
question: t('faq.support.question', 'Wo erhalte ich Support?'),
|
||||
answer: t(
|
||||
'faq.support.answer',
|
||||
'Dieses FAQ dient als Platzhalter. Bitte nutze vorerst den bekannten Support-Kanal, bis die Wissensdatenbank veröffentlicht wird.'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('faq.title', 'FAQ & Hilfe')}
|
||||
subtitle={t('faq.subtitle', 'Antworten und Hinweise rund um den Tenant Admin.')}
|
||||
>
|
||||
<Card className="border-slate-200 bg-white/90 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('faq.intro.title', 'Was dich erwartet')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'faq.intro.description',
|
||||
'Wir sammeln aktuell Feedback und erweitern dieses Hilfe-Center Schritt für Schritt.'
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.question} className="rounded-2xl border border-slate-200/80 p-4 dark:border-white/10">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">{entry.question}</p>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">{entry.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-2xl bg-rose-50/70 p-4 text-sm text-rose-900 dark:bg-rose-200/10 dark:text-rose-100">
|
||||
<p className="font-semibold">
|
||||
{t('faq.cta.needHelp', 'Fehlt dir etwas?')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{t(
|
||||
'faq.cta.description',
|
||||
'Schreib uns dein Feedback direkt aus dem Admin oder per Support-Mail – wir erweitern dieses FAQ mit deinen Themen.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-3 rounded-full"
|
||||
onClick={() => window.open('mailto:hello@fotospiel.app', '_blank')}
|
||||
>
|
||||
{t('faq.cta.contact', 'Support kontaktieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlignLeft,
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Heading,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
Printer,
|
||||
QrCode,
|
||||
@@ -27,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
@@ -241,6 +244,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [fitScale, setFitScale] = React.useState(1);
|
||||
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
|
||||
const [isCompact, setIsCompact] = React.useState(false);
|
||||
const fitScaleRef = React.useRef(1);
|
||||
const manualZoomRef = React.useRef(false);
|
||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const draftSignatureRef = React.useRef<string | null>(null);
|
||||
const initialElementsRef = React.useRef<LayoutElement[]>([]);
|
||||
const activeCustomization = React.useMemo(
|
||||
() => draftCustomization ?? initialCustomization ?? null,
|
||||
[draftCustomization, initialCustomization],
|
||||
@@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({
|
||||
const appliedLayoutRef = React.useRef<string | null>(null);
|
||||
const appliedInviteRef = React.useRef<number | string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
setIsCompact(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = window.matchMedia('(max-width: 1023px)');
|
||||
const update = (event?: MediaQueryListEvent) => {
|
||||
if (typeof event?.matches === 'boolean') {
|
||||
setIsCompact(event.matches);
|
||||
return;
|
||||
}
|
||||
setIsCompact(query.matches);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
const listener = (event: MediaQueryListEvent) => update(event);
|
||||
query.addEventListener('change', listener);
|
||||
return () => query.removeEventListener('change', listener);
|
||||
}
|
||||
|
||||
const legacyListener = (event: MediaQueryListEvent) => update(event);
|
||||
query.addListener(legacyListener);
|
||||
return () => query.removeListener(legacyListener);
|
||||
}, []);
|
||||
|
||||
const clampZoom = React.useCallback(
|
||||
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
|
||||
[],
|
||||
@@ -410,7 +443,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
const commitElements = React.useCallback(
|
||||
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
|
||||
setElements((prev) => {
|
||||
const base = cloneElements(prev);
|
||||
const source = prev.length ? prev : initialElementsRef.current;
|
||||
const base = cloneElements(source.length ? source : []);
|
||||
const produced = producer(base);
|
||||
const normalized = normalizeElements(produced);
|
||||
if (elementsAreEqual(prev, normalized)) {
|
||||
@@ -514,6 +548,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
}, [clampZoom, zoomScale, fitScale, previewMode]);
|
||||
const zoomPercent = Math.round(effectiveScale * 100);
|
||||
|
||||
const handleZoomStep = React.useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP));
|
||||
},
|
||||
[clampZoom]
|
||||
);
|
||||
|
||||
const updateElement = React.useCallback(
|
||||
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
|
||||
commitElements(
|
||||
@@ -646,6 +688,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
setInstructions([]);
|
||||
commitElements(() => [], { silent: true });
|
||||
resetHistory([]);
|
||||
initialElementsRef.current = [];
|
||||
appliedSignatureRef.current = null;
|
||||
appliedLayoutRef.current = layoutId;
|
||||
appliedInviteRef.current = inviteKey;
|
||||
@@ -723,12 +766,15 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (isCustomizedAdvanced) {
|
||||
const initialElements = normalizeElements(payloadToElements(newForm.elements));
|
||||
initialElementsRef.current = initialElements;
|
||||
commitElements(() => initialElements, { silent: true });
|
||||
resetHistory(initialElements);
|
||||
} else {
|
||||
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
|
||||
commitElements(() => defaults, { silent: true });
|
||||
resetHistory(defaults);
|
||||
const normalizedDefaults = normalizeElements(defaults);
|
||||
initialElementsRef.current = normalizedDefaults;
|
||||
commitElements(() => normalizedDefaults, { silent: true });
|
||||
resetHistory(normalizedDefaults);
|
||||
}
|
||||
|
||||
appliedSignatureRef.current = incomingSignature ?? null;
|
||||
@@ -1515,6 +1561,38 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
const highlightedElementId = activeElementId ?? inspectorElementId;
|
||||
|
||||
const renderResponsiveSection = React.useCallback(
|
||||
(id: string, title: string, description: string, content: React.ReactNode) => {
|
||||
const body = <div className="space-y-4">{content}</div>;
|
||||
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</header>
|
||||
{body}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
|
||||
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
[isCompact]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
|
||||
@@ -1525,63 +1603,58 @@ export function InviteLayoutCustomizerPanel({
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
|
||||
</header>
|
||||
<div className="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className={cn('order-2 space-y-6', 'xl:order-1')}>
|
||||
{renderResponsiveSection(
|
||||
'layouts',
|
||||
t('invites.customizer.sections.layouts', 'Layouts'),
|
||||
t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
|
||||
<>
|
||||
<Select
|
||||
value={activeLayout?.id ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const layout = availableLayouts.find((item) => item.id === value);
|
||||
if (layout) {
|
||||
handleLayoutSelect(layout);
|
||||
}
|
||||
}}
|
||||
disabled={!availableLayouts.length}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{availableLayouts.map((layout) => (
|
||||
<SelectItem key={layout.id} value={layout.id}>
|
||||
<div className="flex w-full flex-col gap-1 text-left">
|
||||
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
|
||||
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
|
||||
{layout.formats?.length ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={activeLayout?.id ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const layout = availableLayouts.find((item) => item.id === value);
|
||||
if (layout) {
|
||||
handleLayoutSelect(layout);
|
||||
}
|
||||
}}
|
||||
disabled={!availableLayouts.length}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{availableLayouts.map((layout) => (
|
||||
<SelectItem key={layout.id} value={layout.id}>
|
||||
<div className="flex w-full flex-col gap-1 text-left">
|
||||
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
|
||||
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
|
||||
{layout.formats?.length ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{activeLayout ? (
|
||||
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
|
||||
<p className="font-medium text-foreground">{activeLayout.name}</p>
|
||||
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
|
||||
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('invites.customizer.elements.title', 'Elemente & Positionierung')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.')}
|
||||
</p>
|
||||
</header>
|
||||
{activeLayout ? (
|
||||
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
|
||||
<p className="font-medium text-foreground">{activeLayout.name}</p>
|
||||
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
|
||||
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderResponsiveSection(
|
||||
'elements',
|
||||
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
|
||||
t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.'),
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{sortedElements.map((element) => {
|
||||
const Icon = elementIconFor(element);
|
||||
@@ -1652,16 +1725,20 @@ export function InviteLayoutCustomizerPanel({
|
||||
{t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
{renderResponsiveSection(
|
||||
'content',
|
||||
t('invites.customizer.sections.content', 'Texte & Branding'),
|
||||
t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'),
|
||||
<Tabs defaultValue="text" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-3 gap-1 text-xs sm:text-sm">
|
||||
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
|
||||
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
|
||||
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1825,34 +1902,60 @@ export function InviteLayoutCustomizerPanel({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||||
</form>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<div className={cn('order-1 flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors', 'xl:order-2')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={effectiveScale}
|
||||
onChange={(event) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={previewMode === 'full'}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={effectiveScale}
|
||||
onChange={(event) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={previewMode === 'full'}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleZoomStep(-1)}
|
||||
aria-label={t('invites.customizer.controls.zoomOut', 'Verkleinern')}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center text-xs font-medium tabular-nums text-muted-foreground">{zoomPercent}%</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleZoomStep(1)}
|
||||
aria-label={t('invites.customizer.controls.zoomIn', 'Vergrößern')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
|
||||
<ToggleGroupItem value="fit" className="px-2 text-xs">
|
||||
Fit
|
||||
@@ -1861,20 +1964,37 @@ export function InviteLayoutCustomizerPanel({
|
||||
100%
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
{!isCompact ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
aria-label={t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
||||
@@ -312,11 +312,29 @@ export function DesignerCanvas({
|
||||
canvas.on('selection:cleared', handleSelectionCleared);
|
||||
canvas.on('object:modified', handleObjectModified);
|
||||
|
||||
const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const target = event?.target;
|
||||
if (!target || typeof target.elementId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedText = typeof (target as fabric.Textbox).text === 'string' ? (target as fabric.Textbox).text : target.text ?? '';
|
||||
handleObjectModified({ target });
|
||||
onChange(target.elementId, { content: updatedText });
|
||||
canvas.requestRenderAll();
|
||||
};
|
||||
|
||||
canvas.on('editing:exited', handleEditingExited);
|
||||
|
||||
return () => {
|
||||
canvas.off('selection:created', handleSelection);
|
||||
canvas.off('selection:updated', handleSelection);
|
||||
canvas.off('selection:cleared', handleSelectionCleared);
|
||||
canvas.off('object:modified', handleObjectModified);
|
||||
canvas.off('editing:exited', handleEditingExited);
|
||||
};
|
||||
}, [onChange, onSelect, readOnly]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user