Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-19 11:35:38 +01:00
parent 926bc7d070
commit fbff2afa3e
43 changed files with 6846 additions and 6323 deletions

View File

@@ -180,6 +180,63 @@ export type EventStats = {
pending_photos?: number;
};
export type EventEngagementSummary = {
totalPhotos: number;
uniqueGuests: number;
tasksSolved: number;
likesTotal: number;
};
export type EventEngagementLeaderboardEntry = {
guest: string;
photos: number;
likes: number;
};
export type EventEngagementTopPhoto = {
photoId: number;
guest: string;
likes: number;
task?: string | null;
createdAt: string;
thumbnail: string | null;
};
export type EventEngagementTrendingEmotion = {
emotionId: number;
name: string;
count: number;
};
export type EventEngagementTimelinePoint = {
date: string;
photos: number;
guests: number;
};
export type EventEngagementFeedEntry = {
photoId: number;
guest: string;
task?: string | null;
likes: number;
createdAt: string;
thumbnail: string | null;
};
export type EventEngagement = {
summary: EventEngagementSummary;
leaderboards: {
uploads: EventEngagementLeaderboardEntry[];
likes: EventEngagementLeaderboardEntry[];
};
highlights: {
topPhoto: EventEngagementTopPhoto | null;
trendingEmotion: EventEngagementTrendingEmotion | null;
timeline: EventEngagementTimelinePoint[];
};
feed: EventEngagementFeedEntry[];
};
export type PhotoboothStatusMetrics = {
uploads_last_hour?: number | null;
uploads_today?: number | null;
@@ -255,6 +312,7 @@ export type TenantFont = {
export type WatermarkSettings = {
mode?: 'base' | 'custom' | 'off';
asset?: string | null;
asset_url?: string | null;
asset_data_url?: string | null;
position?:
| 'top-left'
@@ -1321,6 +1379,113 @@ function normalizeQrInvite(raw: JsonValue): EventQrInvite {
};
}
function toEngagementNumber(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 toEngagementString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeEventEngagement(payload: JsonValue): EventEngagement {
const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const summary = record.summary && typeof record.summary === 'object' ? (record.summary as Record<string, unknown>) : {};
const leaderboards = record.leaderboards && typeof record.leaderboards === 'object'
? (record.leaderboards as Record<string, unknown>)
: {};
const highlights = record.highlights && typeof record.highlights === 'object'
? (record.highlights as Record<string, unknown>)
: {};
const feedRaw = Array.isArray(record.feed) ? record.feed : [];
const uploadsBoard = Array.isArray(leaderboards.uploads)
? (leaderboards.uploads as Record<string, unknown>[]).map((row) => ({
guest: toEngagementString(row.guest),
photos: toEngagementNumber(row.photos),
likes: toEngagementNumber(row.likes),
}))
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? (leaderboards.likes as Record<string, unknown>[]).map((row) => ({
guest: toEngagementString(row.guest),
photos: toEngagementNumber(row.photos),
likes: toEngagementNumber(row.likes),
}))
: [];
const topPhotoRaw = highlights.top_photo && typeof highlights.top_photo === 'object'
? (highlights.top_photo as Record<string, unknown>)
: null;
const topPhoto = topPhotoRaw
? {
photoId: toEngagementNumber(topPhotoRaw.photo_id),
guest: toEngagementString(topPhotoRaw.guest),
likes: toEngagementNumber(topPhotoRaw.likes),
task: (topPhotoRaw as { task?: string | null }).task ?? null,
createdAt: toEngagementString(topPhotoRaw.created_at),
thumbnail: topPhotoRaw.thumbnail ? toEngagementString(topPhotoRaw.thumbnail) : null,
}
: null;
const trendingRaw = highlights.trending_emotion && typeof highlights.trending_emotion === 'object'
? (highlights.trending_emotion as Record<string, unknown>)
: null;
const trendingEmotion = trendingRaw
? {
emotionId: toEngagementNumber(trendingRaw.emotion_id),
name: toEngagementString(trendingRaw.name),
count: toEngagementNumber(trendingRaw.count),
}
: null;
const timeline = Array.isArray(highlights.timeline)
? (highlights.timeline as Record<string, unknown>[]).map((row) => ({
date: toEngagementString(row.date),
photos: toEngagementNumber(row.photos),
guests: toEngagementNumber(row.guests),
}))
: [];
const feed = feedRaw.map((row) => {
const entry = row as Record<string, unknown>;
return {
photoId: toEngagementNumber(entry.photo_id),
guest: toEngagementString(entry.guest),
task: (entry as { task?: string | null }).task ?? null,
likes: toEngagementNumber(entry.likes),
createdAt: toEngagementString(entry.created_at),
thumbnail: entry.thumbnail ? toEngagementString(entry.thumbnail) : null,
};
});
return {
summary: {
totalPhotos: toEngagementNumber(summary.total_photos),
uniqueGuests: toEngagementNumber(summary.unique_guests),
tasksSolved: toEngagementNumber(summary.tasks_solved),
likesTotal: toEngagementNumber(summary.likes_total),
},
leaderboards: {
uploads: uploadsBoard,
likes: likesBoard,
},
highlights: {
topPhoto,
trendingEmotion,
timeline,
},
feed,
};
}
function normalizeGuestNotification(raw: JsonValue): GuestNotificationSummary | null {
if (!raw || typeof raw !== 'object') {
return null;
@@ -1818,6 +1983,37 @@ export async function getEventStats(slug: string): Promise<EventStats> {
};
}
export async function getEventEngagement(
token: string,
options: { locale?: string; guestName?: string; signal?: AbortSignal } = {}
): Promise<EventEngagement> {
const params = new URLSearchParams();
if (options.guestName) {
params.set('guest_name', options.guestName);
}
if (options.locale) {
params.set('locale', options.locale);
}
const query = params.toString();
const response = await fetch(
`/api/v1/events/${encodeURIComponent(token)}/achievements${query ? `?${query}` : ''}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
...(options.locale ? { 'X-Locale': options.locale } : {}),
},
credentials: 'same-origin',
cache: 'no-store',
signal: options.signal,
}
);
const data = await jsonOrThrow<JsonValue>(response, 'Failed to load engagement data', { suppressToast: true });
return normalizeEventEngagement(data);
}
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');