refactor(guest): retire legacy guest app and move shared modules
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-02-06 08:42:53 +01:00
parent b14435df8b
commit 0a08f2704f
191 changed files with 243 additions and 12631 deletions

View File

@@ -0,0 +1,271 @@
// @ts-nocheck
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): AchievementBadge => {
const record = badge as Record<string, unknown>;
return {
id: safeString(record.id),
title: safeString(record.title),
description: safeString(record.description),
earned: Boolean(record.earned),
progress: toNumber(record.progress),
target: toNumber(record.target, 1),
};
})
: [],
}
: null;
const uploadsBoard = Array.isArray(leaderboards.uploads)
? leaderboards.uploads.map((row): LeaderboardEntry => {
const record = row as Record<string, unknown>;
return {
guest: safeString(record.guest),
photos: toNumber(record.photos),
likes: toNumber(record.likes),
};
})
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? leaderboards.likes.map((row): LeaderboardEntry => {
const record = row as Record<string, unknown>;
return {
guest: safeString(record.guest),
photos: toNumber(record.photos),
likes: toNumber(record.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): TimelinePoint => {
const record = row as Record<string, unknown>;
return {
date: safeString(record.date),
photos: toNumber(record.photos),
guests: toNumber(record.guests),
};
})
: [];
const feed = feedRaw.map((row): FeedEntry => {
const record = row as Record<string, unknown>;
return {
photoId: toNumber(record.photo_id),
guest: safeString(record.guest),
task: (record as { task?: string }).task ?? null,
likes: toNumber(record.likes),
createdAt: safeString(record.created_at),
thumbnail: record.thumbnail ? safeString(record.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;
}

View File

@@ -0,0 +1,362 @@
import { getDeviceId } from '../lib/device';
import { DEFAULT_LOCALE } from '../i18n/messages';
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;
welcome_message?: string | 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;
engagement_mode?: 'tasks' | 'photo_only' | 'no_tasks';
created_at: string;
updated_at: string;
join_token?: string | null;
demo_read_only?: boolean;
photobooth_enabled?: boolean | null;
type?: {
slug: string;
name: string;
icon: string | null;
};
branding?: EventBrandingPayload | null;
guest_upload_visibility?: 'immediate' | 'review';
live_show?: {
moderation_mode?: 'off' | 'manual' | 'trusted_only';
};
}
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;
guestCount: number;
likesCount: number;
latestPhotoAt: string | null;
}
export type FetchEventErrorCode =
| 'invalid_token'
| 'token_expired'
| 'token_revoked'
| 'token_rate_limited'
| 'access_rate_limited'
| 'guest_limit_exceeded'
| '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;
}
}
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferredKeys = ['de', 'en'];
for (const key of preferredKeys) {
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;
}
const API_ERROR_CODES: FetchEventErrorCode[] = [
'invalid_token',
'token_expired',
'token_revoked',
'token_rate_limited',
'access_rate_limited',
'guest_limit_exceeded',
'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 'guest_limit_exceeded':
return 'Dieses Event hat sein Gäste-Limit erreicht. Bitte kontaktiere die Veranstalter:innen.';
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<EventData> {
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`, {
headers: {
'X-Device-Id': getDeviceId(),
},
});
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,
});
}
const json = await res.json();
const moderationMode = json?.live_show?.moderation_mode;
const normalized: EventData = {
...json,
name: coerceLocalized(json?.name, 'Fotospiel Event'),
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
? json.default_locale
: DEFAULT_LOCALE,
engagement_mode: (json?.engagement_mode as 'tasks' | 'photo_only' | 'no_tasks' | undefined) ?? 'tasks',
guest_upload_visibility:
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
live_show: {
moderation_mode: moderationMode === 'off' || moderationMode === 'manual' || moderationMode === 'trusted_only'
? moderationMode
: 'manual',
},
demo_read_only: Boolean(json?.demo_read_only),
};
if (json?.type) {
normalized.type = {
...json.type,
name: coerceLocalized(json.type?.name, 'Event'),
};
}
return normalized;
} 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<EventStats> {
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,
guestCount: json.guest_count ?? json.guestCount ?? 0,
likesCount: json.likes_count ?? json.likesCount ?? 0,
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
};
}
export async function getEventPackage(eventToken: string): Promise<EventPackage | null> {
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;
}

View File

@@ -0,0 +1,113 @@
import type { LocaleCode } from '../i18n/messages';
import type { EventBrandingPayload } from './eventApi';
export type GalleryBranding = EventBrandingPayload;
export interface GalleryMetaResponse {
event: {
id: number;
name: string;
slug?: string | null;
description?: string | null;
gallery_expires_at?: string | null;
guest_downloads_enabled?: boolean;
guest_sharing_enabled?: boolean;
};
branding: GalleryBranding;
}
export interface GalleryPhotoResource {
id: number;
thumbnail_url: string | null;
full_url: string | null;
download_url: string;
likes_count: number;
guest_name?: string | null;
created_at?: string | null;
}
export interface GalleryPhotosResponse {
data: GalleryPhotoResource[];
next_cursor: string | null;
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
error.code = errorPayload?.error?.code ?? response.status;
throw error;
}
return data as T;
}
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
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;
}
export async function fetchGalleryMeta(token: string, locale?: LocaleCode): Promise<GalleryMetaResponse> {
const params = new URLSearchParams();
if (locale) params.set('locale', locale);
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
const data = await handleResponse<GalleryMetaResponse>(response);
if (data?.event) {
data.event = {
...data.event,
name: coerceLocalized((data.event as any).name, 'Fotospiel Event'),
description: coerceLocalized((data.event as any).description, ''),
};
}
return data;
}
export async function fetchGalleryPhotos(token: string, cursor?: string | null, limit = 30): Promise<GalleryPhotosResponse> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (cursor) {
params.set('cursor', cursor);
}
const response = await fetch(`/api/v1/gallery/${encodeURIComponent(token)}/photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
},
credentials: 'omit',
});
return handleResponse<GalleryPhotosResponse>(response);
}

View File

@@ -0,0 +1,162 @@
import type { LocaleCode } from '../i18n/messages';
export type HelpArticleSummary = {
slug: string;
title: string;
summary: string;
version_introduced?: string;
requires_app_version?: string | null;
status?: string;
translation_state?: string;
last_reviewed_at?: string;
owner?: string;
updated_at?: string;
related?: Array<{ slug: string; title?: string }>;
};
export type HelpArticleDetail = HelpArticleSummary & {
body_markdown?: string;
body_html?: string;
source_path?: string;
};
export interface HelpListResult {
articles: HelpArticleSummary[];
servedFromCache: boolean;
}
export interface HelpArticleResult {
article: HelpArticleDetail;
servedFromCache: boolean;
}
const AUDIENCE = 'guest';
const LIST_CACHE_TTL = 1000 * 60 * 60 * 6; // 6 hours
const DETAIL_CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
interface CacheRecord<T> {
storedAt: number;
data: T;
}
function listCacheKey(locale: LocaleCode): string {
return `help:list:${AUDIENCE}:${locale}`;
}
function detailCacheKey(locale: LocaleCode, slug: string): string {
return `help:article:${AUDIENCE}:${locale}:${slug}`;
}
function readCache<T>(key: string, ttl: number): CacheRecord<T> | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(key);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as CacheRecord<T>;
if (!parsed?.data || !parsed?.storedAt) {
return null;
}
if (Date.now() - parsed.storedAt > ttl) {
return null;
}
return parsed;
} catch (error) {
console.warn('[HelpApi] Failed to read cache', error);
return null;
}
}
function writeCache<T>(key: string, data: T): void {
if (typeof window === 'undefined') {
return;
}
try {
const payload: CacheRecord<T> = {
storedAt: Date.now(),
data,
};
window.localStorage.setItem(key, JSON.stringify(payload));
} catch (error) {
console.warn('[HelpApi] Failed to write cache', error);
}
}
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
},
credentials: 'same-origin',
});
if (!response.ok) {
const error = new Error('Help request failed') as Error & { status?: number };
error.status = response.status;
throw error;
}
const payload = await response.json();
return payload as T;
}
export async function getHelpArticles(locale: LocaleCode, options?: { forceRefresh?: boolean }): Promise<HelpListResult> {
const cacheKey = listCacheKey(locale);
const cached = readCache<HelpArticleSummary[]>(cacheKey, LIST_CACHE_TTL);
if (cached && !options?.forceRefresh) {
return { articles: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleSummary[] }>(`/api/v1/help?${params.toString()}`);
const articles = Array.isArray(data?.data) ? data.data : [];
writeCache(cacheKey, articles);
return { articles, servedFromCache: false };
} catch (error) {
if (cached) {
return { articles: cached.data, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help articles', error);
throw error;
}
}
export async function getHelpArticle(slug: string, locale: LocaleCode): Promise<HelpArticleResult> {
const cacheKey = detailCacheKey(locale, slug);
const cached = readCache<HelpArticleDetail>(cacheKey, DETAIL_CACHE_TTL);
if (cached) {
return { article: cached.data, servedFromCache: true };
}
try {
const params = new URLSearchParams({
audience: AUDIENCE,
locale,
});
const data = await requestJson<{ data?: HelpArticleDetail }>(`/api/v1/help/${encodeURIComponent(slug)}?${params.toString()}`);
const article: HelpArticleDetail | undefined = data?.data;
const safeArticle: HelpArticleDetail = article ?? { slug, title: slug, summary: '' };
writeCache(cacheKey, safeArticle);
return { article: safeArticle, servedFromCache: false };
} catch (error) {
const cachedArticle: HelpArticleDetail | undefined = (cached as { data?: HelpArticleDetail } | null | undefined)?.data;
if (cachedArticle) {
return { article: cachedArticle, servedFromCache: true };
}
console.error('[HelpApi] Failed to fetch help article', error);
throw error;
}
}

View File

@@ -0,0 +1,302 @@
export type LiveShowModerationMode = 'off' | 'manual' | 'trusted_only';
export type LiveShowPlaybackMode = 'newest_first' | 'balanced' | 'curated';
export type LiveShowPaceMode = 'auto' | 'fixed';
export type LiveShowLayoutMode = 'single' | 'split' | 'grid_burst';
export type LiveShowEffectPreset =
| 'film_cut'
| 'shutter_flash'
| 'polaroid_toss'
| 'parallax_glide'
| 'light_effects';
export type LiveShowBackgroundMode = 'blur_last' | 'gradient' | 'solid' | 'brand';
export type LiveShowSettings = {
retention_window_hours: number;
moderation_mode: LiveShowModerationMode;
playback_mode: LiveShowPlaybackMode;
pace_mode: LiveShowPaceMode;
fixed_interval_seconds: number;
layout_mode: LiveShowLayoutMode;
effect_preset: LiveShowEffectPreset;
effect_intensity: number;
background_mode: LiveShowBackgroundMode;
};
export type LiveShowCursor = {
approved_at: string | null;
id: number;
};
export type LiveShowEvent = {
id: number;
slug?: string | null;
name: string;
default_locale?: string | null;
};
export type LiveShowPhoto = {
id: number;
full_url: string;
thumb_url: string | null;
approved_at: string | null;
width?: number | null;
height?: number | null;
is_featured: boolean;
live_priority: number;
};
export type LiveShowState = {
event: LiveShowEvent;
settings: LiveShowSettings;
settings_version: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export type LiveShowUpdates = {
settings: LiveShowSettings | null;
settings_version: string;
photos: LiveShowPhoto[];
cursor: LiveShowCursor | null;
};
export type LiveShowErrorCode = 'not_found' | 'invalid_cursor' | 'rate_limited' | 'unknown';
export class LiveShowError extends Error {
readonly code: LiveShowErrorCode;
readonly status?: number;
constructor(code: LiveShowErrorCode, message: string, status?: number) {
super(message);
this.name = 'LiveShowError';
this.code = code;
this.status = status;
}
}
export const DEFAULT_LIVE_SHOW_SETTINGS: LiveShowSettings = {
retention_window_hours: 12,
moderation_mode: 'manual',
playback_mode: 'newest_first',
pace_mode: 'auto',
fixed_interval_seconds: 8,
layout_mode: 'single',
effect_preset: 'film_cut',
effect_intensity: 70,
background_mode: 'blur_last',
};
const DEFAULT_EVENT_NAME = 'Fotospiel Live Show';
function coerceLocalized(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
const preferredKeys = ['de', 'en'];
for (const key of preferredKeys) {
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;
}
function toNumber(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function normalizeLiveShowSettings(raw?: Partial<LiveShowSettings> | null): LiveShowSettings {
const merged = {
...DEFAULT_LIVE_SHOW_SETTINGS,
...(raw ?? {}),
};
return {
retention_window_hours: toNumber(merged.retention_window_hours, DEFAULT_LIVE_SHOW_SETTINGS.retention_window_hours),
moderation_mode: merged.moderation_mode,
playback_mode: merged.playback_mode,
pace_mode: merged.pace_mode,
fixed_interval_seconds: toNumber(merged.fixed_interval_seconds, DEFAULT_LIVE_SHOW_SETTINGS.fixed_interval_seconds),
layout_mode: merged.layout_mode,
effect_preset: merged.effect_preset,
effect_intensity: toNumber(merged.effect_intensity, DEFAULT_LIVE_SHOW_SETTINGS.effect_intensity),
background_mode: merged.background_mode,
};
}
function normalizeLiveShowEvent(raw: Record<string, unknown>): LiveShowEvent {
return {
id: Number(raw.id ?? 0),
slug: typeof raw.slug === 'string' ? raw.slug : null,
name: coerceLocalized(raw.name, DEFAULT_EVENT_NAME),
default_locale: typeof raw.default_locale === 'string' ? raw.default_locale : null,
};
}
function normalizeLiveShowPhoto(raw: Record<string, unknown>): LiveShowPhoto {
return {
id: Number(raw.id ?? 0),
full_url: String(raw.full_url ?? ''),
thumb_url: typeof raw.thumb_url === 'string' ? raw.thumb_url : null,
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
width: typeof raw.width === 'number' ? raw.width : null,
height: typeof raw.height === 'number' ? raw.height : null,
is_featured: Boolean(raw.is_featured),
live_priority: Number(raw.live_priority ?? 0),
};
}
function normalizeCursor(raw: Record<string, unknown> | null): LiveShowCursor | null {
if (!raw) {
return null;
}
return {
approved_at: typeof raw.approved_at === 'string' ? raw.approved_at : null,
id: Number(raw.id ?? 0),
};
}
function resolveErrorCode(status: number, payload: Record<string, unknown> | null): LiveShowErrorCode {
const error = typeof payload?.error === 'string' ? payload.error : null;
if (error === 'live_show_not_found' || status === 404) {
return 'not_found';
}
if (error === 'invalid_cursor' || status === 422) {
return 'invalid_cursor';
}
if (status === 429) {
return 'rate_limited';
}
return 'unknown';
}
async function handleResponse<T>(response: Response): Promise<T> {
if (response.status === 204) {
return {} as T;
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const payload = data as Record<string, unknown> | null;
const code = resolveErrorCode(response.status, payload);
const message =
typeof payload?.message === 'string'
? payload.message
: typeof payload?.error === 'string'
? payload.error
: 'Live show request failed';
throw new LiveShowError(code, message, response.status);
}
return data as T;
}
function buildParams(options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}): URLSearchParams {
const params = new URLSearchParams();
if (options.limit) {
params.set('limit', String(options.limit));
}
if (options.settingsVersion) {
params.set('settings_version', options.settingsVersion);
}
if (options.cursor?.approved_at) {
params.set('after_approved_at', options.cursor.approved_at);
params.set('after_id', String(options.cursor.id));
}
return params;
}
export function buildLiveShowStreamUrl(
token: string,
options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}
): string {
const params = buildParams(options);
const base = `/api/v1/live-show/${encodeURIComponent(token)}/stream`;
const query = params.toString();
return query ? `${base}?${query}` : base;
}
export async function fetchLiveShowState(token: string, limit = 50): Promise<LiveShowState> {
const params = new URLSearchParams();
if (limit) {
params.set('limit', String(limit));
}
const response = await fetch(
`/api/v1/live-show/${encodeURIComponent(token)}${params.toString() ? `?${params.toString()}` : ''}`,
{
headers: { Accept: 'application/json' },
credentials: 'omit',
}
);
const data = await handleResponse<Record<string, unknown>>(response);
const rawEvent = (data.event as Record<string, unknown>) ?? {};
return {
event: normalizeLiveShowEvent(rawEvent),
settings: normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings> | null),
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}
export async function fetchLiveShowUpdates(
token: string,
options: {
cursor?: LiveShowCursor | null;
settingsVersion?: string;
limit?: number;
} = {}
): Promise<LiveShowUpdates> {
const params = buildParams(options);
const response = await fetch(
`/api/v1/live-show/${encodeURIComponent(token)}/updates${params.toString() ? `?${params.toString()}` : ''}`,
{
headers: { Accept: 'application/json' },
credentials: 'omit',
}
);
const data = await handleResponse<Record<string, unknown>>(response);
return {
settings: data.settings ? normalizeLiveShowSettings(data.settings as Partial<LiveShowSettings>) : null,
settings_version: String(data.settings_version ?? ''),
photos: Array.isArray(data.photos)
? data.photos.map((photo) => normalizeLiveShowPhoto(photo as Record<string, unknown>))
: [],
cursor: normalizeCursor((data.cursor as Record<string, unknown>) ?? null),
};
}

View File

@@ -0,0 +1,154 @@
import { getDeviceId } from '../lib/device';
export type GuestNotificationCta = {
label: string;
href: string;
};
export type GuestNotificationItem = {
id: number;
type: string;
title: string;
body: string | null;
status: 'new' | 'read' | 'dismissed';
createdAt: string;
readAt?: string | null;
dismissedAt?: string | null;
cta?: GuestNotificationCta | null;
payload?: Record<string, unknown> | null;
};
export type GuestNotificationFetchResult = {
notifications: GuestNotificationItem[];
unreadCount: number;
etag: string | null;
notModified: boolean;
};
type GuestNotificationResponse = {
data?: Array<{
id?: number | string;
type?: string;
title?: string;
body?: string | null;
status?: 'new' | 'read' | 'dismissed';
created_at?: string;
read_at?: string | null;
dismissed_at?: string | null;
cta?: GuestNotificationCta | null;
payload?: Record<string, unknown> | null;
}>;
meta?: {
unread_count?: number;
};
};
type GuestNotificationRow = NonNullable<GuestNotificationResponse['data']>[number];
function buildHeaders(etag?: string | null): HeadersInit {
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Device-Id': getDeviceId(),
};
if (etag) {
headers['If-None-Match'] = etag;
}
return headers;
}
function mapNotification(payload: GuestNotificationRow): GuestNotificationItem {
return {
id: Number(payload.id ?? 0),
type: payload.type ?? 'broadcast',
title: payload.title ?? '',
body: payload.body ?? null,
status: payload.status === 'read' || payload.status === 'dismissed' ? payload.status : 'new',
createdAt: payload.created_at ?? new Date().toISOString(),
readAt: payload.read_at ?? null,
dismissedAt: payload.dismissed_at ?? null,
cta: payload.cta ?? null,
payload: payload.payload ?? null,
};
}
export async function fetchGuestNotifications(
eventToken: string,
etag?: string | null,
options?: { status?: 'unread' | 'read' | 'dismissed'; scope?: 'all' | 'uploads' | 'tips' | 'general' }
): Promise<GuestNotificationFetchResult> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.scope && options.scope !== 'all') params.set('scope', options.scope);
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications${params.toString() ? `?${params.toString()}` : ''}`, {
method: 'GET',
headers: buildHeaders(etag),
credentials: 'include',
});
if (response.status === 304 && etag) {
return {
notifications: [],
unreadCount: 0,
etag,
notModified: true,
};
}
if (!response.ok) {
const reason = await safeParseError(response);
throw new Error(reason ?? 'Benachrichtigungen konnten nicht geladen werden.');
}
const body = (await response.json()) as GuestNotificationResponse;
const rows = Array.isArray(body.data) ? body.data : [];
const notifications = rows.map(mapNotification);
const unreadCount = typeof body.meta?.unread_count === 'number'
? body.meta.unread_count
: notifications.filter((item) => item.status === 'new').length;
return {
notifications,
unreadCount,
etag: response.headers.get('ETag'),
notModified: false,
};
}
export async function markGuestNotificationRead(eventToken: string, notificationId: number): Promise<void> {
await postNotificationAction(eventToken, notificationId, 'read');
}
export async function dismissGuestNotification(eventToken: string, notificationId: number): Promise<void> {
await postNotificationAction(eventToken, notificationId, 'dismiss');
}
async function postNotificationAction(eventToken: string, notificationId: number, action: 'read' | 'dismiss'): Promise<void> {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/notifications/${notificationId}/${action}`, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
});
if (!response.ok) {
const reason = await safeParseError(response);
throw new Error(reason ?? 'Aktion konnte nicht ausgeführt werden.');
}
}
async function safeParseError(response: Response): Promise<string | null> {
try {
const payload = await response.clone().json();
const message = payload?.error?.message ?? payload?.message;
if (typeof message === 'string' && message.trim() !== '') {
return message.trim();
}
} catch (error) {
console.warn('Failed to parse notification API error', error);
}
return null;
}

View File

@@ -0,0 +1,52 @@
import { getDeviceId } from '../lib/device';
export type PendingUpload = {
id: number;
status: 'pending' | 'approved' | 'rejected';
created_at?: string | null;
thumbnail_url?: string | null;
full_url?: string | null;
};
type PendingUploadsResponse = {
data: PendingUpload[];
meta?: {
total_count?: number;
};
};
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) {
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
error.code = errorPayload?.error?.code ?? response.status;
throw error;
}
return data as T;
}
export async function fetchPendingUploadsSummary(
token: string,
limit = 12
): Promise<{ items: PendingUpload[]; totalCount: number }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
const response = await fetch(`/api/v1/events/${encodeURIComponent(token)}/pending-photos?${params.toString()}`, {
headers: {
'Accept': 'application/json',
'X-Device-Id': getDeviceId(),
},
credentials: 'omit',
});
const payload = await handleResponse<PendingUploadsResponse>(response);
return {
items: payload.data ?? [],
totalCount: payload.meta?.total_count ?? (payload.data?.length ?? 0),
};
}

View File

@@ -0,0 +1,333 @@
// @ts-nocheck
import { getDeviceId } from '../lib/device';
import { buildCsrfHeaders } from '../lib/csrf';
export type UploadError = Error & {
code?: string;
status?: number;
meta?: Record<string, unknown>;
};
export async function likePhoto(id: number): Promise<number> {
const headers = buildCsrfHeaders();
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<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function unlikePhoto(id: number): Promise<number> {
const headers = buildCsrfHeaders();
const res = await fetch(`/api/v1/photos/${id}/like`, {
method: 'DELETE',
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('Unlike 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 ?? `Unlike failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
const json = await res.json();
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function deletePhoto(eventToken: string, id: number): Promise<void> {
const headers = buildCsrfHeaders();
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/photos/${id}`;
const res = await fetch(url, {
method: 'DELETE',
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('Delete 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 ?? `Delete failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'delete_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
}
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<number> {
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 = buildCsrfHeaders();
const attemptUpload = (): Promise<Record<string, unknown>> =>
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<string, unknown> | 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<string, unknown> } } | 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 = buildCsrfHeaders();
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<string, unknown>;
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;
}

View File

@@ -0,0 +1,71 @@
import { getDeviceId } from '../lib/device';
type PushSubscriptionPayload = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
expirationTime?: number | null;
contentEncoding?: string | null;
};
function buildHeaders(): HeadersInit {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Device-Id': getDeviceId(),
};
}
export async function registerPushSubscription(eventToken: string, subscription: PushSubscription): Promise<void> {
const json = subscription.toJSON() as PushSubscriptionPayload;
const body = {
endpoint: json.endpoint,
keys: json.keys,
expiration_time: json.expirationTime ?? null,
content_encoding: json.contentEncoding ?? null,
};
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'POST',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify(body),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push-Registrierung fehlgeschlagen.');
}
}
export async function unregisterPushSubscription(eventToken: string, endpoint: string): Promise<void> {
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/push-subscriptions`, {
method: 'DELETE',
headers: buildHeaders(),
credentials: 'include',
body: JSON.stringify({ endpoint }),
});
if (!response.ok) {
const message = await parseError(response);
throw new Error(message ?? 'Push konnte nicht deaktiviert werden.');
}
}
async function parseError(response: Response): Promise<string | null> {
try {
const payload = await response.clone().json();
const errorMessage = payload?.error?.message ?? payload?.message;
if (typeof errorMessage === 'string' && errorMessage.trim() !== '') {
return errorMessage;
}
} catch (error) {
console.warn('Failed to parse push API error', error);
}
return null;
}