import { getDeviceId } from '../lib/device'; export interface EventBrandingPayload { primary_color?: string | null; secondary_color?: string | null; background_color?: string | null; font_family?: string | null; logo_url?: string | null; surface_color?: string | null; heading_font?: string | null; body_font?: string | null; font_size?: 's' | 'm' | 'l' | null; icon?: string | null; logo_mode?: 'emoticon' | 'upload' | null; logo_value?: string | null; logo_position?: 'left' | 'right' | 'center' | null; logo_size?: 's' | 'm' | 'l' | null; button_style?: 'filled' | 'outline' | null; button_radius?: number | null; button_primary_color?: string | null; button_secondary_color?: string | null; link_color?: string | null; mode?: 'light' | 'dark' | 'auto' | null; use_default_branding?: boolean | null; palette?: { primary?: string | null; secondary?: string | null; background?: string | null; surface?: string | null; } | null; typography?: { heading?: string | null; body?: string | null; size?: 's' | 'm' | 'l' | null; } | null; logo?: { mode?: 'emoticon' | 'upload'; value?: string | null; position?: 'left' | 'right' | 'center'; size?: 's' | 'm' | 'l'; } | null; buttons?: { style?: 'filled' | 'outline'; radius?: number | null; primary?: string | null; secondary?: string | null; link_color?: string | null; } | null; } export interface EventData { id: number; slug: string; name: string; default_locale: string; created_at: string; updated_at: string; join_token?: string | null; photobooth_enabled?: boolean | null; type?: { slug: string; name: string; icon: string | null; }; branding?: EventBrandingPayload | null; } export interface PackageData { id: number; name: string; max_photos: number; max_guests?: number | null; gallery_days?: number | null; } export interface LimitUsageSummary { limit: number | null; used: number; remaining: number | null; percentage: number | null; state: 'ok' | 'warning' | 'limit_reached' | 'unlimited'; threshold_reached: number | null; next_threshold: number | null; thresholds: number[]; } export interface GallerySummary { state: 'ok' | 'warning' | 'expired' | 'unlimited'; expires_at: string | null; days_remaining: number | null; warning_thresholds: number[]; warning_triggered: number | null; warning_sent_at: string | null; expired_notified_at: string | null; } export interface EventPackageLimits { photos: LimitUsageSummary | null; guests: LimitUsageSummary | null; gallery: GallerySummary | null; can_upload_photos: boolean; can_add_guests: boolean; } export interface EventPackage { id: number; event_id?: number; package_id?: number; used_photos: number; used_guests?: number; expires_at: string | null; package: PackageData | null; limits: EventPackageLimits | null; } export interface EventStats { onlineGuests: number; tasksSolved: number; latestPhotoAt: string | null; } export type FetchEventErrorCode = | 'invalid_token' | 'token_expired' | 'token_revoked' | 'token_rate_limited' | 'access_rate_limited' | 'gallery_expired' | 'event_not_public' | 'network_error' | 'server_error' | 'unknown'; interface FetchEventErrorOptions { code: FetchEventErrorCode; message: string; status?: number; } export class FetchEventError extends Error { readonly code: FetchEventErrorCode; readonly status?: number; constructor({ code, message, status }: FetchEventErrorOptions) { super(message); this.name = 'FetchEventError'; this.code = code; this.status = status; } } const API_ERROR_CODES: FetchEventErrorCode[] = [ 'invalid_token', 'token_expired', 'token_revoked', 'token_rate_limited', 'access_rate_limited', 'gallery_expired', 'event_not_public', ]; function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode { if (typeof rawCode === 'string') { const normalized = rawCode.toLowerCase() as FetchEventErrorCode; if ((API_ERROR_CODES as string[]).includes(normalized)) { return normalized; } } if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited'; if (status === 404) return 'event_not_public'; if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired'; if (status === 401) return 'invalid_token'; if (status === 403) return 'token_revoked'; if (status >= 500) return 'server_error'; return 'unknown'; } function defaultMessageForCode(code: FetchEventErrorCode): string { switch (code) { case 'invalid_token': return 'Der eingegebene Zugriffscode ist ungültig.'; case 'token_revoked': return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.'; case 'token_expired': return 'Dieser Zugriffscode ist abgelaufen.'; case 'token_rate_limited': return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.'; case 'access_rate_limited': return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.'; case 'gallery_expired': return 'Die Galerie ist nicht mehr verfügbar.'; case 'event_not_public': return 'Dieses Event ist nicht öffentlich verfügbar.'; case 'network_error': return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.'; case 'server_error': return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.'; case 'unknown': default: return 'Event konnte nicht geladen werden.'; } } export async function fetchEvent(eventKey: string): Promise { try { const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`); if (!res.ok) { let apiMessage: string | null = null; let rawCode: unknown; try { const data = await res.json(); rawCode = data?.error?.code ?? data?.code; const message = data?.error?.message ?? data?.message; if (typeof message === 'string' && message.trim() !== '') { apiMessage = message.trim(); } } catch { // ignore parse errors and fall back to defaults } const code = resolveErrorCode(rawCode, res.status); const message = apiMessage ?? defaultMessageForCode(code); throw new FetchEventError({ code, message, status: res.status, }); } return await res.json(); } catch (error) { if (error instanceof FetchEventError) { throw error; } if (error instanceof TypeError) { throw new FetchEventError({ code: 'network_error', message: defaultMessageForCode('network_error'), status: 0, }); } if (error instanceof Error) { throw new FetchEventError({ code: 'unknown', message: error.message || defaultMessageForCode('unknown'), status: 0, }); } throw new FetchEventError({ code: 'unknown', message: defaultMessageForCode('unknown'), status: 0, }); } } export async function fetchStats(eventKey: string): Promise { const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/stats`, { headers: { 'X-Device-Id': getDeviceId(), }, }); if (!res.ok) throw new Error('Stats fetch failed'); const json = await res.json(); return { onlineGuests: json.online_guests ?? json.onlineGuests ?? 0, tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0, latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null, }; } export async function getEventPackage(eventToken: string): Promise { const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/package`); if (!res.ok) { if (res.status === 404) return null; throw new Error('Failed to load event package'); } const payload = await res.json(); return { ...payload, limits: payload?.limits ?? null, } as EventPackage; }