feat: extend event toolkit and polish guest pwa
This commit is contained in:
@@ -3,7 +3,7 @@ import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
|
||||
export type EventJoinTokenLayout = {
|
||||
export type EventQrInviteLayout = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -41,6 +41,8 @@ export type TenantEvent = {
|
||||
description?: string | null;
|
||||
photo_count?: number;
|
||||
like_count?: number;
|
||||
engagement_mode?: 'tasks' | 'photo_only';
|
||||
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
|
||||
package?: {
|
||||
id: number | string | null;
|
||||
name: string | null;
|
||||
@@ -246,7 +248,7 @@ export type EventMember = {
|
||||
type EventListResponse = { data?: JsonValue[] };
|
||||
type EventResponse = { data: JsonValue };
|
||||
|
||||
export type EventJoinToken = {
|
||||
export type EventQrInvite = {
|
||||
id: number;
|
||||
token: string;
|
||||
url: string;
|
||||
@@ -258,9 +260,48 @@ export type EventJoinToken = {
|
||||
is_active: boolean;
|
||||
created_at: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
layouts: EventJoinTokenLayout[];
|
||||
layouts: EventQrInviteLayout[];
|
||||
layouts_url: string | null;
|
||||
};
|
||||
|
||||
export type EventToolkitTask = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
is_completed: boolean;
|
||||
priority?: string | null;
|
||||
};
|
||||
|
||||
export type EventToolkit = {
|
||||
event: TenantEvent;
|
||||
metrics: {
|
||||
uploads_total: number;
|
||||
uploads_24h: number;
|
||||
pending_photos: number;
|
||||
active_invites: number;
|
||||
engagement_mode: 'tasks' | 'photo_only';
|
||||
};
|
||||
tasks: {
|
||||
summary: {
|
||||
total: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
};
|
||||
items: EventToolkitTask[];
|
||||
};
|
||||
photos: {
|
||||
pending: TenantPhoto[];
|
||||
recent: TenantPhoto[];
|
||||
};
|
||||
invites: {
|
||||
summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
items: EventQrInvite[];
|
||||
};
|
||||
alerts: string[];
|
||||
};
|
||||
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
|
||||
type PhotoResponse = { message: string; data: TenantPhoto };
|
||||
|
||||
@@ -272,6 +313,7 @@ type EventSavePayload = {
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
is_active?: boolean;
|
||||
package_id?: number;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
@@ -380,6 +422,10 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
|
||||
|
||||
function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
|
||||
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
|
||||
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
|
||||
| 'tasks'
|
||||
| 'photo_only';
|
||||
const normalized: TenantEvent = {
|
||||
...(event as Record<string, unknown>),
|
||||
id: Number(event.id ?? 0),
|
||||
@@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent {
|
||||
description: event.description ?? null,
|
||||
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
|
||||
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
|
||||
engagement_mode: engagementMode,
|
||||
settings,
|
||||
package: event.package ?? null,
|
||||
};
|
||||
|
||||
@@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
||||
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
|
||||
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
|
||||
const layouts: EventJoinTokenLayout[] = rawLayouts
|
||||
const layouts: EventQrInviteLayout[] = rawLayouts
|
||||
.map((layout: any) => {
|
||||
const formats = Array.isArray(layout.formats)
|
||||
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
|
||||
@@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
|
||||
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
|
||||
};
|
||||
})
|
||||
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0);
|
||||
.filter((layout: EventQrInviteLayout) => layout.id.length > 0);
|
||||
|
||||
return {
|
||||
id: Number(raw.id ?? 0),
|
||||
@@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise<EventStats> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> {
|
||||
export async function getEventQrInvites(slug: string): Promise<EventQrInvite[]> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
|
||||
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
|
||||
const list = Array.isArray(payload.data) ? payload.data : [];
|
||||
return list.map(normalizeJoinToken);
|
||||
return list.map(normalizeQrInvite);
|
||||
}
|
||||
|
||||
export async function createInviteLink(
|
||||
export async function createQrInvite(
|
||||
slug: string,
|
||||
payload?: { label?: string; usage_limit?: number; expires_at?: string }
|
||||
): Promise<EventJoinToken> {
|
||||
): Promise<EventQrInvite> {
|
||||
const body = JSON.stringify(payload ?? {});
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
|
||||
method: 'POST',
|
||||
@@ -739,14 +787,14 @@ export async function createInviteLink(
|
||||
body,
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
return normalizeQrInvite(data.data ?? {});
|
||||
}
|
||||
|
||||
export async function revokeEventJoinToken(
|
||||
export async function revokeEventQrInvite(
|
||||
slug: string,
|
||||
tokenId: number,
|
||||
reason?: string
|
||||
): Promise<EventJoinToken> {
|
||||
): Promise<EventQrInvite> {
|
||||
const options: RequestInit = { method: 'DELETE' };
|
||||
if (reason) {
|
||||
options.headers = { 'Content-Type': 'application/json' };
|
||||
@@ -754,7 +802,107 @@ export async function revokeEventJoinToken(
|
||||
}
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
|
||||
return normalizeJoinToken(data.data ?? {});
|
||||
return normalizeQrInvite(data.data ?? {});
|
||||
}
|
||||
|
||||
export async function updateEventQrInvite(
|
||||
slug: string,
|
||||
tokenId: number,
|
||||
payload: {
|
||||
label?: string | null;
|
||||
expires_at?: string | null;
|
||||
usage_limit?: number | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
): Promise<EventQrInvite> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation');
|
||||
return normalizeQrInvite(data.data ?? {});
|
||||
}
|
||||
|
||||
export async function getEventToolkit(slug: string): Promise<EventToolkit> {
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`);
|
||||
const json = await jsonOrThrow<Record<string, JsonValue>>(response, 'Failed to load toolkit');
|
||||
|
||||
const metrics = json.metrics ?? {};
|
||||
const tasks = json.tasks ?? {};
|
||||
const photos = json.photos ?? {};
|
||||
const invites = json.invites ?? {};
|
||||
|
||||
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
|
||||
? (photos as Record<string, JsonValue>).pending
|
||||
: [];
|
||||
const recentPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).recent)
|
||||
? (photos as Record<string, JsonValue>).recent
|
||||
: [];
|
||||
|
||||
const toolkit: EventToolkit = {
|
||||
event: normalizeEvent(json.event ?? {}),
|
||||
metrics: {
|
||||
uploads_total: Number((metrics as JsonValue).uploads_total ?? 0),
|
||||
uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0),
|
||||
pending_photos: Number((metrics as JsonValue).pending_photos ?? 0),
|
||||
active_invites: Number((metrics as JsonValue).active_invites ?? 0),
|
||||
engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks',
|
||||
},
|
||||
tasks: {
|
||||
summary: {
|
||||
total: Number((tasks as JsonValue)?.summary?.total ?? 0),
|
||||
completed: Number((tasks as JsonValue)?.summary?.completed ?? 0),
|
||||
pending: Number((tasks as JsonValue)?.summary?.pending ?? 0),
|
||||
},
|
||||
items: Array.isArray((tasks as JsonValue)?.items)
|
||||
? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({
|
||||
id: Number(item?.id ?? 0),
|
||||
title: String(item?.title ?? ''),
|
||||
description: item?.description !== undefined && item?.description !== null ? String(item.description) : null,
|
||||
is_completed: Boolean(item?.is_completed ?? false),
|
||||
priority: item?.priority !== undefined ? String(item.priority) : null,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
photos: {
|
||||
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
||||
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
|
||||
},
|
||||
invites: {
|
||||
summary: {
|
||||
total: Number((invites as JsonValue)?.summary?.total ?? 0),
|
||||
active: Number((invites as JsonValue)?.summary?.active ?? 0),
|
||||
},
|
||||
items: Array.isArray((invites as JsonValue)?.items)
|
||||
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
|
||||
: [],
|
||||
},
|
||||
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
|
||||
};
|
||||
|
||||
return toolkit;
|
||||
}
|
||||
|
||||
export async function submitTenantFeedback(payload: {
|
||||
category: string;
|
||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||
rating?: number | null;
|
||||
title?: string | null;
|
||||
message?: string | null;
|
||||
event_slug?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}): Promise<void> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await safeJson(response);
|
||||
console.error('[API] Failed to submit feedback', response.status, body);
|
||||
throw new Error('Failed to submit feedback');
|
||||
}
|
||||
}
|
||||
|
||||
export type Package = {
|
||||
|
||||
@@ -24,3 +24,4 @@ export const ADMIN_EVENT_EDIT_PATH = (slug: string): string => adminPath(`/event
|
||||
export const ADMIN_EVENT_PHOTOS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/photos`);
|
||||
export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/members`);
|
||||
export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/tasks`);
|
||||
export const ADMIN_EVENT_TOOLKIT_PATH = (slug: string): string => adminPath(`/events/${encodeURIComponent(slug)}/toolkit`);
|
||||
|
||||
@@ -51,7 +51,7 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`);
|
||||
const callbackUrl = await requestAuthorization(`/api/v1/oauth/authorize?${authorizeParams}`, redirectUri);
|
||||
verifyState(callbackUrl.searchParams.get('state'), state);
|
||||
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
@@ -115,22 +115,53 @@ if (import.meta.env.DEV || import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true
|
||||
globalThis.fotospielDemoAuth = api;
|
||||
}
|
||||
|
||||
function requestAuthorization(url: string): Promise<URL> {
|
||||
function requestAuthorization(url: string, fallbackRedirect?: string): Promise<URL> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState !== XMLHttpRequest.DONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = xhr.getResponseHeader('Content-Type') ?? '';
|
||||
const responseUrl = xhr.responseURL || xhr.getResponseHeader('Location');
|
||||
if ((xhr.status >= 200 && xhr.status < 400) || xhr.status === 0) {
|
||||
if (responseUrl) {
|
||||
resolve(new URL(responseUrl, window.location.origin));
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText ?? '{}') as {
|
||||
code?: string;
|
||||
state?: string | null;
|
||||
redirect_url?: string | null;
|
||||
};
|
||||
const target = payload.redirect_url ?? fallbackRedirect;
|
||||
if (!target) {
|
||||
throw new Error('Authorize response missing redirect target');
|
||||
}
|
||||
|
||||
const finalUrl = new URL(target, window.location.origin);
|
||||
if (payload.code && !finalUrl.searchParams.has('code')) {
|
||||
finalUrl.searchParams.set('code', payload.code);
|
||||
}
|
||||
if (payload.state && !finalUrl.searchParams.has('state')) {
|
||||
finalUrl.searchParams.set('state', payload.state);
|
||||
}
|
||||
|
||||
resolve(finalUrl);
|
||||
return;
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error(`Authorize failed with ${xhr.status}`));
|
||||
|
||||
@@ -36,6 +36,36 @@
|
||||
"lowCredits": "Auffüllen empfohlen"
|
||||
}
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Bereit für den Eventstart",
|
||||
"description": "Erledige diese Schritte, damit Gäste ohne Reibung loslegen können.",
|
||||
"pending": "Noch offen",
|
||||
"complete": "Erledigt",
|
||||
"items": {
|
||||
"event": {
|
||||
"title": "Event angelegt",
|
||||
"hint": "Lege dein erstes Event an oder öffne dein jüngstes Event."
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Aufgaben kuratiert",
|
||||
"hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben."
|
||||
},
|
||||
"qr": {
|
||||
"title": "QR-Einladung erstellt",
|
||||
"hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter."
|
||||
},
|
||||
"package": {
|
||||
"title": "Paket aktiv",
|
||||
"hint": "Wähle ein Paket, das zu eurem Umfang passt."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"createEvent": "Event erstellen",
|
||||
"openTasks": "Tasks öffnen",
|
||||
"openQr": "QR-Einladungen",
|
||||
"openPackages": "Pakete ansehen"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Schnellaktionen",
|
||||
"description": "Starte durch mit den wichtigsten Aktionen.",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event-Slug angegeben.",
|
||||
"load": "Event-Tasks konnten nicht geladen werden.",
|
||||
"assign": "Tasks konnten nicht zugewiesen werden."
|
||||
"assign": "Tasks konnten nicht zugewiesen werden.",
|
||||
"photoOnlyEnable": "Foto-Modus konnte nicht aktiviert werden.",
|
||||
"photoOnlyDisable": "Foto-Modus konnte nicht deaktiviert werden."
|
||||
},
|
||||
"alerts": {
|
||||
"notFoundTitle": "Event nicht gefunden",
|
||||
@@ -169,6 +171,147 @@
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch",
|
||||
"urgent": "Dringend"
|
||||
},
|
||||
"modes": {
|
||||
"title": "Aufgaben & Foto-Modus",
|
||||
"photoOnlyHint": "Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.",
|
||||
"tasksHint": "Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.",
|
||||
"photoOnly": "Foto-Modus",
|
||||
"tasks": "Aufgaben aktiv",
|
||||
"switchLabel": "Foto-Modus aktivieren",
|
||||
"updating": "Einstellung wird gespeichert ..."
|
||||
},
|
||||
"toolkit": {
|
||||
"titleFallback": "Event-Day Toolkit",
|
||||
"subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.",
|
||||
"errors": {
|
||||
"missingSlug": "Kein Event-Slug angegeben.",
|
||||
"loadFailed": "Toolkit konnte nicht geladen werden.",
|
||||
"feedbackFailed": "Feedback konnte nicht gesendet werden."
|
||||
},
|
||||
"actions": {
|
||||
"backToEvent": "Zurück zum Event",
|
||||
"moderate": "Fotos moderieren",
|
||||
"manageTasks": "Tasks öffnen",
|
||||
"refresh": "Aktualisieren"
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Fehler",
|
||||
"attention": "Achtung",
|
||||
"noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.",
|
||||
"noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.",
|
||||
"pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen."
|
||||
},
|
||||
"metrics": {
|
||||
"uploadsTotal": "Uploads gesamt",
|
||||
"uploads24h": "Uploads (24h)",
|
||||
"pendingPhotos": "Unmoderierte Fotos",
|
||||
"activeInvites": "Aktive Einladungen",
|
||||
"engagementMode": "Modus",
|
||||
"modePhotoOnly": "Foto-Modus",
|
||||
"modeTasks": "Aufgaben"
|
||||
},
|
||||
"pending": {
|
||||
"title": "Wartende Fotos",
|
||||
"subtitle": "Moderationsempfehlung für neue Uploads.",
|
||||
"cta": "Zur Moderation",
|
||||
"empty": "Aktuell warten keine Fotos auf Freigabe.",
|
||||
"unknownUploader": "Unbekannter Gast",
|
||||
"uploadedAt": "Hochgeladen:",
|
||||
"statusPending": "Status: Prüfung ausstehend"
|
||||
},
|
||||
"invites": {
|
||||
"title": "QR-Einladungen",
|
||||
"subtitle": "Aktive Links und Layouts im Blick behalten.",
|
||||
"activeCount": "{{count}} aktiv",
|
||||
"totalCount": "{{count}} gesamt",
|
||||
"empty": "Noch keine QR-Einladungen erstellt.",
|
||||
"statusActive": "Aktiv",
|
||||
"statusInactive": "Inaktiv",
|
||||
"manage": "Einladungen verwalten"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Aktive Aufgaben",
|
||||
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
|
||||
"summary": "{{completed}} von {{total}} erledigt",
|
||||
"empty": "Noch keine Aufgaben zugewiesen.",
|
||||
"manage": "Tasks verwalten",
|
||||
"completed": "Erledigt",
|
||||
"open": "Offen"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Neueste Uploads",
|
||||
"subtitle": "Ein Blick auf die letzten Fotos der Gäste.",
|
||||
"empty": "Noch keine freigegebenen Fotos vorhanden."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Wie hilfreich ist dieses Toolkit?",
|
||||
"subtitle": "Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.",
|
||||
"positive": "Hilfreich",
|
||||
"neutral": "Ganz okay",
|
||||
"negative": "Verbesserungsbedarf",
|
||||
"placeholder": "Erzähle uns kurz, was dir gefallen hat oder was fehlt …",
|
||||
"disclaimer": "Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.",
|
||||
"submit": "Feedback senden",
|
||||
"thanksTitle": "Danke!",
|
||||
"thanksDescription": "Wir haben dein Feedback erhalten.",
|
||||
"badge": "Angepasst"
|
||||
}
|
||||
},
|
||||
"customizer": {
|
||||
"title": "QR-Einladung anpassen",
|
||||
"description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.",
|
||||
"layout": "Layout",
|
||||
"selectLayout": "Layout auswählen",
|
||||
"headline": "Überschrift",
|
||||
"subtitle": "Unterzeile",
|
||||
"descriptionLabel": "Beschreibung",
|
||||
"badgeLabel": "Badge",
|
||||
"instructionsHeading": "Anleitungstitel",
|
||||
"instructionsLabel": "Hinweistexte",
|
||||
"addInstruction": "Hinweis hinzufügen",
|
||||
"removeInstruction": "Entfernen",
|
||||
"linkHeading": "Link-Titel",
|
||||
"linkLabel": "Link",
|
||||
"ctaLabel": "Call-to-Action",
|
||||
"colors": {
|
||||
"accent": "Akzentfarbe",
|
||||
"text": "Textfarbe",
|
||||
"background": "Hintergrund",
|
||||
"secondary": "Sekundärfarbe",
|
||||
"badge": "Badge-Farbe"
|
||||
},
|
||||
"logo": {
|
||||
"label": "Logo",
|
||||
"hint": "PNG oder SVG, max. 1 MB. Wird oben rechts platziert.",
|
||||
"remove": "Logo entfernen"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Vorschau",
|
||||
"hint": "Farben und Texte, wie sie im Layout erscheinen. Speichere, um neue PDFs/SVGs zu erhalten."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"badge": "Angepasst",
|
||||
"actionLabel": "Layout anpassen",
|
||||
"errors": {
|
||||
"logoTooLarge": "Das Logo darf maximal 1 MB groß sein.",
|
||||
"noLayout": "Bitte wähle ein Layout aus."
|
||||
},
|
||||
"defaults": {
|
||||
"badgeLabel": "Digitale Gästebox",
|
||||
"instructionsHeading": "So funktioniert's",
|
||||
"linkHeading": "Alternative zum Einscannen",
|
||||
"ctaLabel": "Scan mich & starte direkt",
|
||||
"instructions": [
|
||||
"QR-Code scannen",
|
||||
"Profil anlegen",
|
||||
"Fotos teilen"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
@@ -316,4 +459,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,36 @@
|
||||
"lowCredits": "Top up recommended"
|
||||
}
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Ready for event day",
|
||||
"description": "Complete these steps so guests can join without friction.",
|
||||
"pending": "Pending",
|
||||
"complete": "Done",
|
||||
"items": {
|
||||
"event": {
|
||||
"title": "Event created",
|
||||
"hint": "Create your first event or open the most recent one."
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks curated",
|
||||
"hint": "Assign fitting tasks or enable the photo-only mode."
|
||||
},
|
||||
"qr": {
|
||||
"title": "QR invite live",
|
||||
"hint": "Create a QR invite and download the print layouts."
|
||||
},
|
||||
"package": {
|
||||
"title": "Package active",
|
||||
"hint": "Pick the package that matches your scope."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"createEvent": "Create event",
|
||||
"openTasks": "Open tasks",
|
||||
"openQr": "QR invites",
|
||||
"openPackages": "View packages"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick actions",
|
||||
"description": "Jump straight to the most important actions.",
|
||||
|
||||
@@ -147,7 +147,9 @@
|
||||
"errors": {
|
||||
"missingSlug": "No event slug provided.",
|
||||
"load": "Event tasks could not be loaded.",
|
||||
"assign": "Tasks could not be assigned."
|
||||
"assign": "Tasks could not be assigned.",
|
||||
"photoOnlyEnable": "Photo-only mode could not be enabled.",
|
||||
"photoOnlyDisable": "Photo-only mode could not be disabled."
|
||||
},
|
||||
"alerts": {
|
||||
"notFoundTitle": "Event not found",
|
||||
@@ -169,6 +171,147 @@
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"modes": {
|
||||
"title": "Tasks & photo mode",
|
||||
"photoOnlyHint": "Photo-only mode is active. Guests can upload photos but won’t see tasks.",
|
||||
"tasksHint": "Tasks are visible in the guest app. Switch to photo-only for uploads without prompts.",
|
||||
"photoOnly": "Photo-only",
|
||||
"tasks": "Tasks active",
|
||||
"switchLabel": "Enable photo-only mode",
|
||||
"updating": "Saving setting ..."
|
||||
},
|
||||
"toolkit": {
|
||||
"titleFallback": "Event-Day Toolkit",
|
||||
"subtitle": "Stay on top of uploads, tasks, and invites while your event is live.",
|
||||
"errors": {
|
||||
"missingSlug": "No event slug provided.",
|
||||
"loadFailed": "Toolkit could not be loaded.",
|
||||
"feedbackFailed": "Feedback could not be sent."
|
||||
},
|
||||
"actions": {
|
||||
"backToEvent": "Back to event",
|
||||
"moderate": "Moderate photos",
|
||||
"manageTasks": "Open tasks",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"alerts": {
|
||||
"errorTitle": "Error",
|
||||
"attention": "Heads-up",
|
||||
"noTasks": "No tasks assigned yet – pick a package or curate prompts.",
|
||||
"noInvites": "There are no active QR invites. Create one to welcome guests.",
|
||||
"pendingPhotos": "Photos are waiting for moderation. Review uploads before publishing."
|
||||
},
|
||||
"metrics": {
|
||||
"uploadsTotal": "Total uploads",
|
||||
"uploads24h": "Uploads (24h)",
|
||||
"pendingPhotos": "Pending moderation",
|
||||
"activeInvites": "Active invites",
|
||||
"engagementMode": "Mode",
|
||||
"modePhotoOnly": "Photo mode",
|
||||
"modeTasks": "Tasks"
|
||||
},
|
||||
"pending": {
|
||||
"title": "Waiting photos",
|
||||
"subtitle": "Moderation suggestions for new uploads.",
|
||||
"cta": "Go to moderation",
|
||||
"empty": "No photos waiting for review right now.",
|
||||
"unknownUploader": "Unknown guest",
|
||||
"uploadedAt": "Uploaded:",
|
||||
"statusPending": "Status: awaiting review"
|
||||
},
|
||||
"invites": {
|
||||
"title": "QR invites",
|
||||
"subtitle": "Keep an eye on links and brandable layouts.",
|
||||
"activeCount": "{{count}} active",
|
||||
"totalCount": "{{count}} total",
|
||||
"empty": "No QR invites yet.",
|
||||
"statusActive": "Active",
|
||||
"statusInactive": "Inactive",
|
||||
"manage": "Manage invites"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Active tasks",
|
||||
"subtitle": "Motivate guests with clear prompts and highlights.",
|
||||
"summary": "{{completed}} of {{total}} done",
|
||||
"empty": "No tasks assigned yet.",
|
||||
"manage": "Manage tasks",
|
||||
"completed": "Done",
|
||||
"open": "Open"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Latest uploads",
|
||||
"subtitle": "A quick glance at freshly approved photos.",
|
||||
"empty": "No approved photos yet."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "How helpful is this toolkit?",
|
||||
"subtitle": "Your input helps us fine-tune the event-day experience.",
|
||||
"positive": "Helpful",
|
||||
"neutral": "Okay",
|
||||
"negative": "Needs work",
|
||||
"placeholder": "Let us know what worked well or what you’re missing …",
|
||||
"disclaimer": "We’ll keep your feedback private and use it to improve the product.",
|
||||
"submit": "Send feedback",
|
||||
"thanksTitle": "Thank you!",
|
||||
"thanksDescription": "We’ve received your feedback.",
|
||||
"badge": "Custom"
|
||||
}
|
||||
},
|
||||
"customizer": {
|
||||
"title": "Customize QR invite",
|
||||
"description": "Adjust layout, texts, colors, and logo for your printable invite.",
|
||||
"layout": "Layout",
|
||||
"selectLayout": "Select layout",
|
||||
"headline": "Headline",
|
||||
"subtitle": "Sub headline",
|
||||
"descriptionLabel": "Description",
|
||||
"badgeLabel": "Badge",
|
||||
"instructionsHeading": "Instructions heading",
|
||||
"instructionsLabel": "Hints",
|
||||
"addInstruction": "Add hint",
|
||||
"removeInstruction": "Remove",
|
||||
"linkHeading": "Link title",
|
||||
"linkLabel": "Link",
|
||||
"ctaLabel": "Call to action",
|
||||
"colors": {
|
||||
"accent": "Accent colour",
|
||||
"text": "Text colour",
|
||||
"background": "Background",
|
||||
"secondary": "Secondary colour",
|
||||
"badge": "Badge colour"
|
||||
},
|
||||
"logo": {
|
||||
"label": "Logo",
|
||||
"hint": "PNG or SVG, max. 1 MB. Appears in the top right corner.",
|
||||
"remove": "Remove logo"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"hint": "Visual reference for colours and texts. Save to generate new PDFs/SVGs."
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"badge": "Custom",
|
||||
"actionLabel": "Customize layout",
|
||||
"errors": {
|
||||
"logoTooLarge": "Logo must not exceed 1 MB.",
|
||||
"noLayout": "Please select a layout."
|
||||
},
|
||||
"defaults": {
|
||||
"badgeLabel": "Digital guest box",
|
||||
"instructionsHeading": "How it works",
|
||||
"linkHeading": "Alternative link",
|
||||
"ctaLabel": "Scan and get started",
|
||||
"instructions": [
|
||||
"Scan the QR code",
|
||||
"Create your profile",
|
||||
"Share your photos"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
@@ -316,4 +459,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDays, Camera, Sparkles, Users, Plus, Settings } from 'lucide-react';
|
||||
import {
|
||||
CalendarDays,
|
||||
Camera,
|
||||
Sparkles,
|
||||
Users,
|
||||
Plus,
|
||||
Settings,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
QrCode,
|
||||
ClipboardList,
|
||||
Package as PackageIcon,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -14,6 +27,8 @@ import {
|
||||
getDashboardSummary,
|
||||
getEvents,
|
||||
getTenantPackagesOverview,
|
||||
getEventTasks,
|
||||
getEventQrInvites,
|
||||
TenantEvent,
|
||||
TenantPackageSummary,
|
||||
} from '../api';
|
||||
@@ -23,6 +38,7 @@ import {
|
||||
adminPath,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
ADMIN_EVENTS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_TASKS_PATH,
|
||||
ADMIN_BILLING_PATH,
|
||||
ADMIN_SETTINGS_PATH,
|
||||
@@ -39,6 +55,16 @@ interface DashboardState {
|
||||
errorKey: string | null;
|
||||
}
|
||||
|
||||
type ReadinessState = {
|
||||
hasEvent: boolean;
|
||||
hasTasks: boolean;
|
||||
hasQrInvites: boolean;
|
||||
hasPackage: boolean;
|
||||
primaryEventSlug: string | null;
|
||||
primaryEventName: string | null;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -66,6 +92,16 @@ export default function DashboardPage() {
|
||||
errorKey: null,
|
||||
});
|
||||
|
||||
const [readiness, setReadiness] = React.useState<ReadinessState>({
|
||||
hasEvent: false,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
hasPackage: false,
|
||||
primaryEventSlug: null,
|
||||
primaryEventName: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -81,14 +117,56 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const fallbackSummary = buildSummaryFallback(events, packages.activePackage);
|
||||
const primaryEvent = events[0] ?? null;
|
||||
const primaryEventName = primaryEvent ? resolveEventName(primaryEvent.name, primaryEvent.slug) : null;
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
activePackage: packages.activePackage,
|
||||
setReadiness({
|
||||
hasEvent: events.length > 0,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
hasPackage: Boolean(packages.activePackage),
|
||||
primaryEventSlug: primaryEvent?.slug ?? null,
|
||||
primaryEventName,
|
||||
loading: Boolean(primaryEvent),
|
||||
});
|
||||
|
||||
setState({
|
||||
summary: summary ?? fallbackSummary,
|
||||
events,
|
||||
activePackage: packages.activePackage,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
});
|
||||
|
||||
if (primaryEvent) {
|
||||
try {
|
||||
const [eventTasks, qrInvites] = await Promise.all([
|
||||
getEventTasks(primaryEvent.id, 1),
|
||||
getEventQrInvites(primaryEvent.slug),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: (eventTasks.data ?? []).length > 0,
|
||||
hasQrInvites: qrInvites.length > 0,
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
} catch (readinessError) {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to load readiness checklist', readinessError);
|
||||
setReadiness((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}
|
||||
} else if (!cancelled) {
|
||||
setReadiness((prev) => ({
|
||||
...prev,
|
||||
hasTasks: false,
|
||||
hasQrInvites: false,
|
||||
loading: false,
|
||||
errorKey: null,
|
||||
});
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
@@ -271,6 +349,52 @@ export default function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReadinessCard
|
||||
readiness={readiness}
|
||||
labels={{
|
||||
title: translate('readiness.title'),
|
||||
description: translate('readiness.description'),
|
||||
pending: translate('readiness.pending'),
|
||||
complete: translate('readiness.complete'),
|
||||
items: {
|
||||
event: {
|
||||
title: translate('readiness.items.event.title'),
|
||||
hint: translate('readiness.items.event.hint'),
|
||||
},
|
||||
tasks: {
|
||||
title: translate('readiness.items.tasks.title'),
|
||||
hint: translate('readiness.items.tasks.hint'),
|
||||
},
|
||||
qr: {
|
||||
title: translate('readiness.items.qr.title'),
|
||||
hint: translate('readiness.items.qr.hint'),
|
||||
},
|
||||
package: {
|
||||
title: translate('readiness.items.package.title'),
|
||||
hint: translate('readiness.items.package.hint'),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
createEvent: translate('readiness.actions.createEvent'),
|
||||
openTasks: translate('readiness.actions.openTasks'),
|
||||
openQr: translate('readiness.actions.openQr'),
|
||||
openPackages: translate('readiness.actions.openPackages'),
|
||||
},
|
||||
}}
|
||||
onCreateEvent={() => navigate(ADMIN_EVENT_CREATE_PATH)}
|
||||
onOpenTasks={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(ADMIN_EVENT_TASKS_PATH(readiness.primaryEventSlug))
|
||||
: navigate(ADMIN_TASKS_PATH)
|
||||
}
|
||||
onOpenQr={() =>
|
||||
readiness.primaryEventSlug
|
||||
? navigate(`${ADMIN_EVENT_VIEW_PATH(readiness.primaryEventSlug)}#qr-invites`)
|
||||
: navigate(ADMIN_EVENTS_PATH)
|
||||
}
|
||||
onOpenPackages={() => navigate(ADMIN_BILLING_PATH)}
|
||||
/>
|
||||
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
@@ -315,6 +439,27 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], fallbackSlug: string): string {
|
||||
if (typeof name === 'string' && name.trim().length > 0) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (name && typeof name === 'object') {
|
||||
if (typeof name.de === 'string' && name.de.trim().length > 0) {
|
||||
return name.de;
|
||||
}
|
||||
if (typeof name.en === 'string' && name.en.trim().length > 0) {
|
||||
return name.en;
|
||||
}
|
||||
const first = Object.values(name).find((value) => typeof value === 'string' && value.trim().length > 0);
|
||||
if (typeof first === 'string') {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackSlug || 'Event';
|
||||
}
|
||||
|
||||
function buildSummaryFallback(
|
||||
events: TenantEvent[],
|
||||
activePackage: TenantPackageSummary | null
|
||||
@@ -353,6 +498,170 @@ function getUpcomingEvents(events: TenantEvent[]): TenantEvent[] {
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
type ReadinessLabels = {
|
||||
title: string;
|
||||
description: string;
|
||||
pending: string;
|
||||
complete: string;
|
||||
items: {
|
||||
event: { title: string; hint: string };
|
||||
tasks: { title: string; hint: string };
|
||||
qr: { title: string; hint: string };
|
||||
package: { title: string; hint: string };
|
||||
};
|
||||
actions: {
|
||||
createEvent: string;
|
||||
openTasks: string;
|
||||
openQr: string;
|
||||
openPackages: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ReadinessCard({
|
||||
readiness,
|
||||
labels,
|
||||
onCreateEvent,
|
||||
onOpenTasks,
|
||||
onOpenQr,
|
||||
onOpenPackages,
|
||||
}: {
|
||||
readiness: ReadinessState;
|
||||
labels: ReadinessLabels;
|
||||
onCreateEvent: () => void;
|
||||
onOpenTasks: () => void;
|
||||
onOpenQr: () => void;
|
||||
onOpenPackages: () => void;
|
||||
}) {
|
||||
const checklistItems = [
|
||||
{
|
||||
key: 'event',
|
||||
icon: <CalendarDays className="h-5 w-5" />,
|
||||
completed: readiness.hasEvent,
|
||||
label: labels.items.event.title,
|
||||
hint: labels.items.event.hint,
|
||||
actionLabel: labels.actions.createEvent,
|
||||
onAction: onCreateEvent,
|
||||
showAction: !readiness.hasEvent,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
completed: readiness.hasTasks,
|
||||
label: labels.items.tasks.title,
|
||||
hint: labels.items.tasks.hint,
|
||||
actionLabel: labels.actions.openTasks,
|
||||
onAction: onOpenTasks,
|
||||
showAction: readiness.hasEvent && !readiness.hasTasks,
|
||||
},
|
||||
{
|
||||
key: 'qr',
|
||||
icon: <QrCode className="h-5 w-5" />,
|
||||
completed: readiness.hasQrInvites,
|
||||
label: labels.items.qr.title,
|
||||
hint: labels.items.qr.hint,
|
||||
actionLabel: labels.actions.openQr,
|
||||
onAction: onOpenQr,
|
||||
showAction: readiness.hasEvent && !readiness.hasQrInvites,
|
||||
},
|
||||
{
|
||||
key: 'package',
|
||||
icon: <PackageIcon className="h-5 w-5" />,
|
||||
completed: readiness.hasPackage,
|
||||
label: labels.items.package.title,
|
||||
hint: labels.items.package.hint,
|
||||
actionLabel: labels.actions.openPackages,
|
||||
onAction: onOpenPackages,
|
||||
showAction: !readiness.hasPackage,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const activeEventName = readiness.primaryEventName;
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-brand-card shadow-brand-primary">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-xl text-slate-900">{labels.title}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{labels.description}</CardDescription>
|
||||
{activeEventName ? (
|
||||
<p className="text-xs uppercase tracking-wide text-brand-rose-soft">
|
||||
{activeEventName}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{readiness.loading ? (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/70 px-4 py-3 text-xs text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{labels.pending}
|
||||
</div>
|
||||
) : (
|
||||
checklistItems.map((item) => (
|
||||
<ChecklistRow
|
||||
key={item.key}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
hint={item.hint}
|
||||
completed={item.completed}
|
||||
status={{ complete: labels.complete, pending: labels.pending }}
|
||||
action={
|
||||
item.showAction
|
||||
? {
|
||||
label: item.actionLabel,
|
||||
onClick: item.onAction,
|
||||
disabled:
|
||||
(item.key === 'tasks' || item.key === 'qr') && !readiness.primaryEventSlug,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistRow({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
completed,
|
||||
status,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
completed: boolean;
|
||||
status: { complete: string; pending: string };
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-brand-rose-soft/40 bg-white/85 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${completed ? 'bg-emerald-100 text-emerald-600' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{label}</p>
|
||||
<p className="text-xs text-slate-600">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{completed ? status.complete : status.pending}
|
||||
</span>
|
||||
{action ? (
|
||||
<Button size="sm" variant="outline" onClick={action.onClick} disabled={action.disabled}>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Camera, Copy, Download, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -8,17 +9,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
createInviteLink,
|
||||
EventJoinToken,
|
||||
EventJoinTokenLayout,
|
||||
createQrInvite,
|
||||
EventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
EventStats as TenantEventStats,
|
||||
getEvent,
|
||||
getEventJoinTokens,
|
||||
getEventQrInvites,
|
||||
getEventStats,
|
||||
TenantEvent,
|
||||
toggleEvent,
|
||||
revokeEventJoinToken,
|
||||
revokeEventQrInvite,
|
||||
updateEventQrInvite,
|
||||
} from '../api';
|
||||
import QrInviteCustomizationDialog, { QrLayoutCustomization } from './components/QrInviteCustomizationDialog';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
ADMIN_EVENTS_PATH,
|
||||
@@ -26,12 +29,13 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
} from '../constants';
|
||||
|
||||
interface State {
|
||||
event: TenantEvent | null;
|
||||
stats: TenantEventStats | null;
|
||||
tokens: EventJoinToken[];
|
||||
invites: EventQrInvite[];
|
||||
inviteLink: string | null;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
@@ -47,14 +51,16 @@ export default function EventDetailPage() {
|
||||
const [state, setState] = React.useState<State>({
|
||||
event: null,
|
||||
stats: null,
|
||||
tokens: [],
|
||||
invites: [],
|
||||
inviteLink: null,
|
||||
error: null,
|
||||
loading: true,
|
||||
busy: false,
|
||||
});
|
||||
const [creatingToken, setCreatingToken] = React.useState(false);
|
||||
const [creatingInvite, setCreatingInvite] = React.useState(false);
|
||||
const [revokingId, setRevokingId] = React.useState<number | null>(null);
|
||||
const [customizingInvite, setCustomizingInvite] = React.useState<EventQrInvite | null>(null);
|
||||
const [customizerSaving, setCustomizerSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -64,22 +70,22 @@ export default function EventDetailPage() {
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const [eventData, statsData, joinTokens] = await Promise.all([
|
||||
const [eventData, statsData, qrInvites] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventStats(slug),
|
||||
getEventJoinTokens(slug),
|
||||
getEventQrInvites(slug),
|
||||
]);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
event: eventData,
|
||||
stats: statsData,
|
||||
tokens: joinTokens,
|
||||
invites: qrInvites,
|
||||
loading: false,
|
||||
inviteLink: prev.inviteLink,
|
||||
}));
|
||||
} catch (err) {
|
||||
if (isAuthError(err)) return;
|
||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, tokens: [] }));
|
||||
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false, invites: [] }));
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
@@ -108,58 +114,131 @@ export default function EventDetailPage() {
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
if (!slug || creatingToken) return;
|
||||
setCreatingToken(true);
|
||||
if (!slug || creatingInvite) return;
|
||||
setCreatingInvite(true);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const token = await createInviteLink(slug);
|
||||
const invite = await createQrInvite(slug);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
inviteLink: token.url,
|
||||
tokens: [token, ...prev.tokens.filter((t) => t.id !== token.id)],
|
||||
inviteLink: invite.url,
|
||||
invites: [invite, ...prev.invites.filter((existing) => existing.id !== invite.id)],
|
||||
}));
|
||||
try {
|
||||
await navigator.clipboard.writeText(token.url);
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
} catch {
|
||||
// clipboard may be unavailable, ignore silently
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.' }));
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht erzeugt werden.' }));
|
||||
}
|
||||
}
|
||||
setCreatingToken(false);
|
||||
setCreatingInvite(false);
|
||||
}
|
||||
|
||||
async function handleCopy(token: EventJoinToken) {
|
||||
async function handleCopy(invite: EventQrInvite) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token.url);
|
||||
setState((prev) => ({ ...prev, inviteLink: token.url }));
|
||||
await navigator.clipboard.writeText(invite.url);
|
||||
setState((prev) => ({ ...prev, inviteLink: invite.url }));
|
||||
} catch (err) {
|
||||
console.warn('Clipboard copy failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(token: EventJoinToken) {
|
||||
if (!slug || token.revoked_at) return;
|
||||
setRevokingId(token.id);
|
||||
async function handleRevoke(invite: EventQrInvite) {
|
||||
if (!slug || invite.revoked_at) return;
|
||||
setRevokingId(invite.id);
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
try {
|
||||
const updated = await revokeEventJoinToken(slug, token.id);
|
||||
const updated = await revokeEventQrInvite(slug, invite.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tokens: prev.tokens.map((existing) => (existing.id === updated.id ? updated : existing)),
|
||||
invites: prev.invites.map((existing) => (existing.id === updated.id ? updated : existing)),
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setState((prev) => ({ ...prev, error: 'Einladung konnte nicht deaktiviert werden.' }));
|
||||
setState((prev) => ({ ...prev, error: 'QR-Einladung konnte nicht deaktiviert werden.' }));
|
||||
}
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const { event, stats, tokens, inviteLink, error, loading, busy } = state;
|
||||
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 currentCustomization = React.useMemo(() => {
|
||||
if (!customizingInvite) {
|
||||
return null;
|
||||
}
|
||||
const metadata = customizingInvite.metadata as Record<string, unknown> | undefined | null;
|
||||
const raw = metadata?.layout_customization;
|
||||
return raw && typeof raw === 'object' ? (raw as QrLayoutCustomization) : null;
|
||||
}, [customizingInvite]);
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
@@ -193,6 +272,13 @@ export default function EventDetailPage() {
|
||||
>
|
||||
Tasks
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(ADMIN_EVENT_TOOLKIT_PATH(event.slug))}
|
||||
className="border-emerald-200 text-emerald-600 hover:bg-emerald-50"
|
||||
>
|
||||
Event-Day Toolkit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -261,33 +347,33 @@ export default function EventDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card id="join-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<Card id="qr-invites" className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> Einladungslinks & QR-Layouts
|
||||
<Share2 className="h-5 w-5 text-amber-500" /> QR-Einladungen & Drucklayouts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
Teile Gaesteeinladungen als Link oder drucke sie als fertige Layouts mit QR-Code - ganz ohne technisches
|
||||
Vokabular.
|
||||
Erzeuge QR-Codes für eure Gäste und ladet direkt passende A4-Vorlagen – inklusive Branding und Anleitungen –
|
||||
zum Ausdrucken herunter.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-slate-700">
|
||||
<div className="space-y-2 rounded-xl border border-amber-100 bg-amber-50/70 p-3 text-xs text-amber-800">
|
||||
<p>
|
||||
Teile den generierten Link oder drucke eine Vorlage aus, um Gaeste sicher ins Event zu leiten. Einladungen
|
||||
kannst du jederzeit erneuern oder deaktivieren.
|
||||
Drucke die Layouts aus, platziere sie am Eventort oder teile den QR-Link digital. Du kannst Einladungen
|
||||
jederzeit erneuern oder deaktivieren.
|
||||
</p>
|
||||
{tokens.length > 0 && (
|
||||
{invites.length > 0 && (
|
||||
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-amber-600">
|
||||
Aktive Einladungen: {tokens.filter((token) => token.is_active && !token.revoked_at).length} · Gesamt:{' '}
|
||||
{tokens.length}
|
||||
Aktive QR-Einladungen: {invites.filter((invite) => invite.is_active && !invite.revoked_at).length} · Gesamt:{' '}
|
||||
{invites.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleInvite} disabled={creatingToken} className="w-full">
|
||||
{creatingToken ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
Einladung erstellen
|
||||
<Button onClick={handleInvite} disabled={creatingInvite} className="w-full">
|
||||
{creatingInvite ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
|
||||
QR-Einladung erstellen
|
||||
</Button>
|
||||
|
||||
{inviteLink && (
|
||||
@@ -297,20 +383,22 @@ export default function EventDetailPage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{tokens.length > 0 ? (
|
||||
tokens.map((token) => (
|
||||
{invites.length > 0 ? (
|
||||
invites.map((invite) => (
|
||||
<InvitationCard
|
||||
key={token.id}
|
||||
token={token}
|
||||
onCopy={() => handleCopy(token)}
|
||||
onRevoke={() => handleRevoke(token)}
|
||||
revoking={revokingId === token.id}
|
||||
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 Einladungslinks. Erstelle jetzt den ersten Link, um QR-Layouts mit QR-Code
|
||||
herunterzuladen und zu teilen.
|
||||
Es gibt noch keine QR-Einladungen. Erstelle jetzt eine Einladung, um Layouts mit QR-Code zu generieren
|
||||
und zu teilen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -340,6 +428,18 @@ export default function EventDetailPage() {
|
||||
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<QrInviteCustomizationDialog
|
||||
open={Boolean(customizingInvite)}
|
||||
onClose={closeCustomizer}
|
||||
onSubmit={handleApplyCustomization}
|
||||
onReset={handleResetCustomization}
|
||||
saving={customizerSaving}
|
||||
inviteUrl={customizingInvite?.url ?? ''}
|
||||
eventName={eventDisplayName}
|
||||
layouts={customizingInvite?.layouts ?? []}
|
||||
initialCustomization={currentCustomization}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
@@ -373,21 +473,29 @@ function StatChip({ label, value }: { label: string; value: string | number }) {
|
||||
}
|
||||
|
||||
function InvitationCard({
|
||||
token,
|
||||
invite,
|
||||
onCopy,
|
||||
onRevoke,
|
||||
revoking,
|
||||
onCustomize,
|
||||
eventName,
|
||||
}: {
|
||||
token: EventJoinToken;
|
||||
invite: EventQrInvite;
|
||||
onCopy: () => void;
|
||||
onRevoke: () => void;
|
||||
revoking: boolean;
|
||||
onCustomize: () => void;
|
||||
eventName: string;
|
||||
}) {
|
||||
const status = getTokenStatus(token);
|
||||
const layouts = Array.isArray(token.layouts) ? token.layouts : [];
|
||||
const usageLabel = token.usage_limit ? `${token.usage_count} / ${token.usage_limit}` : `${token.usage_count}`;
|
||||
const metadata = (token.metadata ?? {}) as Record<string, unknown>;
|
||||
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'
|
||||
@@ -401,17 +509,22 @@ function InvitationCard({
|
||||
<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">{token.label?.trim() || `Einladung #${token.id}`}</span>
|
||||
<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">
|
||||
{token.url}
|
||||
{invite.url}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -425,19 +538,28 @@ function InvitationCard({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<span>Nutzung: {usageLabel}</span>
|
||||
{token.expires_at ? <span>Gültig bis {formatDateTime(token.expires_at)}</span> : null}
|
||||
{token.created_at ? <span>Erstellt am {formatDateTime(token.created_at)}</span> : null}
|
||||
{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">
|
||||
{token.layouts_url ? (
|
||||
<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={token.layouts_url} target="_blank" rel="noreferrer">
|
||||
<a href={invite.layouts_url} target="_blank" rel="noreferrer">
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
Layout-Übersicht
|
||||
</a>
|
||||
@@ -447,7 +569,7 @@ function InvitationCard({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRevoke}
|
||||
disabled={revoking || token.revoked_at !== null || !token.is_active}
|
||||
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'}
|
||||
@@ -458,10 +580,16 @@ function InvitationCard({
|
||||
{layouts.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{layouts.map((layout) => (
|
||||
<LayoutPreviewCard key={layout.id} layout={layout} />
|
||||
<LayoutPreviewCard
|
||||
key={layout.id}
|
||||
layout={layout}
|
||||
customization={layout.id === preferredLayoutId ? customization : null}
|
||||
selected={layout.id === preferredLayoutId}
|
||||
eventName={eventName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : token.layouts_url ? (
|
||||
) : 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>
|
||||
@@ -470,38 +598,63 @@ function InvitationCard({
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutPreviewCard({ layout }: { layout: EventJoinTokenLayout }) {
|
||||
const gradient = layout.preview?.background_gradient;
|
||||
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 ?? 135}deg, ${stops.join(', ')})`,
|
||||
backgroundImage: `linear-gradient(${gradient?.angle ?? customization?.background_gradient?.angle ?? 135}deg, ${stops.join(', ')})`,
|
||||
}
|
||||
: {
|
||||
backgroundColor: layout.preview?.background ?? '#F8FAFC',
|
||||
backgroundColor: customization?.background_color ?? layout.preview?.background ?? '#F8FAFC',
|
||||
};
|
||||
const textColor = layout.preview?.text ?? '#0F172A';
|
||||
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 border-amber-100 bg-white shadow-sm">
|
||||
<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 bg-white/30 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide">
|
||||
<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">{layout.name}</div>
|
||||
{layout.subtitle ? (
|
||||
<div className="text-[11px] opacity-80">{layout.subtitle}</div>
|
||||
) : null}
|
||||
<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">
|
||||
{layout.description ? <p className="text-xs text-slate-600">{layout.description}</p> : null}
|
||||
{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();
|
||||
@@ -557,15 +710,15 @@ function formatDateTime(iso: string | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
function getTokenStatus(token: EventJoinToken): 'Aktiv' | 'Deaktiviert' | 'Abgelaufen' {
|
||||
if (token.revoked_at) return 'Deaktiviert';
|
||||
if (token.expires_at) {
|
||||
const expiry = new Date(token.expires_at);
|
||||
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 token.is_active ? 'Aktiv' : 'Deaktiviert';
|
||||
return invite.is_active ? 'Aktiv' : 'Deaktiviert';
|
||||
}
|
||||
|
||||
function renderName(name: TenantEvent['name']): string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
getEvent,
|
||||
getEventTasks,
|
||||
getTasks,
|
||||
updateEvent,
|
||||
TenantEvent,
|
||||
TenantTask,
|
||||
} from '../api';
|
||||
@@ -34,6 +36,7 @@ export default function EventTasksPage() {
|
||||
const [selected, setSelected] = React.useState<number[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [modeSaving, setModeSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const statusLabels = React.useMemo(
|
||||
@@ -101,6 +104,35 @@ export default function EventTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const isPhotoOnlyMode = event?.engagement_mode === 'photo_only';
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
if (!event || !slug) return;
|
||||
|
||||
setModeSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const nextMode = checked ? 'photo_only' : 'tasks';
|
||||
const updated = await updateEvent(slug, {
|
||||
settings: {
|
||||
engagement_mode: nextMode,
|
||||
},
|
||||
});
|
||||
setEvent(updated);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(
|
||||
checked
|
||||
? t('management.tasks.errors.photoOnlyEnable', 'Foto-Modus konnte nicht aktiviert werden.')
|
||||
: t('management.tasks.errors.photoOnlyDisable', 'Foto-Modus konnte nicht deaktiviert werden.'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setModeSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = (
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENTS_PATH)} className="border-pink-200 text-pink-600">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -138,6 +170,45 @@ export default function EventTasksPage() {
|
||||
status: statusLabels[event.status as keyof typeof statusLabels] ?? event.status,
|
||||
})}
|
||||
</CardDescription>
|
||||
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('management.tasks.modes.title', 'Aufgaben & Foto-Modus')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{isPhotoOnlyMode
|
||||
? t(
|
||||
'management.tasks.modes.photoOnlyHint',
|
||||
'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.',
|
||||
)
|
||||
: t(
|
||||
'management.tasks.modes.tasksHint',
|
||||
'Aufgaben werden in der Gäste-App angezeigt. Deaktiviere sie für einen reinen Foto-Modus.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{isPhotoOnlyMode
|
||||
? t('management.tasks.modes.photoOnly', 'Foto-Modus')
|
||||
: t('management.tasks.modes.tasks', 'Aufgaben aktiv')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isPhotoOnlyMode}
|
||||
onCheckedChange={handleModeChange}
|
||||
disabled={modeSaving}
|
||||
aria-label={t('management.tasks.modes.switchLabel', 'Foto-Modus aktivieren')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{modeSaving ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('management.tasks.modes.updating', 'Einstellung wird gespeichert ...')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="space-y-3">
|
||||
@@ -182,6 +253,7 @@ export default function EventTasksPage() {
|
||||
checked ? [...prev, task.id] : prev.filter((id) => id !== task.id)
|
||||
)
|
||||
}
|
||||
disabled={isPhotoOnlyMode}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
@@ -191,10 +263,13 @@ export default function EventTasksPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => void handleAssign()} disabled={saving || selected.length === 0}>
|
||||
<Button
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={saving || selected.length === 0 || isPhotoOnlyMode}
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : t('management.tasks.actions.assign', 'Ausgewählte Tasks zuweisen')}
|
||||
</Button>
|
||||
</section>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
563
resources/js/admin/pages/EventToolkitPage.tsx
Normal file
563
resources/js/admin/pages/EventToolkitPage.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} 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 { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_VIEW_PATH,
|
||||
} from '../constants';
|
||||
import {
|
||||
EventToolkit,
|
||||
EventToolkitTask,
|
||||
getEventToolkit,
|
||||
submitTenantFeedback,
|
||||
TenantPhoto,
|
||||
TenantEvent,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
interface ToolkitState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
data: EventToolkit | null;
|
||||
}
|
||||
|
||||
export default function EventToolkitPage(): JSX.Element {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [state, setState] = React.useState<ToolkitState>({ loading: true, error: null, data: null });
|
||||
const [feedbackSentiment, setFeedbackSentiment] = React.useState<'positive' | 'neutral' | 'negative' | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = React.useState('');
|
||||
const [feedbackSubmitting, setFeedbackSubmitting] = React.useState(false);
|
||||
const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setState({ loading: false, error: t('toolkit.errors.missingSlug', 'Kein Event-Slug angegeben.'), data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const toolkit = await getEventToolkit(slug);
|
||||
setState({ loading: false, error: null, data: toolkit });
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ loading: false, error: t('toolkit.errors.loadFailed', 'Toolkit konnte nicht geladen werden.'), data: null });
|
||||
}
|
||||
}
|
||||
}, [slug, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const { data, loading } = state;
|
||||
const eventName = data?.event ? resolveEventName(data.event.name, i18n.language) : '';
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('toolkit.actions.backToEvent', 'Zurück zum Event')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t('toolkit.actions.moderate', 'Fotos moderieren')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))}>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('toolkit.actions.manageTasks', 'Tasks öffnen')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{t('toolkit.actions.refresh', 'Aktualisieren')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName || t('toolkit.titleFallback', 'Event-Day Toolkit')}
|
||||
subtitle={t('toolkit.subtitle', 'Behalte Uploads, Aufgaben und Einladungen am Eventtag im Blick.')}
|
||||
actions={actions}
|
||||
>
|
||||
{state.error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>{t('toolkit.alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
<AlertDescription>{state.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<ToolkitSkeleton />
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<AlertList alerts={data.alerts} />
|
||||
|
||||
<MetricsGrid metrics={data.metrics} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
|
||||
<PendingPhotosCard
|
||||
photos={data.photos.pending}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(slug ?? ''))}
|
||||
/>
|
||||
<InviteSummary invites={data.invites} navigateToEvent={() => navigate(ADMIN_EVENT_VIEW_PATH(slug ?? ''))} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TaskOverviewCard tasks={data.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(slug ?? ''))} />
|
||||
<RecentUploadsCard photos={data.photos.recent} />
|
||||
</div>
|
||||
|
||||
<FeedbackCard
|
||||
submitting={feedbackSubmitting}
|
||||
submitted={feedbackSubmitted}
|
||||
sentiment={feedbackSentiment}
|
||||
message={feedbackMessage}
|
||||
onSelectSentiment={setFeedbackSentiment}
|
||||
onMessageChange={setFeedbackMessage}
|
||||
onSubmit={async () => {
|
||||
if (!slug) return;
|
||||
setFeedbackSubmitting(true);
|
||||
try {
|
||||
await submitTenantFeedback({
|
||||
category: 'event_toolkit',
|
||||
sentiment: feedbackSentiment ?? undefined,
|
||||
message: feedbackMessage ? feedbackMessage.trim() : undefined,
|
||||
event_slug: slug,
|
||||
});
|
||||
setFeedbackSentiment(null);
|
||||
setFeedbackMessage('');
|
||||
setFeedbackSubmitted(true);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: t('toolkit.errors.feedbackFailed', 'Feedback konnte nicht gesendet werden.'),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setFeedbackSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEventName(name: TenantEvent['name'], locale?: string): string {
|
||||
if (typeof name === 'string') {
|
||||
return name;
|
||||
}
|
||||
if (name && typeof name === 'object') {
|
||||
if (locale && name[locale]) {
|
||||
return name[locale];
|
||||
}
|
||||
const short = locale && locale.includes('-') ? locale.split('-')[0] : null;
|
||||
if (short && name[short]) {
|
||||
return name[short];
|
||||
}
|
||||
return name.de ?? name.en ?? Object.values(name)[0] ?? 'Event';
|
||||
}
|
||||
return 'Event';
|
||||
}
|
||||
|
||||
function AlertList({ alerts }: { alerts: string[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
if (!alerts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertMap: Record<string, string> = {
|
||||
no_tasks: t('toolkit.alerts.noTasks', 'Noch keine Tasks zugeordnet.'),
|
||||
no_invites: t('toolkit.alerts.noInvites', 'Es gibt keine aktiven QR-Einladungen.'),
|
||||
pending_photos: t('toolkit.alerts.pendingPhotos', 'Es warten Fotos auf Moderation.'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((code) => (
|
||||
<Alert key={code} variant="warning" className="border-amber-200 bg-amber-50 text-amber-900">
|
||||
<AlertTitle>{t('toolkit.alerts.attention', 'Achtung')}</AlertTitle>
|
||||
<AlertDescription>{alertMap[code] ?? code}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: EventToolkit['metrics'];
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const cards = [
|
||||
{
|
||||
label: t('toolkit.metrics.uploadsTotal', 'Uploads gesamt'),
|
||||
value: metrics.uploads_total,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.uploads24h', 'Uploads (24h)'),
|
||||
value: metrics.uploads_24h,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.pendingPhotos', 'Unmoderierte Fotos'),
|
||||
value: metrics.pending_photos,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.activeInvites', 'Aktive Einladungen'),
|
||||
value: metrics.active_invites,
|
||||
},
|
||||
{
|
||||
label: t('toolkit.metrics.engagementMode', 'Modus'),
|
||||
value:
|
||||
metrics.engagement_mode === 'photo_only'
|
||||
? t('toolkit.metrics.modePhotoOnly', 'Foto-Modus')
|
||||
: t('toolkit.metrics.modeTasks', 'Aufgaben'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} className="border-0 bg-white/90 shadow-sm shadow-amber-100/50">
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPhotosCard({
|
||||
photos,
|
||||
navigateToModeration,
|
||||
}: {
|
||||
photos: TenantPhoto[];
|
||||
navigateToModeration: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-xl shadow-slate-100/70">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Camera className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.pending.title', 'Wartende Fotos')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.pending.subtitle', 'Moderationsempfehlung für neue Uploads.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" onClick={navigateToModeration}>
|
||||
{t('toolkit.pending.cta', 'Zur Moderation')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.pending.empty', 'Aktuell warten keine Fotos auf Freigabe.')}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="flex gap-3 rounded-xl border border-amber-100 bg-amber-50/60 p-3">
|
||||
<img
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-16 w-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-slate-600">
|
||||
<p className="font-semibold text-slate-800">{photo.uploader_name ?? t('toolkit.pending.unknownUploader', 'Unbekannter Gast')}</p>
|
||||
<p>{t('toolkit.pending.uploadedAt', 'Hochgeladen:')} {formatDateTime(photo.uploaded_at)}</p>
|
||||
<p className="text-[11px] text-amber-700">{t('toolkit.pending.statusPending', 'Status: Prüfung ausstehend')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteSummary({
|
||||
invites,
|
||||
navigateToEvent,
|
||||
}: {
|
||||
invites: EventToolkit['invites'];
|
||||
navigateToEvent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.invites.title', 'QR-Einladungen')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.invites.subtitle', 'Aktive Links und Layouts im Blick behalten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-xs text-slate-600">
|
||||
<div className="flex gap-2 text-sm text-slate-900">
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.activeCount', { defaultValue: '{{count}} aktiv', count: invites.summary.active })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-amber-200 text-amber-700">
|
||||
{t('toolkit.invites.totalCount', { defaultValue: '{{count}} gesamt', count: invites.summary.total })}
|
||||
</Badge>
|
||||
</div>
|
||||
{invites.items.length === 0 ? (
|
||||
<p>{t('toolkit.invites.empty', 'Noch keine QR-Einladungen erstellt.')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{invites.items.map((invite) => (
|
||||
<li key={invite.id} className="rounded-lg border border-amber-100 bg-amber-50/70 p-3">
|
||||
<p className="text-sm font-semibold text-slate-900">{invite.label ?? invite.url}</p>
|
||||
<p className="truncate text-xs text-slate-500">{invite.url}</p>
|
||||
<p className="text-[11px] text-amber-700">
|
||||
{invite.is_active
|
||||
? t('toolkit.invites.statusActive', 'Aktiv')
|
||||
: t('toolkit.invites.statusInactive', 'Inaktiv')}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToEvent}>
|
||||
{t('toolkit.invites.manage', 'Einladungen verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskOverviewCard({ tasks, navigateToTasks }: { tasks: EventToolkit['tasks']; navigateToTasks: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-pink-100/60">
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
|
||||
<Sparkles className="h-5 w-5 text-pink-500" />
|
||||
{t('toolkit.tasks.title', 'Aktive Aufgaben')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.tasks.subtitle', 'Motiviere Gäste mit klaren Aufgaben & Highlights.')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{t('toolkit.tasks.summary', {
|
||||
defaultValue: '{{completed}} von {{total}} erledigt',
|
||||
completed: tasks.summary.completed,
|
||||
total: tasks.summary.total,
|
||||
})}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{tasks.items.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.items.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" onClick={navigateToTasks}>
|
||||
{t('toolkit.tasks.manage', 'Tasks verwalten')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-pink-100 bg-white/80 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description ? <p className="text-xs text-slate-600">{task.description}</p> : null}
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${task.is_completed ? 'text-emerald-600' : 'text-slate-500'}`}>
|
||||
{task.is_completed ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{task.is_completed ? t('toolkit.tasks.completed', 'Erledigt') : t('toolkit.tasks.open', 'Offen')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentUploadsCard({ photos }: { photos: TenantPhoto[] }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Card className="border-0 bg-white/90 shadow-md shadow-sky-100/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Camera className="h-5 w-5 text-sky-500" />
|
||||
{t('toolkit.recent.title', 'Neueste Uploads')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.recent.subtitle', 'Ein Blick auf die letzten Fotos der Gäste.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{t('toolkit.recent.empty', 'Noch keine freigegebenen Fotos vorhanden.')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{photos.map((photo) => (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
className="h-24 w-full rounded-lg object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCard({
|
||||
submitting,
|
||||
submitted,
|
||||
sentiment,
|
||||
message,
|
||||
onSelectSentiment,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
submitting: boolean;
|
||||
submitted: boolean;
|
||||
sentiment: 'positive' | 'neutral' | 'negative' | null;
|
||||
message: string;
|
||||
onSelectSentiment: (value: 'positive' | 'neutral' | 'negative') => void;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-white/95 shadow-lg shadow-amber-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<MessageSquare className="h-5 w-5 text-amber-500" />
|
||||
{t('toolkit.feedback.title', 'Wie hilfreich ist dieses Toolkit?')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{t('toolkit.feedback.subtitle', 'Dein Feedback hilft uns, den Eventtag noch besser zu begleiten.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{submitted ? (
|
||||
<Alert variant="success">
|
||||
<AlertTitle>{t('toolkit.feedback.thanksTitle', 'Danke!')}</AlertTitle>
|
||||
<AlertDescription>{t('toolkit.feedback.thanksDescription', 'Wir haben dein Feedback erhalten.')}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'positive' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('positive')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" /> {t('toolkit.feedback.positive', 'Hilfreich')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'neutral' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('neutral')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('toolkit.feedback.neutral', 'Ganz okay')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={sentiment === 'negative' ? 'default' : 'outline'}
|
||||
onClick={() => onSelectSentiment('negative')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" /> {t('toolkit.feedback.negative', 'Verbesserungsbedarf')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder={t('toolkit.feedback.placeholder', 'Erzähle uns kurz, was dir gefallen hat oder was fehlt …')}
|
||||
value={message}
|
||||
onChange={(event) => onMessageChange(event.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{t('toolkit.feedback.disclaimer', 'Dein Feedback wird vertraulich behandelt und hilft uns beim Feinschliff.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void onSubmit()} disabled={submitting || (!sentiment && message.trim() === '')}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{t('toolkit.feedback.submit', 'Feedback senden')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolkitSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '–';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_MEMBERS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
ADMIN_EVENT_TOOLKIT_PATH,
|
||||
} from '../constants';
|
||||
|
||||
export default function EventsPage() {
|
||||
@@ -156,10 +157,13 @@ function EventCard({ event }: { event: TenantEvent }) {
|
||||
<Link to={ADMIN_EVENT_TASKS_PATH(slug)}>Tasks</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-slate-200 text-slate-700 hover:bg-slate-50">
|
||||
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#join-invites`}>
|
||||
<Share2 className="h-3.5 w-3.5" /> Einladungen
|
||||
<Link to={`${ADMIN_EVENT_VIEW_PATH(slug)}#qr-invites`}>
|
||||
<Share2 className="h-3.5 w-3.5" /> QR-Einladungen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="border-emerald-200 text-emerald-600 hover:bg-emerald-50">
|
||||
<Link to={ADMIN_EVENT_TOOLKIT_PATH(slug)}>Toolkit</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
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;
|
||||
@@ -9,6 +9,7 @@ import EventPhotosPage from './pages/EventPhotosPage';
|
||||
import EventDetailPage from './pages/EventDetailPage';
|
||||
import EventMembersPage from './pages/EventMembersPage';
|
||||
import EventTasksPage from './pages/EventTasksPage';
|
||||
import EventToolkitPage from './pages/EventToolkitPage';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
||||
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
|
||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
|
||||
@@ -1,53 +1,72 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { HTMLAttributes, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const [prefersDark, setPrefersDark] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
const [documentLang, setDocumentLang] = useState<string>(() => (typeof document !== 'undefined' ? document.documentElement.lang ?? '' : ''));
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
switch (appearance) {
|
||||
case 'dark':
|
||||
return <Moon className="h-5 w-5" />;
|
||||
case 'light':
|
||||
return <Sun className="h-5 w-5" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5" />;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (event: MediaQueryListEvent) => setPrefersDark(event.matches);
|
||||
setPrefersDark(media.matches);
|
||||
media.addEventListener('change', handleChange);
|
||||
|
||||
return () => media.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => setDocumentLang(document.documentElement.lang ?? ''));
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const resolvedAppearance = appearance === 'system' ? (prefersDark ? 'dark' : 'light') : appearance;
|
||||
const isDark = resolvedAppearance === 'dark';
|
||||
const Icon = isDark ? Moon : Sun;
|
||||
|
||||
const { ariaLabel, title } = useMemo(() => {
|
||||
const isGerman = documentLang.toLowerCase().startsWith('de');
|
||||
if (isDark) {
|
||||
return {
|
||||
ariaLabel: isGerman ? 'Zum hellen Modus wechseln' : 'Switch to light mode',
|
||||
title: isGerman ? 'Zum hellen Modus wechseln' : 'Switch to light mode',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ariaLabel: isGerman ? 'Zum dunklen Modus wechseln' : 'Switch to dark mode',
|
||||
title: isGerman ? 'Zum dunklen Modus wechseln' : 'Switch to dark mode',
|
||||
};
|
||||
}, [documentLang, isDark]);
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
{getCurrentIcon()}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => updateAppearance('light')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5" />
|
||||
Light
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('dark')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Dark
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => updateAppearance('system')}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="h-5 w-5" />
|
||||
System
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
const handleToggle = () => {
|
||||
updateAppearance(isDark ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-md"
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-pressed={isDark}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function BottomNav() {
|
||||
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-black/30 px-2 py-2 backdrop-blur-xl shadow-xl dark:bg-black/40 dark:border-gray-800/50">
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/30 bg-white/70 px-2 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-gradient-to-t dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
|
||||
<div className="mx-auto flex max-w-sm items-center justify-around">
|
||||
<TabLink to={`${base}`} isActive={isHomeActive}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
|
||||
@@ -16,8 +16,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
common: {
|
||||
hi: 'Hi',
|
||||
actions: {
|
||||
close: 'Schliessen',
|
||||
loading: 'Laedt...',
|
||||
close: 'Schließen',
|
||||
loading: 'Lädt...',
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
@@ -30,34 +30,34 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
loading: 'Lade Event...',
|
||||
stats: {
|
||||
online: 'online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
tasksSolved: 'Aufgaben gelöst',
|
||||
},
|
||||
},
|
||||
eventAccess: {
|
||||
loading: {
|
||||
title: 'Wir pruefen deinen Zugang...',
|
||||
title: 'Wir prüfen deinen Zugang...',
|
||||
subtitle: 'Einen Moment bitte.',
|
||||
},
|
||||
error: {
|
||||
invalid_token: {
|
||||
title: 'Zugriffscode ungueltig',
|
||||
title: 'Zugriffscode ungültig',
|
||||
description: 'Der eingegebene Code konnte nicht verifiziert werden.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_revoked: {
|
||||
title: 'Zugriffscode deaktiviert',
|
||||
description: 'Dieser Code wurde zurueckgezogen. Bitte fordere einen neuen Code an.',
|
||||
description: 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
token_expired: {
|
||||
title: 'Zugriffscode abgelaufen',
|
||||
description: 'Der Code ist nicht mehr gueltig. Aktualisiere deinen Code, um fortzufahren.',
|
||||
description: 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.',
|
||||
ctaLabel: 'Code aktualisieren',
|
||||
},
|
||||
token_rate_limited: {
|
||||
title: 'Zu viele Versuche',
|
||||
description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.',
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.',
|
||||
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
|
||||
},
|
||||
access_rate_limited: {
|
||||
title: 'Zu viele Aufrufe',
|
||||
@@ -65,22 +65,22 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
hint: 'Tipp: Du kannst es gleich noch einmal versuchen.',
|
||||
},
|
||||
gallery_expired: {
|
||||
title: 'Galerie nicht mehr verfuegbar',
|
||||
description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.',
|
||||
title: 'Galerie nicht mehr verfügbar',
|
||||
description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.',
|
||||
ctaLabel: 'Neuen Code anfordern',
|
||||
},
|
||||
event_not_public: {
|
||||
title: 'Event nicht oeffentlich',
|
||||
description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.',
|
||||
title: 'Event nicht öffentlich',
|
||||
description: 'Dieses Event ist aktuell nicht öffentlich zugänglich.',
|
||||
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
|
||||
},
|
||||
network_error: {
|
||||
title: 'Verbindungsproblem',
|
||||
description: 'Wir konnten keine Verbindung zum Server herstellen. Pruefe deine Internetverbindung und versuche es erneut.',
|
||||
description: 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.',
|
||||
},
|
||||
server_error: {
|
||||
title: 'Server nicht erreichbar',
|
||||
description: 'Der Server reagiert derzeit nicht. Versuche es spaeter erneut.',
|
||||
description: 'Der Server reagiert derzeit nicht. Versuche es später erneut.',
|
||||
},
|
||||
default: {
|
||||
title: 'Event nicht erreichbar',
|
||||
@@ -93,10 +93,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
loading: 'Lade Event...',
|
||||
error: {
|
||||
default: 'Event nicht gefunden.',
|
||||
backToStart: 'Zurueck zur Startseite',
|
||||
backToStart: 'Zurück zur Startseite',
|
||||
},
|
||||
card: {
|
||||
description: 'Fange den schoensten Moment ein!',
|
||||
description: 'Fange den schönsten Moment ein!',
|
||||
},
|
||||
form: {
|
||||
label: 'Dein Name (z.B. Anna)',
|
||||
@@ -108,12 +108,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
landing: {
|
||||
pageTitle: 'Willkommen bei der Fotobox!',
|
||||
headline: 'Willkommen bei der Fotobox!',
|
||||
subheadline: 'Dein Schluessel zu unvergesslichen Momenten.',
|
||||
subheadline: 'Dein Schlüssel zu unvergesslichen Momenten.',
|
||||
join: {
|
||||
title: 'Event beitreten',
|
||||
description: 'Scanne den QR-Code oder gib den Code manuell ein.',
|
||||
button: 'Event beitreten',
|
||||
buttonLoading: 'Pruefe...',
|
||||
buttonLoading: 'Prüfe...',
|
||||
},
|
||||
scan: {
|
||||
start: 'QR-Code scannen',
|
||||
@@ -125,7 +125,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
errors: {
|
||||
eventClosed: 'Event nicht gefunden oder geschlossen.',
|
||||
network: 'Netzwerkfehler. Bitte spaeter erneut versuchen.',
|
||||
network: 'Netzwerkfehler. Bitte später erneut versuchen.',
|
||||
camera: 'Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.',
|
||||
},
|
||||
},
|
||||
@@ -134,27 +134,27 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
hero: {
|
||||
subtitle: 'Willkommen zur Party',
|
||||
title: 'Hey {name}!',
|
||||
description: 'Du bist bereit fuer "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gaesten.',
|
||||
description: 'Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.',
|
||||
progress: {
|
||||
some: 'Schon {count} Aufgaben erledigt - weiter so!',
|
||||
none: 'Starte mit deiner ersten Aufgabe - wir zaehlen auf dich!',
|
||||
some: 'Schon {count} Aufgaben erledigt – weiter so!',
|
||||
none: 'Starte mit deiner ersten Aufgabe – wir zählen auf dich!',
|
||||
},
|
||||
defaultEventName: 'Dein Event',
|
||||
},
|
||||
stats: {
|
||||
online: 'Gleichzeitig online',
|
||||
tasksSolved: 'Aufgaben geloest',
|
||||
tasksSolved: 'Aufgaben gelöst',
|
||||
lastUpload: 'Letzter Upload',
|
||||
completedTasks: 'Deine erledigten Aufgaben',
|
||||
},
|
||||
actions: {
|
||||
title: 'Deine Aktionen',
|
||||
subtitle: 'Waehle aus, womit du starten willst',
|
||||
subtitle: 'Wähle aus, womit du starten willst',
|
||||
queueButton: 'Uploads in Warteschlange ansehen',
|
||||
items: {
|
||||
tasks: {
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
description: 'Hol dir deine nächste Challenge',
|
||||
},
|
||||
upload: {
|
||||
label: 'Direkt hochladen',
|
||||
@@ -168,10 +168,10 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
checklist: {
|
||||
title: 'Dein Fortschritt',
|
||||
description: 'Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.',
|
||||
description: 'Halte dich an diese drei kurzen Schritte für die besten Ergebnisse.',
|
||||
steps: {
|
||||
first: 'Aufgabe auswaehlen oder starten',
|
||||
second: 'Emotion festhalten und Foto schiessen',
|
||||
first: 'Aufgabe auswählen oder starten',
|
||||
second: 'Emotion festhalten und Foto schießen',
|
||||
third: 'Bild hochladen und Credits sammeln',
|
||||
},
|
||||
},
|
||||
@@ -225,35 +225,35 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
retry: 'Nochmal versuchen',
|
||||
},
|
||||
primer: {
|
||||
title: 'Bereit fuer dein Shooting?',
|
||||
title: 'Bereit für dein Shooting?',
|
||||
body: {
|
||||
part1: 'Lass uns sicherstellen, dass alles sitzt: pruefe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
||||
part2: 'Du kannst zwischen Front- und Rueckkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
||||
part1: 'Lass uns sicherstellen, dass alles sitzt: prüfe das Licht, wisch die Kamera sauber und richte alle Personen im Bild aus.',
|
||||
part2: 'Du kannst zwischen Front- und Rückkamera wechseln und bei Bedarf ein Raster aktivieren.',
|
||||
},
|
||||
dismiss: 'Verstanden',
|
||||
},
|
||||
cameraUnsupported: {
|
||||
title: 'Kamera nicht verfuegbar',
|
||||
message: 'Dein Geraet unterstuetzt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||
openGallery: 'Foto aus Galerie waehlen',
|
||||
title: 'Kamera nicht verfügbar',
|
||||
message: 'Dein Gerät unterstützt keine Kameravorschau im Browser. Du kannst stattdessen Fotos aus deiner Galerie hochladen.',
|
||||
openGallery: 'Foto aus Galerie wählen',
|
||||
},
|
||||
cameraDenied: {
|
||||
title: 'Kamera-Zugriff verweigert',
|
||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu koennen.',
|
||||
reopenPrompt: 'Systemdialog erneut oeffnen',
|
||||
chooseFile: 'Foto aus Galerie waehlen',
|
||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder waehle alternativ ein Foto aus deiner Galerie.',
|
||||
explanation: 'Du musst den Zugriff auf die Kamera erlauben, um Fotos aufnehmen zu können.',
|
||||
reopenPrompt: 'Systemdialog erneut öffnen',
|
||||
chooseFile: 'Foto aus Galerie wählen',
|
||||
prompt: 'Wir brauchen Zugriff auf deine Kamera. Erlaube die Anfrage oder wähle alternativ ein Foto aus deiner Galerie.',
|
||||
},
|
||||
cameraError: {
|
||||
title: 'Kamera konnte nicht gestartet werden',
|
||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Pruefe die Berechtigungen oder starte dein Geraet neu.',
|
||||
explanation: 'Wir konnten keine Verbindung zur Kamera herstellen. Prüfe die Berechtigungen oder starte dein Gerät neu.',
|
||||
tryAgain: 'Nochmals versuchen',
|
||||
},
|
||||
readyOverlay: {
|
||||
title: 'Kamera bereit',
|
||||
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto waehlen.',
|
||||
message: 'Wenn alle im Bild sind, kannst du den Countdown starten oder ein vorhandenes Foto wählen.',
|
||||
start: 'Countdown starten',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
chooseFile: 'Foto auswählen',
|
||||
},
|
||||
taskInfo: {
|
||||
countdown: 'Countdown',
|
||||
@@ -266,7 +266,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
timeEstimate: '{count} Min',
|
||||
fallbackTitle: 'Aufgabe {id}',
|
||||
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gaesten.',
|
||||
fallbackDescription: 'Halte den Moment fest und teile ihn mit allen Gästen.',
|
||||
fallbackInstructions: 'Positioniere alle im Bild, starte den Countdown und lass die Emotion wirken.',
|
||||
badge: 'Aufgabe #{id}',
|
||||
},
|
||||
@@ -276,7 +276,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
review: {
|
||||
retake: 'Nochmal aufnehmen',
|
||||
keep: 'Foto verwenden',
|
||||
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau pruefen.',
|
||||
readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.',
|
||||
},
|
||||
status: {
|
||||
saving: 'Speichere Foto...',
|
||||
@@ -289,23 +289,23 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
controls: {
|
||||
toggleGrid: 'Raster umschalten',
|
||||
toggleCountdown: 'Countdown umschalten',
|
||||
toggleMirror: 'Spiegelung fuer Frontkamera umschalten',
|
||||
toggleFlash: 'Blitzpraeferenz umschalten',
|
||||
toggleMirror: 'Spiegelung für Frontkamera umschalten',
|
||||
toggleFlash: 'Blitzpräferenz umschalten',
|
||||
capture: 'Foto aufnehmen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
chooseFile: 'Foto auswaehlen',
|
||||
chooseFile: 'Foto auswählen',
|
||||
},
|
||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter fuer ein Upgrade.',
|
||||
limitReached: 'Upload-Limit erreicht ({used} / {max} Fotos). Bitte kontaktiere die Veranstalter für ein Upgrade.',
|
||||
limitUnlimited: 'unbegrenzt',
|
||||
cameraInactive: 'Kamera ist nicht aktiv. {hint}',
|
||||
cameraInactiveHint: 'Tippe auf "{label}", um loszulegen.',
|
||||
captureError: 'Foto konnte nicht erstellt werden.',
|
||||
feedError: 'Kamera liefert kein Bild. Bitte starte die Kamera neu.',
|
||||
canvasError: 'Canvas konnte nicht initialisiert werden.',
|
||||
limitCheckError: 'Fehler beim Pruefen des Upload-Limits. Upload deaktiviert.',
|
||||
limitCheckError: 'Fehler beim Prüfen des Upload-Limits. Upload deaktiviert.',
|
||||
galleryPickError: 'Auswahl fehlgeschlagen. Bitte versuche es erneut.',
|
||||
captureButton: 'Foto aufnehmen',
|
||||
galleryButton: 'Foto aus Galerie waehlen',
|
||||
galleryButton: 'Foto aus Galerie wählen',
|
||||
switchCamera: 'Kamera wechseln',
|
||||
countdownLabel: 'Countdown: {seconds}s',
|
||||
countdownReady: 'Bereit machen ...',
|
||||
@@ -319,7 +319,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
subtitle: 'Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.',
|
||||
language: {
|
||||
title: 'Sprache',
|
||||
description: 'Waehle deine bevorzugte Sprache fuer diese Veranstaltung.',
|
||||
description: 'Wähle deine bevorzugte Sprache für diese Veranstaltung.',
|
||||
activeBadge: 'aktiv',
|
||||
option: {
|
||||
de: 'Deutsch',
|
||||
@@ -328,12 +328,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
name: {
|
||||
title: 'Dein Name',
|
||||
description: 'Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.',
|
||||
description: 'Passe an, wie wir dich im Event begrüßen. Der Name wird nur lokal gespeichert.',
|
||||
label: 'Anzeigename',
|
||||
placeholder: 'z.B. Anna',
|
||||
save: 'Name speichern',
|
||||
saving: 'Speichere...',
|
||||
reset: 'Zuruecksetzen',
|
||||
reset: 'Zurücksetzen',
|
||||
saved: 'Gespeichert (ok)',
|
||||
loading: 'Lade gespeicherten Namen...',
|
||||
},
|
||||
@@ -341,7 +341,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
title: 'Rechtliches',
|
||||
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
|
||||
loading: 'Dokument wird geladen...',
|
||||
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.',
|
||||
error: 'Das Dokument konnte nicht geladen werden. Bitte versuche es später erneut.',
|
||||
fallbackTitle: 'Rechtlicher Hinweis',
|
||||
section: {
|
||||
impressum: 'Impressum',
|
||||
@@ -350,19 +350,19 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
title: 'Offline Cache',
|
||||
description: 'Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.',
|
||||
title: 'Offline-Cache',
|
||||
description: 'Lösche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads hängen bleiben.',
|
||||
clear: 'Cache leeren',
|
||||
clearing: 'Leere Cache...',
|
||||
cleared: 'Cache geloescht.',
|
||||
note: 'Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.',
|
||||
cleared: 'Cache gelöscht.',
|
||||
note: 'Dies betrifft nur diesen Browser und muss pro Gerät erneut ausgeführt werden.',
|
||||
},
|
||||
footer: {
|
||||
notice: 'Gastbereich - Daten werden lokal im Browser gespeichert.',
|
||||
},
|
||||
sheet: {
|
||||
openLabel: 'Einstellungen oeffnen',
|
||||
backLabel: 'Zurueck',
|
||||
openLabel: 'Einstellungen öffnen',
|
||||
backLabel: 'Zurück',
|
||||
legalDescription: 'Rechtlicher Hinweis',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { uploadPhoto } from '../services/photosApi';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { useAppearance } from '../../hooks/use-appearance';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -46,6 +45,39 @@ type CameraPreferences = {
|
||||
flashPreferred: boolean;
|
||||
};
|
||||
|
||||
type TaskPayload = Partial<Task> & { id: number };
|
||||
|
||||
function isTaskPayload(value: unknown): value is TaskPayload {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as { id?: unknown };
|
||||
return typeof candidate.id === 'number';
|
||||
}
|
||||
|
||||
function getErrorName(error: unknown): string | undefined {
|
||||
if (typeof error === 'object' && error !== null && 'name' in error) {
|
||||
const name = (error as { name?: unknown }).name;
|
||||
return typeof name === 'string' ? name : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
return typeof message === 'string' ? message : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: CameraPreferences = {
|
||||
facingMode: 'environment',
|
||||
countdownSeconds: 3,
|
||||
@@ -60,8 +92,6 @@ export default function UploadPage() {
|
||||
const eventKey = token ?? '';
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { appearance } = useAppearance();
|
||||
const isDarkMode = appearance === 'dark';
|
||||
const { markCompleted } = useGuestTaskProgress(token);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -75,7 +105,6 @@ export default function UploadPage() {
|
||||
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [loadingTask, setLoadingTask] = useState(true);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
|
||||
const [permissionState, setPermissionState] = useState<PermissionState>('idle');
|
||||
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
|
||||
@@ -138,8 +167,7 @@ export default function UploadPage() {
|
||||
|
||||
// Load task metadata
|
||||
useEffect(() => {
|
||||
if (!token || !taskId) {
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
if (!token || taskId === null) {
|
||||
setLoadingTask(false);
|
||||
return;
|
||||
}
|
||||
@@ -147,18 +175,19 @@ export default function UploadPage() {
|
||||
let active = true;
|
||||
|
||||
async function loadTask() {
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${taskId!}`);
|
||||
const currentTaskId = taskId;
|
||||
const fallbackTitle = t('upload.taskInfo.fallbackTitle').replace('{id}', `${currentTaskId}`);
|
||||
const fallbackDescription = t('upload.taskInfo.fallbackDescription');
|
||||
const fallbackInstructions = t('upload.taskInfo.fallbackInstructions');
|
||||
|
||||
try {
|
||||
setLoadingTask(true);
|
||||
setTaskError(null);
|
||||
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
|
||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
const tasks = await res.json();
|
||||
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
|
||||
const payload = (await res.json()) as unknown;
|
||||
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
|
||||
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
|
||||
|
||||
if (!active) return;
|
||||
|
||||
@@ -174,7 +203,7 @@ export default function UploadPage() {
|
||||
});
|
||||
} else {
|
||||
setTask({
|
||||
id: taskId!,
|
||||
id: currentTaskId,
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
@@ -188,9 +217,8 @@ export default function UploadPage() {
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task', error);
|
||||
if (active) {
|
||||
setTaskError(t('upload.loadError.title'));
|
||||
setTask({
|
||||
id: taskId!,
|
||||
id: currentTaskId,
|
||||
title: fallbackTitle,
|
||||
description: fallbackDescription,
|
||||
instructions: fallbackInstructions,
|
||||
@@ -210,7 +238,7 @@ export default function UploadPage() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [eventKey, taskId, emotionSlug, t]);
|
||||
}, [eventKey, taskId, emotionSlug, t, token]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
@@ -294,14 +322,15 @@ export default function UploadPage() {
|
||||
streamRef.current = stream;
|
||||
attachStreamToVideo(stream);
|
||||
setPermissionState('granted');
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Camera access error', error);
|
||||
stopStream();
|
||||
|
||||
if (error?.name === 'NotAllowedError') {
|
||||
const errorName = getErrorName(error);
|
||||
if (errorName === 'NotAllowedError') {
|
||||
setPermissionState('denied');
|
||||
setPermissionMessage(t('upload.cameraDenied.explanation'));
|
||||
} else if (error?.name === 'NotFoundError') {
|
||||
} else if (errorName === 'NotFoundError') {
|
||||
setPermissionState('error');
|
||||
setPermissionMessage(t('upload.cameraUnsupported.message'));
|
||||
} else {
|
||||
@@ -489,9 +518,9 @@ export default function UploadPage() {
|
||||
markCompleted(task.id);
|
||||
stopStream();
|
||||
navigateAfterUpload(photoId);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Upload failed', error);
|
||||
setUploadError(error?.message || t('upload.status.failed'));
|
||||
setUploadError(getErrorMessage(error) || t('upload.status.failed'));
|
||||
setMode('review');
|
||||
} finally {
|
||||
if (uploadProgressTimerRef.current) {
|
||||
@@ -533,7 +562,6 @@ export default function UploadPage() {
|
||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||
const showTaskOverlay = task && mode !== 'uploading';
|
||||
|
||||
const isUploadDisabled = !canUpload || !task;
|
||||
|
||||
useEffect(() => () => {
|
||||
resetCountdownTimer();
|
||||
@@ -542,49 +570,41 @@ export default function UploadPage() {
|
||||
}
|
||||
}, [resetCountdownTimer]);
|
||||
|
||||
const renderPage = (content: ReactNode, mainClassName = 'px-4 py-6') => (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className={mainClassName}>{content}</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!supportsCamera && !task) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
return renderPage(
|
||||
<Alert>
|
||||
<AlertDescription>{t('upload.cameraUnsupported.message')}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadingTask) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</main>
|
||||
<BottomNav />
|
||||
return renderPage(
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-pink-500" />
|
||||
<p className="text-sm text-muted-foreground">{t('upload.preparing')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
return renderPage(
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('upload.limitReached')
|
||||
.replace('{used}', `${eventPackage?.used_photos || 0}`)
|
||||
.replace('{max}', `${eventPackage?.package.max_photos || 0}`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,18 +656,16 @@ export default function UploadPage() {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header eventToken={eventKey} title={t('upload.cameraTitle')} />
|
||||
<main className="relative flex flex-col gap-4 pb-4">
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
</div>
|
||||
<div className="pt-32" />
|
||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||
return renderPage(
|
||||
<>
|
||||
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
|
||||
{renderPrimer()}
|
||||
</div>
|
||||
<div className="pt-32" />
|
||||
{permissionState !== 'granted' && renderPermissionNotice()}
|
||||
|
||||
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||
<section className="relative mx-4 overflow-hidden rounded-2xl border border-white/10 bg-black text-white shadow-xl">
|
||||
<div className="relative aspect-[3/4] sm:aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={cn(
|
||||
@@ -863,9 +881,10 @@ export default function UploadPage() {
|
||||
/>
|
||||
|
||||
<div ref={liveRegionRef} className="sr-only" aria-live="assertive" />
|
||||
</main>
|
||||
<BottomNav />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</>
|
||||
,
|
||||
'relative flex flex-col gap-4 pb-4'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user