implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history

This commit is contained in:
Codex Agent
2025-11-21 11:25:45 +01:00
parent 07fe049b8a
commit 7a8d22a238
58 changed files with 3339 additions and 60 deletions

View File

@@ -82,6 +82,7 @@ export type TenantEvent = {
expires_at: string | null;
} | null;
limits?: EventLimitSummary | null;
addons?: EventAddonSummary[];
[key: string]: unknown;
};
@@ -156,6 +157,32 @@ export type PhotoboothStatus = {
};
};
export type EventAddonCheckout = {
addon_key: string;
quantity?: number;
checkout_url: string | null;
checkout_id: string | null;
expires_at: string | null;
};
export type EventAddonCatalogItem = {
key: string;
label: string;
price_id: string | null;
increments?: Record<string, number>;
};
export type EventAddonSummary = {
id: number;
key: string;
label?: string | null;
status: 'pending' | 'completed' | 'failed';
extra_photos: number;
extra_guests: number;
extra_gallery_days: number;
purchased_at: string | null;
};
export type HelpCenterArticleSummary = {
slug: string;
title: string;
@@ -338,6 +365,28 @@ export type PaddleTransactionSummary = {
tax?: number | null;
};
export type TenantAddonEventSummary = {
id: number;
slug: string;
name: string | Record<string, string> | null;
};
export type TenantAddonHistoryEntry = {
id: number;
addon_key: string;
label?: string | null;
event: TenantAddonEventSummary | null;
amount: number | null;
currency: string | null;
status: 'pending' | 'completed' | 'failed';
purchased_at: string | null;
extra_photos: number;
extra_guests: number;
extra_gallery_days: number;
quantity: number;
receipt_url?: string | null;
};
export type CreditLedgerEntry = {
id: number;
delta: number;
@@ -829,6 +878,48 @@ function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary
};
}
function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry {
let event: TenantAddonEventSummary | null = null;
if (entry.event && typeof entry.event === 'object') {
const rawEvent = entry.event as JsonValue;
const id = Number((rawEvent as { id?: unknown }).id ?? 0);
const slugValue = (rawEvent as { slug?: unknown }).slug;
const rawName = (rawEvent as { name?: unknown }).name ?? null;
let name: TenantAddonEventSummary['name'] = null;
if (typeof rawName === 'string') {
name = rawName;
} else if (rawName && typeof rawName === 'object') {
name = normalizeTranslationMap(rawName, undefined, true);
}
event = {
id,
slug: typeof slugValue === 'string' ? slugValue : '',
name,
};
}
const amountValue = entry.amount;
return {
id: Number(entry.id ?? 0),
addon_key: String(entry.addon_key ?? ''),
label: typeof entry.label === 'string' ? entry.label : null,
event,
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
currency: typeof entry.currency === 'string' ? entry.currency : null,
status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending',
purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null,
extra_photos: Number(entry.extra_photos ?? 0),
extra_guests: Number(entry.extra_guests ?? 0),
extra_gallery_days: Number(entry.extra_gallery_days ?? 0),
quantity: Number(entry.quantity ?? 1),
receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : null,
};
}
function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
@@ -1122,6 +1213,28 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
return normalizeEvent(data.data);
}
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return await jsonOrThrow<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }>(
response,
'Failed to create addon checkout'
);
}
export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');
return data.data ?? [];
}
export async function getEventTypes(): Promise<TenantEventType[]> {
const response = await authorizedFetch('/api/v1/tenant/event-types');
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
@@ -1675,6 +1788,42 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
};
}
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
data: TenantAddonHistoryEntry[];
meta: PaginationMeta;
}> {
const params = new URLSearchParams({
page: String(Math.max(1, page)),
per_page: String(Math.max(1, Math.min(perPage, 100))),
});
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
if (response.status === 404) {
return {
data: [],
meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 },
};
}
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
response,
'Failed to load add-on history'
);
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
const metaSource = payload.meta ?? payload;
const meta: PaginationMeta = {
current_page: Number(metaSource.current_page ?? 1),
last_page: Number(metaSource.last_page ?? 1),
per_page: Number(metaSource.per_page ?? perPage),
total: Number(metaSource.total ?? rows.length),
};
return { data: rows, meta };
}
export async function getCreditBalance(): Promise<CreditBalance> {
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
if (response.status === 404) {