// @ts-nocheck import { getDeviceId } from '../lib/device'; export type UploadError = Error & { code?: string; status?: number; meta?: Record; }; function getCsrfToken(): string | null { // Method 1: Meta tag (preferred for SPA) const metaToken = document.querySelector('meta[name="csrf-token"]'); if (metaToken) { return (metaToken as HTMLMetaElement).getAttribute('content') || null; } // Method 2: XSRF-TOKEN cookie (Sanctum fallback) const name = 'XSRF-TOKEN='; const decodedCookie = decodeURIComponent(document.cookie); const ca = decodedCookie.split(';'); for (let i = 0; i < ca.length; i++) { const c = ca[i].trimStart(); if (c.startsWith(name)) { const token = c.substring(name.length); try { // Decode base64 if needed return decodeURIComponent(atob(token)); } catch { return token; } } } console.warn('No CSRF token found - API requests may fail'); return null; } function getCsrfHeaders(): Record { const token = getCsrfToken(); const headers: Record = { 'X-Device-Id': getDeviceId(), 'Accept': 'application/json', }; if (token) { headers['X-CSRF-TOKEN'] = token; headers['X-XSRF-TOKEN'] = token; } return headers; } export async function likePhoto(id: number): Promise { const headers = getCsrfHeaders(); const res = await fetch(`/api/v1/photos/${id}/like`, { method: 'POST', credentials: 'include', headers: { ...headers, 'Content-Type': 'application/json', }, }); if (!res.ok) { let payload: unknown = null; try { payload = await res.clone().json(); } catch (error) { console.warn('Like photo: failed to parse error payload', error); } if (res.status === 419) { const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.'); error.code = 'csrf_mismatch'; error.status = res.status; throw error; } const error: UploadError = new Error( (payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}` ); error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed'; error.status = res.status; const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; if (meta) { error.meta = meta; } throw error; } const json = await res.json(); return json.likes_count ?? json.data?.likes_count ?? 0; } type UploadOptions = { guestName?: string; onProgress?: (percent: number) => void; signal?: AbortSignal; maxRetries?: number; onRetry?: (attempt: number) => void; liveShowOptIn?: boolean; }; export async function uploadPhoto( eventToken: string, file: File, taskId?: number, emotionSlug?: string, options: UploadOptions = {} ): Promise { const formData = new FormData(); formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`); if (taskId) formData.append('task_id', taskId.toString()); if (emotionSlug) formData.append('emotion_slug', emotionSlug); if (options.guestName) formData.append('guest_name', options.guestName); if (typeof options.liveShowOptIn === 'boolean') { formData.append('live_show_opt_in', options.liveShowOptIn ? '1' : '0'); } formData.append('device_id', getDeviceId()); const maxRetries = options.maxRetries ?? 2; const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`; const headers = getCsrfHeaders(); const attemptUpload = (): Promise> => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.withCredentials = true; xhr.responseType = 'json'; Object.entries(headers).forEach(([key, value]) => { xhr.setRequestHeader(key, value); }); if (options.signal) { const onAbort = () => xhr.abort(); options.signal.addEventListener('abort', onAbort, { once: true }); } xhr.upload.onprogress = (event) => { if (event.lengthComputable && options.onProgress) { const percent = Math.min(99, Math.round((event.loaded / event.total) * 100)); options.onProgress(percent); } }; xhr.onload = () => { const status = xhr.status; const payload = (xhr.response ?? null) as Record | null; if (status >= 200 && status < 300) { resolve(payload); return; } const error: UploadError = new Error( (payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}` ); error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed'); error.status = status; const meta = (payload as { error?: { meta?: Record } } | null)?.error?.meta; if (meta) { error.meta = meta; } reject(error); }; xhr.onerror = () => { const error: UploadError = new Error('Network error during upload'); error.code = 'network_error'; reject(error); }; xhr.ontimeout = () => { const error: UploadError = new Error('Upload timed out'); error.code = 'timeout'; reject(error); }; xhr.send(formData); }); for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const json = await attemptUpload(); const payload = json as { photo_id?: number; id?: number; data?: { id?: number } }; return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0; } catch (error) { const err = error as UploadError; if (attempt < maxRetries && (err.code === 'network_error' || (err.status ?? 0) >= 500)) { options.onRetry?.(attempt + 1); const delay = 300 * (attempt + 1); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } // Map CSRF mismatch specifically for caller handling if ((err.status ?? 0) === 419) { err.code = 'csrf_mismatch'; } // Flag common validation failure for file size/validation if ((err.status ?? 0) === 422 && !err.code) { err.code = 'validation_error'; } throw err; } } throw new Error('Upload failed after retries'); } export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> { const headers = getCsrfHeaders(); const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, { method: 'POST', credentials: 'include', headers, }); if (!res.ok) { let payload: unknown = null; try { payload = await res.clone().json(); } catch (error) { console.warn('Share link error payload parse failed', error); } const errorPayload = payload as { error?: { message?: string; code?: string } } | null; const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed'); error.code = errorPayload?.error?.code ?? 'share_failed'; error.status = res.status; throw error; } return res.json(); } export async function fetchPhotoShare(slug: string) { const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, { headers: { Accept: 'application/json' }, }); if (!res.ok) { const payload = await res.json().catch(() => null); const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable'); error.code = payload?.error?.code ?? 'share_unavailable'; error.status = res.status; throw error; } const payload = await res.json(); const normalize = (value: unknown, fallback: string): string => { if (typeof value === 'string' && value.trim() !== '') { return value; } if (value && typeof value === 'object') { const obj = value as Record; const preferred = ['de', 'en']; for (const key of preferred) { const candidate = obj[key]; if (typeof candidate === 'string' && candidate.trim() !== '') { return candidate; } } const firstString = Object.values(obj).find((candidate) => typeof candidate === 'string' && candidate.trim() !== ''); if (typeof firstString === 'string') { return firstString; } } return fallback; }; if (payload?.event) { payload.event = { ...payload.event, name: normalize(payload.event?.name, 'Fotospiel Event'), }; } return payload; }