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

@@ -4,12 +4,12 @@ namespace App\Filament\Resources\InviteLayouts\Schemas;
use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class InviteLayoutForm class InviteLayoutForm

View File

@@ -39,10 +39,26 @@ class EventController extends Controller
$query = Event::where('tenant_id', $tenantId) $query = Event::where('tenant_id', $tenantId)
->with([ ->with([
'eventType', 'eventType',
'photos',
'eventPackages.package', 'eventPackages.package',
'eventPackage.package', 'eventPackage.package',
]) ])
->withCount([
'photos',
'photos as pending_photos_count' => fn ($photoQuery) => $photoQuery->where('status', 'pending'),
'tasks as tasks_count',
'joinTokens as total_join_tokens_count',
'joinTokens as active_join_tokens_count' => fn ($tokenQuery) => $tokenQuery
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
}),
])
->withSum('photos as likes_sum', 'likes_count')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
if ($request->has('status')) { if ($request->has('status')) {

View File

@@ -265,14 +265,6 @@ class MarketingController extends Controller
$converter = new MarkdownConverter($environment); $converter = new MarkdownConverter($environment);
$contentHtml = (string) $converter->convert($markdown); $contentHtml = (string) $converter->convert($markdown);
// Debug log for content_html
\Log::info('BlogShow Debug: content_html type and preview', [
'type' => gettype($contentHtml),
'is_string' => is_string($contentHtml),
'length' => strlen($contentHtml ?? ''),
'preview' => substr((string) $contentHtml, 0, 200).'...',
]);
$post = [ $post = [
'id' => $postModel->id, 'id' => $postModel->id,
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '', 'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '',
@@ -287,13 +279,6 @@ class MarketingController extends Controller
] : null, ] : null,
]; ];
// Debug log for final postArray
\Log::info('BlogShow Debug: Final post content_html', [
'type' => gettype($post['content_html']),
'is_string' => is_string($post['content_html']),
'length' => strlen($post['content_html'] ?? ''),
]);
return Inertia::render('marketing/BlogShow', compact('post')); return Inertia::render('marketing/BlogShow', compact('post'));
} }

View File

@@ -20,11 +20,11 @@ class EventJoinTokenResource extends JsonResource
$eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null); $eventContext = $eventFromRoute instanceof Event ? $eventFromRoute : ($this->resource->event ?? null);
$layouts = []; $layouts = [];
if ($eventContext && Route::has('tenant.events.join-tokens.layouts.download')) { if ($eventContext && Route::has('api.v1.tenant.events.join-tokens.layouts.download')) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) { $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($eventContext) {
return route('tenant.events.join-tokens.layouts.download', [ return route('api.v1.tenant.events.join-tokens.layouts.download', [
'event' => $eventContext, 'event' => $eventContext,
'joinToken' => $this->resource, 'joinToken' => $eventContext instanceof Event ? $this->resource->id : $this->resource,
'layout' => $layoutId, 'layout' => $layoutId,
'format' => $format, 'format' => $format,
]); ]);
@@ -32,10 +32,10 @@ class EventJoinTokenResource extends JsonResource
} }
$layoutsUrl = null; $layoutsUrl = null;
if ($eventContext && Route::has('tenant.events.join-tokens.layouts.index')) { if ($eventContext && Route::has('api.v1.tenant.events.join-tokens.layouts.index')) {
$layoutsUrl = route('tenant.events.join-tokens.layouts.index', [ $layoutsUrl = route('api.v1.tenant.events.join-tokens.layouts.index', [
'event' => $eventContext, 'event' => $eventContext,
'joinToken' => $this->resource, 'joinToken' => $eventContext instanceof Event ? $this->resource->id : $this->resource,
]); ]);
} }

View File

@@ -49,8 +49,14 @@ class EventResource extends JsonResource
'event_type_id' => $this->event_type_id, 'event_type_id' => $this->event_type_id,
'created_at' => $this->created_at?->toISOString(), 'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(),
'photo_count' => $this->photos_count ?? 0, 'photo_count' => (int) ($this->photos_count ?? 0),
'like_count' => $this->whenLoaded('photos', fn () => $this->photos->sum('likes_count'), 0), 'pending_photo_count' => isset($this->pending_photos_count) ? (int) $this->pending_photos_count : null,
'like_count' => isset($this->likes_sum)
? (int) $this->likes_sum
: $this->whenLoaded('photos', fn () => (int) $this->photos->sum('likes_count'), 0),
'tasks_count' => isset($this->tasks_count) ? (int) $this->tasks_count : null,
'active_invites_count' => isset($this->active_join_tokens_count) ? (int) $this->active_join_tokens_count : null,
'total_invites_count' => isset($this->total_join_tokens_count) ? (int) $this->total_join_tokens_count : null,
'is_public' => $this->status === 'published', 'is_public' => $this->status === 'published',
'public_share_url' => null, 'public_share_url' => null,
'qr_code_url' => null, 'qr_code_url' => null,

View File

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

View File

@@ -6,19 +6,15 @@ import {
ADMIN_HOME_PATH, ADMIN_HOME_PATH,
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
ADMIN_SETTINGS_PATH, ADMIN_SETTINGS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH, ADMIN_BILLING_PATH,
ADMIN_TASK_COLLECTIONS_PATH, ADMIN_ENGAGEMENT_PATH,
ADMIN_EMOTIONS_PATH,
} from '../constants'; } from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher'; import { LanguageSwitcher } from './LanguageSwitcher';
const navItems = [ const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true }, { to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' }, { to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
{ to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' }, { to: ADMIN_ENGAGEMENT_PATH, labelKey: 'navigation.engagement' },
{ to: ADMIN_TASK_COLLECTIONS_PATH, labelKey: 'navigation.collections' },
{ to: ADMIN_EMOTIONS_PATH, labelKey: 'navigation.emotions' },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' }, { to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' }, { 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_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events'); export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings'); export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_TASKS_PATH = adminPath('/tasks'); export const ADMIN_ENGAGEMENT_PATH = adminPath('/engagement');
export const ADMIN_TASK_COLLECTIONS_PATH = adminPath('/task-collections'); export const buildEngagementTabPath = (tab: 'tasks' | 'collections' | 'emotions'): string =>
export const ADMIN_EMOTIONS_PATH = adminPath('/emotions'); `${ADMIN_ENGAGEMENT_PATH}?tab=${encodeURIComponent(tab)}`;
export const ADMIN_BILLING_PATH = adminPath('/billing'); export const ADMIN_BILLING_PATH = adminPath('/billing');
export const ADMIN_PHOTOS_PATH = adminPath('/photos'); export const ADMIN_PHOTOS_PATH = adminPath('/photos');
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome'); 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_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_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_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", "tasks": "Aufgaben",
"collections": "Aufgabenvorlagen", "collections": "Aufgabenvorlagen",
"emotions": "Emotionen", "emotions": "Emotionen",
"engagement": "Aufgaben & Co.",
"billing": "Abrechnung", "billing": "Abrechnung",
"settings": "Einstellungen" "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": { "collections": {
"title": "Aufgabenvorlagen", "title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.", "subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
@@ -365,6 +442,9 @@
"page": "Seite {{current}} von {{total}}" "page": "Seite {{current}} von {{total}}"
} }
}, },
"engagement": {
"subtitle": "Plane Aufgaben, Vorlagen und Emotionen gebündelt für deine Events."
},
"emotions": { "emotions": {
"title": "Emotionen", "title": "Emotionen",
"subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.", "subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.",

View File

@@ -9,6 +9,7 @@
"tasks": "Tasks", "tasks": "Tasks",
"collections": "Collections", "collections": "Collections",
"emotions": "Emotions", "emotions": "Emotions",
"engagement": "Tasks & More",
"billing": "Billing", "billing": "Billing",
"settings": "Settings" "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": { "collections": {
"title": "Task collections", "title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.", "subtitle": "Browse curated task bundles or activate them for your events.",
@@ -364,6 +441,9 @@
"next": "Next", "next": "Next",
"page": "Page {{current}} of {{total}}" "page": "Page {{current}} of {{total}}"
} }
},
"engagement": {
"subtitle": "Manage tasks, collections, and emotions from a single workspace."
}, },
"emotions": { "emotions": {
"title": "Emotions", "title": "Emotions",

View File

@@ -26,7 +26,7 @@ export default function AuthCallbackPage() {
if (isAuthError(err) && err.code === 'token_exchange_failed') { if (isAuthError(err) && err.code === 'token_exchange_failed') {
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.'); setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
} else if (isAuthError(err) && err.code === 'invalid_state') { } 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 { } else {
setError('Unbekannter Fehler beim Login.'); setError('Unbekannter Fehler beim Login.');
} }

View File

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

View File

@@ -3,34 +3,20 @@ import { useTranslation } from 'react-i18next';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale'; import { de, enGB } from 'date-fns/locale';
import type { Locale } from 'date-fns'; 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; 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 = { type EmotionFormState = {
name: string; name: string;
@@ -41,6 +27,7 @@ type EmotionFormState = {
sort_order: number; sort_order: number;
}; };
const DEFAULT_COLOR = '#6366f1';
const INITIAL_FORM_STATE: EmotionFormState = { const INITIAL_FORM_STATE: EmotionFormState = {
name: '', name: '',
description: '', description: '',
@@ -50,7 +37,11 @@ const INITIAL_FORM_STATE: EmotionFormState = {
sort_order: 0, 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 { t, i18n } = useTranslation('management');
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]); const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
@@ -89,10 +80,10 @@ export default function EmotionsPage(): JSX.Element {
}; };
}, [t]); }, [t]);
function openCreateDialog() { const openCreateDialog = React.useCallback(() => {
setForm(INITIAL_FORM_STATE); setForm(INITIAL_FORM_STATE);
setDialogOpen(true); setDialogOpen(true);
} }, []);
async function handleCreate(event: React.FormEvent<HTMLFormElement>) { async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -137,21 +128,13 @@ export default function EmotionsPage(): JSX.Element {
} }
const locale = i18n.language.startsWith('en') ? enGB : de; 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 ( return (
<AdminLayout <div className="space-y-6">
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>
}
>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle> <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"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader> <CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<CardTitle className="text-xl text-slate-900">{t('emotions.title')}</CardTitle> <div>
<CardDescription className="text-sm text-slate-600">{t('emotions.subtitle')}</CardDescription> <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> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{loading ? ( {loading ? (
<EmotionSkeleton /> <EmotionSkeleton />
) : emotions.length === 0 ? ( ) : emotions.length === 0 ? (
<EmptyEmotionsState /> <EmptyEmotionsState onCreate={openCreateDialog} />
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => ( {emotions.map((emotion) => (
@@ -177,7 +172,6 @@ export default function EmotionsPage(): JSX.Element {
emotion={emotion} emotion={emotion}
onToggle={() => toggleEmotion(emotion)} onToggle={() => toggleEmotion(emotion)}
locale={locale} locale={locale}
canToggle={!emotion.is_global}
/> />
))} ))}
</div> </div>
@@ -193,6 +187,15 @@ export default function EmotionsPage(): JSX.Element {
saving={saving} saving={saving}
onSubmit={handleCreate} 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> </AdminLayout>
); );
} }
@@ -201,95 +204,60 @@ function EmotionCard({
emotion, emotion,
onToggle, onToggle,
locale, locale,
canToggle,
}: { }: {
emotion: TenantEmotion; emotion: TenantEmotion;
onToggle: () => void; onToggle: () => void;
locale: Locale; locale: Locale;
canToggle: boolean;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const updatedLabel = emotion.updated_at const updated = emotion.updated_at ? format(new Date(emotion.updated_at), 'Pp', { locale }) : null;
? format(new Date(emotion.updated_at), 'PP', { locale })
: null;
return ( return (
<Card className="border border-slate-100 bg-white/90 shadow-sm"> <Card className="border border-slate-200/70 bg-white/85 shadow-md shadow-pink-100/20">
<CardHeader className="space-y-3"> <CardHeader className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <div
variant="outline" className="flex h-9 w-9 items-center justify-center rounded-full"
className={emotion.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'} 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>
</div>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Smile className="h-4 w-4" /> <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> </div>
{updatedLabel && <span>{t('emotions.labels.updated', { date: updatedLabel })}</span>} <div>
</CardContent> <CardTitle className="text-base text-slate-900">{emotion.name}</CardTitle>
<CardFooter className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-4 py-3"> {emotion.description ? (
<span className="text-xs text-slate-500"> <CardDescription className="text-xs text-slate-500">{emotion.description}</CardDescription>
) : null}
</div>
</div>
<Badge variant={emotion.is_active ? 'default' : 'secondary'}>
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')} {emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
</span> </Badge>
<Button </CardHeader>
variant="outline" <CardContent className="space-y-3 text-sm text-slate-600">
size="sm" <div className="flex flex-wrap gap-2">
onClick={onToggle} <Badge variant="outline">#{emotion.icon}</Badge>
disabled={!canToggle} {emotion.event_types?.length ? (
className={!canToggle ? 'pointer-events-none opacity-60' : ''} emotion.event_types.map((eventType) => (
> <Badge key={eventType.id} variant="outline">
<Power className="mr-2 h-4 w-4" /> {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')} {emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button> </Button>
<div className="h-8 w-8 rounded-full border border-slate-200" style={{ backgroundColor: emotion.color ?? DEFAULT_COLOR }} />
</CardFooter> </CardFooter>
</Card> </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({ function EmotionDialog({
open, open,
onOpenChange, onOpenChange,
@@ -308,12 +276,11 @@ function EmotionDialog({
const { t } = useTranslation('management'); const { t } = useTranslation('management');
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle> <DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label> <Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
<Input <Input
@@ -323,17 +290,14 @@ function EmotionDialog({
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label> <Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
<textarea <Input
id="emotion-description" id="emotion-description"
value={form.description} value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} 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>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label> <Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
@@ -347,28 +311,20 @@ function EmotionDialog({
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label> <Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
<Input <Input
id="emotion-color" id="emotion-color"
type="color"
value={form.color} value={form.color}
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
placeholder="#6366f1"
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3">
<div className="flex items-center justify-between rounded-lg border border-indigo-100 bg-indigo-50/60 p-3">
<div> <div>
<Label htmlFor="emotion-active" className="text-sm font-medium text-slate-800"> <p className="text-sm font-medium text-slate-700">{t('emotions.dialogs.activeLabel')}</p>
{t('emotions.dialogs.activeLabel')}
</Label>
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p> <p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
</div> </div>
<Switch <Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
id="emotion-active"
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: Boolean(checked) }))}
/>
</div> </div>
<DialogFooter className="flex gap-2">
<DialogFooter className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('emotions.dialogs.cancel')} {t('emotions.dialogs.cancel')}
</Button> </Button>
@@ -382,3 +338,27 @@ function EmotionDialog({
</Dialog> </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 React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Sparkles, QrCode } from 'lucide-react';
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -9,19 +8,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
createQrInvite,
EventQrInvite,
EventQrInviteLayout,
EventStats as TenantEventStats, EventStats as TenantEventStats,
getEvent, getEvent,
getEventQrInvites,
getEventStats, getEventStats,
TenantEvent, TenantEvent,
toggleEvent, toggleEvent,
revokeEventQrInvite,
updateEventQrInvite,
} from '../api'; } from '../api';
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { import {
ADMIN_EVENTS_PATH, ADMIN_EVENTS_PATH,
@@ -30,13 +22,12 @@ import {
ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_MEMBERS_PATH,
ADMIN_EVENT_TASKS_PATH, ADMIN_EVENT_TASKS_PATH,
ADMIN_EVENT_TOOLKIT_PATH, ADMIN_EVENT_TOOLKIT_PATH,
ADMIN_EVENT_INVITES_PATH,
} from '../constants'; } from '../constants';
interface State { interface State {
event: TenantEvent | null; event: TenantEvent | null;
stats: TenantEventStats | null; stats: TenantEventStats | null;
invites: EventQrInvite[];
inviteLink: string | null;
error: string | null; error: string | null;
loading: boolean; loading: boolean;
busy: boolean; busy: boolean;
@@ -51,16 +42,10 @@ export default function EventDetailPage() {
const [state, setState] = React.useState<State>({ const [state, setState] = React.useState<State>({
event: null, event: null,
stats: null, stats: null,
invites: [],
inviteLink: null,
error: null, error: null,
loading: true, loading: true,
busy: false, 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 () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -70,22 +55,19 @@ export default function EventDetailPage() {
setState((prev) => ({ ...prev, loading: true, error: null })); setState((prev) => ({ ...prev, loading: true, error: null }));
try { try {
const [eventData, statsData, qrInvites] = await Promise.all([ const [eventData, statsData] = await Promise.all([
getEvent(slug), getEvent(slug),
getEventStats(slug), getEventStats(slug),
getEventQrInvites(slug),
]); ]);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
event: eventData, event: eventData,
stats: statsData, stats: statsData,
invites: qrInvites,
loading: false, loading: false,
inviteLink: prev.inviteLink,
})); }));
} catch (err) { } catch (err) {
if (isAuthError(err)) return; 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]); }, [slug]);
@@ -113,132 +95,10 @@ export default function EventDetailPage() {
} }
} }
async function handleInvite() { const { event, stats, error, loading, busy } = state;
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 eventDisplayName = event ? renderName(event.name) : ''; const eventDisplayName = event ? renderName(event.name) : '';
const currentCustomization = React.useMemo(() => { const activeInvitesCount = event?.active_invites_count ?? 0;
if (!customizingInvite) { const totalInvitesCount = event?.total_invites_count ?? activeInvitesCount;
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 actions = ( const actions = (
<> <>
@@ -247,7 +107,7 @@ export default function EventDetailPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)} onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50" 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> </Button>
{event && ( {event && (
<> <>
@@ -272,6 +132,13 @@ export default function EventDetailPage() {
> >
Tasks Tasks
</Button> </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 <Button
variant="outline" variant="outline"
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))} onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
@@ -286,10 +153,10 @@ export default function EventDetailPage() {
if (!slug) { if (!slug) {
return ( 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"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600"> <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> </CardContent>
</Card> </Card>
</AdminLayout> </AdminLayout>
@@ -319,12 +186,12 @@ export default function EventDetailPage() {
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten <Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <CardDescription className="text-sm text-slate-600">
Grundlegende Informationen fuer Gaeste und Moderation. Grundlegende Informationen für Gäste und Moderation.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700"> <CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow label="Slug" value={event.slug} /> <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="Datum" value={formatDate(event.event_date)} />
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} /> <InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
<div className="flex flex-wrap gap-3 pt-2"> <div className="flex flex-wrap gap-3 pt-2">
@@ -347,60 +214,35 @@ export default function EventDetailPage() {
</CardContent> </CardContent>
</Card> </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"> <CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900"> <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> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <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 Steuere QR-Einladungen, Layouts und Branding gesammelt auf einer eigenen Seite.
zum Ausdrucken herunter.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700"> <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"> <div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
<p> <p>
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen Aktive QR-Einladungen: {activeInvitesCount} · Gesamt erstellt: {totalInvitesCount}
jederzeit erneuern oder deaktivieren.
</p> </p>
{invites.length > 0 && ( <p>
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600"> Bereite deine Drucklayouts vor, personalisiere Texte und Logos und drucke sie direkt aus.
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
{invites.length}
</p> </p>
)}
</div> </div>
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full"> <div className="space-y-2">
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />} <Button
QR-Einladung erstellen 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> </Button>
<p className="text-xs text-slate-500">
{inviteLink && ( Du kannst bestehende Layouts duplizieren, Farben anpassen und neue PDFs generieren.
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
</p> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -425,21 +267,9 @@ export default function EventDetailPage() {
) : ( ) : (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle> <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> </Alert>
)} )}
<QrInviteCustomizationDialog
open={Boolean(customizingInvite)}
onClose={closeCustomizer}
onSubmit={handleApplyCustomization}
onReset={handleResetCustomization}
saving={customizerSaving}
inviteUrl={customizingInvite?.url ?? ''}
eventName={eventDisplayName}
layouts={customizingInvite?.layouts ?? []}
initialCustomization={currentCustomization}
/>
</AdminLayout> </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 { function formatDate(iso: string | null): string {
if (!iso) return 'Noch kein Datum'; if (!iso) return 'Noch kein Datum';
const date = new Date(iso); 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' }); 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 { function renderName(name: TenantEvent['name']): string {
if (typeof name === 'string') { if (typeof name === 'string') {
return name; return name;

View File

@@ -200,12 +200,12 @@ export default function EventFormPage() {
} }
if (!trimmedSlug) { if (!trimmedSlug) {
setError('Bitte waehle einen Slug fuer die Event-URL.'); setError('Bitte wähle einen Slug für die Event-URL.');
return; return;
} }
if (!form.eventTypeId) { if (!form.eventTypeId) {
setError('Bitte waehle einen Event-Typ aus.'); setError('Bitte wähle einen Event-Typ aus.');
return; return;
} }
@@ -242,7 +242,7 @@ export default function EventFormPage() {
} }
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.'); setError('Speichern fehlgeschlagen. Bitte prüfe deine Eingaben.');
} }
} finally { } finally {
setSaving(false); setSaving(false);
@@ -326,14 +326,14 @@ export default function EventFormPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)} onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50" 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> </Button>
); );
return ( return (
<AdminLayout <AdminLayout
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'} 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} actions={actions}
> >
{error && ( {error && (
@@ -349,7 +349,7 @@ export default function EventFormPage() {
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails <Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -377,8 +377,8 @@ export default function EventFormPage() {
onChange={(e) => handleSlugChange(e.target.value)} onChange={(e) => handleSlugChange(e.target.value)}
/> />
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Diese Kennung wird intern verwendet. Gaeste betreten dein Event ausschliesslich ueber ihre Diese Kennung wird intern verwendet. Gäste betreten dein Event ausschließlich über ihre
Einladungslinks und die dazugehoerigen QR-Layouts. Einladungslinks und die dazugehörigen QR-Layouts.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -399,7 +399,7 @@ export default function EventFormPage() {
> >
<SelectTrigger id="event-type"> <SelectTrigger id="event-type">
<SelectValue <SelectValue
placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswaehlen'} placeholder={eventTypesLoading ? 'Event-Typ wird geladen…' : 'Event-Typ auswählen'}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -412,7 +412,7 @@ export default function EventFormPage() {
</Select> </Select>
{!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? ( {!eventTypesLoading && (!eventTypes || eventTypes.length === 0) ? (
<p className="text-xs text-amber-600"> <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> </p>
) : null} ) : null}
</div> </div>
@@ -506,7 +506,7 @@ export default function EventFormPage() {
Event sofort veroeffentlichen Event sofort veroeffentlichen
</Label> </Label>
<p className="text-xs text-slate-600"> <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> </p>
</div> </div>
</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) { if (!slug) {
return ( 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"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600"> <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)}> <Button className="mt-4" onClick={() => navigate(ADMIN_EVENTS_PATH)}>
Zurueck zur Liste Zurück zur Liste
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -98,7 +98,7 @@ export default function EventPhotosPage() {
onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))} onClick={() => slug && navigate(ADMIN_EVENT_VIEW_PATH(slug))}
className="border-pink-200 text-pink-600 hover:bg-pink-50" className="border-pink-200 text-pink-600 hover:bg-pink-50"
> >
Zurueck zum Event Zurück zum Event
</Button> </Button>
); );
@@ -121,7 +121,7 @@ export default function EventPhotosPage() {
<Camera className="h-5 w-5 text-sky-500" /> Galerie <Camera className="h-5 w-5 text-sky-500" /> Galerie
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -164,7 +164,7 @@ export default function EventPhotosPage() {
disabled={busyId === photo.id} disabled={busyId === photo.id}
> >
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Loeschen Löschen
</Button> </Button>
</div> </div>
</div> </div>
@@ -195,7 +195,7 @@ function EmptyGallery() {
<Camera className="h-5 w-5" /> <Camera className="h-5 w-5" />
</div> </div>
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3> <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> </div>
); );
} }

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export default function SettingsPage() {
onClick={() => navigate(ADMIN_EVENTS_PATH)} onClick={() => navigate(ADMIN_EVENTS_PATH)}
className="border-pink-200 text-pink-600 hover:bg-pink-50" className="border-pink-200 text-pink-600 hover:bg-pink-50"
> >
Zurueck zur Uebersicht Zurück zur Übersicht
</Button> </Button>
); );
@@ -40,14 +40,14 @@ export default function SettingsPage() {
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account <Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<section className="space-y-2"> <section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Darstellung</h2> <h2 className="text-sm font-semibold text-slate-800">Darstellung</h2>
<p className="text-sm text-slate-600"> <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> </p>
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
</section> </section>

View File

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

View File

@@ -24,7 +24,7 @@ import {
updateTask, updateTask,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH, ADMIN_TASK_COLLECTIONS_PATH } from '../constants'; import { buildEngagementTabPath } from '../constants';
type TaskFormState = { type TaskFormState = {
title: string; title: string;
@@ -42,9 +42,15 @@ const INITIAL_FORM: TaskFormState = {
is_completed: false, 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 navigate = useNavigate();
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const [tasks, setTasks] = React.useState<TenantTask[]>([]); const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null); const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
@@ -83,13 +89,13 @@ export default function TasksPage() {
}; };
}, [page, search]); }, [page, search]);
function openCreate() { const openCreate = React.useCallback(() => {
setEditingTask(null); setEditingTask(null);
setForm(INITIAL_FORM); setForm(INITIAL_FORM);
setDialogOpen(true); setDialogOpen(true);
} }, []);
function openEdit(task: TenantTask) { const openEdit = React.useCallback((task: TenantTask) => {
setEditingTask(task); setEditingTask(task);
setForm({ setForm({
title: task.title, title: task.title,
@@ -99,7 +105,15 @@ export default function TasksPage() {
is_completed: task.is_completed, is_completed: task.is_completed,
}); });
setDialogOpen(true); setDialogOpen(true);
}, []);
const handleNavigateToCollections = React.useCallback(() => {
if (onNavigateToCollections) {
onNavigateToCollections();
return;
} }
navigate(buildEngagementTabPath('collections'));
}, [navigate, onNavigateToCollections]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -137,7 +151,7 @@ export default function TasksPage() {
} }
async function handleDelete(taskId: number) { async function handleDelete(taskId: number) {
if (!window.confirm('Task wirklich loeschen?')) { if (!window.confirm('Task wirklich löschen?')) {
return; return;
} }
@@ -146,7 +160,7 @@ export default function TasksPage() {
setTasks((prev) => prev.filter((task) => task.id !== taskId)); setTasks((prev) => prev.filter((task) => task.id !== taskId));
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError('Task konnte nicht geloescht werden.'); setError('Task konnte nicht gelöscht werden.');
} }
} }
} }
@@ -165,13 +179,28 @@ 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 ( return (
<AdminLayout <div className="space-y-6">
title="Task Bibliothek" {error && (
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events." <Alert variant="destructive">
actions={ <AlertTitle>Fehler</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<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"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_TASK_COLLECTIONS_PATH)}> <Button variant="outline" onClick={handleNavigateToCollections}>
{t('navigation.collections')} {t('navigation.collections')}
</Button> </Button>
<Button <Button
@@ -182,86 +211,152 @@ export default function TasksPage() {
Neu Neu
</Button> </Button>
</div> </div>
}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Fehler</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<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> </CardHeader>
<CardContent className="space-y-6"> <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 <Input
placeholder="Nach Tasks suchen..." placeholder="Nach Aufgaben suchen ..."
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => {
className="sm:max-w-sm" setPage(1);
setSearch(event.target.value);
}}
className="max-w-sm"
/> />
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)}> {meta && meta.total > 0 ? (
Events oeffnen <div className="text-xs text-slate-500">
</Button> Seite {meta.current_page} von {meta.last_page} · {meta.total} Einträge
</div>
) : null}
</div> </div>
{loading ? ( {loading ? (
<TaskSkeleton /> <TasksSkeleton />
) : tasks.length === 0 ? ( ) : tasks.length === 0 ? (
<EmptyTasksState onCreate={openCreate} /> <EmptyState onCreate={openCreate} />
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-3">
{tasks.map((task) => ( {tasks.map((task) => (
<TaskRow <TaskRow
key={task.id} key={task.id}
task={task} task={task}
onToggle={() => void toggleCompletion(task)} onToggle={() => toggleCompletion(task)}
onEdit={() => openEdit(task)} onEdit={() => openEdit(task)}
onDelete={() => void handleDelete(task.id)} onDelete={() => handleDelete(task.id)}
/> />
))} ))}
</div> </div>
)} )}
{meta && meta.last_page > 1 && ( {meta && meta.last_page > 1 ? (
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-4 text-sm">
<Button <div className="text-slate-500">
variant="outline" Insgesamt {meta.total} Aufgaben · Seite {meta.current_page} von {meta.last_page}
disabled={page <= 1} </div>
onClick={() => setPage((prev) => Math.max(prev - 1, 1))} <div className="flex gap-2">
> <Button variant="outline" size="sm" onClick={() => setPage((page) => Math.max(page - 1, 1))} disabled={meta.current_page <= 1}>
Zurueck Zurück
</Button> </Button>
<span className="text-xs text-slate-500">
Seite {meta.current_page} von {meta.last_page}
</span>
<Button <Button
variant="outline" variant="outline"
disabled={page >= meta.last_page} size="sm"
onClick={() => setPage((prev) => Math.min(prev + 1, meta.last_page))} onClick={() => setPage((page) => Math.min(page + 1, meta.last_page ?? page + 1))}
disabled={meta.current_page >= (meta.last_page ?? 1)}
> >
Weiter Weiter
</Button> </Button>
</div> </div>
)} </div>
) : null}
</CardContent> </CardContent>
</Card> </Card>
<TaskDialog <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
open={dialogOpen} <DialogContent>
onOpenChange={setDialogOpen} <DialogHeader>
onSubmit={handleSubmit} <DialogTitle>{editingTask ? 'Task bearbeiten' : 'Neue Task erstellen'}</DialogTitle>
form={form} </DialogHeader>
setForm={setForm} <form onSubmit={handleSubmit} className="space-y-4">
saving={saving} <div className="space-y-2">
isEditing={Boolean(editingTask)} <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> </AdminLayout>
); );
} }
@@ -277,207 +372,68 @@ function TaskRow({
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0; const isCompleted = task.is_completed;
const completed = task.is_completed; const statusIcon = isCompleted ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Circle className="h-4 w-4 text-slate-300" />;
const isGlobal = task.tenant_id === null;
return ( 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-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 flex-1 items-start gap-3"> <div className="flex items-start gap-3">
<button <button type="button" onClick={onToggle} className="mt-1 text-slate-500 transition-colors hover:text-emerald-500">
type="button" {statusIcon}
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" />}
</button> </button>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}> <span className="text-sm font-medium text-slate-900">{task.title}</span>
{task.title} {task.priority ? <PriorityBadge priority={task.priority} /> : null}
</p> {task.collection_id ? <Badge variant="secondary">Vorlage #{task.collection_id}</Badge> : null}
<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> </div>
{task.description && <p className="text-xs text-slate-600">{task.description}</p>} {task.description ? <p className="text-xs text-slate-500">{task.description}</p> : null}
<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>
</div> </div>
</div> </div>
</div> <div className="flex gap-2">
<div className="flex flex-wrap items-center gap-2"> <Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} disabled={isGlobal} className={isGlobal ? 'opacity-50' : ''}> <Pencil className="mr-1 h-4 w-4" />
<Pencil className="h-4 w-4" /> Bearbeiten
</Button> </Button>
<Button <Button variant="ghost" size="sm" onClick={onDelete} className="text-slate-500 hover:text-rose-500">
variant="outline" <Trash2 className="mr-1 h-4 w-4" />
size="sm" Löschen
onClick={onDelete}
className={`text-rose-600 ${isGlobal ? 'opacity-50' : ''}`}
disabled={isGlobal}
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
function TaskDialog({ function PriorityBadge({ priority }: { priority: NonNullable<TaskPayload['priority']> }) {
open, const mapping: Record<NonNullable<TaskPayload['priority']>, { label: string; className: string }> = {
onOpenChange, low: { label: 'Niedrig', className: 'bg-emerald-50 text-emerald-600' },
onSubmit, medium: { label: 'Mittel', className: 'bg-amber-50 text-amber-600' },
form, high: { label: 'Hoch', className: 'bg-rose-50 text-rose-600' },
setForm, };
saving, const { label, className } = mapping[priority];
isEditing, return <Badge className={`border-none ${className}`}>{label}</Badge>;
}: {
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 EmptyTasksState({ onCreate }: { onCreate: () => void }) { function TasksSkeleton() {
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() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => ( {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> </div>
); );
} }
function mapPriority(priority: TenantTask['priority']): string { function EmptyState({ onCreate }: { onCreate: () => void }) {
switch (priority) { return (
case 'low': <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">
return 'Niedrig'; <h3 className="text-base font-semibold text-slate-800">Noch keine Tasks angelegt</h3>
case 'high': <p className="text-sm text-slate-500">
return 'Hoch'; Starte mit einer neuen Aufgabe oder importiere Aufgabenvorlagen, um deine Gäste zu inspirieren.
case 'urgent': </p>
return 'Dringend'; <Button onClick={onCreate} className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white">
default: <Plus className="mr-1 h-4 w-4" />
return 'Mittel'; Erste Task erstellen
} </Button>
} </div>
);
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' });
} }

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 EventMembersPage from './pages/EventMembersPage';
import EventTasksPage from './pages/EventTasksPage'; import EventTasksPage from './pages/EventTasksPage';
import EventToolkitPage from './pages/EventToolkitPage'; import EventToolkitPage from './pages/EventToolkitPage';
import EventInvitesPage from './pages/EventInvitesPage';
import EngagementPage from './pages/EngagementPage';
import BillingPage from './pages/BillingPage'; import BillingPage from './pages/BillingPage';
import TasksPage from './pages/TasksPage'; import TasksPage from './pages/TasksPage';
import TaskCollectionsPage from './pages/TaskCollectionsPage'; import TaskCollectionsPage from './pages/TaskCollectionsPage';
@@ -86,7 +88,9 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/photos', element: <EventPhotosPage /> }, { path: 'events/:slug/photos', element: <EventPhotosPage /> },
{ path: 'events/:slug/members', element: <EventMembersPage /> }, { path: 'events/:slug/members', element: <EventMembersPage /> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> }, { path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> }, { path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
{ path: 'engagement', element: <EngagementPage /> },
{ path: 'tasks', element: <TasksPage /> }, { path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> }, { path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> }, { 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 { Link, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -90,10 +89,10 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Badges</CardTitle> <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> </CardHeader>
<CardContent> <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> </CardContent>
</Card> </Card>
); );
@@ -103,7 +102,7 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Badges</CardTitle> <CardTitle>Badges</CardTitle>
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription> <CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{badges.map((badge) => ( {badges.map((badge) => (
@@ -139,7 +138,7 @@ function Timeline({ points }: { points: TimelinePoint[] }) {
{points.map((point) => ( {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"> <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="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> </div>
))} ))}
</CardContent> </CardContent>
@@ -156,7 +155,7 @@ function Feed({ feed }: { feed: FeedEntry[] }) {
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription> <CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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> </CardContent>
</Card> </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 className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
)} )}
</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>} {topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p> <p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
</CardContent> </CardContent>
@@ -252,13 +251,13 @@ function SummaryCards({ data }: { data: AchievementsPayload }) {
</Card> </Card>
<Card> <Card>
<CardContent className="flex flex-col gap-1 py-4"> <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> <span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex flex-col gap-1 py-4"> <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> <span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
</CardContent> </CardContent>
</Card> </Card>
@@ -339,7 +338,7 @@ export default function AchievementsPage() {
</div> </div>
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1> <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> </div>
</div> </div>
@@ -424,13 +423,13 @@ export default function AchievementsPage() {
title="Top Uploads" title="Top Uploads"
icon={Users} icon={Users}
entries={data.leaderboards.uploads} 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 <Leaderboard
title="Beliebteste Gaeste" title="Beliebteste Gäste"
icon={Trophy} icon={Trophy}
entries={data.leaderboards.likes} 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>
</div> </div>

View File

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