event admin verfeinert und UI reduziert.
This commit is contained in:
@@ -169,7 +169,7 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand
|
|||||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-b from-white/80 via-white/60 to-transparent dark:from-slate-950 dark:via-slate-950/90 dark:to-slate-950/80" />
|
||||||
<div className="relative z-10 flex min-h-svh flex-col">
|
<div className="relative z-10 flex min-h-svh flex-col">
|
||||||
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
|
<header className="sticky top-0 z-40 border-b border-slate-200/70 bg-white/90 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/80">
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-start justify-between gap-3 px-4 py-4 sm:flex-nowrap sm:items-center sm:px-6">
|
<div className="mx-auto grid w-full max-w-6xl grid-cols-[1fr_auto] items-start gap-3 px-4 py-4 sm:px-6">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
|
<p className="text-[10px] font-semibold uppercase tracking-[0.4em] text-rose-500 dark:text-rose-200">{t('app.brand')}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -177,7 +177,7 @@ export function AdminLayout({ title, subtitle, actions, children, disableCommand
|
|||||||
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
|
{subtitle ? <p className="text-xs text-slate-600 dark:text-slate-300 sm:text-sm">{subtitle}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{disableCommandShelf ? <EventSwitcher compact /> : null}
|
{disableCommandShelf ? <EventSwitcher compact /> : null}
|
||||||
{actions}
|
{actions}
|
||||||
<NotificationCenter />
|
<NotificationCenter />
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export function DashboardEventFocusCard({
|
|||||||
description: t('actions.photosHint', 'Neueste Uploads ansehen und verstecken.'),
|
description: t('actions.photosHint', 'Neueste Uploads ansehen und verstecken.'),
|
||||||
icon: Camera,
|
icon: Camera,
|
||||||
handler: onOpenPhotos,
|
handler: onOpenPhotos,
|
||||||
|
disabled: !isLive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'invites',
|
key: 'invites',
|
||||||
@@ -130,8 +131,6 @@ export function DashboardEventFocusCard({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const latestUploads = summary?.new_photos ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="border border-slate-200 bg-white/90 shadow-lg shadow-rose-100/40 dark:border-white/10 dark:bg-white/5">
|
<Card className="border border-slate-200 bg-white/90 shadow-lg shadow-rose-100/40 dark:border-white/10 dark:bg-white/5">
|
||||||
@@ -172,11 +171,11 @@ export function DashboardEventFocusCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{overviewStats.map((stat) => (
|
{overviewStats.map((stat) => (
|
||||||
<div key={stat.key} className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-sm text-slate-600 dark:border-white/10 dark:bg-white/5">
|
<div key={stat.key} className="rounded-2xl border border-slate-200 bg-white/80 p-3 text-xs text-slate-600 dark:border-white/10 dark:bg-white/5">
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-300">{stat.label}</p>
|
<p className="text-[11px] uppercase tracking-wide text-slate-500 dark:text-slate-300">{stat.label}</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">{stat.value}</p>
|
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">{stat.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +186,8 @@ export function DashboardEventFocusCard({
|
|||||||
key={action.key}
|
key={action.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={action.handler}
|
onClick={action.handler}
|
||||||
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left transition hover:border-rose-200 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5"
|
disabled={action.disabled}
|
||||||
|
className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white/90 p-4 text-left transition hover:border-rose-200 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-white/5"
|
||||||
>
|
>
|
||||||
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
<action.icon className="mt-1 h-5 w-5 text-rose-500 dark:text-rose-200" />
|
||||||
<div>
|
<div>
|
||||||
@@ -197,31 +197,6 @@ export function DashboardEventFocusCard({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-sky-50 p-4 text-slate-800 shadow-inner shadow-sky-100 dark:border-white/10 dark:bg-white/10 dark:text-white">
|
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('latestUploads.title', 'Neueste Uploads')}</p>
|
|
||||||
<p className="mt-2 text-3xl font-semibold">{latestUploads}</p>
|
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-300">{t('latestUploads.hint', 'Gerade eingetroffen – prüfe sie schnell.')}</p>
|
|
||||||
<Button size="sm" variant="secondary" className="mt-4" onClick={onOpenPhotos}>
|
|
||||||
{t('actions.photos', 'Uploads prüfen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white/80 p-4 text-slate-800 shadow-inner shadow-slate-100 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">{t('invitesCard.title', 'Galerie & Einladungen')}</p>
|
|
||||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-200">
|
|
||||||
{t('invitesCard.description', 'Kopiere den Gästelink oder exportiere QR-Karten.')}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={onOpenInvites}>
|
|
||||||
{t('invitesCard.cta', 'Links verwalten')}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenPhotobooth}>
|
|
||||||
{t('invitesCard.secondaryCta', 'Photobooth öffnen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import React from 'react';
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
CalendarDays,
|
|
||||||
Camera,
|
Camera,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Users,
|
CalendarDays,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
QrCode,
|
QrCode,
|
||||||
@@ -20,11 +19,11 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
TenantOnboardingChecklistCard,
|
TenantOnboardingChecklistCard,
|
||||||
SectionCard,
|
SectionCard,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
StatCarousel,
|
|
||||||
ActionGrid,
|
ActionGrid,
|
||||||
} from '../components/tenant';
|
} from '../components/tenant';
|
||||||
import type { ChecklistStep } from '../components/tenant';
|
import type { ChecklistStep } from '../components/tenant';
|
||||||
@@ -83,7 +82,7 @@ export default function DashboardPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { events: ctxEvents, activeEvent: ctxActiveEvent } = useEventContext();
|
const { events: ctxEvents, activeEvent: ctxActiveEvent, selectEvent } = useEventContext();
|
||||||
const { progress, markStep } = useOnboardingProgress();
|
const { progress, markStep } = useOnboardingProgress();
|
||||||
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
|
const { t, i18n } = useTranslation('dashboard', { keyPrefix: 'dashboard' });
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
@@ -226,11 +225,19 @@ export default function DashboardPage() {
|
|||||||
return activePackage.remaining_events > 0;
|
return activePackage.remaining_events > 0;
|
||||||
}, [activePackage]);
|
}, [activePackage]);
|
||||||
|
|
||||||
const upcomingEvents = getUpcomingEvents(ctxEvents.length ? ctxEvents : events);
|
const eventOptions = ctxEvents.length ? ctxEvents : events;
|
||||||
const publishedEvents = (ctxEvents.length ? ctxEvents : events).filter((event) => event.status === 'published');
|
const [selectedSlug, setSelectedSlug] = React.useState<string | null>(() => ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
|
||||||
const primaryEvent = ctxActiveEvent ?? (ctxEvents[0] ?? events[0] ?? null);
|
React.useEffect(() => {
|
||||||
|
setSelectedSlug(ctxActiveEvent?.slug ?? eventOptions[0]?.slug ?? null);
|
||||||
|
}, [ctxActiveEvent?.slug, eventOptions]);
|
||||||
|
const upcomingEvents = getUpcomingEvents(eventOptions);
|
||||||
|
const publishedEvents = eventOptions.filter((event) => event.status === 'published');
|
||||||
|
const primaryEvent = React.useMemo(
|
||||||
|
() => eventOptions.find((event) => event.slug === selectedSlug) ?? eventOptions[0] ?? null,
|
||||||
|
[eventOptions, selectedSlug],
|
||||||
|
);
|
||||||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||||
const singleEvent = ctxEvents.length === 1 ? ctxEvents[0] : (events.length === 1 ? events[0] : null);
|
const singleEvent = eventOptions.length === 1 ? eventOptions[0] : null;
|
||||||
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
|
const singleEventName = singleEvent ? resolveEventName(singleEvent.name, singleEvent.slug) : null;
|
||||||
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
|
const singleEventDateLabel = singleEvent?.event_date ? formatDate(singleEvent.event_date, dateLocale) : null;
|
||||||
const primaryEventLimits = primaryEvent?.limits ?? null;
|
const primaryEventLimits = primaryEvent?.limits ?? null;
|
||||||
@@ -305,45 +312,6 @@ export default function DashboardPage() {
|
|||||||
return now >= eventStart && now <= eventStart + windowLengthMs;
|
return now >= eventStart && now <= eventStart + windowLengthMs;
|
||||||
});
|
});
|
||||||
}, [events]);
|
}, [events]);
|
||||||
const statItems = React.useMemo(
|
|
||||||
() => ([
|
|
||||||
{
|
|
||||||
key: 'activeEvents',
|
|
||||||
label: translate('overview.stats.activeEvents'),
|
|
||||||
value: summary?.active_events ?? publishedEvents.length,
|
|
||||||
hint: translate('overview.stats.publishedHint', { count: publishedEvents.length }),
|
|
||||||
icon: <CalendarDays className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'newPhotos',
|
|
||||||
label: translate('overview.stats.newPhotos', 'Neueste Uploads'),
|
|
||||||
value: summary?.new_photos ?? 0,
|
|
||||||
icon: <Camera className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'taskProgress',
|
|
||||||
label: translate('overview.stats.taskProgress'),
|
|
||||||
value: `${Math.round(summary?.task_progress ?? 0)}%`,
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
activePackage
|
|
||||||
? {
|
|
||||||
key: 'package',
|
|
||||||
label: translate('overview.stats.activePackage', 'Aktives Paket'),
|
|
||||||
value: activePackage.package_name,
|
|
||||||
icon: <PackageIcon className="h-4 w-4" />,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
].filter(Boolean) as {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
hint?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}[]),
|
|
||||||
[summary, publishedEvents.length, translate, activePackage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
|
const onboardingChecklist = React.useMemo<ChecklistStep[]>(() => {
|
||||||
const steps: ChecklistStep[] = [
|
const steps: ChecklistStep[] = [
|
||||||
{
|
{
|
||||||
@@ -577,6 +545,34 @@ export default function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div id="overview" className="space-y-6 scroll-mt-32">
|
<div id="overview" className="space-y-6 scroll-mt-32">
|
||||||
|
{eventOptions.length > 1 ? (
|
||||||
|
<SectionCard className="space-y-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={translate('overview.eventSwitcherEyebrow', 'Events')}
|
||||||
|
title={translate('overview.eventSwitcherTitle', 'Event auswählen')}
|
||||||
|
description={translate('overview.eventSwitcherDescription', 'Wechsle das Event, für das das Dashboard Daten anzeigt.')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={selectedSlug ?? ''}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedSlug(value);
|
||||||
|
selectEvent(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={translate('overview.eventSwitcherPlaceholder', 'Event auswählen')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{eventOptions.map((event) => (
|
||||||
|
<SelectItem key={event.slug} value={event.slug}>
|
||||||
|
{resolveEventName(event.name, event.slug)}
|
||||||
|
{event.event_date ? ` — ${formatDate(event.event_date, dateLocale) ?? ''}` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SectionCard>
|
||||||
|
) : null}
|
||||||
<DashboardEventFocusCard
|
<DashboardEventFocusCard
|
||||||
event={primaryEvent}
|
event={primaryEvent}
|
||||||
limitWarnings={limitWarnings}
|
limitWarnings={limitWarnings}
|
||||||
@@ -589,20 +585,6 @@ export default function DashboardPage() {
|
|||||||
onOpenTasks={focusActions.openTasks}
|
onOpenTasks={focusActions.openTasks}
|
||||||
onOpenPhotobooth={focusActions.openPhotobooth}
|
onOpenPhotobooth={focusActions.openPhotobooth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionCard className="space-y-3">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={translate('overview.title')}
|
|
||||||
title={translate('overview.title')}
|
|
||||||
description={translate('overview.description')}
|
|
||||||
endSlot={(
|
|
||||||
<Badge className="bg-brand-rose-soft text-brand-rose">
|
|
||||||
{activePackage?.package_name ?? translate('overview.noPackage')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<StatCarousel items={statItems} />
|
|
||||||
</SectionCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="live" className="space-y-6 scroll-mt-32">
|
<div id="live" className="space-y-6 scroll-mt-32">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Menu,
|
Menu,
|
||||||
Users,
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -85,7 +86,6 @@ export default function EventDetailPage() {
|
|||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { t: tCommon } = useTranslation('common');
|
const { t: tCommon } = useTranslation('common');
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
|
<p className="text-[10px] font-semibold uppercase tracking-[0.35em] text-rose-500 dark:text-rose-200">
|
||||||
{t('events.workspace.hero.badge', 'Event')}
|
{t('events.workspace.hero.badge', 'Event')}
|
||||||
</p>
|
</p>
|
||||||
@@ -435,6 +435,24 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||||
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
|
{t('events.workspace.hero.description', 'Konzentriere dich auf Aufgaben, Moderation und Einladungen für dieses Event.')}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
<Badge variant="secondary" className="gap-1 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-100">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{getStatusLabel(event, t)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||||||
|
<CalendarIcon />
|
||||||
|
{formatDate(event.event_date)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||||||
|
<Circle className={`h-3 w-3 ${event.is_active ? 'text-emerald-500' : 'text-slate-400'}`} />
|
||||||
|
{t('events.workspace.hero.liveBadge', 'Live?')} {event.is_active ? t('events.workspace.activeYes', 'Ja') : t('events.workspace.activeNo', 'Nein')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="gap-1 rounded-full border-slate-200 text-slate-700 dark:border-white/10 dark:text-white">
|
||||||
|
<Smile className="h-3.5 w-3.5 text-rose-500" />
|
||||||
|
{resolveEventType(event)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
|
<Button variant="outline" size="sm" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="rounded-full border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||||
@@ -450,13 +468,18 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-full border-slate-200"
|
className="rounded-full border-slate-200"
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : event.is_active ? <Circle className="mr-2 h-4 w-4" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
{busy ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : event.is_active ? (
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="mr-2 h-4 w-4 text-slate-500" />
|
||||||
|
)}
|
||||||
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
|
{event.is_active ? t('events.workspace.actions.pause', 'Event pausieren') : t('events.workspace.actions.activate', 'Event aktivieren')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||||||
<InviteSummary
|
<InviteSummary
|
||||||
@@ -514,52 +537,6 @@ function resolveName(name: TenantEvent['name']): string {
|
|||||||
return 'Event';
|
return 'Event';
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusCard({ event, stats, busy, onToggle }: { event: TenantEvent; stats: EventStats | null; busy: boolean; onToggle: () => void }) {
|
|
||||||
const { t } = useTranslation('management');
|
|
||||||
|
|
||||||
const statusLabel = getStatusLabel(event, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard className="space-y-4">
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow={t('events.workspace.sections.statusBadge', 'Status')}
|
|
||||||
title={t('events.workspace.sections.statusTitle', 'Eventstatus & Sichtbarkeit')}
|
|
||||||
description={t('events.workspace.sections.statusSubtitle', 'Aktiviere dein Event für Gäste oder verstecke es vorübergehend.')}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<InfoRow icon={<Sparkles className="h-4 w-4 text-pink-500" />} label={t('events.workspace.fields.status', 'Status')} value={statusLabel} />
|
|
||||||
<InfoRow icon={<Circle className="h-4 w-4 text-amber-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')} />
|
|
||||||
<InfoRow icon={<CalendarIcon />} label={t('events.workspace.fields.date', 'Eventdatum')} value={formatDate(event.event_date)} />
|
|
||||||
<InfoRow icon={<Smile className="h-4 w-4 text-rose-500" />} label={t('events.workspace.fields.eventType', 'Event-Typ')} value={resolveEventType(event)} />
|
|
||||||
|
|
||||||
{stats && (
|
|
||||||
<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 ?? stats.total ?? 0,
|
|
||||||
})}
|
|
||||||
{' · '}
|
|
||||||
{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 ?? stats.likes ?? 0,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
|
function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnType<typeof useNavigate> }) {
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
|
|
||||||
@@ -577,7 +554,8 @@ function QuickActionsMenu({ slug, navigate }: { slug: string; navigate: ReturnTy
|
|||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
className="fixed bottom-6 right-6 z-40 h-12 w-12 rounded-full bg-rose-500 text-white shadow-xl shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-2 focus-visible:ring-rose-300"
|
className="fixed right-6 z-40 h-12 w-12 rounded-full bg-rose-500 text-white shadow-xl shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-2 focus-visible:ring-rose-300"
|
||||||
|
style={{ bottom: '90px' }}
|
||||||
aria-label={t('events.quickActions.badge', 'Schnellaktionen')}
|
aria-label={t('events.quickActions.badge', 'Schnellaktionen')}
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
@@ -1296,18 +1274,6 @@ function getAudienceLabel(scope: string, t: ReturnType<typeof useTranslation>['t
|
|||||||
return t('events.notifications.audienceAll', 'Alle Gäste');
|
return t('events.notifications.audienceAll', 'Alle Gäste');
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-slate-100 bg-white/70 px-3 py-2 text-sm text-slate-700">
|
|
||||||
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-600">{icon}</span>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
|
||||||
<p className="text-sm font-semibold text-slate-900">{value || '—'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
|
function getStatusLabel(event: TenantEvent, t: ReturnType<typeof useTranslation>['t']): string {
|
||||||
if (event.status === 'published') {
|
if (event.status === 'published') {
|
||||||
return t('events.status.published', 'Veröffentlicht');
|
return t('events.status.published', 'Veröffentlicht');
|
||||||
@@ -1328,13 +1294,24 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveEventType(event: TenantEvent): string {
|
function resolveEventType(event: TenantEvent): string {
|
||||||
if (event.event_type?.name) {
|
const type = event.event_type;
|
||||||
if (typeof event.event_type.name === 'string') {
|
if (!type) {
|
||||||
return event.event_type.name;
|
return '—';
|
||||||
}
|
}
|
||||||
const translations = event.event_type.name as Record<string, string>;
|
|
||||||
|
if (type.name_translations && Object.keys(type.name_translations).length > 0) {
|
||||||
|
return type.name_translations.de ?? type.name_translations.en ?? Object.values(type.name_translations)[0] ?? type.name ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof type.name === 'string' && type.name.trim().length > 0) {
|
||||||
|
return type.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.name && typeof type.name === 'object') {
|
||||||
|
const translations = type.name as Record<string, string>;
|
||||||
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
|
return translations.de ?? translations.en ?? Object.values(translations)[0] ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -948,12 +948,12 @@ export default function EventInvitesPage(): React.ReactElement {
|
|||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||||
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
|
||||||
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
|
||||||
{t('invites.tabs.layout', 'Layout anpassen')}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
<TabsTrigger value="share" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
{t('invites.tabs.share', 'Links & QR teilen')}
|
{t('invites.tabs.share', 'Links & QR teilen')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
|
{t('invites.tabs.layout', 'Layout anpassen')}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
<TabsTrigger value="export" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||||
{t('invites.tabs.export', 'Drucken & Export')}
|
{t('invites.tabs.export', 'Drucken & Export')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -1623,30 +1623,157 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
<SelectContent className="max-h-60">
|
<SelectContent className="max-h-60">
|
||||||
{availableLayouts.map((layout) => (
|
{availableLayouts.map((layout) => (
|
||||||
<SelectItem key={layout.id} value={layout.id}>
|
<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">
|
||||||
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
|
{layout.name || t('invites.customizer.layoutFallback', 'Layout')}
|
||||||
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
|
</span>
|
||||||
{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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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 gap-1 text-xs sm:text-sm">
|
||||||
|
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</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">
|
||||||
|
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-headline"
|
||||||
|
value={form.headline ?? ''}
|
||||||
|
onChange={(event) => updateForm('headline', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-subtitle"
|
||||||
|
value={form.subtitle ?? ''}
|
||||||
|
onChange={(event) => updateForm('subtitle', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-description">{t('invites.customizer.fields.description', 'Beschreibung')}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="invite-description"
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onChange={(event) => updateForm('description', event.target.value)}
|
||||||
|
className="min-h-[96px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-badge"
|
||||||
|
value={form.badge_label ?? ''}
|
||||||
|
onChange={(event) => updateForm('badge_label', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-cta"
|
||||||
|
value={form.cta_label ?? ''}
|
||||||
|
onChange={(event) => updateForm('cta_label', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-link-heading"
|
||||||
|
value={form.link_heading ?? ''}
|
||||||
|
onChange={(event) => updateForm('link_heading', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-link-label"
|
||||||
|
value={form.link_label ?? ''}
|
||||||
|
onChange={(event) => updateForm('link_label', event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="branding" className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-accent">{t('invites.customizer.fields.accentColor', 'Akzentfarbe')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-accent"
|
||||||
|
type="color"
|
||||||
|
value={form.accent_color ?? '#6366F1'}
|
||||||
|
onChange={(event) => updateForm('accent_color', event.target.value)}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-text-color">{t('invites.customizer.fields.textColor', 'Textfarbe')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-text-color"
|
||||||
|
type="color"
|
||||||
|
value={form.text_color ?? '#111827'}
|
||||||
|
onChange={(event) => updateForm('text_color', event.target.value)}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-background-color">{t('invites.customizer.fields.backgroundColor', 'Hintergrund')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-background-color"
|
||||||
|
type="color"
|
||||||
|
value={form.background_color ?? '#FFFFFF'}
|
||||||
|
onChange={(event) => updateForm('background_color', event.target.value)}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-badge-color"
|
||||||
|
type="color"
|
||||||
|
value={form.badge_color ?? '#2563EB'}
|
||||||
|
onChange={(event) => updateForm('badge_color', event.target.value)}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
||||||
|
{form.logo_data_url ? (
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-3 text-[var(--tenant-foreground-soft)]">
|
||||||
|
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-[var(--tenant-border-strong)] object-contain" />
|
||||||
|
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-destructive hover:text-destructive/80">
|
||||||
|
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-4 py-3 text-sm text-muted-foreground hover:border-primary">
|
||||||
|
<UploadCloud className="h-4 w-4" />
|
||||||
|
<span>{t('invites.customizer.actions.uploadLogo', 'Logo hochladen (max. 1 MB)')}</span>
|
||||||
|
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
{renderResponsiveSection(
|
{renderResponsiveSection(
|
||||||
'elements',
|
'elements',
|
||||||
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
|
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
|
||||||
@@ -1725,182 +1852,6 @@ export function InviteLayoutCustomizerPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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 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">
|
|
||||||
<Label htmlFor="invite-headline">{t('invites.customizer.fields.headline', 'Überschrift')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-headline"
|
|
||||||
value={form.headline ?? ''}
|
|
||||||
onChange={(event) => updateForm('headline', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-subtitle">{t('invites.customizer.fields.subtitle', 'Unterzeile')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-subtitle"
|
|
||||||
value={form.subtitle ?? ''}
|
|
||||||
onChange={(event) => updateForm('subtitle', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-description">{t('invites.customizer.fields.description', 'Beschreibung')}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="invite-description"
|
|
||||||
value={form.description ?? ''}
|
|
||||||
onChange={(event) => updateForm('description', event.target.value)}
|
|
||||||
className="min-h-[96px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-badge">{t('invites.customizer.fields.badge', 'Badge-Label')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-badge"
|
|
||||||
value={form.badge_label ?? ''}
|
|
||||||
onChange={(event) => updateForm('badge_label', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-cta">{t('invites.customizer.fields.cta', 'Call-to-Action')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-cta"
|
|
||||||
value={form.cta_label ?? ''}
|
|
||||||
onChange={(event) => updateForm('cta_label', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-link-heading">{t('invites.customizer.fields.linkHeading', 'Link-Überschrift')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-link-heading"
|
|
||||||
value={form.link_heading ?? ''}
|
|
||||||
onChange={(event) => updateForm('link_heading', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-link-label">{t('invites.customizer.fields.linkLabel', 'Link/Begleittext')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-link-label"
|
|
||||||
value={form.link_label ?? ''}
|
|
||||||
onChange={(event) => updateForm('link_label', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="instructions" className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-instruction-heading">{t('invites.customizer.fields.instructionsHeading', 'Abschnittsüberschrift')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-instruction-heading"
|
|
||||||
value={form.instructions_heading ?? ''}
|
|
||||||
onChange={(event) => updateForm('instructions_heading', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{instructions.map((entry, index) => (
|
|
||||||
<div key={`instruction-${index}`} className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={entry}
|
|
||||||
onChange={(event) => handleInstructionChange(index, event.target.value)}
|
|
||||||
placeholder={t('invites.customizer.fields.instructionPlaceholder', 'Beschreibung des Schritts')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => handleRemoveInstruction(index)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddInstruction} disabled={instructions.length >= MAX_INSTRUCTIONS}>
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
|
||||||
{t('invites.customizer.actions.addInstruction', 'Punkt hinzufügen')}
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="branding" className="space-y-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-accent">{t('invites.customizer.fields.accentColor', 'Akzentfarbe')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-accent"
|
|
||||||
type="color"
|
|
||||||
value={form.accent_color ?? '#6366F1'}
|
|
||||||
onChange={(event) => updateForm('accent_color', event.target.value)}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-text-color">{t('invites.customizer.fields.textColor', 'Textfarbe')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-text-color"
|
|
||||||
type="color"
|
|
||||||
value={form.text_color ?? '#111827'}
|
|
||||||
onChange={(event) => updateForm('text_color', event.target.value)}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-background-color">{t('invites.customizer.fields.backgroundColor', 'Hintergrund')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-background-color"
|
|
||||||
type="color"
|
|
||||||
value={form.background_color ?? '#FFFFFF'}
|
|
||||||
onChange={(event) => updateForm('background_color', event.target.value)}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="invite-badge-color">{t('invites.customizer.fields.badgeColor', 'Badge')}</Label>
|
|
||||||
<Input
|
|
||||||
id="invite-badge-color"
|
|
||||||
type="color"
|
|
||||||
value={form.badge_color ?? '#2563EB'}
|
|
||||||
onChange={(event) => updateForm('badge_color', event.target.value)}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('invites.customizer.fields.logo', 'Logo')}</Label>
|
|
||||||
{form.logo_data_url ? (
|
|
||||||
<div className="flex items-center gap-4 rounded-lg border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-3 text-[var(--tenant-foreground-soft)]">
|
|
||||||
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-[var(--tenant-border-strong)] object-contain" />
|
|
||||||
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-destructive hover:text-destructive/80">
|
|
||||||
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] px-4 py-3 text-sm text-muted-foreground hover:border-primary">
|
|
||||||
<UploadCloud className="h-4 w-4" />
|
|
||||||
<span>{t('invites.customizer.actions.uploadLogo', 'Logo hochladen (max. 1 MB)')}</span>
|
|
||||||
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||||
{renderActionButtons('inline')}
|
{renderActionButtons('inline')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user