rearranged tenant admin layout, invite layouts now visible and manageable

This commit is contained in:
Codex Agent
2025-10-29 12:36:34 +01:00
parent a7bbf230fd
commit d781448914
31 changed files with 2190 additions and 1685 deletions

View File

@@ -1,7 +1,7 @@
import { authorizedFetch } from './auth/tokens';
import i18n from './i18n';
type JsonValue = Record<string, any>;
type JsonValue = Record<string, unknown>;
export type EventQrInviteLayout = {
id: string;
@@ -40,7 +40,11 @@ export type TenantEvent = {
is_active?: boolean;
description?: string | null;
photo_count?: number;
pending_photo_count?: number;
like_count?: number;
tasks_count?: number;
active_invites_count?: number;
total_invites_count?: number;
engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
package?: {
@@ -442,7 +446,26 @@ function normalizeEvent(event: JsonValue): TenantEvent {
is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined,
description: event.description ?? null,
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
pending_photo_count: event.pending_photo_count !== undefined ? Number(event.pending_photo_count ?? 0) : undefined,
like_count:
event.like_count !== undefined
? Number(event.like_count ?? 0)
: event.likes_sum !== undefined
? Number(event.likes_sum ?? 0)
: undefined,
tasks_count: event.tasks_count !== undefined ? Number(event.tasks_count ?? 0) : undefined,
active_invites_count:
event.active_invites_count !== undefined
? Number(event.active_invites_count ?? 0)
: event.active_join_tokens_count !== undefined
? Number(event.active_join_tokens_count ?? 0)
: undefined,
total_invites_count:
event.total_invites_count !== undefined
? Number(event.total_invites_count ?? 0)
: event.total_join_tokens_count !== undefined
? Number(event.total_join_tokens_count ?? 0)
: undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null,
@@ -638,9 +661,9 @@ function normalizeMember(member: JsonValue): EventMember {
}
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
const rawLayouts = Array.isArray(raw.layouts) ? (raw.layouts as JsonValue[]) : [];
const layouts: EventQrInviteLayout[] = rawLayouts
.map((layout: any) => {
.map((layout: JsonValue) => {
const formats = Array.isArray(layout.formats)
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
: [];

View File

@@ -6,19 +6,15 @@ import {
ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_TASK_COLLECTIONS_PATH,
ADMIN_EMOTIONS_PATH,
ADMIN_ENGAGEMENT_PATH,
} from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
{ to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' },
{ to: ADMIN_TASK_COLLECTIONS_PATH, labelKey: 'navigation.collections' },
{ to: ADMIN_EMOTIONS_PATH, labelKey: 'navigation.emotions' },
{ to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement' },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
];

View File

@@ -8,9 +8,9 @@ export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_TASKS_PATH = adminPath('/tasks');
export const ADMIN_TASK_COLLECTIONS_PATH = adminPath('/task-collections');
export const ADMIN_EMOTIONS_PATH = adminPath('/emotions');
export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
`${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
export const ADMIN_BILLING_PATH = adminPath('/billing');
export const ADMIN_PHOTOS_PATH = adminPath('/photos');
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome');
@@ -25,3 +25,4 @@ export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/eve
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/invites`);

View File

@@ -9,6 +9,7 @@
"tasks": "Aufgaben",
"collections": "Aufgabenvorlagen",
"emotions": "Emotionen",
"engagement": "Aufgaben & Co.",
"billing": "Abrechnung",
"settings": "Einstellungen"
},

View File

@@ -314,6 +314,83 @@
}
}
},
"invites": {
"cardTitle": "QR-Einladungen & Layouts",
"cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.",
"subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.",
"summary": {
"active": "Aktive Einladungen",
"total": "Gesamt"
},
"actions": {
"refresh": "Aktualisieren",
"create": "Neue Einladung erstellen",
"backToList": "Zurück zur Übersicht",
"backToEvent": "Event öffnen",
"copy": "Link kopieren",
"copied": "Kopiert!",
"deactivate": "Deaktivieren"
},
"labels": {
"usage": "Nutzung",
"layout": "Layout",
"layoutFallback": "Standard",
"selected": "Aktuell ausgewählt",
"tapToEdit": "Zum Anpassen auswählen",
"noPrintSource": "Keine druckbare Version verfügbar."
},
"empty": {
"title": "Noch keine Einladungen",
"copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten."
},
"errorTitle": "Aktion fehlgeschlagen",
"customizer": {
"heading": "Layout anpassen",
"copy": "Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.",
"actions": {
"save": "Layout speichern",
"reset": "Zurücksetzen",
"print": "Drucken",
"removeLogo": "Logo entfernen",
"uploadLogo": "Logo hochladen (max. 1 MB)",
"addInstruction": "Punkt hinzufügen"
},
"sections": {
"layouts": "Layouts",
"layoutsHint": "Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.",
"text": "Texte",
"instructions": "Schritt-für-Schritt",
"instructionsHint": "Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.",
"branding": "Branding"
},
"fields": {
"headline": "Überschrift",
"subtitle": "Unterzeile",
"description": "Beschreibung",
"badge": "Badge-Label",
"cta": "Call-to-Action",
"linkHeading": "Link-Überschrift",
"linkLabel": "Link/Begleittext",
"instructionsHeading": "Abschnittsüberschrift",
"instructionPlaceholder": "Beschreibung des Schritts",
"accentColor": "Akzentfarbe",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrund",
"badgeColor": "Badge",
"logo": "Logo"
},
"preview": {
"title": "Live-Vorschau",
"subtitle": "So sieht dein Layout beim Export aus."
},
"placeholderTitle": "Kein Layout verfügbar",
"placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.",
"loadingTitle": "Layouts werden geladen",
"loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.",
"loadingError": "Layouts konnten nicht geladen werden.",
"layoutFallback": "Layout"
}
},
"collections": {
"title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
@@ -365,6 +442,9 @@
"page": "Seite {{current}} von {{total}}"
}
},
"engagement": {
"subtitle": "Plane Aufgaben, Vorlagen und Emotionen gebündelt für deine Events."
},
"emotions": {
"title": "Emotionen",
"subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.",

View File

@@ -9,6 +9,7 @@
"tasks": "Tasks",
"collections": "Collections",
"emotions": "Emotions",
"engagement": "Tasks & More",
"billing": "Billing",
"settings": "Settings"
},

View File

@@ -314,6 +314,83 @@
}
}
},
"invites": {
"cardTitle": "QR invites & layouts",
"cardDescription": "Create invite links, customise layouts, and prepare print-ready PDFs.",
"subtitle": "Manage invite links, layouts, and branding for your guests.",
"summary": {
"active": "Active invites",
"total": "Total"
},
"actions": {
"refresh": "Refresh",
"create": "Create invite",
"backToList": "Back to list",
"backToEvent": "Open event",
"copy": "Copy link",
"copied": "Copied!",
"deactivate": "Deactivate"
},
"labels": {
"usage": "Usage",
"layout": "Layout",
"layoutFallback": "Default",
"selected": "Currently selected",
"tapToEdit": "Select to edit",
"noPrintSource": "No printable version available."
},
"empty": {
"title": "No invites yet",
"copy": "Create an invite to generate ready-to-print QR layouts."
},
"errorTitle": "Action failed",
"customizer": {
"heading": "Customise layout",
"copy": "Make the invite your own adjust copy, colours, and logos in real time.",
"actions": {
"save": "Save layout",
"reset": "Reset",
"print": "Print",
"removeLogo": "Remove logo",
"uploadLogo": "Upload logo (max. 1 MB)",
"addInstruction": "Add step"
},
"sections": {
"layouts": "Layouts",
"layoutsHint": "Pick a starting template. You can switch at any time.",
"text": "Text",
"instructions": "Step-by-step",
"instructionsHint": "Guide guests with clear steps. Maximum of five.",
"branding": "Branding"
},
"fields": {
"headline": "Headline",
"subtitle": "Subheading",
"description": "Description",
"badge": "Badge label",
"cta": "Call-to-action",
"linkHeading": "Link heading",
"linkLabel": "Link/short URL",
"instructionsHeading": "Section heading",
"instructionPlaceholder": "Describe this step",
"accentColor": "Accent colour",
"textColor": "Text colour",
"backgroundColor": "Background",
"badgeColor": "Badge colour",
"logo": "Logo"
},
"preview": {
"title": "Live preview",
"subtitle": "See the export-ready version instantly."
},
"placeholderTitle": "No layout available",
"placeholderCopy": "Create an invite first to customise copy, colours, and print layouts.",
"loadingTitle": "Loading layouts",
"loadingDescription": "One moment we are preparing the available layouts.",
"loadingError": "Layouts could not be loaded.",
"layoutFallback": "Layout"
}
},
"collections": {
"title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.",
@@ -362,8 +439,11 @@
"pagination": {
"prev": "Previous",
"next": "Next",
"page": "Page {{current}} of {{total}}"
}
"page": "Page {{current}} of {{total}}"
}
},
"engagement": {
"subtitle": "Manage tasks, collections, and emotions from a single workspace."
},
"emotions": {
"title": "Emotions",

View File

@@ -26,7 +26,7 @@ export default function AuthCallbackPage() {
if (isAuthError(err) && err.code === 'token_exchange_failed') {
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
} else if (isAuthError(err) && err.code === 'invalid_state') {
setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
setError('Ungültiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
} else {
setError('Unbekannter Fehler beim Login.');
}

View File

@@ -27,8 +27,6 @@ import {
getDashboardSummary,
getEvents,
getTenantPackagesOverview,
getEventTasks,
getEventQrInvites,
TenantEvent,
TenantPackageSummary,
} from '../api';
@@ -39,11 +37,11 @@ import {
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENTS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_SETTINGS_PATH,
ADMIN_WELCOME_BASE_PATH,
ADMIN_EVENT_CREATE_PATH,
buildEngagementTabPath,
} from '../constants';
import { useOnboardingProgress } from '../onboarding';
@@ -122,12 +120,18 @@ export default function DashboardPage() {
setReadiness({
hasEvent: events.length > 0,
hasTasks: false,
hasQrInvites: false,
hasTasks: primaryEvent ? (Number(primaryEvent.tasks_count ?? 0) > 0) : false,
hasQrInvites: primaryEvent
? Number(
primaryEvent.active_invites_count ??
primaryEvent.active_join_tokens_count ??
0
) > 0
: false,
hasPackage: Boolean(packages.activePackage),
primaryEventSlug: primaryEvent?.slug ?? null,
primaryEventName,
loading: Boolean(primaryEvent),
loading: false,
});
setState({
@@ -138,28 +142,7 @@ export default function DashboardPage() {
errorKey: null,
});
if (primaryEvent) {
try {
const [eventTasks, qrInvites] = await Promise.all([
getEventTasks(primaryEvent.id, 1),
getEventQrInvites(primaryEvent.slug),
]);
if (!cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: (eventTasks.data ?? []).length > 0,
hasQrInvites: qrInvites.length > 0,
loading: false,
}));
}
} catch (readinessError) {
if (!cancelled) {
console.warn('Failed to load readiness checklist', readinessError);
setReadiness((prev) => ({ ...prev, loading: false }));
}
}
} else if (!cancelled) {
if (!primaryEvent && !cancelled) {
setReadiness((prev) => ({
...prev,
hasTasks: false,
@@ -338,7 +321,7 @@ export default function DashboardPage() {
icon={<Users className="h-5 w-5" />}
label={translate('quickActions.organiseTasks.label')}
description={translate('quickActions.organiseTasks.description')}
onClick={() => navigate(ADMIN_TASKS_PATH)}
onClick={() => navigate(buildEngagementTabPath('tasks'))}
/>
<QuickAction
icon={<Sparkles className="h-5 w-5" />}
@@ -385,7 +368,7 @@ export default function DashboardPage() {
onOpenTasks={() =>
readiness.primaryEventSlug
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
: navigate(ADMIN_TASKS_PATH)
: navigate(buildEngagementTabPath('tasks'))
}
onOpenQr={() =>
readiness.primaryEventSlug

View File

@@ -3,34 +3,20 @@ import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import type { Locale } from 'date-fns';
import { Palette, Plus, Power, Smile } from 'lucide-react';
import { Loader2, Palette, Plus, Power, Smile } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import {
getEmotions,
createEmotion,
updateEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
const DEFAULT_COLOR = '#6366f1';
import { AdminLayout } from '../components/AdminLayout';
import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api';
import { isAuthError } from '../auth/tokens';
type EmotionFormState = {
name: string;
@@ -41,6 +27,7 @@ type EmotionFormState = {
sort_order: number;
};
const DEFAULT_COLOR = '#6366f1';
const INITIAL_FORM_STATE: EmotionFormState = {
name: '',
description: '',
@@ -50,7 +37,11 @@ const INITIAL_FORM_STATE: EmotionFormState = {
sort_order: 0,
};
export default function EmotionsPage(): JSX.Element {
export type EmotionsSectionProps = {
embedded?: boolean;
};
export function EmotionsSection({ embedded = false }: EmotionsSectionProps): JSX.Element {
const { t, i18n } = useTranslation('management');
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
@@ -89,10 +80,10 @@ export default function EmotionsPage(): JSX.Element {
};
}, [t]);
function openCreateDialog() {
const openCreateDialog = React.useCallback(() => {
setForm(INITIAL_FORM_STATE);
setDialogOpen(true);
}
}, []);
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -137,21 +128,13 @@ export default function EmotionsPage(): JSX.Element {
}
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = embedded ? t('emotions.title') : t('emotions.title');
const subtitle = embedded
? t('emotions.subtitle')
: t('emotions.subtitle');
return (
<AdminLayout
title={t('emotions.title') ?? 'Emotions'}
subtitle={t('emotions.subtitle') ?? ''}
actions={
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
{t('emotions.actions.create')}
</Button>
}
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle>
@@ -160,15 +143,27 @@ export default function EmotionsPage(): JSX.Element {
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{t('emotions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">{t('emotions.subtitle')}</CardDescription>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Palette className="h-5 w-5 text-pink-500" />
{title}
</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
{t('emotions.actions.create')}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{loading ? (
<EmotionSkeleton />
) : emotions.length === 0 ? (
<EmptyEmotionsState />
<EmptyEmotionsState onCreate={openCreateDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
@@ -177,7 +172,6 @@ export default function EmotionsPage(): JSX.Element {
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
locale={locale}
canToggle={!emotion.is_global}
/>
))}
</div>
@@ -193,6 +187,15 @@ export default function EmotionsPage(): JSX.Element {
saving={saving}
onSubmit={handleCreate}
/>
</div>
);
}
export default function EmotionsPage(): JSX.Element {
const { t } = useTranslation('management');
return (
<AdminLayout title={t('emotions.title')} subtitle={t('emotions.subtitle')}>
<EmotionsSection />
</AdminLayout>
);
}
@@ -201,95 +204,60 @@ function EmotionCard({
emotion,
onToggle,
locale,
canToggle,
}: {
emotion: TenantEmotion;
onToggle: () => void;
locale: Locale;
canToggle: boolean;
}) {
const { t } = useTranslation('management');
const updatedLabel = emotion.updated_at
? format(new Date(emotion.updated_at), 'PP', { locale })
: null;
const updated = emotion.updated_at ? format(new Date(emotion.updated_at), 'Pp', { locale }) : null;
return (
<Card className="border border-slate-100 bg-white/90 shadow-sm">
<CardHeader className="space-y-3">
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
<CardHeader className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={emotion.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
<div
className="flex h-9 w-9 items-center justify-center rounded-full"
style={{ backgroundColor: `${emotion.color}20`, color: emotion.color ?? DEFAULT_COLOR }}
>
{emotion.is_global ? t('emotions.scope.global') : t('emotions.scope.tenant')}
</Badge>
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{emotion.event_types.map((type) => type.name).join(', ') || t('emotions.labels.noEventType')}
</Badge>
<Smile className="h-4 w-4" />
</div>
<div>
<CardTitle className="text-base text-slate-900">{emotion.name}</CardTitle>
{emotion.description ? (
<CardDescription className="text-xs text-slate-500">{emotion.description}</CardDescription>
) : null}
</div>
</div>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Smile className="h-4 w-4" />
{emotion.name}
</CardTitle>
{emotion.description && (
<CardDescription className="text-sm text-slate-600">{emotion.description}</CardDescription>
)}
</CardHeader>
<CardContent className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4" />
<span>{emotion.color}</span>
</div>
{updatedLabel && <span>{t('emotions.labels.updated', { date: updatedLabel })}</span>}
</CardContent>
<CardFooter className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-4 py-3">
<span className="text-xs text-slate-500">
<Badge variant={emotion.is_active ? 'default' : 'secondary'}>
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
</span>
<Button
variant="outline"
size="sm"
onClick={onToggle}
disabled={!canToggle}
className={!canToggle ? 'pointer-events-none opacity-60' : ''}
>
<Power className="mr-2 h-4 w-4" />
</Badge>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-600">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">#{emotion.icon}</Badge>
{emotion.event_types?.length ? (
emotion.event_types.map((eventType) => (
<Badge key={eventType.id} variant="outline">
{eventType.name}
</Badge>
))
) : (
<Badge variant="outline">{t('emotions.labels.noEventType')}</Badge>
)}
</div>
{updated ? <p className="text-xs text-slate-400">{t('emotions.labels.updated', { date: updated })}</p> : null}
</CardContent>
<CardFooter className="flex justify-between gap-2">
<Button variant="ghost" onClick={onToggle} className="text-slate-500 hover:text-emerald-600">
<Power className="mr-1 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
</CardFooter>
</Card>
);
}
function EmptyEmotionsState() {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-indigo-200 bg-indigo-50/40 p-12 text-center">
<div className="rounded-full bg-white p-4 shadow-inner">
<Smile className="h-8 w-8 text-indigo-500" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
<p className="text-sm text-slate-600">{t('emotions.empty.description')}</p>
</div>
</div>
);
}
function EmotionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
<div className="h-16 rounded bg-slate-100" />
</div>
))}
</div>
);
}
function EmotionDialog({
open,
onOpenChange,
@@ -308,12 +276,11 @@ function EmotionDialog({
const { t } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
<Input
@@ -323,17 +290,14 @@ function EmotionDialog({
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
<textarea
<Input
id="emotion-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-200"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
@@ -347,28 +311,20 @@ function EmotionDialog({
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
<Input
id="emotion-color"
type="color"
value={form.color}
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
placeholder="#6366f1"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-indigo-100 bg-indigo-50/60 p-3">
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div>
<Label htmlFor="emotion-active" className="text-sm font-medium text-slate-800">
{t('emotions.dialogs.activeLabel')}
</Label>
<p className="text-sm font-medium text-slate-700">{t('emotions.dialogs.activeLabel')}</p>
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
</div>
<Switch
id="emotion-active"
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: Boolean(checked) }))}
/>
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
</div>
<DialogFooter className="flex justify-end gap-2">
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('emotions.dialogs.cancel')}
</Button>
@@ -382,3 +338,27 @@ function EmotionDialog({
</Dialog>
);
}
function EmotionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={`emotion-skeleton-${index}`} className="h-36 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyEmotionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
<p className="text-sm text-slate-500">{t('emotions.empty.description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
{t('emotions.actions.create')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AdminLayout } from '../components/AdminLayout';
import { TasksSection } from './TasksPage';
import { TaskCollectionsSection } from './TaskCollectionsPage';
import { EmotionsSection } from './EmotionsPage';
const TAB_KEYS = ['tasks', 'collections', 'emotions'] as const;
type EngagementTab = (typeof TAB_KEYS)[number];
function ensureValidTab(value: string | null): EngagementTab {
if (value && (TAB_KEYS as readonly string[]).includes(value)) {
return value as EngagementTab;
}
return 'tasks';
}
export default function EngagementPage(): JSX.Element {
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common');
const [searchParams, setSearchParams] = useSearchParams();
const initialTab = React.useMemo(() => ensureValidTab(searchParams.get('tab')), [searchParams]);
const [activeTab, setActiveTab] = React.useState<EngagementTab>(initialTab);
const handleTabChange = React.useCallback(
(next: string) => {
const valid = ensureValidTab(next);
setActiveTab(valid);
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set('tab', valid);
return params;
});
},
[setSearchParams]
);
const heading = tc('navigation.engagement');
return (
<AdminLayout
title={heading}
subtitle={t('engagement.subtitle', 'Bündle Aufgaben, Vorlagen und Emotionen für deine Events.')}
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full grid-cols-3 bg-white/60 shadow-sm">
<TabsTrigger value="tasks">{tc('navigation.tasks')}</TabsTrigger>
<TabsTrigger value="collections">{tc('navigation.collections')}</TabsTrigger>
<TabsTrigger value="emotions">{tc('navigation.emotions')}</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="space-y-6">
<TasksSection
embedded
onNavigateToCollections={() => handleTabChange('collections')}
/>
</TabsContent>
<TabsContent value="collections" className="space-y-6">
<TaskCollectionsSection
embedded
onNavigateToTasks={() => handleTabChange('tasks')}
/>
</TabsContent>
<TabsContent value="emotions" className="space-y-6">
<EmotionsSection embedded />
</TabsContent>
</Tabs>
</AdminLayout>
);
}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -9,19 +8,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout';
import {
createQrInvite,
EventQrInvite,
EventQrInviteLayout,
EventStats as TenantEventStats,
getEvent,
getEventQrInvites,
getEventStats,
TenantEvent,
toggleEvent,
revokeEventQrInvite,
updateEventQrInvite,
} from '../api';
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
@@ -30,13 +22,12 @@ import {
ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_INVITES_PATH,
} from '../constants';
interface State {
event: TenantEvent | null;
stats: TenantEventStats | null;
invites: EventQrInvite[];
inviteLink: string | null;
error: string | null;
loading: boolean;
busy: boolean;
@@ -51,16 +42,10 @@ export default function EventDetailPage() {
const [state, setState] = React.useState<State>({
event: null,
stats: null,
invites: [],
inviteLink: null,
error: null,
loading: true,
busy: false,
});
const [creatingInvite, setCreatingInvite] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null);
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
@@ -70,22 +55,19 @@ export default function EventDetailPage() {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, statsData, qrInvites] = await Promise.all([
const [eventData, statsData] = await Promise.all([
getEvent(slug),
getEventStats(slug),
getEventQrInvites(slug),
]);
setState((prev) => ({
...prev,
event: eventData,
stats: statsData,
invites: qrInvites,
loading: false,
inviteLink: prev.inviteLink,
}));
} catch (err) {
if (isAuthError(err)) return;
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
}
}, [slug]);
@@ -113,132 +95,10 @@ export default function EventDetailPage() {
}
}
async function handleInvite() {
if (!slug || creatingInvite) return;
setCreatingInvite(true);
setState((prev) => ({ ...prev, error: null }));
try {
const invite = await createQrInvite(slug);
setState((prev) => ({
...prev,
inviteLink: invite.url,
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
}));
try {
await navigator.clipboard.writeText(invite.url);
} catch {
// clipboard may be unavailable, ignore silently
}
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
}
}
setCreatingInvite(false);
}
async function handleCopy(invite: EventQrInvite) {
try {
await navigator.clipboard.writeText(invite.url);
setState((prev) => ({ ...prev, inviteLink: invite.url }));
} catch (err) {
console.warn('Clipboard copy failed', err);
}
}
async function handleRevoke(invite: EventQrInvite) {
if (!slug || invite.revoked_at) return;
setRevokingId(invite.id);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await revokeEventQrInvite(slug, invite.id);
setState((prev) => ({
...prev,
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
}));
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
}
}
function openCustomizer(invite: EventQrInvite) {
setState((prev) => ({ ...prev, error: null }));
setCustomizingInvite(invite);
}
function closeCustomizer() {
if (customizerSaving) {
return;
}
setCustomizingInvite(null);
}
async function handleApplyCustomization(customization: QrLayoutCustomization) {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: customization,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht gespeichert werden.' }));
}
setCustomizerSaving(false);
}
}
async function handleResetCustomization() {
if (!slug || !customizingInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, customizingInvite.id, {
metadata: {
layout_customization: null,
},
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
setCustomizerSaving(false);
setCustomizingInvite(null);
} catch (err) {
if (!isAuthError(err)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
}
setCustomizerSaving(false);
}
}
const { event, stats, invites, inviteLink, error, loading, busy } = state;
const { event, stats, error, loading, busy } = state;
const eventDisplayName = event ? renderName(event.name) : '';
const currentCustomization = React.useMemo(() => {
if (!customizingInvite) {
return null;
}
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
const raw = metadata?.layout_customization;
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [customizingInvite]);
const activeInvitesCount = event?.active_invites_count ?? 0;
const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
const actions = (
<>
@@ -247,7 +107,7 @@ export default function EventDetailPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
</Button>
{event && (
<>
@@ -272,6 +132,13 @@ export default function EventDetailPage() {
>
Tasks
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
className="border-amber-200 text-amber-600 hover:bg-amber-50"
>
QR &amp; Einladungen
</Button>
<Button
variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
@@ -286,10 +153,10 @@ export default function EventDetailPage() {
if (!slug) {
return (
<AdminLayout title="Event nicht gefunden" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={actions}>
<AdminLayout title="Event nicht gefunden" subtitle="Bitte wähle ein Event aus der Übersicht." actions={actions}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Ohne gueltigen Slug koennen wir keine Daten laden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
Ohne gültigen Slug können wir keine Daten laden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
</CardContent>
</Card>
</AdminLayout>
@@ -319,12 +186,12 @@ export default function EventDetailPage() {
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Grundlegende Informationen fuer Gaeste und Moderation.
Grundlegende Informationen für Gäste und Moderation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow label="Slug" value={event.slug} />
<InfoRow label="Status" value={event.status === 'published' ? 'Veroeffentlicht' : event.status} />
<InfoRow label="Status" value={event.status === 'published' ? 'Veröffentlicht' : event.status} />
<InfoRow label="Datum" value={formatDate(event.event_date)} />
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
<div className="flex flex-wrap gap-3 pt-2">
@@ -347,60 +214,35 @@ export default function EventDetailPage() {
</CardContent>
</Card>
<Card id="qr-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen &amp; Drucklayouts
<QrCode className="h-5 w-5 text-amber-500" /> Einladungen &amp; QR
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen inklusive Branding und Anleitungen
zum Ausdrucken herunter.
Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p>
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
jederzeit erneuern oder deaktivieren.
Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
</p>
<p>
Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
</p>
{invites.length > 0 && (
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
{invites.length}
</p>
)}
</div>
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
QR-Einladung erstellen
</Button>
{inviteLink && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
<div className="space-y-2">
<Button
className="w-full bg-amber-500 text-white shadow-lg shadow-amber-500/20 hover:bg-amber-500/90"
onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(event.slug))}
>
Einladungen &amp; Layouts verwalten
</Button>
<p className="text-xs text-slate-500">
Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
</p>
)}
<div className="space-y-3">
{invites.length > 0 ? (
invites.map((invite) => (
<InvitationCard
key={invite.id}
invite={invite}
onCopy={() => handleCopy(invite)}
onRevoke={() => handleRevoke(invite)}
revoking={revokingId === invite.id}
onCustomize={() => openCustomizer(invite)}
eventName={eventDisplayName}
/>
))
) : (
<div className="rounded-lg border border-slate-200 bg-white/80 p-4 text-xs text-slate-500">
Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
und zu teilen.
</div>
)}
</div>
</CardContent>
</Card>
@@ -425,21 +267,9 @@ export default function EventDetailPage() {
) : (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
<AlertDescription>Bitte prüfe den Slug und versuche es erneut.</AlertDescription>
</Alert>
)}
<QrInviteCustomizationDialog
open={Boolean(customizingInvite)}
onClose={closeCustomizer}
onSubmit={handleApplyCustomization}
onReset={handleResetCustomization}
saving={customizerSaving}
inviteUrl={customizingInvite?.url ?? ''}
eventName={eventDisplayName}
layouts={customizingInvite?.layouts ?? []}
initialCustomization={currentCustomization}
/>
</AdminLayout>
);
}
@@ -472,220 +302,6 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
);
}
function InvitationCard({
invite,
onCopy,
onRevoke,
revoking,
onCustomize,
eventName,
}: {
invite: EventQrInvite;
onCopy: () => void;
onRevoke: () => void;
revoking: boolean;
onCustomize: () => void;
eventName: string;
}) {
const { t } = useTranslation('management');
const status = getInviteStatus(invite);
const layouts = Array.isArray(invite.layouts) ? invite.layouts : [];
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
const isAutoGenerated = Boolean(metadata.auto_generated);
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
const preferredLayoutId = customization?.layout_id ?? (layouts[0]?.id ?? null);
const hasCustomization = customization ? Object.keys(customization).length > 0 : false;
const statusClassname =
status === 'Aktiv'
? 'bg-emerald-100 text-emerald-700'
: status === 'Abgelaufen'
? 'bg-orange-100 text-orange-700'
: 'bg-slate-200 text-slate-700';
return (
<div className="flex flex-col gap-4 rounded-2xl border border-amber-100 bg-white/90 p-4 shadow-md shadow-amber-100/40">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClassname}`}>{status}</span>
{isAutoGenerated ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Standard
</span>
) : null}
{hasCustomization ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
{t('tasks.customizer.badge', 'Angepasst')}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-md border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
{invite.url}
</span>
<Button
variant="outline"
size="sm"
onClick={onCopy}
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<Copy className="mr-1 h-3 w-3" />
Link kopieren
</Button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>Nutzung: {usageLabel}</span>
{invite.expires_at ? <span>Gültig bis {formatDateTime(invite.expires_at)}</span> : null}
{invite.created_at ? <span>Erstellt am {formatDateTime(invite.created_at)}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={hasCustomization ? 'default' : 'outline'}
size="sm"
onClick={onCustomize}
className={hasCustomization ? 'bg-amber-500 text-white hover:bg-amber-500/90 border-amber-200' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
>
<Sparkles className="mr-1 h-3 w-3" />
{t('tasks.customizer.actionLabel', 'Layout anpassen')}
</Button>
{invite.layouts_url ? (
<Button
asChild
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={invite.layouts_url} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
Layout-Übersicht
</a>
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={onRevoke}
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-slate-600 hover:bg-slate-100 disabled:opacity-50"
>
{revoking ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deaktivieren'}
</Button>
</div>
</div>
{layouts.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{layouts.map((layout) => (
<LayoutPreviewCard
key={layout.id}
layout={layout}
customization={layout.id === preferredLayoutId ? customization : null}
selected={layout.id === preferredLayoutId}
eventName={eventName}
/>
))}
</div>
) : invite.layouts_url ? (
<div className="rounded-xl border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-800">
Für diese Einladung stehen Layouts bereit. Öffne die Übersicht, um PDF- oder SVG-Versionen zu laden.
</div>
) : null}
</div>
);
}
function LayoutPreviewCard({
layout,
customization,
selected,
eventName,
}: {
layout: EventQrInviteLayout;
customization: QrLayoutCustomization | null;
selected: boolean;
eventName: string;
}) {
const gradient = customization?.background_gradient ?? layout.preview?.background_gradient;
const stops = Array.isArray(gradient?.stops) ? gradient?.stops ?? [] : [];
const gradientStyle = stops.length
? {
backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
}
: {
backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
};
const textColor = customization?.text_color ?? layout.preview?.text ?? '#0F172A';
const badgeColor = customization?.badge_color ?? customization?.accent_color ?? layout.preview?.accent ?? '#0EA5E9';
const formats = Array.isArray(layout.formats) ? layout.formats : [];
const headline = customization?.headline ?? layout.name ?? eventName;
const subtitle = customization?.subtitle ?? layout.subtitle ?? '';
const description = customization?.description ?? layout.description ?? '';
const instructions = customization?.instructions ?? [];
return (
<div
className={`overflow-hidden rounded-xl border bg-white shadow-sm ${selected ? 'border-amber-300 ring-2 ring-amber-200' : 'border-amber-100'}`}
>
<div className="relative h-28">
<div className="absolute inset-0" style={gradientStyle} />
<div className="absolute inset-0 flex flex-col justify-between p-3 text-xs" style={{ color: textColor }}>
<span
className="w-fit rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
style={{ backgroundColor: badgeColor, color: '#ffffff' }}
>
QR-Layout
</span>
<div>
<div className="text-sm font-semibold leading-tight">{headline}</div>
{subtitle ? <div className="text-[11px] opacity-80">{subtitle}</div> : null}
</div>
</div>
</div>
<div className="space-y-3 p-3">
{description ? <p className="text-xs text-slate-600">{description}</p> : null}
{instructions.length > 0 ? (
<ul className="space-y-1 text-[11px] text-slate-500">
{instructions.slice(0, 3).map((item, index) => (
<li key={`${layout.id}-instruction-${index}`}> {item}</li>
))}
</ul>
) : null}
<div className="flex flex-wrap gap-2">
{formats.map((format) => {
const key = String(format ?? '').toLowerCase();
const href = layout.download_urls?.[key] ?? layout.download_urls?.[String(format ?? '')] ?? null;
if (!href) {
return null;
}
const label = String(format ?? '').toUpperCase() || 'PDF';
return (
<Button
asChild
key={`${layout.id}-${label}`}
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-100"
>
<a href={href} target="_blank" rel="noreferrer">
<Download className="mr-1 h-3 w-3" />
{label}
</a>
</Button>
);
})}
</div>
</div>
</div>
);
}
function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum';
const date = new Date(iso);
@@ -695,32 +311,6 @@ function formatDate(iso: string | null): string {
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function formatDateTime(iso: string | null): string {
if (!iso) return 'unbekannt';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'unbekannt';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (invite.revoked_at) return 'Deaktiviert';
if (invite.expires_at) {
const expiry = new Date(invite.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen';
}
}
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
}
function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;

View File

@@ -200,12 +200,12 @@ export default function EventFormPage() {
}
if (!trimmedSlug) {
setError('Bitte waehle einen Slug fuer die Event-URL.');
setError('Bitte wähle einen Slug für die Event-URL.');
return;
}
if (!form.eventTypeId) {
setError('Bitte waehle einen Event-Typ aus.');
setError('Bitte wähle einen Event-Typ aus.');
return;
}
@@ -242,7 +242,7 @@ export default function EventFormPage() {
}
} catch (err) {
if (!isAuthError(err)) {
setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.');
setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.');
}
} finally {
setSaving(false);
@@ -326,14 +326,14 @@ export default function EventFormPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
<ArrowLeft className="h-4 w-4" /> Zurück zur Liste
</Button>
);
return (
<AdminLayout
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
subtitle="Fuelle die wichtigsten Angaben aus und teile dein Event mit Gaesten."
subtitle="Fülle die wichtigsten Angaben aus und teile dein Event mit Gästen."
actions={actions}
>
{error && (
@@ -349,7 +349,7 @@ export default function EventFormPage() {
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Name, URL und Datum bestimmen das Auftreten deines Events im Gaesteportal.
Name, URL und Datum bestimmen das Auftreten deines Events im Gästeportal.
</CardDescription>
</CardHeader>
<CardContent>
@@ -377,8 +377,8 @@ export default function EventFormPage() {
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre
Einladungslinks und die dazugehoerigen QR-Layouts.
Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
Einladungslinks und die dazugehörigen QR-Layouts.
</p>
</div>
<div className="space-y-2">
@@ -399,7 +399,7 @@ export default function EventFormPage() {
>
<SelectTrigger id="event-type">
<SelectValue
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'}
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
/>
</SelectTrigger>
<SelectContent>
@@ -412,7 +412,7 @@ export default function EventFormPage() {
</Select>
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
<p className="text-xs text-amber-600">
Keine Event-Typen verfuegbar. Bitte lege einen Typ im Adminbereich an.
Keine Event-Typen verfügbar. Bitte lege einen Typ im Adminbereich an.
</p>
) : null}
</div>
@@ -506,7 +506,7 @@ export default function EventFormPage() {
Event sofort veroeffentlichen
</Label>
<p className="text-xs text-slate-600">
Aktiviere diese Option, wenn Gaeste das Event direkt sehen sollen. Du kannst den Status spaeter aendern.
Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern.
</p>
</div>
</div>

View File

@@ -0,0 +1,516 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Copy, Loader2, QrCode, RefreshCw, Share2, Sparkles, X } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AdminLayout } from '../components/AdminLayout';
import {
createQrInvite,
EventQrInvite,
getEvent,
getEventQrInvites,
revokeEventQrInvite,
TenantEvent,
updateEventQrInvite,
EventQrInviteLayout,
} from '../api';
import { isAuthError } from '../auth/tokens';
import {
ADMIN_EVENTS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_PHOTOS_PATH,
} from '../constants';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
interface PageState {
event: TenantEvent | null;
invites: EventQrInvite[];
loading: boolean;
error: string | null;
}
export default function EventInvitesPage(): JSX.Element {
const { slug } = useParams<{ slug?: string }>();
const navigate = useNavigate();
const { t } = useTranslation('management');
const [state, setState] = React.useState<PageState>({ event: null, invites: [], loading: true, error: null });
const [creatingInvite, setCreatingInvite] = React.useState(false);
const [revokingId, setRevokingId] = React.useState<number | null>(null);
const [selectedInviteId, setSelectedInviteId] = React.useState<number | null>(null);
const [copiedInviteId, setCopiedInviteId] = React.useState<number | null>(null);
const [customizerSaving, setCustomizerSaving] = React.useState(false);
const [customizerResetting, setCustomizerResetting] = React.useState(false);
const load = React.useCallback(async () => {
if (!slug) {
setState({ event: null, invites: [], loading: false, error: 'Kein Event-Slug angegeben.' });
return;
}
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
setState({ event: eventData, invites: invitesData, loading: false, error: null });
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
} catch (error) {
if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
}
}
}, [slug]);
React.useEffect(() => {
void load();
}, [load]);
const event = state.event;
const eventName = event ? renderEventName(event.name) : t('toolkit.titleFallback', 'Event');
const selectedInvite = React.useMemo(
() => state.invites.find((invite) => invite.id === selectedInviteId) ?? null,
[state.invites, selectedInviteId]
);
React.useEffect(() => {
if (state.invites.length === 0) {
setSelectedInviteId(null);
return;
}
setSelectedInviteId((current) => {
if (current && state.invites.some((invite) => invite.id === current)) {
return current;
}
return state.invites[0]?.id ?? null;
});
}, [state.invites]);
const currentCustomization = React.useMemo(() => {
if (!selectedInvite) {
return null;
}
const metadata = selectedInvite.metadata as Record<string, unknown> | undefined | null;
const raw = metadata?.layout_customization;
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
}, [selectedInvite]);
React.useEffect(() => {
if (selectedInvite) {
console.debug('[Invites] Selected invite', selectedInvite.id, selectedInvite.layouts, selectedInvite.layouts_url);
}
}, [selectedInvite]);
const inviteCountSummary = React.useMemo(() => {
const active = state.invites.filter((invite) => invite.is_active && !invite.revoked_at).length;
const total = state.invites.length;
return { active, total };
}, [state.invites]);
async function handleCreateInvite() {
if (!slug || creatingInvite) {
return;
}
setCreatingInvite(true);
setState((prev) => ({ ...prev, error: null }));
try {
const invite = await createQrInvite(slug);
setState((prev) => ({
...prev,
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
}));
setSelectedInviteId(invite.id);
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
} catch {
// ignore clipboard failures
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erstellt werden.' }));
}
} finally {
setCreatingInvite(false);
}
}
async function handleCopy(invite: EventQrInvite) {
try {
await navigator.clipboard.writeText(invite.url);
setCopiedInviteId(invite.id);
} catch (error) {
console.warn('[Invites] Clipboard copy failed', error);
}
}
React.useEffect(() => {
if (!copiedInviteId) return;
const timeout = setTimeout(() => setCopiedInviteId(null), 3000);
return () => clearTimeout(timeout);
}, [copiedInviteId]);
async function handleRevoke(invite: EventQrInvite) {
if (!slug || invite.revoked_at) {
return;
}
setRevokingId(invite.id);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await revokeEventQrInvite(slug, invite.id);
setState((prev) => ({
...prev,
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
}));
if (selectedInviteId === invite.id && !updated.is_active) {
setSelectedInviteId((prevId) => (prevId === updated.id ? null : prevId));
}
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
}
} finally {
setRevokingId(null);
}
}
async function handleSaveCustomization(customization: QrLayoutCustomization) {
if (!slug || !selectedInvite) {
return;
}
setCustomizerSaving(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
metadata: { layout_customization: customization },
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassung konnte nicht gespeichert werden.' }));
}
} finally {
setCustomizerSaving(false);
}
}
async function handleResetCustomization() {
if (!slug || !selectedInvite) {
return;
}
setCustomizerResetting(true);
setState((prev) => ({ ...prev, error: null }));
try {
const updated = await updateEventQrInvite(slug, selectedInvite.id, {
metadata: { layout_customization: null },
});
setState((prev) => ({
...prev,
invites: prev.invites.map((invite) => (invite.id === updated.id ? updated : invite)),
}));
} catch (error) {
if (!isAuthError(error)) {
setState((prev) => ({ ...prev, error: 'Anpassungen konnten nicht zurückgesetzt werden.' }));
}
} finally {
setCustomizerResetting(false);
}
}
const actions = (
<>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600 hover:bg-pink-50">
<ArrowLeft className="h-4 w-4" />
{t('invites.actions.backToList', 'Zurück zur Übersicht')}
</Button>
{slug ? (
<>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug))} className="border-slate-200 text-slate-600 hover:bg-slate-50">
{t('invites.actions.backToEvent', 'Event öffnen')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug))} className="border-sky-200 text-sky-700 hover:bg-sky-50">
{t('toolkit.actions.moderate', 'Fotos moderieren')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(slug))} className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
{t('toolkit.actions.backToEvent', 'Event-Day Toolkit')}
</Button>
</>
) : null}
</>
);
return (
<AdminLayout
title={eventName}
subtitle={t('invites.subtitle', 'Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.')}
actions={actions}
>
{state.error ? (
<Alert variant="destructive">
<AlertTitle>{t('invites.errorTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
) : null}
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<QrCode className="h-5 w-5 text-amber-500" />
{t('invites.cardTitle', 'QR-Einladungen & Layouts')}
</CardTitle>
<CardDescription className="text-sm text-slate-600">
{t('invites.cardDescription', 'Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.')}
</CardDescription>
<div className="rounded-lg border border-amber-100 bg-amber-50/70 px-3 py-2 text-xs text-amber-700">
{t('invites.summary.active', 'Aktive Einladungen')}: {inviteCountSummary.active} ·{' '}
{t('invites.summary.total', 'Gesamt')}: {inviteCountSummary.total}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => void load()} disabled={state.loading} className="border-amber-200 text-amber-700 hover:bg-amber-100">
{state.loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{t('invites.actions.refresh', 'Aktualisieren')}
</Button>
<Button
onClick={handleCreateInvite}
disabled={creatingInvite}
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-amber-500/20"
>
{creatingInvite ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Share2 className="mr-2 h-4 w-4" />}
{t('invites.actions.create', 'Neue Einladung erstellen')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{state.loading ? (
<InviteSkeleton />
) : state.invites.length === 0 ? (
<EmptyState onCreate={handleCreateInvite} />
) : (
<div className="grid gap-3">
{state.invites.map((invite) => (
<InviteListCard
key={invite.id}
invite={invite}
onSelect={() => setSelectedInviteId(invite.id)}
onCopy={() => handleCopy(invite)}
onRevoke={() => handleRevoke(invite)}
selected={invite.id === selectedInvite?.id}
revoking={revokingId === invite.id}
copied={copiedInviteId === invite.id}
/>
))}
</div>
)}
</CardContent>
</Card>
<InviteLayoutCustomizerPanel
invite={selectedInvite ?? null}
eventName={eventName}
saving={customizerSaving}
resetting={customizerResetting}
onSave={handleSaveCustomization}
onReset={handleResetCustomization}
initialCustomization={currentCustomization}
/>
</AdminLayout>
);
}
function InviteListCard({
invite,
selected,
onSelect,
onCopy,
onRevoke,
revoking,
copied,
}: {
invite: EventQrInvite;
selected: boolean;
onSelect: () => void;
onCopy: () => void;
onRevoke: () => void;
revoking: boolean;
copied: boolean;
}) {
const { t } = useTranslation('management');
const status = getInviteStatus(invite);
const metadata = (invite.metadata ?? {}) as Record<string, unknown>;
const customization = (metadata.layout_customization ?? null) as QrLayoutCustomization | null;
const preferredLayoutId = customization?.layout_id ?? invite.layouts[0]?.id ?? null;
const isAutoGenerated = Boolean(metadata.auto_generated);
const usageLabel = invite.usage_limit ? `${invite.usage_count} / ${invite.usage_limit}` : `${invite.usage_count}`;
const layoutsById = React.useMemo(() => {
const map = new Map<string, EventQrInviteLayout>();
invite.layouts.forEach((layout) => map.set(layout.id, layout));
return map;
}, [invite.layouts]);
const layoutName = preferredLayoutId ? layoutsById.get(preferredLayoutId)?.name ?? invite.layouts[0]?.name ?? '' : '';
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect();
}
}}
className={`flex flex-col gap-3 rounded-2xl border p-4 transition-shadow ${selected ? 'border-amber-400 bg-amber-50/70 shadow-lg shadow-amber-200/30' : 'border-slate-200 bg-white/80 hover:border-amber-200'}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{invite.label?.trim() || `Einladung #${invite.id}`}</span>
<Badge variant="outline" className={statusBadgeClass(status)}>
{status}
</Badge>
{isAutoGenerated ? (
<Badge variant="secondary" className="bg-slate-200 text-slate-700">{t('invites.labels.standard', 'Standard')}</Badge>
) : null}
{customization ? (
<Badge className="bg-emerald-500/15 text-emerald-700">{t('tasks.customizer.badge', 'Angepasst')}</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="break-all rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 font-mono text-xs text-slate-700">
{invite.url}
</span>
<Button
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
onCopy();
}}
className={copied ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-200 text-amber-700 hover:bg-amber-100'}
>
<Copy className="mr-1 h-3 w-3" />
{copied ? t('invites.actions.copied', 'Kopiert!') : t('invites.actions.copy', 'Link kopieren')}
</Button>
</div>
<div className="grid gap-1 text-xs text-slate-500 sm:grid-cols-2">
<span>
{t('invites.labels.usage', 'Nutzung')}: {usageLabel}
</span>
<span>
{t('invites.labels.layout', 'Layout')}: {layoutName || t('invites.labels.layoutFallback', 'Standard')}
</span>
{invite.expires_at ? <span>{t('invites.labels.validUntil', 'Gültig bis')} {formatDateTime(invite.expires_at)}</span> : null}
{invite.created_at ? <span>{t('invites.labels.createdAt', 'Erstellt am')} {formatDateTime(invite.created_at)}</span> : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{selected ? (
<Badge variant="outline" className="border-amber-300 bg-amber-100/70 text-amber-700">
{t('invites.labels.selected', 'Aktuell ausgewählt')}
</Badge>
) : (
<div className="text-xs text-slate-500">{t('invites.labels.tapToEdit', 'Zum Anpassen auswählen')}</div>
)}
<Button
variant="ghost"
size="sm"
onClick={(event) => {
event.stopPropagation();
onRevoke();
}}
disabled={revoking || invite.revoked_at !== null || !invite.is_active}
className="text-slate-500 hover:text-rose-500 disabled:opacity-50"
>
{revoking ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <X className="mr-1 h-4 w-4" />}
{t('invites.actions.deactivate', 'Deaktivieren')}
</Button>
</div>
</div>
);
}
function InviteSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={`invite-skeleton-${index}`} className="h-32 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('invites.empty.title', 'Noch keine Einladungen')}</h3>
<p className="text-sm text-slate-500">{t('invites.empty.copy', 'Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten.')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
<Share2 className="mr-1 h-4 w-4" />
{t('invites.actions.create', 'Neue Einladung erstellen')}
</Button>
</div>
);
}
function renderEventName(name: TenantEvent['name']): string {
if (typeof name === 'string') {
return name;
}
if (name && typeof name === 'object') {
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
}
return 'Event';
}
function getInviteStatus(invite: EventQrInvite): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
if (invite.revoked_at) return 'Deaktiviert';
if (invite.expires_at) {
const expiry = new Date(invite.expires_at);
if (!Number.isNaN(expiry.getTime()) && expiry.getTime() < Date.now()) {
return 'Abgelaufen';
}
}
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
}
function statusBadgeClass(status: string): string {
if (status === 'Aktiv') {
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
}
if (status === 'Abgelaufen') {
return 'bg-orange-100 text-orange-700 border-orange-200';
}
return 'bg-slate-200 text-slate-700 border-slate-300';
}
function formatDateTime(iso: string | null): string {
if (!iso) return 'unbekannt';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
return 'unbekannt';
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

View File

@@ -79,12 +79,12 @@ export default function EventPhotosPage() {
if (!slug) {
return (
<AdminLayout title="Fotos moderieren" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={null}>
<AdminLayout title="Fotos moderieren" subtitle="Bitte wähle ein Event aus der Übersicht." actions={null}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurück und wähle dort ein Event aus.
<Button className="mt-4" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
Zurueck zur Liste
Zurück zur Liste
</Button>
</CardContent>
</Card>
@@ -98,7 +98,7 @@ export default function EventPhotosPage() {
onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurueck zum Event
Zurück zum Event
</Button>
);
@@ -121,7 +121,7 @@ export default function EventPhotosPage() {
<Camera className="h-5 w-5 text-sky-500" /> Galerie
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Klick auf ein Foto, um es hervorzuheben oder zu loeschen.
Klick auf ein Foto, um es hervorzuheben oder zu löschen.
</CardDescription>
</CardHeader>
<CardContent>
@@ -164,7 +164,7 @@ export default function EventPhotosPage() {
disabled={busyId === photo.id}
>
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Loeschen
Löschen
</Button>
</div>
</div>
@@ -195,7 +195,7 @@ function EmptyGallery() {
<Camera className="h-5 w-5" />
</div>
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
<p className="text-sm text-slate-600">Motiviere deine Gaeste zum Hochladen - hier erscheint anschliessend die Galerie.</p>
<p className="text-sm text-slate-600">Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.</p>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import {
Sparkles,
ThumbsDown,
ThumbsUp,
QrCode,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -26,6 +27,7 @@ import {
ADMIN_EVENT_PHOTOS_PATH,
ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_VIEW_PATH,
ADMIN_EVENT_INVITES_PATH,
} from '../constants';
import {
EventToolkit,
@@ -92,6 +94,10 @@ export default function EventToolkitPage(): JSX.Element {
<Sparkles className="h-4 w-4" />
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
</Button>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_INVITES_PATH(slug ?? ''))}>
<QrCode className="h-4 w-4" />
{t('toolkit.actions.manageInvites', 'QR-Einladungen')}
</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('toolkit.actions.refresh', 'Aktualisieren')}

View File

@@ -33,7 +33,7 @@ export default function EventsPage() {
setRows(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError('Laden fehlgeschlagen. Bitte spaeter erneut versuchen.');
setError('Laden fehlgeschlagen. Bitte später erneut versuchen.');
}
} finally {
setLoading(false);
@@ -73,7 +73,7 @@ export default function EventsPage() {
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-semibold text-slate-900">Uebersicht</CardTitle>
<CardTitle className="text-xl font-semibold text-slate-900">Übersicht</CardTitle>
<CardDescription className="text-slate-600">
{rows.length === 0
? 'Noch keine Events - starte jetzt und lege dein erstes Event an.'
@@ -191,7 +191,7 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
<p className="text-sm text-slate-600">
Starte jetzt mit deinem ersten Event und lade Gaeste in dein farbenfrohes Erlebnisportal ein.
Starte jetzt mit deinem ersten Event und lade Gäste in dein farbenfrohes Erlebnisportal ein.
</p>
</div>
<Button

View File

@@ -24,7 +24,7 @@ export default function SettingsPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurueck zur Uebersicht
Zurück zur Übersicht
</Button>
);
@@ -40,14 +40,14 @@ export default function SettingsPage() {
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Gestalte den Admin-Bereich so farbenfroh wie dein Gaesteportal.
Gestalte den Admin-Bereich so farbenfroh wie dein Gästeportal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Darstellung</h2>
<p className="text-sm text-slate-600">
Wechsel zwischen Hell- und Dunkelmodus oder uebernimm automatisch die Systemeinstellung.
Wechsel zwischen Hell- und Dunkelmodus oder übernimm automatisch die Systemeinstellung.
</p>
<AppearanceToggleDropdown />
</section>

View File

@@ -5,6 +5,15 @@ import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import { Layers, Library, Loader2, Plus } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AdminLayout } from '../components/AdminLayout';
import {
getTaskCollections,
@@ -14,29 +23,8 @@ import {
TenantEvent,
TenantTaskCollection,
} from '../api';
import { ADMIN_TASKS_PATH } from '../constants';
import { buildEngagementTabPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const DEFAULT_PAGE_SIZE = 12;
@@ -47,7 +35,12 @@ type CollectionsState = {
meta: PaginationMeta | null;
};
export default function TaskCollectionsPage(): JSX.Element {
export type TaskCollectionsSectionProps = {
embedded?: boolean;
onNavigateToTasks?: () => void;
};
export function TaskCollectionsSection({ embedded = false, onNavigateToTasks }: TaskCollectionsSectionProps): JSX.Element {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
@@ -163,27 +156,23 @@ export default function TaskCollectionsPage(): JSX.Element {
}
const showEmpty = !loading && collectionsState.items.length === 0;
const locale = i18n.language.startsWith('en') ? enGB : de;
const title = t('collections.title') ?? 'Task Collections';
const subtitle = embedded
? t('collections.subtitle') ?? ''
: t('collections.subtitle') ?? '';
const navigateToTasks = React.useCallback(() => {
if (onNavigateToTasks) {
onNavigateToTasks();
return;
}
navigate(buildEngagementTabPath('tasks'));
}, [navigate, onNavigateToTasks]);
return (
<AdminLayout
title={t('collections.title') ?? 'Task Collections'}
subtitle={t('collections.subtitle') ?? ''}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_TASKS_PATH)}>
<Library className="mr-2 h-4 w-4" />
{t('collections.actions.openTasks')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => navigate(ADMIN_TASKS_PATH)}
>
<Plus className="mr-2 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
}
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>{t('collections.notifications.error')}</AlertTitle>
@@ -199,253 +188,236 @@ export default function TaskCollectionsPage(): JSX.Element {
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{t('collections.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">{t('collections.subtitle')}</CardDescription>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Layers className="h-5 w-5 text-pink-500" />
{title}
</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={navigateToTasks}>
<Library className="mr-2 h-4 w-4" />
{t('collections.actions.openTasks')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => setScope('tenant')}
>
{t('collections.scope.tenant')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),220px]">
<Input
placeholder={t('collections.filters.search') ?? 'Nach Vorlagen suchen'}
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder={t('collections.filters.search') ?? 'Search collections'}
className="w-full lg:max-w-md"
/>
<div className="flex flex-wrap gap-3">
<Select
value={scope}
onValueChange={(value) => {
setScope(value as ScopeFilter);
setPage(1);
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder={t('collections.filters.scope')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={scope} onValueChange={(value: ScopeFilter) => { setPage(1); setScope(value); }}>
<SelectTrigger>
<SelectValue placeholder={t('collections.filters.allScopes')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
</SelectContent>
</Select>
</div>
{loading ? (
<CollectionSkeleton />
<CollectionsSkeleton />
) : showEmpty ? (
<EmptyCollectionsState onCreate={() => navigate(ADMIN_TASKS_PATH)} />
<EmptyCollectionsState onCreate={navigateToTasks} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{collectionsState.items.map((collection) => (
<CollectionCard
<TaskCollectionCard
key={collection.id}
collection={collection}
locale={locale}
onImport={() => openImportDialog(collection)}
onNavigateToTasks={navigateToTasks}
/>
))}
</div>
)}
{collectionsState.meta && collectionsState.meta.last_page > 1 && (
<div className="flex items-center justify-between">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
>
{t('collections.pagination.prev')}
</Button>
<span className="text-xs text-slate-500">
{collectionsState.meta && collectionsState.meta.last_page > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<div className="text-slate-500">
{t('collections.pagination.page', {
current: collectionsState.meta.current_page,
total: collectionsState.meta.last_page,
current: collectionsState.meta.current_page ?? 1,
total: collectionsState.meta.last_page ?? 1,
})}
</span>
<Button
variant="outline"
disabled={page >= collectionsState.meta.last_page}
onClick={() => setPage((prev) => Math.min(prev + 1, collectionsState.meta!.last_page))}
>
{t('collections.pagination.next')}
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((value) => Math.max(value - 1, 1))}
disabled={(collectionsState.meta.current_page ?? 1) <= 1}
>
{t('collections.pagination.prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((value) => Math.min(value + 1, collectionsState.meta?.last_page ?? value + 1))}
disabled={(collectionsState.meta.current_page ?? 1) >= (collectionsState.meta.last_page ?? 1)}
>
{t('collections.pagination.next')}
</Button>
</div>
</div>
)}
) : null}
</CardContent>
</Card>
<ImportDialog
<ImportCollectionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
collection={selectedCollection}
events={events}
eventError={eventError}
eventsLoading={eventsLoading}
selectedEventSlug={selectedEventSlug}
onEventChange={setSelectedEventSlug}
onSelectedEventChange={setSelectedEventSlug}
onSubmit={handleImport}
importing={importing}
error={eventError}
locale={locale}
/>
</div>
);
}
export default function TaskCollectionsPage(): JSX.Element {
const navigate = useNavigate();
const { t } = useTranslation('management');
return (
<AdminLayout
title={t('collections.title') ?? 'Task Collections'}
subtitle={t('collections.subtitle') ?? ''}
>
<TaskCollectionsSection onNavigateToTasks={() => navigate(buildEngagementTabPath('tasks'))} />
</AdminLayout>
);
}
function CollectionCard({
function TaskCollectionCard({
collection,
locale,
onImport,
onNavigateToTasks,
}: {
collection: TenantTaskCollection;
locale: Locale;
onImport: () => void;
onNavigateToTasks: () => void;
}) {
const { t, i18n } = useTranslation('management');
const locale = i18n.language.startsWith('en') ? enGB : de;
const updatedLabel = collection.updated_at
? format(new Date(collection.updated_at), 'PP', { locale })
: null;
const scopeLabel = collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant');
const eventTypeLabel = collection.event_type?.name ?? t('collections.filters.allEventTypes');
const { t } = useTranslation('management');
const updatedAt = collection.updated_at ? format(new Date(collection.updated_at), 'Pp', { locale }) : null;
return (
<Card className="border border-slate-100 bg-white/90 shadow-sm">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={collection.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
>
{scopeLabel}
</Badge>
{eventTypeLabel && (
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{eventTypeLabel}
</Badge>
)}
</div>
<CardTitle className="text-lg text-slate-900">{collection.name}</CardTitle>
{collection.description && (
<CardDescription className="text-sm text-slate-600">
{collection.description}
</CardDescription>
)}
<Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
<CardHeader>
<CardTitle className="text-base text-slate-900">{collection.name}</CardTitle>
{collection.description ? (
<CardDescription className="text-xs text-slate-500">{collection.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-slate-500">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4" />
<span>{t('collections.labels.taskCount', { count: collection.tasks_count })}</span>
<CardContent className="space-y-3 text-sm text-slate-600">
<div className="flex flex-wrap gap-2">
<Badge variant={collection.is_global ? 'secondary' : 'default'}>
{collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant')}
</Badge>
<Badge variant="outline">{t('collections.labels.taskCount', { count: collection.tasks_count })}</Badge>
{collection.event_type ? (
<Badge variant="outline">{collection.event_type.name}</Badge>
) : null}
</div>
{updatedLabel && <span>{t('collections.labels.updated', { date: updatedLabel })}</span>}
{updatedAt ? (
<p className="text-xs text-slate-400">{t('collections.labels.updated', { date: updatedAt })}</p>
) : null}
</CardContent>
<CardFooter className="flex justify-end">
<Button variant="outline" onClick={onImport}>
<CardFooter className="flex flex-wrap gap-2">
<Button className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white" onClick={onImport}>
<Plus className="mr-1 h-4 w-4" />
{t('collections.actions.import')}
</Button>
<Button variant="outline" onClick={onNavigateToTasks}>
{t('collections.actions.openTasks')}
</Button>
</CardFooter>
</Card>
);
}
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-pink-200 bg-pink-50/40 p-12 text-center">
<div className="rounded-full bg-white p-4 shadow-inner">
<Layers className="h-8 w-8 text-pink-500" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-800">{t('collections.empty.title')}</h3>
<p className="text-sm text-slate-600">{t('collections.empty.description')}</p>
</div>
<div className="flex gap-2">
<Button onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
</div>
);
}
function CollectionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
<div className="h-16 rounded bg-slate-100" />
</div>
))}
</div>
);
}
function ImportDialog({
function ImportCollectionDialog({
open,
onOpenChange,
collection,
events,
eventsLoading,
eventError,
selectedEventSlug,
onEventChange,
onSelectedEventChange,
onSubmit,
importing,
error,
locale,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
collection: TenantTaskCollection | null;
events: TenantEvent[];
eventsLoading: boolean;
eventError: string | null;
selectedEventSlug: string;
onEventChange: (value: string) => void;
onSelectedEventChange: (slug: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
importing: boolean;
error: string | null;
locale: Locale;
}) {
const { t, i18n } = useTranslation('management');
const { t } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('collections.dialogs.importTitle')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="collection-name">{t('collections.dialogs.collectionLabel')}</Label>
<Input id="collection-name" value={collection?.name ?? ''} readOnly disabled className="bg-slate-100" />
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1">
<Label>{t('collections.dialogs.collectionLabel')}</Label>
<p className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
{collection?.name ?? 'Unbekannte Sammlung'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="collection-event">{t('collections.dialogs.selectEvent')}</Label>
<Select
value={selectedEventSlug}
onValueChange={onEventChange}
disabled={eventsLoading || events.length === 0}
>
<Select value={selectedEventSlug} onValueChange={onSelectedEventChange} disabled={eventsLoading || !events.length}>
<SelectTrigger id="collection-event">
<SelectValue placeholder={t('collections.dialogs.selectEvent') ?? 'Event auswählen'} />
<SelectValue placeholder={eventsLoading ? t('collections.errors.eventsLoad') : t('collections.dialogs.selectEvent')} />
</SelectTrigger>
<SelectContent>
{events.map((event) => (
<SelectItem key={event.slug} value={event.slug}>
{formatEventLabel(event, i18n.language)}
</SelectItem>
))}
{events.map((event) => {
const eventDate = event.event_date ? format(new Date(event.event_date), 'PPP', { locale }) : null;
return (
<SelectItem key={event.slug} value={event.slug}>
{event.name && typeof event.name === 'string' ? event.name : event.slug}
{eventDate ? ` · ${eventDate}` : ''}
</SelectItem>
);
})}
</SelectContent>
</Select>
{events.length === 0 && !eventsLoading && (
<p className="text-xs text-slate-500">
{t('collections.errors.noEvents') ?? 'Noch keine Events vorhanden.'}
</p>
)}
{eventError && <p className="text-xs text-red-500">{eventError}</p>}
{error ? <p className="text-xs text-rose-600">{error}</p> : null}
</div>
<DialogFooter className="flex justify-end gap-2">
<DialogFooter className="flex gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('collections.dialogs.cancel')}
</Button>
@@ -460,33 +432,26 @@ function ImportDialog({
);
}
function formatEventLabel(event: TenantEvent, language: string): string {
const locales = [language, language?.split('-')[0], 'de', 'en'].filter(Boolean) as string[];
let name: string | undefined;
if (typeof event.name === 'string') {
name = event.name;
} else if (event.name && typeof event.name === 'object') {
for (const locale of locales) {
const value = (event.name as Record<string, string>)[locale!];
if (value) {
name = value;
break;
}
}
if (!name) {
const first = Object.values(event.name as Record<string, string>)[0];
if (first) {
name = first;
}
}
}
const eventDate = event.event_date ? new Date(event.event_date) : null;
if (!eventDate) {
return name ?? event.slug;
}
const locale = language.startsWith('en') ? enGB : de;
return `${name ?? event.slug} (${format(eventDate, 'PP', { locale })})`;
function CollectionsSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={`collection-skeleton-${index}`} className="h-40 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">{t('collections.empty.title')}</h3>
<p className="text-sm text-slate-500">{t('collections.empty.description')}</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
);
}

View File

@@ -24,7 +24,7 @@ import {
updateTask,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH, ADMIN_TASK_COLLECTIONS_PATH } from '../constants';
import { buildEngagementTabPath } from '../constants';
type TaskFormState = {
title: string;
@@ -42,9 +42,15 @@ const INITIAL_FORM: TaskFormState = {
is_completed: false,
};
export default function TasksPage() {
export type TasksSectionProps = {
embedded?: boolean;
onNavigateToCollections?: () => void;
};
export function TasksSection({ embedded = false, onNavigateToCollections }: TasksSectionProps): JSX.Element {
const navigate = useNavigate();
const { t } = useTranslation('common');
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const [page, setPage] = React.useState(1);
@@ -83,13 +89,13 @@ export default function TasksPage() {
};
}, [page, search]);
function openCreate() {
const openCreate = React.useCallback(() => {
setEditingTask(null);
setForm(INITIAL_FORM);
setDialogOpen(true);
}
}, []);
function openEdit(task: TenantTask) {
const openEdit = React.useCallback((task: TenantTask) => {
setEditingTask(task);
setForm({
title: task.title,
@@ -99,7 +105,15 @@ export default function TasksPage() {
is_completed: task.is_completed,
});
setDialogOpen(true);
}
}, []);
const handleNavigateToCollections = React.useCallback(() => {
if (onNavigateToCollections) {
onNavigateToCollections();
return;
}
navigate(buildEngagementTabPath('collections'));
}, [navigate, onNavigateToCollections]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -137,7 +151,7 @@ export default function TasksPage() {
}
async function handleDelete(taskId: number) {
if (!window.confirm('Task wirklich loeschen?')) {
if (!window.confirm('Task wirklich löschen?')) {
return;
}
@@ -146,7 +160,7 @@ export default function TasksPage() {
setTasks((prev) => prev.filter((task) => task.id !== taskId));
} catch (err) {
if (!isAuthError(err)) {
setError('Task konnte nicht geloescht werden.');
setError('Task konnte nicht gelöscht werden.');
}
}
}
@@ -165,25 +179,13 @@ export default function TasksPage() {
}
}
const title = embedded ? 'Aufgaben' : 'Task Bibliothek';
const subtitle = embedded
? 'Plane Aufgaben, Aktionen und Highlights für deine Gäste.'
: 'Weise Aufgaben zu und tracke Fortschritt rund um deine Events.';
return (
<AdminLayout
title="Task Bibliothek"
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_TASK_COLLECTIONS_PATH)}>
{t('navigation.collections')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreate}
>
<Plus className="h-4 w-4" />
Neu
</Button>
</div>
}
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
@@ -192,76 +194,169 @@ export default function TasksPage() {
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">Tasks verwalten</CardTitle>
<CardDescription className="text-sm text-slate-600">
Erstelle Aufgaben und ordne sie deinen Events zu.
</CardDescription>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle className="text-xl text-slate-900">{title}</CardTitle>
<CardDescription className="text-sm text-slate-600">{subtitle}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleNavigateToCollections}>
{t('navigation.collections')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreate}
>
<Plus className="h-4 w-4" />
Neu
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Input
placeholder="Nach Tasks suchen..."
placeholder="Nach Aufgaben suchen ..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="sm:max-w-sm"
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
className="max-w-sm"
/>
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
Events oeffnen
</Button>
{meta && meta.total > 0 ? (
<div className="text-xs text-slate-500">
Seite {meta.current_page} von {meta.last_page} · {meta.total} Einträge
</div>
) : null}
</div>
{loading ? (
<TaskSkeleton />
<TasksSkeleton />
) : tasks.length === 0 ? (
<EmptyTasksState onCreate={openCreate} />
<EmptyState onCreate={openCreate} />
) : (
<div className="grid gap-4">
<div className="grid gap-3">
{tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={() => void toggleCompletion(task)}
onToggle={() => toggleCompletion(task)}
onEdit={() => openEdit(task)}
onDelete={() => void handleDelete(task.id)}
onDelete={() => handleDelete(task.id)}
/>
))}
</div>
)}
{meta && meta.last_page > 1 && (
<div className="flex items-center justify-between">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
>
Zurueck
</Button>
<span className="text-xs text-slate-500">
Seite {meta.current_page} von {meta.last_page}
</span>
<Button
variant="outline"
disabled={page >= meta.last_page}
onClick={() => setPage((prev) => Math.min(prev + 1, meta.last_page))}
>
Weiter
</Button>
{meta && meta.last_page > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<div className="text-slate-500">
Insgesamt {meta.total} Aufgaben · Seite {meta.current_page} von {meta.last_page}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
Zurück
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
disabled={meta.current_page >= (meta.last_page ?? 1)}
>
Weiter
</Button>
</div>
</div>
)}
) : null}
</CardContent>
</Card>
<TaskDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleSubmit}
form={form}
setForm={setForm}
saving={saving}
isEditing={Boolean(editingTask)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="task-title">Titel</Label>
<Input
id="task-title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-description">Beschreibung</Label>
<Input
id="task-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
placeholder="Was sollen Gäste machen?"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="task-priority">Priorität</Label>
<Select
value={form.priority ?? 'medium'}
onValueChange={(value: TaskPayload['priority']) => setForm((prev) => ({ ...prev, priority: value }))}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder="Priorität wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="task-due-date">Fälligkeitsdatum</Label>
<Input
id="task-due-date"
type="date"
value={form.due_date}
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div>
<p className="text-sm font-medium text-slate-700">Bereits erledigt?</p>
<p className="text-xs text-slate-500">Markiere Aufgaben als abgeschlossen, wenn sie nicht mehr sichtbar sein sollen.</p>
</div>
<Switch checked={form.is_completed} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: checked }))} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={saving} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Speichern
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}
export default function TasksPage(): JSX.Element {
const navigate = useNavigate();
const { t } = useTranslation('management');
const { t: tc } = useTranslation('common');
return (
<AdminLayout
title={tc('navigation.tasks')}
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
>
<TasksSection onNavigateToCollections={() => navigate(buildEngagementTabPath('collections'))} />
</AdminLayout>
);
}
@@ -277,207 +372,68 @@ function TaskRow({
onEdit: () => void;
onDelete: () => void;
}) {
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
const completed = task.is_completed;
const isGlobal = task.tenant_id === null;
const isCompleted = task.is_completed;
const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
return (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<button
type="button"
onClick={isGlobal ? undefined : onToggle}
aria-disabled={isGlobal}
className={`rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition ${
isGlobal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-pink-50'
}`}
>
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200/70 bg-white/80 p-4 shadow-sm shadow-pink-100/20 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<button type="button" onClick={onToggle} className="mt-1 text-slate-500 transition-colors hover:text-emerald-500">
{statusIcon}
</button>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
{task.title}
</p>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority)}
</Badge>
{isGlobal && (
<Badge variant="secondary" className="bg-slate-100 text-slate-600">
Global
</Badge>
)}
</div>
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{task.due_date && <span>Faellig: {formatDate(task.due_date)}</span>}
<span>Zugeordnet: {assignedCount}</span>
<span className="text-sm font-medium text-slate-900">{task.title}</span>
{task.priority ? <PriorityBadge priority={task.priority} /> : null}
{task.collection_id ? <Badge variant="secondary">Vorlage #{task.collection_id}</Badge> : null}
</div>
{task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onEdit} disabled={isGlobal} className={isGlobal ? 'opacity-50' : ''}>
<Pencil className="h-4 w-4" />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="mr-1 h-4 w-4" />
Bearbeiten
</Button>
<Button
variant="outline"
size="sm"
onClick={onDelete}
className={`text-rose-600 ${isGlobal ? 'opacity-50' : ''}`}
disabled={isGlobal}
>
<Trash2 className="h-4 w-4" />
<Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
<Trash2 className="mr-1 h-4 w-4" />
Löschen
</Button>
</div>
</div>
);
}
function TaskDialog({
open,
onOpenChange,
onSubmit,
form,
setForm,
saving,
isEditing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
form: TaskFormState;
setForm: React.Dispatch<React.SetStateAction<TaskFormState>>;
saving: boolean;
isEditing: boolean;
}) {
const handleFormSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(event);
}, [onSubmit]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isEditing ? 'Task bearbeiten' : 'Neuen Task anlegen'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleFormSubmit}>
<div className="space-y-2">
<Label htmlFor="task-title">Titel</Label>
<Input
id="task-title"
value={form.title}
onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="task-description">Beschreibung</Label>
<textarea
id="task-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-pink-300 focus:outline-none focus:ring-2 focus:ring-pink-200"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="task-priority">Prioritaet</Label>
<Select
value={form.priority ?? 'medium'}
onValueChange={(value) => setForm((prev) => ({ ...prev, priority: value as TaskPayload['priority'] }))}
>
<SelectTrigger id="task-priority">
<SelectValue placeholder="Prioritaet waehlen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
<SelectItem value="urgent">Dringend</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="task-due-date">Faellig am</Label>
<Input
id="task-due-date"
type="date"
value={form.due_date}
onChange={(event) => setForm((prev) => ({ ...prev, due_date: event.target.value }))}
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-pink-100 bg-pink-50/60 p-3">
<div>
<Label htmlFor="task-completed" className="text-sm font-medium text-slate-800">
Bereits erledigt
</Label>
<p className="text-xs text-slate-500">Markiere Task als abgeschlossen.</p>
</div>
<Switch
id="task-completed"
checked={form.is_completed}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_completed: Boolean(checked) }))}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Speichern'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
};
const { label, className } = mapping[priority];
return <Badge className={`border-none ${className}`}>{label}</Badge>;
}
function EmptyTasksState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-slate-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<p className="text-sm text-slate-600">Noch keine Tasks angelegt. Lege deinen ersten Task an.</p>
<Button onClick={onCreate}>Task erstellen</Button>
</div>
);
}
function TaskSkeleton() {
function TasksSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
<div key={`task-skeleton-${index}`} className="h-16 animate-pulse rounded-xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function mapPriority(priority: TenantTask['priority']): string {
switch (priority) {
case 'low':
return 'Niedrig';
case 'high':
return 'Hoch';
case 'urgent':
return 'Dringend';
default:
return 'Mittel';
}
}
function formatDate(value: string | null | undefined): string {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' });
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-200 bg-slate-50/60 p-10 text-center">
<h3 className="text-base font-semibold text-slate-800">Noch keine Tasks angelegt</h3>
<p className="text-sm text-slate-500">
Starte mit einer neuen Aufgabe oder importiere Aufgabenvorlagen, um deine Gäste zu inspirieren.
</p>
<Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
<Plus className="mr-1 h-4 w-4" />
Erste Task erstellen
</Button>
</div>
);
}

View File

@@ -0,0 +1,727 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Download, Loader2, Plus, Printer, RotateCcw, Save, UploadCloud } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
import { authorizedFetch } from '../../auth/tokens';
export type QrLayoutCustomization = {
layout_id?: string;
headline?: string;
subtitle?: string;
description?: string;
badge_label?: string;
instructions_heading?: string;
instructions?: string[];
link_heading?: string;
link_label?: string;
cta_label?: string;
accent_color?: string;
text_color?: string;
background_color?: string;
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
logo_data_url?: string | null;
logo_url?: string | null;
};
type InviteLayoutCustomizerPanelProps = {
invite: EventQrInvite | null;
eventName: string;
saving: boolean;
resetting: boolean;
onSave: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
initialCustomization: QrLayoutCustomization | null;
};
const MAX_INSTRUCTIONS = 5;
export function InviteLayoutCustomizerPanel({
invite,
eventName,
saving,
resetting,
onSave,
onReset,
initialCustomization,
}: InviteLayoutCustomizerPanelProps): JSX.Element {
const { t } = useTranslation('management');
const inviteUrl = invite?.url ?? '';
const defaultInstructions = React.useMemo(() => {
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
}, [t]);
const [availableLayouts, setAvailableLayouts] = React.useState<EventQrInviteLayout[]>(invite?.layouts ?? []);
const [layoutsLoading, setLayoutsLoading] = React.useState(false);
const [layoutsError, setLayoutsError] = React.useState<string | null>(null);
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>(initialCustomization?.layout_id ?? invite?.layouts?.[0]?.id);
const [form, setForm] = React.useState<QrLayoutCustomization>({});
const [instructions, setInstructions] = React.useState<string[]>([]);
const [error, setError] = React.useState<string | null>(null);
const formRef = React.useRef<HTMLFormElement | null>(null);
React.useEffect(() => {
if (!invite) {
setAvailableLayouts([]);
setSelectedLayoutId(undefined);
return;
}
const layouts = invite.layouts ?? [];
setAvailableLayouts(layouts);
setLayoutsError(null);
setSelectedLayoutId((current) => {
if (current && layouts.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && layouts.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
}
return layouts[0]?.id;
});
}, [invite?.id, initialCustomization?.layout_id]);
React.useEffect(() => {
let cancelled = false;
async function loadLayouts(url: string) {
try {
setLayoutsLoading(true);
setLayoutsError(null);
const target = (() => {
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
const parsed = new URL(url);
return parsed.pathname + parsed.search;
}
} catch (parseError) {
console.warn('[Invites] Failed to parse layout URL', parseError);
}
return url;
})();
console.debug('[Invites] Fetching layouts', target);
const response = await authorizedFetch(target, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
console.error('[Invites] Layout request failed', response.status, response.statusText);
throw new Error(`Failed with status ${response.status}`);
}
const json = await response.json();
const items = Array.isArray(json?.data) ? (json.data as EventQrInviteLayout[]) : [];
console.debug('[Invites] Layout response items', items);
if (!cancelled) {
setAvailableLayouts(items);
setSelectedLayoutId((current) => {
if (current && items.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && items.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
}
return items[0]?.id;
});
}
} catch (err) {
if (!cancelled) {
console.error('[Invites] Failed to load layouts', err);
setLayoutsError(t('invites.customizer.loadingError', 'Layouts konnten nicht geladen werden.'));
}
} finally {
if (!cancelled) {
setLayoutsLoading(false);
}
}
}
if (!invite || availableLayouts.length > 0 || !invite.layouts_url) {
return () => {
cancelled = true;
};
}
void loadLayouts(invite.layouts_url);
return () => {
cancelled = true;
};
}, [invite, availableLayouts.length, initialCustomization?.layout_id, t]);
React.useEffect(() => {
if (!availableLayouts.length) {
return;
}
setSelectedLayoutId((current) => {
if (current && availableLayouts.some((layout) => layout.id === current)) {
return current;
}
if (initialCustomization?.layout_id && availableLayouts.some((layout) => layout.id === initialCustomization.layout_id)) {
return initialCustomization.layout_id;
}
return availableLayouts[0].id;
});
}, [availableLayouts, initialCustomization?.layout_id]);
const activeLayout = React.useMemo(() => {
if (!availableLayouts.length) {
return null;
}
if (selectedLayoutId) {
const match = availableLayouts.find((layout) => layout.id === selectedLayoutId);
if (match) {
return match;
}
}
return availableLayouts[0];
}, [availableLayouts, selectedLayoutId]);
React.useEffect(() => {
if (!invite || !activeLayout) {
setForm({});
setInstructions([]);
return;
}
const baseInstructions = Array.isArray(initialCustomization?.instructions) && initialCustomization.instructions?.length
? [...(initialCustomization.instructions as string[])]
: [...defaultInstructions];
setInstructions(baseInstructions);
setForm({
layout_id: activeLayout.id,
headline: initialCustomization?.headline ?? eventName,
subtitle: initialCustomization?.subtitle ?? activeLayout.subtitle ?? '',
description: initialCustomization?.description ?? activeLayout.description ?? '',
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
instructions_heading: initialCustomization?.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading'),
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
link_label: initialCustomization?.link_label ?? inviteUrl,
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
accent_color: initialCustomization?.accent_color ?? activeLayout.preview?.accent ?? '#6366F1',
text_color: initialCustomization?.text_color ?? activeLayout.preview?.text ?? '#111827',
background_color: initialCustomization?.background_color ?? activeLayout.preview?.background ?? '#FFFFFF',
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
badge_color: initialCustomization?.badge_color ?? activeLayout.preview?.accent ?? '#2563EB',
background_gradient: initialCustomization?.background_gradient ?? activeLayout.preview?.background_gradient ?? null,
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
});
setError(null);
}, [invite?.id, activeLayout?.id, defaultInstructions, initialCustomization, eventName, inviteUrl, t]);
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
function updateForm<T extends keyof QrLayoutCustomization>(key: T, value: QrLayoutCustomization[T]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleLayoutSelect(layout: EventQrInviteLayout) {
setSelectedLayoutId(layout.id);
updateForm('layout_id', layout.id);
setForm((prev) => ({
...prev,
accent_color: prev.accent_color ?? layout.preview?.accent ?? '#6366F1',
text_color: prev.text_color ?? layout.preview?.text ?? '#111827',
background_color: prev.background_color ?? layout.preview?.background ?? '#FFFFFF',
background_gradient: prev.background_gradient ?? layout.preview?.background_gradient ?? null,
}));
}
function handleInstructionChange(index: number, value: string) {
setInstructions((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
}
function handleAddInstruction() {
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
}
function handleRemoveInstruction(index: number) {
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
}
function handleLogoUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 1024 * 1024) {
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
return;
}
const reader = new FileReader();
reader.onload = () => {
updateForm('logo_data_url', typeof reader.result === 'string' ? reader.result : null);
setError(null);
};
reader.readAsDataURL(file);
}
function handleLogoRemove() {
updateForm('logo_data_url', null);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!invite || !activeLayout) {
return;
}
const payload: QrLayoutCustomization = {
...form,
layout_id: activeLayout.id,
instructions: effectiveInstructions,
};
await onSave(payload);
}
async function handleResetClick() {
await onReset();
}
function handleDownload(format: string, url: string) {
const link = document.createElement('a');
link.href = url;
link.download = `${invite?.token ?? 'invite'}-${format.toLowerCase()}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function handlePrint(preferredUrl?: string | null) {
const url = preferredUrl ?? activeLayout?.download_urls?.pdf ?? activeLayout?.download_urls?.a4 ?? null;
if (!url) {
setError(t('invites.labels.noPrintSource', 'Keine druckbare Version verfügbar.'));
return;
}
const printWindow = window.open(url, '_blank', 'noopener,noreferrer');
printWindow?.focus();
}
const previewStyles = React.useMemo(() => {
const gradient = form.background_gradient ?? activeLayout?.preview?.background_gradient ?? null;
if (gradient?.stops && gradient.stops.length > 0) {
const angle = gradient.angle ?? 180;
const stops = gradient.stops.join(', ');
return { backgroundImage: `linear-gradient(${angle}deg, ${stops})` };
}
return { backgroundColor: form.background_color ?? activeLayout?.preview?.background ?? '#F8FAFC' };
}, [form.background_color, form.background_gradient, activeLayout]);
if (!invite) {
return (
<CardPlaceholder
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
/>
);
}
if (!availableLayouts.length) {
if (layoutsLoading) {
return (
<CardPlaceholder
title={t('invites.customizer.loadingTitle', 'Layouts werden geladen')}
description={t('invites.customizer.loadingDescription', 'Bitte warte einen Moment, wir bereiten die Drucklayouts vor.')}
/>
);
}
return (
<CardPlaceholder
title={layoutsError ?? t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
description={layoutsError ?? t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
/>
);
}
if (!activeLayout) {
return (
<CardPlaceholder
title={t('invites.customizer.placeholderTitle', 'Kein Layout verfügbar')}
description={t('invites.customizer.placeholderCopy', 'Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.')}
/>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{t('invites.customizer.heading', 'Layout anpassen')}</h2>
<p className="text-sm text-slate-600">{t('invites.customizer.copy', 'Verleihe der Einladung euren Ton: Texte, Farben und Logo lassen sich live bearbeiten.')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button
type="button"
onClick={() => {
if (formRef.current) {
if (typeof formRef.current.requestSubmit === 'function') {
formRef.current.requestSubmit();
} else {
formRef.current.submit();
}
}
}}
className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white shadow-lg shadow-rose-500/20"
disabled={saving || resetting}
>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.save', 'Layout speichern')}
</Button>
</div>
</div>
{error ? (
<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 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]">
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
<p className="text-xs text-slate-500">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
</header>
<div className="flex snap-x gap-3 overflow-x-auto pb-2">
{availableLayouts.map((layout) => (
<button
key={layout.id}
type="button"
onClick={() => handleLayoutSelect(layout)}
className={`min-w-[200px] shrink-0 rounded-xl border p-3 text-left transition-all ${layout.id === selectedLayoutId ? 'border-amber-400 bg-amber-50 shadow' : 'border-slate-200 bg-white hover:border-amber-200'}`}
>
<div className="text-sm font-semibold text-slate-900">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</div>
{layout.description ? <div className="mt-1 text-xs text-slate-500">{layout.description}</div> : null}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">
{layout.formats?.map((format) => (
<span key={`${layout.id}-${format}`} className="rounded-full border border-amber-200 px-2 py-0.5 text-amber-600">{String(format).toUpperCase()}</span>
))}
</div>
</button>
))}
</div>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.text', 'Texte')}</h3>
</header>
<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>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</h3>
<p className="text-xs text-slate-500">{t('invites.customizer.sections.instructionsHint', 'Helft euren Gästen mit klaren Aufgaben. Maximal fünf Punkte.')}</p>
</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>
</div>
</header>
<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-slate-500 hover:text-rose-500"
onClick={() => handleRemoveInstruction(index)}
>
×
</Button>
</div>
))}
</div>
</section>
<section className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<header>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.sections.branding', 'Branding')}</h3>
</header>
<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-slate-200 bg-slate-50 p-3">
<img src={form.logo_data_url} alt="Logo" className="h-12 w-12 rounded border border-slate-200 object-contain" />
<Button type="button" variant="ghost" onClick={handleLogoRemove} className="text-rose-500 hover:text-rose-600">
{t('invites.customizer.actions.removeLogo', 'Logo entfernen')}
</Button>
</div>
) : (
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/80 px-4 py-3 text-sm text-slate-500 hover:border-amber-200">
<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>
</section>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleResetClick} disabled={resetting || saving}>
{resetting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCcw className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button type="submit" disabled={saving || resetting} className="bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('invites.customizer.actions.save', 'Layout speichern')}
</Button>
</div>
</form>
<aside className="space-y-4 rounded-2xl border border-slate-100 bg-white/90 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">{t('invites.customizer.preview.title', 'Live-Vorschau')}</h3>
<p className="text-xs text-slate-500">{t('invites.customizer.preview.subtitle', 'So sieht dein Layout beim Export aus.')}</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => handlePrint(activeLayout.download_urls?.pdf ?? activeLayout.download_urls?.a4)}>
<Printer className="mr-1 h-4 w-4" />
{t('invites.customizer.actions.print', 'Drucken')}
</Button>
{activeLayout.formats?.map((format) => {
const key = String(format ?? '').toLowerCase();
const url = activeLayout.download_urls?.[key];
if (!url) return null;
return (
<Button key={`${activeLayout.id}-${key}`} type="button" variant="outline" size="sm" onClick={() => handleDownload(key, url)}>
<Download className="mr-1 h-4 w-4" />
{key.toUpperCase()}
</Button>
);
})}
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-white p-5 shadow-inner">
<div className="rounded-xl p-5 text-slate-900" style={previewStyles}>
<div className="flex items-start justify-between">
<span
className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide"
style={{ backgroundColor: form.badge_color ?? form.accent_color ?? '#2563EB', color: '#ffffff' }}
>
{form.badge_label || t('tasks.customizer.defaults.badgeLabel')}
</span>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-white/80 shadow" style={{ color: form.accent_color ?? '#2563EB' }}>
<SmileIcon />
</div>
</div>
<div className="mt-6 space-y-2">
<h4 className="text-lg font-semibold leading-tight" style={{ color: form.text_color ?? '#111827' }}>
{form.headline || eventName}
</h4>
{form.subtitle ? (
<p className="text-sm" style={{ color: form.text_color ?? '#111827', opacity: 0.75 }}>
{form.subtitle}
</p>
) : null}
</div>
{form.description ? (
<p className="mt-4 text-sm" style={{ color: form.text_color ?? '#111827' }}>
{form.description}
</p>
) : null}
<div className="mt-5 grid gap-3 rounded-xl bg-white/80 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.instructions_heading}</div>
<ol className="grid gap-2 text-sm text-slate-700">
{effectiveInstructions.slice(0, 4).map((item, index) => (
<li key={`preview-instruction-${index}`} className="flex gap-2">
<span className="font-semibold" style={{ color: form.accent_color ?? '#2563EB' }}>{index + 1}.</span>
<span>{item}</span>
</li>
))}
</ol>
</div>
<div className="mt-5 flex flex-col gap-2 rounded-xl bg-white/80 p-4 text-sm text-slate-700">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">{form.link_heading}</span>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium" style={{ color: form.accent_color ?? '#2563EB' }}>
{form.link_label || inviteUrl}
</span>
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-xs text-white">QR</span>
</div>
<Button size="sm" className="w-full bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 text-white">
{form.cta_label || t('tasks.customizer.defaults.ctaLabel')}
</Button>
</div>
</div>
{form.logo_data_url ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-slate-200 bg-slate-50 py-4">
<img src={form.logo_data_url} alt="Logo preview" className="max-h-16 object-contain" />
</div>
) : null}
</div>
</aside>
</div>
</div>
);
}
function CardPlaceholder({ title, description }: { title: string; description: string }) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-10 text-center text-sm text-slate-500">
<h3 className="text-base font-semibold text-slate-700">{title}</h3>
<p className="mt-2 text-sm text-slate-500">{description}</p>
</div>
);
}
function SmileIcon(): JSX.Element {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5">
<circle cx="12" cy="12" r="10" opacity="0.4" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01M15 9h.01" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

View File

@@ -1,514 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { EventQrInviteLayout } from '../../api';
export type QrLayoutCustomization = {
layout_id?: string;
headline?: string;
subtitle?: string;
description?: string;
badge_label?: string;
instructions_heading?: string;
instructions?: string[];
link_heading?: string;
link_label?: string;
cta_label?: string;
accent_color?: string;
text_color?: string;
background_color?: string;
secondary_color?: string;
badge_color?: string;
background_gradient?: { angle?: number; stops?: string[] } | null;
logo_data_url?: string | null;
logo_url?: string | null;
};
const MAX_INSTRUCTIONS = 5;
type Props = {
open: boolean;
onClose: () => void;
onSubmit: (customization: QrLayoutCustomization) => Promise<void>;
onReset: () => Promise<void>;
saving: boolean;
inviteUrl: string;
eventName: string;
layouts: EventQrInviteLayout[];
initialCustomization: QrLayoutCustomization | null;
};
export function QrInviteCustomizationDialog({
open,
onClose,
onSubmit,
onReset,
saving,
inviteUrl,
eventName,
layouts,
initialCustomization,
}: Props) {
const { t } = useTranslation('management');
const [selectedLayoutId, setSelectedLayoutId] = React.useState<string | undefined>();
const [form, setForm] = React.useState<QrLayoutCustomization>({});
const [instructions, setInstructions] = React.useState<string[]>([]);
const [error, setError] = React.useState<string | null>(null);
const defaultInstructions = React.useMemo(() => {
const value = t('tasks.customizer.defaults.instructions', { returnObjects: true }) as unknown;
return Array.isArray(value) ? (value as string[]) : ['QR-Code scannen', 'Profil anlegen', 'Fotos teilen'];
}, [t]);
const selectedLayout = React.useMemo(() => {
if (layouts.length === 0) {
return undefined;
}
const fallback = layouts[0];
if (!selectedLayoutId) {
return fallback;
}
return layouts.find((layout) => layout.id === selectedLayoutId) ?? fallback;
}, [layouts, selectedLayoutId]);
React.useEffect(() => {
if (!open) {
return;
}
const defaultLayout = initialCustomization?.layout_id
? layouts.find((layout) => layout.id === initialCustomization.layout_id)
: undefined;
const layout = defaultLayout ?? layouts[0];
setSelectedLayoutId(layout?.id);
const nextInstructions = Array.isArray(initialCustomization?.instructions)
? initialCustomization!.instructions!
: [];
setInstructions(nextInstructions.length > 0 ? nextInstructions : defaultInstructions);
setForm({
layout_id: layout?.id,
headline: initialCustomization?.headline ?? eventName,
subtitle: initialCustomization?.subtitle ?? layout?.subtitle ?? '',
description: initialCustomization?.description ?? layout?.description ?? '',
badge_label: initialCustomization?.badge_label ?? t('tasks.customizer.defaults.badgeLabel'),
instructions_heading:
initialCustomization?.instructions_heading ?? t("tasks.customizer.defaults.instructionsHeading"),
link_heading: initialCustomization?.link_heading ?? t('tasks.customizer.defaults.linkHeading'),
link_label: initialCustomization?.link_label ?? inviteUrl,
cta_label: initialCustomization?.cta_label ?? t('tasks.customizer.defaults.ctaLabel'),
accent_color: initialCustomization?.accent_color ?? layout?.preview?.accent ?? '#6366F1',
text_color: initialCustomization?.text_color ?? layout?.preview?.text ?? '#111827',
background_color: initialCustomization?.background_color ?? layout?.preview?.background ?? '#FFFFFF',
secondary_color: initialCustomization?.secondary_color ?? 'rgba(15,23,42,0.08)',
badge_color: initialCustomization?.badge_color ?? layout?.preview?.accent ?? '#2563EB',
background_gradient: initialCustomization?.background_gradient ?? layout?.preview?.background_gradient ?? null,
logo_data_url: initialCustomization?.logo_data_url ?? initialCustomization?.logo_url ?? null,
});
setError(null);
}, [open, layouts, initialCustomization, inviteUrl, eventName, t]);
React.useEffect(() => {
if (!selectedLayout) {
return;
}
setForm((prev) => ({
...prev,
layout_id: selectedLayout.id,
accent_color: prev.accent_color ?? selectedLayout.preview?.accent ?? '#6366F1',
text_color: prev.text_color ?? selectedLayout.preview?.text ?? '#111827',
background_color: prev.background_color ?? selectedLayout.preview?.background ?? '#FFFFFF',
background_gradient: prev.background_gradient ?? selectedLayout.preview?.background_gradient ?? null,
}));
}, [selectedLayout]);
const handleColorChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInputChange = (key: keyof QrLayoutCustomization) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setForm((prev) => ({ ...prev, [key]: event.target.value }));
};
const handleInstructionChange = (index: number, value: string) => {
setInstructions((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
};
const handleAddInstruction = () => {
setInstructions((prev) => (prev.length < MAX_INSTRUCTIONS ? [...prev, ''] : prev));
};
const handleRemoveInstruction = (index: number) => {
setInstructions((prev) => prev.filter((_, idx) => idx !== index));
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > 1024 * 1024) {
setError(t('tasks.customizer.errors.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.'));
return;
}
const reader = new FileReader();
reader.onload = () => {
setForm((prev) => ({ ...prev, logo_data_url: typeof reader.result === 'string' ? reader.result : null }));
setError(null);
};
reader.readAsDataURL(file);
};
const handleLogoRemove = () => {
setForm((prev) => ({ ...prev, logo_data_url: null }));
};
const effectiveInstructions = instructions.filter((entry) => entry.trim().length > 0);
const preview = React.useMemo(() => {
const backgroundStyle = form.background_gradient?.stops && form.background_gradient.stops.length > 0
? `linear-gradient(${form.background_gradient.angle ?? 180}deg, ${form.background_gradient.stops.join(',')})`
: form.background_color ?? selectedLayout?.preview?.background ?? '#FFFFFF';
return {
background: backgroundStyle,
accent: form.accent_color ?? selectedLayout?.preview?.accent ?? '#6366F1',
text: form.text_color ?? selectedLayout?.preview?.text ?? '#111827',
secondary: form.secondary_color ?? 'rgba(15,23,42,0.08)',
};
}, [form, selectedLayout]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedLayout) {
setError(t('tasks.customizer.errors.noLayout', 'Bitte wähle ein Layout aus.'));
return;
}
setError(null);
await onSubmit({
...form,
layout_id: selectedLayout.id,
instructions: effectiveInstructions,
});
};
const handleReset = async () => {
setError(null);
await onReset();
};
return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('tasks.customizer.title', 'QR-Einladung anpassen')}</DialogTitle>
<DialogDescription>{t('tasks.customizer.description', 'Passe Layout, Texte und Farben deiner QR-Einladung an.')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-6 md:grid-cols-[2fr,1fr]">
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="qr-layout">{t('tasks.customizer.layout', 'Layout')}</Label>
<Select
value={selectedLayout?.id}
onValueChange={(value) => setSelectedLayoutId(value)}
disabled={layouts.length === 0 || saving}
>
<SelectTrigger id="qr-layout">
<SelectValue placeholder={t('tasks.customizer.selectLayout', 'Layout auswählen')} />
</SelectTrigger>
<SelectContent>
{layouts.map((layout) => (
<SelectItem key={layout.id} value={layout.id}>
{layout.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="headline">{t('tasks.customizer.headline', 'Überschrift')}</Label>
<Input
id="headline"
value={form.headline ?? ''}
onChange={handleInputChange('headline')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="subtitle">{t('tasks.customizer.subtitle', 'Unterzeile')}</Label>
<Input
id="subtitle"
value={form.subtitle ?? ''}
onChange={handleInputChange('subtitle')}
maxLength={160}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="badgeLabel">{t('tasks.customizer.badgeLabel', 'Badge')}</Label>
<Input
id="badgeLabel"
value={form.badge_label ?? ''}
onChange={handleInputChange('badge_label')}
maxLength={80}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t('tasks.customizer.descriptionLabel', 'Beschreibung')}</Label>
<Textarea
id="description"
rows={3}
value={form.description ?? ''}
onChange={handleInputChange('description')}
maxLength={500}
disabled={saving}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="instructionsHeading">{t('tasks.customizer.instructionsHeading', "Anleitungstitel")}</Label>
<Input
id="instructionsHeading"
value={form.instructions_heading ?? ''}
onChange={handleInputChange('instructions_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ctaLabel">{t('tasks.customizer.ctaLabel', 'CTA')}</Label>
<Input
id="ctaLabel"
value={form.cta_label ?? ''}
onChange={handleInputChange('cta_label')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkHeading">{t('tasks.customizer.linkHeading', 'Link-Titel')}</Label>
<Input
id="linkHeading"
value={form.link_heading ?? ''}
onChange={handleInputChange('link_heading')}
maxLength={120}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="linkLabel">{t('tasks.customizer.linkLabel', 'Link')}</Label>
<Input
id="linkLabel"
value={form.link_label ?? ''}
onChange={handleInputChange('link_label')}
maxLength={160}
disabled={saving}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.instructionsLabel', 'Hinweise')}</Label>
<div className="space-y-2">
{instructions.map((instruction, index) => (
<div key={`instruction-${index}`} className="flex items-start gap-2">
<Textarea
rows={2}
value={instruction}
onChange={(event) => handleInstructionChange(index, event.target.value)}
maxLength={160}
disabled={saving}
/>
<Button type="button" variant="outline" onClick={() => handleRemoveInstruction(index)} disabled={saving}>
{t('tasks.customizer.removeInstruction', 'Entfernen')}
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={handleAddInstruction}
disabled={instructions.length >= MAX_INSTRUCTIONS || saving}
>
{t('tasks.customizer.addInstruction', 'Hinweis hinzufügen')}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<ColorField
label={t('tasks.customizer.colors.accent', 'Akzentfarbe')}
value={form.accent_color ?? '#6366F1'}
onChange={handleColorChange('accent_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.text', 'Textfarbe')}
value={form.text_color ?? '#111827'}
onChange={handleColorChange('text_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.background', 'Hintergrund')}
value={form.background_color ?? '#FFFFFF'}
onChange={handleColorChange('background_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.secondary', 'Sekundärfarbe')}
value={form.secondary_color ?? '#CBD5F5'}
onChange={handleColorChange('secondary_color')}
disabled={saving}
/>
<ColorField
label={t('tasks.customizer.colors.badge', 'Badge-Farbe')}
value={form.badge_color ?? '#2563EB'}
onChange={handleColorChange('badge_color')}
disabled={saving}
/>
</div>
<div className="space-y-2">
<Label>{t('tasks.customizer.logo.label', 'Logo')}</Label>
<div className="flex flex-wrap items-center gap-3">
<Input type="file" accept="image/png,image/jpeg,image/svg+xml" onChange={handleLogoUpload} disabled={saving} />
{form.logo_data_url ? (
<Button type="button" variant="outline" onClick={handleLogoRemove} disabled={saving}>
{t('tasks.customizer.logo.remove', 'Logo entfernen')}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t('tasks.customizer.logo.hint', 'PNG oder SVG, max. 1 MB. Wird oben rechts platziert.')}
</p>
</div>
</div>
<aside className="space-y-4">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<h4 className="text-sm font-semibold text-slate-900">
{t('tasks.customizer.preview.title', 'Vorschau')}
</h4>
<p className="text-xs text-slate-600">
{t('tasks.customizer.preview.hint', 'Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten.')}
</p>
</div>
<div
className="space-y-3 rounded-3xl border border-slate-200 p-4 text-xs text-slate-700 shadow-sm"
style={{
background: preview.background,
color: preview.text,
}}
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-[var(--badge-color,#1f2937)] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
style={{ background: form.badge_color ?? preview.accent }}
>
{form.badge_label ?? t('tasks.customizer.defaults.badgeLabel')}
</span>
{form.logo_data_url ? (
<img src={form.logo_data_url} alt="Logo" className="h-12 w-auto object-contain" />
) : null}
</div>
<div className="space-y-1">
<p className="text-base font-semibold">{form.headline ?? eventName}</p>
{form.subtitle ? <p className="text-sm opacity-80">{form.subtitle}</p> : null}
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.instructions_heading ?? t('tasks.customizer.defaults.instructionsHeading')}
</p>
<ul className="space-y-1 text-xs">
{(effectiveInstructions.length > 0 ? effectiveInstructions : defaultInstructions).map((item, index) => (
<li key={`preview-instruction-${index}`}> {item}</li>
))}
</ul>
</div>
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.link_heading ?? t('tasks.customizer.defaults.linkHeading')}
</p>
<div className="rounded-lg border border-white/40 bg-white/80 p-2 text-[11px]" style={{ color: preview.text }}>
{form.link_label ?? inviteUrl}
</div>
</div>
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: preview.accent }}>
{form.cta_label ?? t('tasks.customizer.defaults.ctaLabel')}
</div>
</div>
</aside>
<input type="hidden" value={form.layout_id ?? ''} />
<DialogFooter className="md:col-span-2">
<div className="flex flex-1 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" onClick={handleReset} disabled={saving}>
{t('tasks.customizer.actions.reset', 'Zurücksetzen')}
</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
{t('tasks.customizer.actions.cancel', 'Abbrechen')}
</Button>
</div>
<div className="flex items-center gap-3">
{error ? <span className="text-sm text-destructive">{error}</span> : null}
<Button type="submit" disabled={saving}>
{t('tasks.customizer.actions.save', 'Speichern')}
</Button>
</div>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function ColorField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
disabled: boolean;
}) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<Input type="color" value={value} onChange={onChange} disabled={disabled} className="h-10 w-14 p-1" />
<Input value={value} onChange={onChange} disabled={disabled} pattern="^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$" />
</div>
</div>
);
}
export default QrInviteCustomizationDialog;

View File

@@ -10,6 +10,8 @@ import EventDetailPage from './pages/EventDetailPage';
import EventMembersPage from './pages/EventMembersPage';
import EventTasksPage from './pages/EventTasksPage';
import EventToolkitPage from './pages/EventToolkitPage';
import EventInvitesPage from './pages/EventInvitesPage';
import EngagementPage from './pages/EngagementPage';
import BillingPage from './pages/BillingPage';
import TasksPage from './pages/TasksPage';
import TaskCollectionsPage from './pages/TaskCollectionsPage';
@@ -86,7 +88,9 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
{ path: 'events/:slug/members', element: <EventMembersPage /> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
{ path: 'engagement', element: <EngagementPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> },

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input selection:bg-primary selection:text-primary-foreground flex min-h-[120px] w-full rounded-md border bg-transparent px-3 py-2 text-sm text-foreground shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator';
@@ -90,10 +89,10 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
<CardDescription>Erfülle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Noch keine Badges verfuegbar.</p>
<p className="text-sm text-muted-foreground">Noch keine Badges verfügbar.</p>
</CardContent>
</Card>
);
@@ -103,7 +102,7 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
<Card>
<CardHeader>
<CardTitle>Badges</CardTitle>
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription>
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{badges.map((badge) => (
@@ -139,7 +138,7 @@ function Timeline({ points }: { points: TimelinePoint[] }) {
{points.map((point) => (
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
<span className="font-medium text-foreground">{point.date}</span>
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gaeste</span>
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gäste</span>
</div>
))}
</CardContent>
@@ -156,7 +155,7 @@ function Feed({ feed }: { feed: FeedEntry[] }) {
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Noch keine Uploads - starte die Kamera und lege los!</p>
<p className="text-sm text-muted-foreground">Noch keine Uploads starte die Kamera und lege los!</p>
</CardContent>
</Card>
);
@@ -215,7 +214,7 @@ function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
)}
</div>
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> <EFBFBD> {topPhoto.likes} Likes</p>
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> {topPhoto.likes} Likes</p>
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
</CardContent>
@@ -252,13 +251,13 @@ function SummaryCards({ data }: { data: AchievementsPayload }) {
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Aktive Gaeste</span>
<span className="text-xs uppercase text-muted-foreground">Aktive Gäste</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<span className="text-xs uppercase text-muted-foreground">Erfuellte Aufgaben</span>
<span className="text-xs uppercase text-muted-foreground">Erfüllte Aufgaben</span>
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
</CardContent>
</Card>
@@ -339,7 +338,7 @@ export default function AchievementsPage() {
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1>
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.</p>
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gäste im Blick.</p>
</div>
</div>
</div>
@@ -424,13 +423,13 @@ export default function AchievementsPage() {
title="Top Uploads"
icon={Users}
entries={data.leaderboards.uploads}
emptyCopy="Noch keine Uploads - sobald Fotos vorhanden sind, erscheinen sie hier."
emptyCopy="Noch keine Uploads sobald Fotos vorhanden sind, erscheinen sie hier."
/>
<Leaderboard
title="Beliebteste Gaeste"
title="Beliebteste Gäste"
icon={Trophy}
entries={data.leaderboards.likes}
emptyCopy="Likes fehlen noch - motiviere die Gaeste, Fotos zu liken."
emptyCopy="Likes fehlen noch motiviere die Gäste, Fotos zu liken."
/>
</div>
</div>

View File

@@ -92,12 +92,12 @@ export default function TaskPickerPage() {
map.set(task.emotion.slug, task.emotion.name);
}
});
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: tokenValue, name }));
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
}, [tasks]);
const filteredTasks = React.useMemo(() => {
if (selectedEmotion === 'all') return tasks;
return tasks.filter((task) => task.emotion?.token === selectedEmotion);
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
}, [tasks, selectedEmotion]);
const selectRandomTask = React.useCallback(
@@ -243,14 +243,14 @@ export default function TaskPickerPage() {
<div className="space-y-6">
<header className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswählen</h1>
<Badge variant="secondary" className="whitespace-nowrap">
Schon {completedCount} Aufgaben erledigt
</Badge>
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
<span>Auf dem Weg zum naechsten Erfolg</span>
<span>Auf dem Weg zum nächsten Erfolg</span>
<span>
{completedCount >= TASK_PROGRESS_TARGET
? 'Stark!'
@@ -427,7 +427,7 @@ export default function TaskPickerPage() {
{!loading && !tasks.length && !error && (
<Alert>
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
<AlertDescription>Für dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
</Alert>
)}
</div>
@@ -504,8 +504,8 @@ function EmptyState({
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
<p className="text-sm text-muted-foreground">
{hasTasks
? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
? 'Für deine aktuelle Stimmung gibt es gerade keine Aufgabe. Wähle eine andere Stimmung oder lade neue Aufgaben.'
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es später erneut.'}
</p>
</div>
{hasTasks && emotionOptions.length > 0 && (