rework of the event admin UI
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Circle, Copy, Download, ExternalLink, Link2, Loader2, Mail, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
@@ -63,7 +66,17 @@ interface PageState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type TabKey = 'layout' | 'export' | 'links';
|
||||
type TabKey = 'layout' | 'share' | 'export';
|
||||
|
||||
function resolveTabKey(value: string | null): TabKey {
|
||||
if (value === 'export') {
|
||||
return 'export';
|
||||
}
|
||||
if (value === 'share' || value === 'links') {
|
||||
return 'share';
|
||||
}
|
||||
return 'layout';
|
||||
}
|
||||
|
||||
const HEX_COLOR_FULL = /^#([0-9A-Fa-f]{6})$/;
|
||||
const HEX_COLOR_SHORT = /^#([0-9A-Fa-f]{3})$/;
|
||||
@@ -180,7 +193,7 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
const [customizerDraft, setCustomizerDraft] = React.useState<QrLayoutCustomization | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get('tab');
|
||||
const initialTab = tabParam === 'export' || tabParam === 'links' ? (tabParam as TabKey) : 'layout';
|
||||
const initialTab = resolveTabKey(tabParam);
|
||||
const [activeTab, setActiveTab] = React.useState<TabKey>(initialTab);
|
||||
const [exportDownloadBusy, setExportDownloadBusy] = React.useState<string | null>(null);
|
||||
const [exportPrintBusy, setExportPrintBusy] = React.useState<string | null>(null);
|
||||
@@ -244,20 +257,19 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
}, [recomputeExportScale]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const param = searchParams.get('tab');
|
||||
const nextTab = param === 'export' || param === 'links' ? (param as TabKey) : 'layout';
|
||||
const nextTab = resolveTabKey(searchParams.get('tab'));
|
||||
setActiveTab((current) => (current === nextTab ? current : nextTab));
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
(value: string) => {
|
||||
const nextTab = value === 'export' || value === 'links' ? (value as TabKey) : 'layout';
|
||||
const nextTab = resolveTabKey(value);
|
||||
setActiveTab(nextTab);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
if (nextTab === 'layout') {
|
||||
nextParams.delete('tab');
|
||||
} else {
|
||||
nextParams.set('tab', nextTab);
|
||||
nextParams.set('tab', nextTab === 'share' ? 'share' : 'export');
|
||||
}
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
},
|
||||
@@ -267,6 +279,17 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
const event = state.event;
|
||||
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
|
||||
const eventDate = event?.event_date ?? null;
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event || !slug) {
|
||||
return [];
|
||||
}
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return buildEventTabs(event, translateMenu, {
|
||||
invites: state.invites.length,
|
||||
photos: event.photo_count ?? event.pending_photo_count ?? undefined,
|
||||
tasks: event.tasks_count ?? undefined,
|
||||
});
|
||||
}, [event, slug, state.invites.length, t]);
|
||||
|
||||
const selectedInvite = React.useMemo(
|
||||
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
|
||||
@@ -472,6 +495,39 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
return { active, total };
|
||||
}, [state.invites]);
|
||||
|
||||
const primaryInvite = React.useMemo(() => selectedInvite ?? state.invites[0] ?? null, [selectedInvite, state.invites]);
|
||||
|
||||
const workflowSteps = React.useMemo<InviteWorkflowStep[]>(() => {
|
||||
const layoutReady = Boolean(effectiveCustomization);
|
||||
const shareReady = state.invites.length > 0;
|
||||
const exportReady = Boolean(exportPreview && exportElements.length);
|
||||
const mapStatus = (tab: TabKey, done: boolean) => {
|
||||
if (done) return 'done';
|
||||
if (activeTab === tab) return 'active';
|
||||
return 'pending';
|
||||
};
|
||||
return [
|
||||
{
|
||||
key: 'layout',
|
||||
title: t('invites.workflow.steps.layout.title', 'Vorlage wählen'),
|
||||
description: t('invites.workflow.steps.layout.description', 'Wähle ein Layout und passe Texte, Farben und QR-Elemente an.'),
|
||||
status: mapStatus('layout', layoutReady),
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
title: t('invites.workflow.steps.share.title', 'Links & QR teilen'),
|
||||
description: t('invites.workflow.steps.share.description', 'Aktiviere Gästelinks, kopiere QR-Codes und verteile sie im Team.'),
|
||||
status: mapStatus('share', shareReady),
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
title: t('invites.workflow.steps.export.title', 'Drucken & Export'),
|
||||
description: t('invites.workflow.steps.export.description', 'Erzeuge PDFs oder PNGs für den Druck deiner Karten.'),
|
||||
status: mapStatus('export', exportReady),
|
||||
},
|
||||
];
|
||||
}, [activeTab, effectiveCustomization, exportElements.length, exportPreview, state.invites.length, t]);
|
||||
|
||||
async function handleCreateInvite() {
|
||||
if (!slug || creatingInvite) {
|
||||
return;
|
||||
@@ -830,6 +886,8 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
title={eventName}
|
||||
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
|
||||
actions={actions}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="invites"
|
||||
>
|
||||
{limitWarnings.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
@@ -887,17 +945,19 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<InviteWorkflowSteps steps={workflowSteps} onSelectStep={(tab) => handleTabChange(tab)} />
|
||||
|
||||
<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">
|
||||
<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">
|
||||
{t('invites.tabs.share', 'Links & QR teilen')}
|
||||
</TabsTrigger>
|
||||
<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')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="links" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">
|
||||
{t('invites.tabs.links', 'QR-Codes verwalten')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{state.error ? (
|
||||
@@ -1220,7 +1280,17 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="links" className="space-y-6 focus-visible:outline-hidden">
|
||||
<TabsContent value="share" className="space-y-6 focus-visible:outline-hidden">
|
||||
{primaryInvite ? (
|
||||
<InviteShareSummaryCard
|
||||
invite={primaryInvite}
|
||||
onCopy={() => handleCopy(primaryInvite)}
|
||||
onCreate={handleCreateInvite}
|
||||
onOpenLayout={() => handleTabChange('layout')}
|
||||
onOpenExport={() => handleTabChange('export')}
|
||||
stats={inviteCountSummary}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] shadow-xl shadow-primary/10 backdrop-blur-sm transition-colors">
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
@@ -1292,6 +1362,152 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
type InviteWorkflowStep = {
|
||||
key: TabKey;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'done' | 'active' | 'pending';
|
||||
};
|
||||
|
||||
function InviteWorkflowSteps({ steps, onSelectStep }: { steps: InviteWorkflowStep[]; onSelectStep: (tab: TabKey) => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)]/80 shadow-sm shadow-primary/10">
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-foreground">
|
||||
{t('invites.workflow.title', 'Einladungs-Workflow')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('invites.workflow.description', 'Durchlaufe die Schritte in Reihenfolge – Layout gestalten, Links teilen, Export starten.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/30 text-xs uppercase tracking-[0.2em] text-primary">
|
||||
{t('invites.workflow.badge', 'Setup')}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-3">
|
||||
{steps.map((step) => {
|
||||
const isDone = step.status === 'done';
|
||||
const isActive = step.status === 'active';
|
||||
return (
|
||||
<button
|
||||
key={step.key}
|
||||
type="button"
|
||||
className={`flex flex-col gap-2 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: isDone
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => onSelectStep(step.key)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm font-semibold">{step.title}</span>
|
||||
</div>
|
||||
<p className="text-xs leading-snug text-current/80">{step.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type InviteShareSummaryProps = {
|
||||
invite: EventQrInvite;
|
||||
onCopy: () => void;
|
||||
onCreate: () => void;
|
||||
onOpenLayout: () => void;
|
||||
onOpenExport: () => void;
|
||||
stats: { active: number; total: number };
|
||||
};
|
||||
|
||||
function InviteShareSummaryCard({ invite, onCopy, onCreate, onOpenLayout, onOpenExport, stats }: InviteShareSummaryProps) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border border-[var(--tenant-border-strong)] bg-gradient-to-r from-[var(--tenant-surface-muted)] via-[var(--tenant-surface)] to-[var(--tenant-surface-strong)] shadow-lg shadow-primary/10">
|
||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-foreground">
|
||||
<Link2 className="h-5 w-5 text-primary" />
|
||||
{t('invites.share.title', 'Schnellzugriff auf Gästelink')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t('invites.share.description', 'Nutze den Standardlink, um QR-Codes zu teilen oder weitere Karten zu erzeugen.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
|
||||
{t('invites.share.stats.active', { defaultValue: '{{count}} aktiv', count: stats.active })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-[var(--tenant-border-strong)] text-muted-foreground">
|
||||
{t('invites.share.stats.total', { defaultValue: '{{count}} gesamt', count: stats.total })}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-[var(--tenant-border-strong)] bg-white/90 p-4 text-sm text-muted-foreground">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-muted-foreground">{t('invites.share.primaryLabel', 'Hauptlink')}</span>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<span className="break-all font-mono text-xs text-foreground">{invite.url}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onCopy}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
{t('invites.share.actions.copy', 'Link kopieren')}
|
||||
</Button>
|
||||
{invite.url ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.open(invite.url ?? '#', '_blank', 'noopener');
|
||||
}}
|
||||
className="text-primary"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3.5 w-3.5" />
|
||||
{t('invites.share.actions.open', 'Öffnen')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Button variant="secondary" onClick={onOpenLayout} className="justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{t('invites.share.actions.editLayout', 'Layout bearbeiten')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('invites.share.actions.editHint', 'Farben & Texte direkt im Editor anpassen.')}</p>
|
||||
</div>
|
||||
<ArrowLeft className="h-4 w-4 rotate-180" />
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={onOpenExport} className="flex-1">
|
||||
<Printer className="mr-2 h-4 w-4" />
|
||||
{t('invites.share.actions.export', 'Drucken/Export')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onCreate} className="flex-1">
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
{t('invites.share.actions.create', 'Weitere Einladung')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Mail className="mr-1 inline h-3.5 w-3.5 text-primary" />
|
||||
{t('invites.share.hint', 'Teile den Link direkt im Team oder binde ihn im Newsletter ein.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCustomizerSkeleton(): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user