feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -3,7 +3,7 @@ import i18n from './i18n';
type JsonValue = Record<string, any>;
export type EventJoinTokenLayout = {
export type EventQrInviteLayout = {
id: string;
name: string;
description: string;
@@ -41,6 +41,8 @@ export type TenantEvent = {
description?: string | null;
photo_count?: number;
like_count?: number;
engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' };
package?: {
id: number | string | null;
name: string | null;
@@ -246,7 +248,7 @@ export type EventMember = {
type EventListResponse = { data?: JsonValue[] };
type EventResponse = { data: JsonValue };
export type EventJoinToken = {
export type EventQrInvite = {
id: number;
token: string;
url: string;
@@ -258,9 +260,48 @@ export type EventJoinToken = {
is_active: boolean;
created_at: string | null;
metadata: Record<string, unknown>;
layouts: EventJoinTokenLayout[];
layouts: EventQrInviteLayout[];
layouts_url: string | null;
};
export type EventToolkitTask = {
id: number;
title: string;
description: string | null;
is_completed: boolean;
priority?: string | null;
};
export type EventToolkit = {
event: TenantEvent;
metrics: {
uploads_total: number;
uploads_24h: number;
pending_photos: number;
active_invites: number;
engagement_mode: 'tasks' | 'photo_only';
};
tasks: {
summary: {
total: number;
completed: number;
pending: number;
};
items: EventToolkitTask[];
};
photos: {
pending: TenantPhoto[];
recent: TenantPhoto[];
};
invites: {
summary: {
total: number;
active: number;
};
items: EventQrInvite[];
};
alerts: string[];
};
type CreatedEventResponse = { message: string; data: JsonValue; balance: number };
type PhotoResponse = { message: string; data: TenantPhoto };
@@ -272,6 +313,7 @@ type EventSavePayload = {
status?: 'draft' | 'published' | 'archived';
is_active?: boolean;
package_id?: number;
settings?: Record<string, unknown>;
};
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
@@ -380,6 +422,10 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
function normalizeEvent(event: JsonValue): TenantEvent {
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
| 'tasks'
| 'photo_only';
const normalized: TenantEvent = {
...(event as Record<string, unknown>),
id: Number(event.id ?? 0),
@@ -397,6 +443,8 @@ function normalizeEvent(event: JsonValue): TenantEvent {
description: event.description ?? null,
photo_count: event.photo_count !== undefined ? Number(event.photo_count ?? 0) : undefined,
like_count: event.like_count !== undefined ? Number(event.like_count ?? 0) : undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null,
};
@@ -589,9 +637,9 @@ function normalizeMember(member: JsonValue): EventMember {
};
}
function normalizeJoinToken(raw: JsonValue): EventJoinToken {
function normalizeQrInvite(raw: JsonValue): EventQrInvite {
const rawLayouts = Array.isArray(raw.layouts) ? raw.layouts : [];
const layouts: EventJoinTokenLayout[] = rawLayouts
const layouts: EventQrInviteLayout[] = rawLayouts
.map((layout: any) => {
const formats = Array.isArray(layout.formats)
? layout.formats.map((format: unknown) => String(format ?? '')).filter((format: string) => format.length > 0)
@@ -612,7 +660,7 @@ function normalizeJoinToken(raw: JsonValue): EventJoinToken {
download_urls: (layout.download_urls ?? {}) as Record<string, string>,
};
})
.filter((layout: EventJoinTokenLayout) => layout.id.length > 0);
.filter((layout: EventQrInviteLayout) => layout.id.length > 0);
return {
id: Number(raw.id ?? 0),
@@ -721,17 +769,17 @@ export async function getEventStats(slug: string): Promise<EventStats> {
};
}
export async function getEventJoinTokens(slug: string): Promise<EventJoinToken[]> {
export async function getEventQrInvites(slug: string): Promise<EventQrInvite[]> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`);
const payload = await jsonOrThrow<{ data: JsonValue[] }>(response, 'Failed to load invitations');
const list = Array.isArray(payload.data) ? payload.data : [];
return list.map(normalizeJoinToken);
return list.map(normalizeQrInvite);
}
export async function createInviteLink(
export async function createQrInvite(
slug: string,
payload?: { label?: string; usage_limit?: number; expires_at?: string }
): Promise<EventJoinToken> {
): Promise<EventQrInvite> {
const body = JSON.stringify(payload ?? {});
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens`, {
method: 'POST',
@@ -739,14 +787,14 @@ export async function createInviteLink(
body,
});
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create invitation');
return normalizeJoinToken(data.data ?? {});
return normalizeQrInvite(data.data ?? {});
}
export async function revokeEventJoinToken(
export async function revokeEventQrInvite(
slug: string,
tokenId: number,
reason?: string
): Promise<EventJoinToken> {
): Promise<EventQrInvite> {
const options: RequestInit = { method: 'DELETE' };
if (reason) {
options.headers = { 'Content-Type': 'application/json' };
@@ -754,7 +802,107 @@ export async function revokeEventJoinToken(
}
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, options);
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to revoke invitation');
return normalizeJoinToken(data.data ?? {});
return normalizeQrInvite(data.data ?? {});
}
export async function updateEventQrInvite(
slug: string,
tokenId: number,
payload: {
label?: string | null;
expires_at?: string | null;
usage_limit?: number | null;
metadata?: Record<string, unknown> | null;
}
): Promise<EventQrInvite> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/join-tokens/${tokenId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update invitation');
return normalizeQrInvite(data.data ?? {});
}
export async function getEventToolkit(slug: string): Promise<EventToolkit> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/toolkit`);
const json = await jsonOrThrow<Record<string, JsonValue>>(response, 'Failed to load toolkit');
const metrics = json.metrics ?? {};
const tasks = json.tasks ?? {};
const photos = json.photos ?? {};
const invites = json.invites ?? {};
const pendingPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).pending)
? (photos as Record<string, JsonValue>).pending
: [];
const recentPhotosRaw = Array.isArray((photos as Record<string, JsonValue>).recent)
? (photos as Record<string, JsonValue>).recent
: [];
const toolkit: EventToolkit = {
event: normalizeEvent(json.event ?? {}),
metrics: {
uploads_total: Number((metrics as JsonValue).uploads_total ?? 0),
uploads_24h: Number((metrics as JsonValue).uploads_24h ?? 0),
pending_photos: Number((metrics as JsonValue).pending_photos ?? 0),
active_invites: Number((metrics as JsonValue).active_invites ?? 0),
engagement_mode: ((metrics as JsonValue).engagement_mode as 'tasks' | 'photo_only') ?? 'tasks',
},
tasks: {
summary: {
total: Number((tasks as JsonValue)?.summary?.total ?? 0),
completed: Number((tasks as JsonValue)?.summary?.completed ?? 0),
pending: Number((tasks as JsonValue)?.summary?.pending ?? 0),
},
items: Array.isArray((tasks as JsonValue)?.items)
? ((tasks as JsonValue).items as JsonValue[]).map((item) => ({
id: Number(item?.id ?? 0),
title: String(item?.title ?? ''),
description: item?.description !== undefined && item?.description !== null ? String(item.description) : null,
is_completed: Boolean(item?.is_completed ?? false),
priority: item?.priority !== undefined ? String(item.priority) : null,
}))
: [],
},
photos: {
pending: pendingPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
recent: recentPhotosRaw.map((photo) => normalizePhoto(photo as TenantPhoto)),
},
invites: {
summary: {
total: Number((invites as JsonValue)?.summary?.total ?? 0),
active: Number((invites as JsonValue)?.summary?.active ?? 0),
},
items: Array.isArray((invites as JsonValue)?.items)
? ((invites as JsonValue).items as JsonValue[]).map((item) => normalizeQrInvite(item))
: [],
},
alerts: Array.isArray(json.alerts) ? (json.alerts as string[]) : [],
};
return toolkit;
}
export async function submitTenantFeedback(payload: {
category: string;
sentiment?: 'positive' | 'neutral' | 'negative';
rating?: number | null;
title?: string | null;
message?: string | null;
event_slug?: string | null;
metadata?: Record<string, unknown> | null;
}): Promise<void> {
const response = await authorizedFetch('/api/v1/tenant/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = await safeJson(response);
console.error('[API] Failed to submit feedback', response.status, body);
throw new Error('Failed to submit feedback');
}
}
export type Package = {