refactor(guest): retire legacy guest app and move shared modules
This commit is contained in:
271
resources/js/shared/guest/services/achievementApi.ts
Normal file
271
resources/js/shared/guest/services/achievementApi.ts
Normal 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;
|
||||
}
|
||||
362
resources/js/shared/guest/services/eventApi.ts
Normal file
362
resources/js/shared/guest/services/eventApi.ts
Normal 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;
|
||||
}
|
||||
113
resources/js/shared/guest/services/galleryApi.ts
Normal file
113
resources/js/shared/guest/services/galleryApi.ts
Normal 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);
|
||||
}
|
||||
162
resources/js/shared/guest/services/helpApi.ts
Normal file
162
resources/js/shared/guest/services/helpApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
302
resources/js/shared/guest/services/liveShowApi.ts
Normal file
302
resources/js/shared/guest/services/liveShowApi.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
154
resources/js/shared/guest/services/notificationApi.ts
Normal file
154
resources/js/shared/guest/services/notificationApi.ts
Normal 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;
|
||||
}
|
||||
52
resources/js/shared/guest/services/pendingUploadsApi.ts
Normal file
52
resources/js/shared/guest/services/pendingUploadsApi.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
333
resources/js/shared/guest/services/photosApi.ts
Normal file
333
resources/js/shared/guest/services/photosApi.ts
Normal 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;
|
||||
}
|
||||
71
resources/js/shared/guest/services/pushApi.ts
Normal file
71
resources/js/shared/guest/services/pushApi.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user