256 lines
6.5 KiB
TypeScript
256 lines
6.5 KiB
TypeScript
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<string, AchievementsCacheEntry>();
|
|
|
|
export async function fetchAchievements(
|
|
eventToken: string,
|
|
options: FetchAchievementsOptions = {}
|
|
): Promise<AchievementsPayload> {
|
|
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;
|
|
}
|