feat: extend event toolkit and polish guest pwa
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user