import { getDeviceId } from '../lib/device'; import { DEFAULT_LOCALE } from '../i18n/messages'; export interface AchievementBadge { id: string; title: string; description: string; earned: boolean; progress: number; target: number; } export interface LeaderboardEntry { guest: string; photos: number; likes: number; } export interface TopPhotoHighlight { photoId: number; guest: string; likes: number; task?: string | null; createdAt: string; thumbnail: string | null; } export interface TrendingEmotionHighlight { emotionId: number; name: string; count: number; } export interface TimelinePoint { date: string; photos: number; guests: number; } export interface FeedEntry { photoId: number; guest: string; task?: string | null; likes: number; createdAt: string; thumbnail: string | null; } export interface AchievementsPayload { summary: { totalPhotos: number; uniqueGuests: number; tasksSolved: number; likesTotal: number; }; personal: { guestName: string; photos: number; tasks: number; likes: number; badges: AchievementBadge[]; } | null; leaderboards: { uploads: LeaderboardEntry[]; likes: LeaderboardEntry[]; }; highlights: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null; timeline: TimelinePoint[]; }; feed: FeedEntry[]; } function toNumber(value: unknown, fallback = 0): number { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } return fallback; } function safeString(value: unknown): string { return typeof value === 'string' ? value : ''; } type FetchAchievementsOptions = { guestName?: string; locale?: string; signal?: AbortSignal; forceRefresh?: boolean; }; type AchievementsCacheEntry = { data: AchievementsPayload; etag: string | null; }; const achievementsCache = new Map(); export async function fetchAchievements( eventToken: string, options: FetchAchievementsOptions = {} ): Promise { const { guestName, signal, forceRefresh } = options; const locale = options.locale ?? DEFAULT_LOCALE; const params = new URLSearchParams(); if (guestName && guestName.trim().length > 0) { params.set('guest_name', guestName.trim()); } if (locale) { params.set('locale', locale); } const deviceId = getDeviceId(); const cacheKey = [eventToken, locale, guestName?.trim() ?? '', deviceId].join(':'); const cached = forceRefresh ? null : achievementsCache.get(cacheKey); const headers: HeadersInit = { 'X-Device-Id': deviceId, 'Cache-Control': 'no-store', Accept: 'application/json', 'X-Locale': locale, }; if (cached?.etag) { headers['If-None-Match'] = cached.etag; } const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/achievements?${params.toString()}`, { method: 'GET', headers, signal, }); if (response.status === 304 && cached) { return cached.data; } if (!response.ok) { const message = await response.text(); throw new Error(message || 'Achievements request failed'); } const json = await response.json(); const summary = json.summary ?? {}; const personalRaw = json.personal ?? null; const leaderboards = json.leaderboards ?? {}; const highlights = json.highlights ?? {}; const feedRaw = Array.isArray(json.feed) ? json.feed : []; const personal = personalRaw ? { guestName: safeString(personalRaw.guest_name), photos: toNumber(personalRaw.photos), tasks: toNumber(personalRaw.tasks), likes: toNumber(personalRaw.likes), badges: Array.isArray(personalRaw.badges) ? personalRaw.badges.map((badge: any): AchievementBadge => ({ id: safeString(badge.id), title: safeString(badge.title), description: safeString(badge.description), earned: Boolean(badge.earned), progress: toNumber(badge.progress), target: toNumber(badge.target, 1), })) : [], } : null; const uploadsBoard = Array.isArray(leaderboards.uploads) ? leaderboards.uploads.map((row: any): LeaderboardEntry => ({ guest: safeString(row.guest), photos: toNumber(row.photos), likes: toNumber(row.likes), })) : []; const likesBoard = Array.isArray(leaderboards.likes) ? leaderboards.likes.map((row: any): LeaderboardEntry => ({ guest: safeString(row.guest), photos: toNumber(row.photos), likes: toNumber(row.likes), })) : []; const topPhotoRaw = highlights.top_photo ?? null; const topPhoto = topPhotoRaw ? { photoId: toNumber(topPhotoRaw.photo_id), guest: safeString(topPhotoRaw.guest), likes: toNumber(topPhotoRaw.likes), task: topPhotoRaw.task ?? null, createdAt: safeString(topPhotoRaw.created_at), thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null, } : null; const trendingRaw = highlights.trending_emotion ?? null; const trendingEmotion = trendingRaw ? { emotionId: toNumber(trendingRaw.emotion_id), name: safeString(trendingRaw.name), count: toNumber(trendingRaw.count), } : null; const timeline = Array.isArray(highlights.timeline) ? highlights.timeline.map((row: any): TimelinePoint => ({ date: safeString(row.date), photos: toNumber(row.photos), guests: toNumber(row.guests), })) : []; const feed = feedRaw.map((row: any): FeedEntry => ({ photoId: toNumber(row.photo_id), guest: safeString(row.guest), task: row.task ?? null, likes: toNumber(row.likes), createdAt: safeString(row.created_at), thumbnail: row.thumbnail ? safeString(row.thumbnail) : null, })); const payload: AchievementsPayload = { summary: { totalPhotos: toNumber(summary.total_photos), uniqueGuests: toNumber(summary.unique_guests), tasksSolved: toNumber(summary.tasks_solved), likesTotal: toNumber(summary.likes_total), }, personal, leaderboards: { uploads: uploadsBoard, likes: likesBoard, }, highlights: { topPhoto, trendingEmotion, timeline, }, feed, }; achievementsCache.set(cacheKey, { data: payload, etag: response.headers.get('ETag'), }); return payload; }