feat: implement AI styling foundation and billing scope rework
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 20:01:58 +01:00
parent df00deb0df
commit 36bed12ff9
80 changed files with 8944 additions and 49 deletions

View File

@@ -86,6 +86,13 @@ export type ControlRoomSettings = {
force_review_uploaders?: ControlRoomUploaderRule[];
};
export type EventAiEditingSettings = {
enabled?: boolean;
allow_custom_prompt?: boolean;
allowed_style_keys?: string[];
policy_message?: string | null;
};
export type LiveShowLink = {
token: string;
url: string;
@@ -116,6 +123,7 @@ export type TenantEvent = {
guest_upload_visibility?: 'review' | 'immediate';
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
ai_editing?: EventAiEditingSettings;
watermark?: WatermarkSettings;
watermark_allowed?: boolean | null;
watermark_removal_allowed?: boolean | null;
@@ -129,6 +137,17 @@ export type TenantEvent = {
expires_at: string | null;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features?: string[] | null;
} | null;
capabilities?: {
ai_styling?: boolean;
ai_styling_granted_by?: 'package' | 'addon' | null;
ai_styling_required_feature?: string | null;
ai_styling_addon_keys?: string[] | null;
ai_styling_event_enabled?: boolean | null;
ai_styling_allow_custom_prompt?: boolean | null;
ai_styling_allowed_style_keys?: string[] | null;
ai_styling_policy_message?: string | null;
} | null;
limits?: EventLimitSummary | null;
addons?: EventAddonSummary[];
@@ -136,6 +155,28 @@ export type TenantEvent = {
[key: string]: unknown;
};
export type AiEditStyle = {
id: number;
key: string;
name: string;
category?: string | null;
description?: string | null;
provider?: string | null;
provider_model?: string | null;
requires_source_image?: boolean;
is_premium?: boolean;
metadata?: Record<string, unknown>;
};
export type AiEditUsageSummary = {
event_id: number;
total: number;
failed_total: number;
status_counts: Record<string, number>;
safety_counts: Record<string, number>;
last_requested_at: string | null;
};
export type GuestNotificationSummary = {
id: number;
type: string;
@@ -624,6 +665,11 @@ export type TenantAddonHistoryEntry = {
receipt_url?: string | null;
};
export type TenantBillingAddonScope = {
type: 'tenant' | 'event';
event: TenantAddonEventSummary | null;
};
export type CreditLedgerEntry = {
id: number;
delta: number;
@@ -856,6 +902,7 @@ type EventSavePayload = {
settings?: Record<string, unknown> & {
live_show?: LiveShowSettings;
control_room?: ControlRoomSettings;
ai_editing?: EventAiEditingSettings;
watermark?: WatermarkSettings;
watermark_serve_originals?: boolean | null;
};
@@ -999,12 +1046,33 @@ function normalizeEventType(raw: JsonValue | TenantEventType | null): TenantEven
};
}
function normalizeFeatureList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
.filter((entry) => entry.length > 0);
}
if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>)
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => key.trim())
.filter((key) => key.length > 0);
}
return [];
}
function normalizeEvent(event: JsonValue): TenantEvent {
const normalizedType = normalizeEventType(event.event_type ?? event.eventType ?? null);
const settings = ((event.settings ?? {}) as Record<string, unknown>) ?? {};
const eventPackage = event.package && typeof event.package === 'object' ? (event.package as JsonValue) : null;
const capabilities =
event.capabilities && typeof event.capabilities === 'object' ? (event.capabilities as JsonValue) : null;
const engagementMode = (settings.engagement_mode ?? event.engagement_mode ?? 'tasks') as
| 'tasks'
| 'photo_only';
const eventAddons = Array.isArray(event.addons) ? (event.addons as JsonValue[]) : [];
const normalized: TenantEvent = {
...(event as Record<string, unknown>),
id: Number(event.id ?? 0),
@@ -1043,8 +1111,69 @@ function normalizeEvent(event: JsonValue): TenantEvent {
: undefined,
engagement_mode: engagementMode,
settings,
package: event.package ?? null,
package: eventPackage
? {
id: eventPackage.id ?? null,
name: typeof eventPackage.name === 'string' ? eventPackage.name : null,
price: eventPackage.price !== undefined && eventPackage.price !== null ? Number(eventPackage.price) : null,
purchased_at: typeof eventPackage.purchased_at === 'string' ? eventPackage.purchased_at : null,
expires_at: typeof eventPackage.expires_at === 'string' ? eventPackage.expires_at : null,
branding_allowed:
eventPackage.branding_allowed === undefined ? null : Boolean(eventPackage.branding_allowed),
watermark_allowed:
eventPackage.watermark_allowed === undefined ? null : Boolean(eventPackage.watermark_allowed),
features: normalizeFeatureList(eventPackage.features),
}
: null,
capabilities: capabilities
? {
ai_styling:
capabilities.ai_styling === undefined || capabilities.ai_styling === null
? undefined
: Boolean(capabilities.ai_styling),
ai_styling_granted_by:
capabilities.ai_styling_granted_by === 'package' || capabilities.ai_styling_granted_by === 'addon'
? (capabilities.ai_styling_granted_by as 'package' | 'addon')
: null,
ai_styling_required_feature:
typeof capabilities.ai_styling_required_feature === 'string'
? capabilities.ai_styling_required_feature
: null,
ai_styling_addon_keys: normalizeFeatureList(capabilities.ai_styling_addon_keys),
ai_styling_event_enabled:
capabilities.ai_styling_event_enabled === undefined || capabilities.ai_styling_event_enabled === null
? null
: Boolean(capabilities.ai_styling_event_enabled),
ai_styling_allow_custom_prompt:
capabilities.ai_styling_allow_custom_prompt === undefined || capabilities.ai_styling_allow_custom_prompt === null
? null
: Boolean(capabilities.ai_styling_allow_custom_prompt),
ai_styling_allowed_style_keys: normalizeFeatureList(capabilities.ai_styling_allowed_style_keys),
ai_styling_policy_message:
typeof capabilities.ai_styling_policy_message === 'string'
? capabilities.ai_styling_policy_message
: null,
}
: null,
limits: (event.limits ?? null) as EventLimitSummary | null,
addons: eventAddons
.map((row) => {
if (!row || typeof row !== 'object' || Array.isArray(row)) {
return null;
}
return {
id: Number(row.id ?? 0),
key: typeof row.key === 'string' ? row.key : '',
label: typeof row.label === 'string' ? row.label : null,
status: row.status === 'completed' || row.status === 'failed' ? row.status : 'pending',
extra_photos: Number(row.extra_photos ?? 0),
extra_guests: Number(row.extra_guests ?? 0),
extra_gallery_days: Number(row.extra_gallery_days ?? 0),
purchased_at: typeof row.purchased_at === 'string' ? row.purchased_at : null,
} as EventAddonSummary;
})
.filter((row): row is EventAddonSummary => Boolean(row)),
member_permissions: Array.isArray(event.member_permissions)
? (event.member_permissions as string[])
: event.member_permissions
@@ -1079,6 +1208,49 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
};
}
function normalizeAiEditStyle(row: JsonValue): AiEditStyle | null {
if (!row || typeof row !== 'object') {
return null;
}
const id = Number((row as { id?: unknown }).id ?? 0);
const key = typeof (row as { key?: unknown }).key === 'string' ? String((row as { key?: unknown }).key) : '';
const name = typeof (row as { name?: unknown }).name === 'string' ? String((row as { name?: unknown }).name) : '';
if (id <= 0 || key === '' || name === '') {
return null;
}
const metadata = (row as { metadata?: unknown }).metadata;
return {
id,
key,
name,
category: typeof (row as { category?: unknown }).category === 'string' ? String((row as { category?: unknown }).category) : null,
description:
typeof (row as { description?: unknown }).description === 'string'
? String((row as { description?: unknown }).description)
: null,
provider: typeof (row as { provider?: unknown }).provider === 'string' ? String((row as { provider?: unknown }).provider) : null,
provider_model:
typeof (row as { provider_model?: unknown }).provider_model === 'string'
? String((row as { provider_model?: unknown }).provider_model)
: null,
requires_source_image:
(row as { requires_source_image?: unknown }).requires_source_image === undefined
? undefined
: Boolean((row as { requires_source_image?: unknown }).requires_source_image),
is_premium:
(row as { is_premium?: unknown }).is_premium === undefined
? undefined
: Boolean((row as { is_premium?: unknown }).is_premium),
metadata: metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: {},
};
}
function normalizeDashboard(payload: JsonValue | null): DashboardSummary | null {
if (!payload) {
return null;
@@ -1759,6 +1931,63 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
return normalizeEvent(data.data);
}
export async function getEventAiStyles(slug: string): Promise<{
styles: AiEditStyle[];
meta: {
required_feature?: string | null;
addon_keys?: string[];
event_enabled?: boolean;
allow_custom_prompt?: boolean;
allowed_style_keys?: string[];
policy_message?: string | null;
};
}> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/ai-styles`);
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Record<string, unknown> }>(
response,
'Failed to load AI styles'
);
const rows = Array.isArray(payload.data) ? payload.data : [];
const meta = payload.meta ?? {};
return {
styles: rows
.map((row) => normalizeAiEditStyle(row))
.filter((row): row is AiEditStyle => Boolean(row)),
meta: {
required_feature: typeof meta.required_feature === 'string' ? meta.required_feature : null,
addon_keys: normalizeFeatureList(meta.addon_keys),
event_enabled: meta.event_enabled === undefined ? true : Boolean(meta.event_enabled),
allow_custom_prompt: meta.allow_custom_prompt === undefined ? true : Boolean(meta.allow_custom_prompt),
allowed_style_keys: normalizeFeatureList(meta.allowed_style_keys),
policy_message: typeof meta.policy_message === 'string' ? meta.policy_message : null,
},
};
}
export async function getEventAiEditSummary(slug: string): Promise<AiEditUsageSummary> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/ai-edits/summary`);
const payload = await jsonOrThrow<{ data?: Record<string, unknown> }>(response, 'Failed to load AI usage summary');
const row = payload.data ?? {};
const statusCounts = row.status_counts && typeof row.status_counts === 'object' ? (row.status_counts as Record<string, unknown>) : {};
const safetyCounts = row.safety_counts && typeof row.safety_counts === 'object' ? (row.safety_counts as Record<string, unknown>) : {};
return {
event_id: Number(row.event_id ?? 0),
total: Number(row.total ?? 0),
failed_total: Number(row.failed_total ?? 0),
status_counts: Object.fromEntries(
Object.entries(statusCounts).map(([key, value]) => [key, Number(value ?? 0)])
),
safety_counts: Object.fromEntries(
Object.entries(safetyCounts).map(([key, value]) => [key, Number(value ?? 0)])
),
last_requested_at: typeof row.last_requested_at === 'string' ? row.last_requested_at : null,
};
}
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
@@ -2822,16 +3051,26 @@ export async function downloadTenantBillingReceipt(receiptUrl: string): Promise<
return response.blob();
}
export async function getTenantBillingTransactions(page = 1): Promise<{
export async function getTenantBillingTransactions(page = 1, perPage = 25): Promise<{
data: TenantBillingTransactionSummary[];
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/transactions?${params.toString()}`);
if (response.status === 404) {
return { data: [] };
return {
data: [],
meta: {
current_page: 1,
last_page: 1,
per_page: perPage,
total: 0,
},
};
}
if (!response.ok) {
@@ -2845,6 +3084,7 @@ export async function getTenantBillingTransactions(page = 1): Promise<{
return {
data: entries.map(normalizeTenantBillingTransaction),
meta: buildPagination(payload, entries.length),
};
}
@@ -2898,15 +3138,36 @@ export async function createTenantBillingPortalSession(): Promise<{ url: string
return { url };
}
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
export async function getTenantAddonHistory(options?: {
page?: number;
perPage?: number;
eventId?: number;
eventSlug?: string;
status?: TenantAddonHistoryEntry['status'];
}): Promise<{
data: TenantAddonHistoryEntry[];
meta: PaginationMeta;
meta: PaginationMeta & { scope?: TenantBillingAddonScope };
}> {
const page = options?.page ?? 1;
const perPage = options?.perPage ?? 25;
const params = new URLSearchParams({
page: String(Math.max(1, page)),
per_page: String(Math.max(1, Math.min(perPage, 100))),
});
if (options?.eventId) {
params.set('event_id', String(options.eventId));
}
if (options?.eventSlug) {
params.set('event_slug', options.eventSlug);
}
if (options?.status) {
params.set('status', options.status);
}
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
if (response.status === 404) {
@@ -2916,7 +3177,19 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
};
}
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
const payload = await jsonOrThrow<{
data?: JsonValue[];
meta?: Partial<PaginationMeta> & {
scope?: {
type?: unknown;
event?: JsonValue | null;
};
};
current_page?: number;
last_page?: number;
per_page?: number;
total?: number;
}>(
response,
'Failed to load add-on history'
);
@@ -2924,13 +3197,34 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
const metaSource = payload.meta ?? payload;
const meta: PaginationMeta = {
const meta: PaginationMeta & { scope?: TenantBillingAddonScope } = {
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),
};
const rawScope = payload.meta?.scope;
if (rawScope && typeof rawScope === 'object') {
const scopeEvent = rawScope.event && typeof rawScope.event === 'object' && !Array.isArray(rawScope.event)
? (rawScope.event as Record<string, unknown>)
: null;
meta.scope = {
type: rawScope.type === 'event' ? 'event' : 'tenant',
event: scopeEvent
? {
id: Number(scopeEvent.id ?? 0),
slug: typeof scopeEvent.slug === 'string' ? scopeEvent.slug : '',
name:
typeof scopeEvent.name === 'string' || typeof scopeEvent.name === 'object'
? (scopeEvent.name as TenantAddonEventSummary['name'])
: null,
}
: null,
};
}
return { data: rows, meta };
}

View File

@@ -85,15 +85,28 @@
"lemonsqueezy_voided": "Die PayPal-Zahlung wurde storniert."
},
"sections": {
"currentEvent": {
"title": "Aktuelles Event",
"hint": "Hier siehst du, was für dein aktuell ausgewähltes Event aktiv ist.",
"empty": "Wähle ein Event aus, um eventbezogene Pakete und Add-ons zu sehen.",
"eventLabel": "Ausgewähltes Event",
"packageActive": "Für dieses Event aktiv",
"packageExpires": "Galerie aktiv bis {{date}}",
"noPackage": "Für dieses Event ist derzeit kein Paket zugewiesen.",
"addonsLabel": "Add-ons für dieses Event",
"noAddons": "Für dieses Event wurden noch keine Add-ons gekauft.",
"eventAddonSource": "Quelle: Event-Paket"
},
"invoices": {
"title": "Rechnungen & Zahlungen",
"hint": "Zahlungen prüfen und Belege herunterladen.",
"empty": "Keine Zahlungen gefunden."
},
"addOns": {
"title": "Zusatzpakete",
"hint": "Zusatzkontingente je Event im Blick behalten.",
"empty": "Keine Zusatzpakete gebucht."
"title": "Add-on-Kaufverlauf",
"hint": "Verlauf aller Add-on-Käufe über alle Events. Ein Eintrag hier ist nicht automatisch für das aktuell ausgewählte Event aktiv.",
"empty": "Keine Add-ons gebucht.",
"otherEventNotice": "Für ein anderes Event gekauft"
},
"overview": {
"title": "Paketübersicht",
@@ -125,12 +138,13 @@
}
},
"packages": {
"title": "Pakete",
"hint": "Aktives Paket, Limits und Historie auf einen Blick.",
"title": "Paketverlauf (alle Events)",
"hint": "Alle gekauften Pakete über alle Events hinweg.",
"description": "Übersicht über aktive und vergangene Pakete.",
"empty": "Noch keine Pakete gebucht.",
"card": {
"statusActive": "Aktiv",
"statusActiveTenant": "Derzeit aktiv",
"statusInactive": "Inaktiv",
"used": "Genutzte Events",
"available": "Verfügbar",
@@ -2392,6 +2406,7 @@
"photo_likes_enabled": "Foto-Likes",
"event_checklist": "Event-Checkliste",
"advanced_analytics": "Erweiterte Statistiken",
"ai_styling": "AI-Styling",
"branding_allowed": "Branding",
"watermark_allowed": "Wasserzeichen",
"watermark_base": "Fotospiel-Wasserzeichen",
@@ -2586,7 +2601,47 @@
"saveFailed": "Automatik-Einstellungen konnten nicht gespeichert werden."
},
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange.",
"aiAddon": {
"title": "AI Magic Edits",
"body": "AI-Styling bleibt verborgen, bis dieses Event berechtigt ist. Schalte es per Add-on frei oder nutze Premium.",
"buyAction": "AI-Styling-Add-on kaufen",
"upgradeAction": "Premium-Paket ansehen"
},
"aiSettings": {
"title": "AI-Styling-Steuerung",
"subtitle": "Aktiviere AI-Edits pro Event, steuere erlaubte Presets und überwache Nutzung/Fehler.",
"loadFailed": "AI-Einstellungen konnten nicht geladen werden.",
"saveFailed": "AI-Einstellungen konnten nicht gespeichert werden.",
"saved": "AI-Einstellungen gespeichert.",
"enabled": {
"label": "AI-Edits für dieses Event aktivieren",
"hint": "Wenn deaktiviert, werden Guest- und Admin-AI-Anfragen für dieses Event blockiert."
},
"customPrompt": {
"label": "Freie Prompts erlauben",
"hint": "Wenn deaktiviert, müssen Nutzer ein erlaubtes Preset wählen."
},
"policyMessage": {
"label": "Policy-Hinweis",
"hint": "Wird Nutzern angezeigt, wenn AI-Edits deaktiviert sind oder ein Stil blockiert ist.",
"placeholder": "Optionaler Hinweis für Gäste/Admins"
},
"styles": {
"label": "Erlaubte AI-Stile",
"hint": "Keine Auswahl bedeutet: alle aktiven Stile sind erlaubt.",
"empty": "Keine aktiven AI-Stile gefunden.",
"clear": "Alle Stile erlauben"
},
"usage": {
"title": "Nutzungsübersicht",
"total": "Gesamt",
"succeeded": "Erfolgreich",
"failed": "Fehlgeschlagen",
"lastRequest": "Letzte Anfrage: {{date}}"
},
"save": "AI-Einstellungen speichern"
}
},
"liveShowSettings": {
"title": "Live-Show-Einstellungen",
@@ -3001,6 +3056,7 @@
},
"features": {
"advanced_analytics": "Erweiterte Statistiken",
"ai_styling": "AI-Styling",
"basic_uploads": "Basis-Uploads",
"custom_branding": "Eigenes Branding",
"custom_tasks": "Benutzerdefinierte Fotoaufgaben",

View File

@@ -85,15 +85,28 @@
"lemonsqueezy_voided": "The PayPal payment was voided."
},
"sections": {
"currentEvent": {
"title": "Current event",
"hint": "This section shows what is active for your currently selected event.",
"empty": "Select an event to view event-specific packages and add-ons.",
"eventLabel": "Selected event",
"packageActive": "Active for this event",
"packageExpires": "Gallery active until {{date}}",
"noPackage": "No package is currently assigned to this event.",
"addonsLabel": "Add-ons for this event",
"noAddons": "No add-ons purchased for this event.",
"eventAddonSource": "Source: event package"
},
"invoices": {
"title": "Invoices & payments",
"hint": "Review transactions and download receipts.",
"empty": "No payments found."
},
"addOns": {
"title": "Add-ons",
"hint": "Track extra photo, guest, or time bundles per event.",
"empty": "No add-ons booked."
"title": "Add-on purchase history",
"hint": "History of all add-on purchases across all events. An entry here is not automatically active for the currently selected event.",
"empty": "No add-ons booked.",
"otherEventNotice": "Purchased for another event"
},
"overview": {
"title": "Package overview",
@@ -125,12 +138,13 @@
}
},
"packages": {
"title": "Packages",
"hint": "Active package, limits, and history at a glance.",
"title": "Package history (all events)",
"hint": "All purchased packages across all events.",
"description": "Overview of active and past packages.",
"empty": "No packages purchased yet.",
"card": {
"statusActive": "Active",
"statusActiveTenant": "Currently active",
"statusInactive": "Inactive",
"used": "Events used",
"available": "Remaining",
@@ -2394,6 +2408,7 @@
"photo_likes_enabled": "Photo likes",
"event_checklist": "Event checklist",
"advanced_analytics": "Advanced analytics",
"ai_styling": "AI styling",
"branding_allowed": "Branding",
"watermark_allowed": "Watermarks",
"watermark_base": "Fotospiel watermark",
@@ -2588,7 +2603,47 @@
"saveFailed": "Automation settings could not be saved."
},
"emptyModeration": "No uploads match this filter.",
"emptyLive": "No photos waiting for Live Show."
"emptyLive": "No photos waiting for Live Show.",
"aiAddon": {
"title": "AI Magic Edits",
"body": "AI styling is hidden until this event is entitled. Unlock it via add-on or include it with Premium.",
"buyAction": "Buy AI styling add-on",
"upgradeAction": "View Premium package"
},
"aiSettings": {
"title": "AI Styling Controls",
"subtitle": "Enable AI edits per event, configure allowed presets, and monitor usage/failures.",
"loadFailed": "AI settings could not be loaded.",
"saveFailed": "AI settings could not be saved.",
"saved": "AI settings saved.",
"enabled": {
"label": "Enable AI edits for this event",
"hint": "When disabled, guest and admin AI requests are blocked for this event."
},
"customPrompt": {
"label": "Allow custom prompts",
"hint": "If disabled, users must choose one of the allowed presets."
},
"policyMessage": {
"label": "Policy message",
"hint": "Shown to users when AI edits are disabled or a style is blocked.",
"placeholder": "Optional message for guests/admins"
},
"styles": {
"label": "Allowed AI styles",
"hint": "No selection means all active styles are allowed.",
"empty": "No active AI styles found.",
"clear": "Allow all styles"
},
"usage": {
"title": "Usage overview",
"total": "Total",
"succeeded": "Succeeded",
"failed": "Failed",
"lastRequest": "Last request: {{date}}"
},
"save": "Save AI settings"
}
},
"liveShowSettings": {
"title": "Live Show settings",
@@ -3003,6 +3058,7 @@
},
"features": {
"advanced_analytics": "Advanced Analytics",
"ai_styling": "AI Styling",
"basic_uploads": "Basic uploads",
"custom_branding": "Custom Branding",
"custom_tasks": "Custom photo tasks",

View File

@@ -13,8 +13,12 @@ import {
getTenantBillingTransactions,
getTenantPackagesOverview,
getTenantPackageCheckoutStatus,
getEvent,
TenantPackageSummary,
TenantEvent,
TenantBillingTransactionSummary,
EventAddonSummary,
PaginationMeta,
downloadTenantBillingReceipt,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
@@ -43,18 +47,45 @@ import {
storePendingCheckout,
} from './lib/billingCheckout';
import { triggerDownloadFromBlob } from './invite-layout/export-utils';
import { useEventContext } from '../context/EventContext';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
const BILLING_HISTORY_PAGE_SIZE = 8;
const CURRENT_EVENT_ADDONS_PAGE_SIZE = 6;
function createInitialPaginationMeta(perPage: number): PaginationMeta {
return {
current_page: 1,
last_page: 1,
per_page: perPage,
total: 0,
};
}
export default function MobileBillingPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
const location = useLocation();
const { activeEvent } = useEventContext();
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<TenantBillingTransactionSummary[]>([]);
const [transactionsMeta, setTransactionsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)
);
const [addonsLoadingMore, setAddonsLoadingMore] = React.useState(false);
const [scopeAddons, setScopeAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [scopeAddonsMeta, setScopeAddonsMeta] = React.useState<PaginationMeta>(() =>
createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE)
);
const [scopeAddonsLoadingMore, setScopeAddonsLoadingMore] = React.useState(false);
const [scopeEvent, setScopeEvent] = React.useState<TenantEvent | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [showPackageHistory, setShowPackageHistory] = React.useState(false);
@@ -63,6 +94,7 @@ export default function MobileBillingPage() {
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const mismatchTrackingRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de';
@@ -79,13 +111,51 @@ export default function MobileBillingPage() {
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantBillingTransactions().catch(() => ({ data: [] as TenantBillingTransactionSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({
data: [] as TenantBillingTransactionSummary[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
getTenantAddonHistory({ page: 1, perPage: BILLING_HISTORY_PAGE_SIZE }).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE),
})),
]);
let scopedEvent: TenantEvent | null = null;
let scopedAddons: TenantAddonHistoryEntry[] = [];
const scopeSlug = activeEvent?.slug ?? null;
const scopeEventIdFallback = activeEvent?.id ?? null;
if (scopeSlug) {
scopedEvent = await getEvent(scopeSlug).catch(() => activeEvent ?? null);
const scopedEventId = scopedEvent?.id ?? scopeEventIdFallback;
if (scopedEventId) {
const scopedAddonHistory = await getTenantAddonHistory({
eventId: scopedEventId,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
page: 1,
}).catch(() => ({
data: [] as TenantAddonHistoryEntry[],
meta: createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE),
}));
scopedAddons = scopedAddonHistory.data ?? [];
setScopeAddonsMeta(scopedAddonHistory.meta ?? createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
} else {
setScopeAddonsMeta(createInitialPaginationMeta(CURRENT_EVENT_ADDONS_PAGE_SIZE));
}
setPackages(pkg.packages ?? []);
setActivePackage(pkg.activePackage ?? null);
setTransactions(trx.data ?? []);
setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setAddons(addonHistory.data ?? []);
setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE));
setScopeEvent(scopedEvent);
setScopeAddons(scopedAddons);
setError(null);
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'));
@@ -93,7 +163,7 @@ export default function MobileBillingPage() {
} finally {
setLoading(false);
}
}, [t]);
}, [activeEvent, t]);
const scrollToPackages = React.useCallback(() => {
packagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -116,6 +186,49 @@ export default function MobileBillingPage() {
);
const hiddenPackageCount = Math.max(0, nonActivePackages.length - 3);
const visiblePackageHistory = showPackageHistory ? nonActivePackages : nonActivePackages.slice(0, 3);
const scopedEventName = React.useMemo(() => {
if (!scopeEvent) {
return null;
}
return resolveLinkedEventName(scopeEvent.name, t);
}, [scopeEvent, t]);
const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null;
const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null;
const scopedEventPackage = scopeEvent?.package ?? null;
const scopedEventAddons = React.useMemo<EventAddonSummary[]>(() => {
const rows = scopeEvent?.addons;
return Array.isArray(rows) ? rows : [];
}, [scopeEvent?.addons]);
const scopeMismatchCount = React.useMemo(() => {
if (!activeEventId) {
return 0;
}
return addons.filter((addon) => addon.event?.id && Number(addon.event.id) !== Number(activeEventId)).length;
}, [activeEventId, addons]);
const trackBillingInteraction = React.useCallback(
(action: string, value?: number) => {
if (typeof window === 'undefined') {
return;
}
const maybePaq = (window as unknown as { _paq?: unknown[] })._paq;
if (!Array.isArray(maybePaq)) {
return;
}
const label = activeEventId ? `event:${activeEventId}` : 'event:none';
const payload: (string | number)[] = ['trackEvent', 'Admin Billing', action, label];
if (typeof value === 'number' && Number.isFinite(value)) {
payload.push(value);
}
maybePaq.push(payload);
},
[activeEventId],
);
const handleReceiptDownload = React.useCallback(
async (transaction: TenantBillingTransactionSummary) => {
@@ -138,10 +251,100 @@ export default function MobileBillingPage() {
[t],
);
const loadMoreTransactions = React.useCallback(async () => {
if (transactionsLoadingMore) {
return;
}
if (transactionsMeta.current_page >= transactionsMeta.last_page) {
return;
}
setTransactionsLoadingMore(true);
try {
const nextPage = transactionsMeta.current_page + 1;
const result = await getTenantBillingTransactions(nextPage, BILLING_HISTORY_PAGE_SIZE);
setTransactions((current) => [...current, ...(result.data ?? [])]);
setTransactionsMeta(result.meta ?? transactionsMeta);
trackBillingInteraction('transactions_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setTransactionsLoadingMore(false);
}
}, [trackBillingInteraction, transactionsLoadingMore, transactionsMeta, t]);
const loadMoreAddonHistory = React.useCallback(async () => {
if (addonsLoadingMore) {
return;
}
if (addonsMeta.current_page >= addonsMeta.last_page) {
return;
}
setAddonsLoadingMore(true);
try {
const nextPage = addonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
page: nextPage,
perPage: BILLING_HISTORY_PAGE_SIZE,
});
setAddons((current) => [...current, ...(result.data ?? [])]);
setAddonsMeta(result.meta ?? addonsMeta);
trackBillingInteraction('addon_history_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setAddonsLoadingMore(false);
}
}, [addonsLoadingMore, addonsMeta, t, trackBillingInteraction]);
const loadMoreScopeAddons = React.useCallback(async () => {
if (scopeAddonsLoadingMore) {
return;
}
if (!activeEventId || scopeAddonsMeta.current_page >= scopeAddonsMeta.last_page) {
return;
}
setScopeAddonsLoadingMore(true);
try {
const nextPage = scopeAddonsMeta.current_page + 1;
const result = await getTenantAddonHistory({
eventId: activeEventId,
page: nextPage,
perPage: CURRENT_EVENT_ADDONS_PAGE_SIZE,
});
setScopeAddons((current) => [...current, ...(result.data ?? [])]);
setScopeAddonsMeta(result.meta ?? scopeAddonsMeta);
trackBillingInteraction('scope_addons_load_more', nextPage);
} catch (err) {
toast.error(getApiErrorMessage(err, t('billing.errors.more', 'Konnte weitere Einträge nicht laden.')));
} finally {
setScopeAddonsLoadingMore(false);
}
}, [activeEventId, scopeAddonsLoadingMore, scopeAddonsMeta, t, trackBillingInteraction]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (!activeEventId || scopeMismatchCount <= 0) {
return;
}
const key = `${activeEventId}:${scopeMismatchCount}`;
if (mismatchTrackingRef.current === key) {
return;
}
mismatchTrackingRef.current = key;
trackBillingInteraction('scope_mismatch_visible', scopeMismatchCount);
}, [activeEventId, scopeMismatchCount, trackBillingInteraction]);
React.useEffect(() => {
if (!location.hash) return;
const hash = location.hash.replace('#', '');
@@ -387,15 +590,122 @@ export default function MobileBillingPage() {
</MobileCard>
) : null}
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.currentEvent.title', 'Current event')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t(
'billing.sections.currentEvent.hint',
'This section shows what is active for your currently selected event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('common.loading', 'Lädt...')}
</Text>
) : !scopeEvent ? (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.empty', 'Select an event to view event-specific packages and add-ons.')}
</Text>
) : (
<YStack gap="$2">
<YStack gap="$1">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventLabel', 'Selected event')}
</Text>
{scopedEventName ? (
scopedEventPath ? (
<Pressable onPress={() => navigate(scopedEventPath)}>
<Text fontSize="$sm" color={primary} fontWeight="700">
{scopedEventName}
</Text>
</Pressable>
) : (
<Text fontSize="$sm" color={textStrong} fontWeight="700">
{scopedEventName}
</Text>
)
) : null}
</YStack>
{scopedEventPackage ? (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{scopedEventPackage.name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
<PillBadge tone="success">
{t('billing.sections.currentEvent.packageActive', 'Active for this event')}
</PillBadge>
</XStack>
{scopedEventPackage.expires_at ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.packageExpires', 'Gallery active until {{date}}', {
date: formatDate(scopedEventPackage.expires_at),
})}
</Text>
) : null}
</MobileCard>
) : (
<MobileCard borderColor={border} padding="$3" gap="$1">
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noPackage', 'No package is currently assigned to this event.')}
</Text>
</MobileCard>
)}
<YStack gap="$1.5">
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.addonsLabel', 'Add-ons for this event')}
</Text>
{scopeAddons.length > 0 ? (
<YStack gap="$1.5">
{scopeAddons.map((addon) => (
<AddonRow key={`scope-${addon.id}`} addon={addon} hideEventLink />
))}
{scopeAddonsMeta.current_page < scopeAddonsMeta.last_page ? (
<CTAButton
label={
scopeAddonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreScopeAddons()}
tone="ghost"
/>
) : null}
</YStack>
) : scopedEventAddons.length > 0 ? (
<YStack gap="$1.5">
{scopedEventAddons.slice(0, 6).map((addon) => (
<EventAddonRow key={`event-addon-${addon.id}`} addon={addon} />
))}
</YStack>
) : (
<Text fontSize="$sm" color={text}>
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
</Text>
)}
</YStack>
</YStack>
)}
</MobileCard>
<MobileCard gap="$2" ref={packagesRef as any}>
<XStack alignItems="center" gap="$2">
<Package size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.packages.title', 'Packages')}
{t('billing.sections.packages.title', 'Package history (all events)')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
{t(
'billing.sections.packages.hint',
'All purchased packages across all events.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -406,7 +716,7 @@ export default function MobileBillingPage() {
{activePackage ? (
<PackageCard
pkg={activePackage}
label={t('billing.sections.packages.card.statusActive', 'Aktiv')}
label={t('billing.sections.packages.card.statusActiveTenant', 'Currently active')}
isActive
onOpenShop={() => navigate(shopLink)}
/>
@@ -470,7 +780,7 @@ export default function MobileBillingPage() {
</YStack>
) : (
<YStack gap="$1.5">
{transactions.slice(0, 8).map((trx) => {
{transactions.map((trx) => {
const statusLabel = trx.status
? t(`billing.sections.transactions.status.${trx.status}`, trx.status)
: '—';
@@ -519,6 +829,17 @@ export default function MobileBillingPage() {
</XStack>
);
})}
{transactionsMeta.current_page < transactionsMeta.last_page ? (
<CTAButton
label={
transactionsLoadingMore
? t('billing.sections.transactions.loadingMore', 'Laden…')
: t('billing.sections.transactions.loadMore', 'Weitere Transaktionen laden')
}
onPress={() => void loadMoreTransactions()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -527,11 +848,14 @@ export default function MobileBillingPage() {
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={textStrong} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('billing.sections.addOns.title', 'Add-ons')}
{t('billing.sections.addOns.title', 'Add-on purchase history')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
{t(
'billing.sections.addOns.hint',
'History across all events. Entries here are historical and not automatically active for the current event.'
)}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
@@ -543,9 +867,20 @@ export default function MobileBillingPage() {
</Text>
) : (
<YStack gap="$1.5">
{addons.slice(0, 8).map((addon) => (
<AddonRow key={addon.id} addon={addon} />
{addons.map((addon) => (
<AddonRow key={addon.id} addon={addon} currentEventId={activeEventId} />
))}
{addonsMeta.current_page < addonsMeta.last_page ? (
<CTAButton
label={
addonsLoadingMore
? t('billing.addOns.loadingMore', 'Add-ons werden geladen…')
: t('billing.addOns.loadMore', 'Weitere Add-ons laden')
}
onPress={() => void loadMoreAddonHistory()}
tone="ghost"
/>
) : null}
</YStack>
)}
</MobileCard>
@@ -846,7 +1181,15 @@ function formatAmount(value: number | null | undefined, currency: string | null
}
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
function AddonRow({
addon,
hideEventLink = false,
currentEventId = null,
}: {
addon: TenantAddonHistoryEntry;
hideEventLink?: boolean;
currentEventId?: number | null;
}) {
const { t } = useTranslation('management');
const navigate = useNavigate();
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
@@ -861,6 +1204,12 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
(addon.event?.name && typeof addon.event.name === 'object' ? addon.event.name?.en ?? addon.event.name?.de ?? Object.values(addon.event.name)[0] : null) ||
null;
const eventPath = addon.event?.slug ? ADMIN_EVENT_VIEW_PATH(addon.event.slug) : null;
const purchasedForDifferentEvent = Boolean(
!hideEventLink &&
currentEventId &&
addon.event?.id &&
Number(addon.event.id) !== Number(currentEventId)
);
const hasImpact = Boolean(addon.extra_photos || addon.extra_guests || addon.extra_gallery_days);
const impactBadges = hasImpact ? (
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
@@ -884,7 +1233,7 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
{eventName ? (
{!hideEventLink && eventName ? (
eventPath ? (
<Pressable onPress={() => navigate(eventPath)}>
<XStack alignItems="center" justifyContent="space-between">
@@ -909,9 +1258,56 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
<Text fontSize="$xs" color={muted}>
{formatDate(addon.purchased_at)}
</Text>
{purchasedForDifferentEvent ? (
<Text fontSize="$xs" color={muted}>
{t('billing.sections.addOns.otherEventNotice', 'Purchased for another event')}
</Text>
) : null}
</MobileCard>
);
}
function EventAddonRow({ addon }: { addon: EventAddonSummary }) {
const { t } = useTranslation('management');
const { border, textStrong, text, muted } = useAdminTheme();
const labels: Record<EventAddonSummary['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
};
const status = labels[addon.status];
return (
<MobileCard borderColor={border} padding="$3" gap="$1.5">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
{addon.label ?? addon.key}
</Text>
<PillBadge tone={status.tone}>{status.text}</PillBadge>
</XStack>
<XStack gap="$2" marginTop="$1.5" flexWrap="wrap">
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color={text}>
{formatDate(addon.purchased_at)}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.sections.currentEvent.eventAddonSource', 'Source: event package')}
</Text>
</MobileCard>
);
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);

View File

@@ -11,9 +11,11 @@ import { ScrollView } from '@tamagui/scroll-view';
import { ToggleGroup } from '@tamagui/toggle-group';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, ContentTabs } from './components/Primitives';
import { MobileField, MobileSelect } from './components/FormControls';
import { MobileField, MobileSelect, MobileTextArea } from './components/FormControls';
import { useEventContext } from '../context/EventContext';
import {
AiEditStyle,
AiEditUsageSummary,
approveAndLiveShowPhoto,
approveLiveShowPhoto,
clearLiveShowPhoto,
@@ -21,9 +23,12 @@ import {
ControlRoomUploaderRule,
createEventAddonCheckout,
EventAddonCatalogItem,
EventAiEditingSettings,
EventLimitSummary,
getAddonCatalog,
featurePhoto,
getEventAiEditSummary,
getEventAiStyles,
getEventPhotos,
getEvents,
getLiveShowQueue,
@@ -75,6 +80,20 @@ const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string;
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
];
type AiSettingsDraft = {
enabled: boolean;
allow_custom_prompt: boolean;
allowed_style_keys: string[];
policy_message: string;
};
const DEFAULT_AI_SETTINGS: AiSettingsDraft = {
enabled: true,
allow_custom_prompt: true,
allowed_style_keys: [],
policy_message: '',
};
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
@@ -280,6 +299,53 @@ function formatDeviceId(deviceId: string): string {
return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`;
}
function normalizeAiSettingsDraft(
source: EventAiEditingSettings | null | undefined,
fallbackAllowedStyleKeys: string[] = [],
): AiSettingsDraft {
return {
enabled: source?.enabled === undefined ? true : Boolean(source.enabled),
allow_custom_prompt:
source?.allow_custom_prompt === undefined ? true : Boolean(source.allow_custom_prompt),
allowed_style_keys:
Array.isArray(source?.allowed_style_keys) && source?.allowed_style_keys.length
? source.allowed_style_keys.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: fallbackAllowedStyleKeys,
policy_message: typeof source?.policy_message === 'string' ? source.policy_message : '',
};
}
function normalizeAiStyleKeyList(keys: string[]): string[] {
return Array.from(
new Set(
keys
.filter((value) => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
).sort((left, right) => left.localeCompare(right));
}
function isAiSettingsDirty(current: AiSettingsDraft, initial: AiSettingsDraft): boolean {
if (current.enabled !== initial.enabled) {
return true;
}
if (current.allow_custom_prompt !== initial.allow_custom_prompt) {
return true;
}
if ((current.policy_message ?? '').trim() !== (initial.policy_message ?? '').trim()) {
return true;
}
const currentKeys = normalizeAiStyleKeyList(current.allowed_style_keys);
const initialKeys = normalizeAiStyleKeyList(initial.allowed_style_keys);
if (currentKeys.length !== initialKeys.length) {
return true;
}
return currentKeys.some((key, index) => key !== initialKeys[index]);
}
export default function MobileEventControlRoomPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const navigate = useNavigate();
@@ -312,7 +378,7 @@ export default function MobileEventControlRoomPage() {
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const [consentOpen, setConsentOpen] = React.useState(false);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests' | 'ai'; addonKey: string } | null>(null);
const [consentBusy, setConsentBusy] = React.useState(false);
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
@@ -347,6 +413,31 @@ export default function MobileEventControlRoomPage() {
const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide);
const trustedUploaders = controlRoomSettings.trusted_uploaders ?? [];
const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? [];
const aiCapabilities = (activeEvent?.capabilities ?? null) as
| {
ai_styling?: boolean;
ai_styling_addon_keys?: string[] | null;
}
| null;
const aiStylingEntitled = Boolean(aiCapabilities?.ai_styling);
const aiStylingAddonKeys =
Array.isArray(aiCapabilities?.ai_styling_addon_keys) && aiCapabilities?.ai_styling_addon_keys.length
? aiCapabilities.ai_styling_addon_keys
: ['ai_styling_unlock'];
const [aiStyles, setAiStyles] = React.useState<AiEditStyle[]>([]);
const [aiUsageSummary, setAiUsageSummary] = React.useState<AiEditUsageSummary | null>(null);
const [aiSettingsDraft, setAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
const [initialAiSettingsDraft, setInitialAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
const [aiSettingsLoading, setAiSettingsLoading] = React.useState(false);
const [aiSettingsSaving, setAiSettingsSaving] = React.useState(false);
const [aiSettingsError, setAiSettingsError] = React.useState<string | null>(null);
const aiStylingAddon = React.useMemo(() => {
return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null;
}, [aiStylingAddonKeys, catalogAddons]);
const aiSettingsDirty = React.useMemo(
() => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft),
[aiSettingsDraft, initialAiSettingsDraft],
);
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
const [syncingQueue, setSyncingQueue] = React.useState(false);
@@ -388,6 +479,99 @@ export default function MobileEventControlRoomPage() {
[controlRoomSettings, refetch, slug, t],
);
const loadAiSettings = React.useCallback(async () => {
if (!slug || !aiStylingEntitled) {
setAiStyles([]);
setAiUsageSummary(null);
return;
}
setAiSettingsLoading(true);
setAiSettingsError(null);
try {
const [stylesResult, usageResult] = await Promise.all([
getEventAiStyles(slug),
getEventAiEditSummary(slug),
]);
setAiStyles(stylesResult.styles);
setAiUsageSummary(usageResult);
if (stylesResult.meta.allowed_style_keys && stylesResult.meta.allowed_style_keys.length) {
setAiSettingsDraft((previous) => ({
...previous,
allowed_style_keys: stylesResult.meta.allowed_style_keys ?? previous.allowed_style_keys,
}));
}
} catch (err) {
if (!isAuthError(err)) {
setAiSettingsError(
getApiErrorMessage(
err,
t('controlRoom.aiSettings.loadFailed', 'AI settings could not be loaded.')
),
);
}
} finally {
setAiSettingsLoading(false);
}
}, [aiStylingEntitled, slug, t]);
const saveAiSettings = React.useCallback(async () => {
if (!slug || !aiStylingEntitled || !aiSettingsDirty) {
return;
}
const payload: EventAiEditingSettings = {
enabled: aiSettingsDraft.enabled,
allow_custom_prompt: aiSettingsDraft.allow_custom_prompt,
allowed_style_keys: normalizeAiStyleKeyList(aiSettingsDraft.allowed_style_keys),
policy_message: aiSettingsDraft.policy_message.trim() || null,
};
setAiSettingsSaving(true);
setAiSettingsError(null);
try {
await updateEvent(slug, {
settings: {
ai_editing: payload,
},
});
setInitialAiSettingsDraft({
...aiSettingsDraft,
allowed_style_keys: payload.allowed_style_keys ?? [],
policy_message: payload.policy_message ?? '',
});
await loadAiSettings();
refetch();
toast.success(t('controlRoom.aiSettings.saved', 'AI settings saved.'));
} catch (err) {
setAiSettingsError(
getApiErrorMessage(
err,
t('controlRoom.aiSettings.saveFailed', 'AI settings could not be saved.')
),
);
} finally {
setAiSettingsSaving(false);
}
}, [aiSettingsDirty, aiSettingsDraft, aiStylingEntitled, loadAiSettings, refetch, slug, t]);
const toggleAiStyleKey = React.useCallback((styleKey: string) => {
setAiSettingsDraft((previous) => {
const hasKey = previous.allowed_style_keys.includes(styleKey);
const next = hasKey
? previous.allowed_style_keys.filter((key) => key !== styleKey)
: [...previous.allowed_style_keys, styleKey];
return {
...previous,
allowed_style_keys: normalizeAiStyleKeyList(next),
};
});
}, []);
const uploaderOptions = React.useMemo(() => {
const options = new Map<string, { deviceId: string; label: string }>();
const addPhoto = (photo: TenantPhoto) => {
@@ -503,6 +687,21 @@ export default function MobileEventControlRoomPage() {
setForceReviewSelection('');
}, [activeEvent?.settings?.control_room, activeEvent?.slug]);
React.useEffect(() => {
const eventSettings = (activeEvent?.settings?.ai_editing ?? null) as EventAiEditingSettings | null;
const capabilityStyleKeys = Array.isArray(aiCapabilities?.ai_styling_allowed_style_keys)
? aiCapabilities.ai_styling_allowed_style_keys
: [];
const next = normalizeAiSettingsDraft(eventSettings, capabilityStyleKeys);
setAiSettingsDraft(next);
setInitialAiSettingsDraft(next);
setAiSettingsError(null);
}, [
activeEvent?.settings?.ai_editing,
activeEvent?.slug,
aiCapabilities?.ai_styling_allowed_style_keys,
]);
const ensureSlug = React.useCallback(async () => {
if (slug) {
return slug;
@@ -533,6 +732,16 @@ export default function MobileEventControlRoomPage() {
setLivePage(1);
}, [liveStatusFilter, slug]);
React.useEffect(() => {
if (!slug || !aiStylingEntitled) {
setAiStyles([]);
setAiUsageSummary(null);
return;
}
loadAiSettings();
}, [aiStylingEntitled, loadAiSettings, slug]);
React.useEffect(() => {
if (activeTab === 'moderation') {
moderationResetRef.current = true;
@@ -906,10 +1115,12 @@ export default function MobileEventControlRoomPage() {
],
);
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
const scope =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? scopeOrKey
: scopeOrKey === 'ai' || scopeOrKey.includes('ai')
? 'ai'
: scopeOrKey.includes('gallery')
? 'gallery'
: scopeOrKey.includes('guest')
@@ -918,16 +1129,18 @@ export default function MobileEventControlRoomPage() {
const addonKey =
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
? selectAddonKeyForScope(catalogAddons, scope)
? selectAddonKeyForScope(catalogAddons, scope as 'photos' | 'gallery' | 'guests')
: scopeOrKey === 'ai'
? aiStylingAddon?.key ?? 'ai_styling_unlock'
: scopeOrKey;
return { scope, addonKey };
}
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
if (!slug) return;
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests' | 'ai', addonKey });
setConsentOpen(true);
}
@@ -1433,6 +1646,195 @@ export default function MobileEventControlRoomPage() {
</YStack>
</MobileCard>
{!moderationLoading ? (
aiStylingEntitled ? (
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" gap="$2">
<Sparkles size={18} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('controlRoom.aiSettings.title', 'AI Styling Controls')}
</Text>
</XStack>
<CTAButton
tone="ghost"
fullWidth={false}
disabled={aiSettingsLoading || aiSettingsSaving}
label={t('common.refresh', 'Refresh')}
onPress={() => loadAiSettings()}
/>
</XStack>
<Text fontSize="$sm" color={muted}>
{t(
'controlRoom.aiSettings.subtitle',
'Enable AI edits per event, configure allowed presets, and monitor usage/failures.'
)}
</Text>
{aiSettingsError ? (
<Text fontSize="$sm" fontWeight="700" color={danger}>
{aiSettingsError}
</Text>
) : null}
<MobileField
label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
hint={t(
'controlRoom.aiSettings.enabled.hint',
'When disabled, guest and admin AI requests are blocked for this event.'
)}
>
<Switch
size="$3"
checked={aiSettingsDraft.enabled}
disabled={aiSettingsSaving}
onCheckedChange={(checked: boolean) =>
setAiSettingsDraft((previous) => ({ ...previous, enabled: Boolean(checked) }))
}
aria-label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
hint={t(
'controlRoom.aiSettings.customPrompt.hint',
'If disabled, users must choose one of the allowed presets.'
)}
>
<Switch
size="$3"
checked={aiSettingsDraft.allow_custom_prompt}
disabled={aiSettingsSaving}
onCheckedChange={(checked: boolean) =>
setAiSettingsDraft((previous) => ({ ...previous, allow_custom_prompt: Boolean(checked) }))
}
aria-label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.policyMessage.label', 'Policy message')}
hint={t(
'controlRoom.aiSettings.policyMessage.hint',
'Shown to users when AI edits are disabled or a style is blocked.'
)}
>
<MobileTextArea
value={aiSettingsDraft.policy_message}
maxLength={280}
placeholder={t('controlRoom.aiSettings.policyMessage.placeholder', 'Optional message for guests/admins')}
onChange={(event) =>
setAiSettingsDraft((previous) => ({ ...previous, policy_message: event.target.value }))
}
/>
</MobileField>
<MobileField
label={t('controlRoom.aiSettings.styles.label', 'Allowed AI styles')}
hint={t(
'controlRoom.aiSettings.styles.hint',
'No selection means all active styles are allowed.'
)}
>
{aiSettingsLoading ? (
<SkeletonCard height={78} />
) : aiStyles.length === 0 ? (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.aiSettings.styles.empty', 'No active AI styles found.')}
</Text>
) : (
<YStack gap="$2">
<XStack flexWrap="wrap" gap="$2">
{aiStyles.map((style) => {
const selected = aiSettingsDraft.allowed_style_keys.includes(style.key);
return (
<Pressable
key={style.id}
onPress={() => toggleAiStyleKey(style.key)}
disabled={aiSettingsSaving}
aria-label={style.name}
style={{
padding: '7px 12px',
borderRadius: 999,
borderWidth: 1,
borderStyle: 'solid',
borderColor: selected ? activePillBorder : border,
backgroundColor: selected ? activePillBg : 'transparent',
opacity: aiSettingsSaving ? 0.6 : 1,
}}
>
<Text fontSize="$xs" fontWeight={selected ? '700' : '600'} color={selected ? text : muted}>
{style.name}
</Text>
</Pressable>
);
})}
</XStack>
<CTAButton
tone="ghost"
fullWidth={false}
disabled={aiSettingsSaving || aiSettingsDraft.allowed_style_keys.length === 0}
label={t('controlRoom.aiSettings.styles.clear', 'Allow all styles')}
onPress={() =>
setAiSettingsDraft((previous) => ({ ...previous, allowed_style_keys: [] }))
}
/>
</YStack>
)}
</MobileField>
{aiUsageSummary ? (
<MobileCard borderColor={border} backgroundColor={surfaceMuted}>
<YStack gap="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.aiSettings.usage.title', 'Usage overview')}
</Text>
<XStack gap="$3" flexWrap="wrap">
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.total', 'Total')}</Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{aiUsageSummary.total}</Text>
</YStack>
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.succeeded', 'Succeeded')}</Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{aiUsageSummary.status_counts.succeeded ?? 0}
</Text>
</YStack>
<YStack minWidth={88}>
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.failed', 'Failed')}</Text>
<Text fontSize="$lg" fontWeight="800" color={danger}>{aiUsageSummary.failed_total}</Text>
</YStack>
</XStack>
{aiUsageSummary.last_requested_at ? (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.aiSettings.usage.lastRequest', 'Last request: {{date}}', {
date: new Date(aiUsageSummary.last_requested_at).toLocaleString(),
})}
</Text>
) : null}
</YStack>
</MobileCard>
) : null}
<XStack gap="$2" flexWrap="wrap">
<CTAButton
tone="primary"
fullWidth={false}
disabled={!aiSettingsDirty || aiSettingsSaving}
loading={aiSettingsSaving}
label={t('controlRoom.aiSettings.save', 'Save AI settings')}
onPress={() => saveAiSettings()}
/>
</XStack>
</MobileCard>
) : null
) : null}
{!moderationLoading ? (
<LimitWarnings
limits={limits}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
const navigateMock = vi.fn();
@@ -20,6 +20,9 @@ const tMock = (
const downloadReceiptMock = vi.fn().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }));
const triggerDownloadMock = vi.fn();
const eventContext = {
activeEvent: null as any,
};
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
@@ -119,6 +122,10 @@ vi.mock('../../constants', () => ({
adminPath: (path: string) => path,
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => eventContext,
}));
vi.mock('../billingUsage', () => ({
buildPackageUsageMetrics: () => [],
formatPackageEventAllowance: () => '—',
@@ -145,6 +152,7 @@ vi.mock('../invite-layout/export-utils', () => ({
}));
vi.mock('../../api', () => ({
getEvent: vi.fn(),
getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }),
getTenantBillingTransactions: vi.fn().mockResolvedValue({
data: [
@@ -170,7 +178,150 @@ import MobileBillingPage from '../BillingPage';
import * as api from '../../api';
describe('MobileBillingPage', () => {
beforeEach(() => {
eventContext.activeEvent = null;
vi.clearAllMocks();
vi.mocked(api.getTenantPackagesOverview).mockResolvedValue({ packages: [], activePackage: null });
vi.mocked(api.getTenantBillingTransactions).mockResolvedValue({
data: [
{
id: 1,
status: 'completed',
amount: 49,
currency: 'EUR',
provider: 'paypal',
provider_id: 'ORDER-1',
package_name: 'Starter',
purchased_at: '2024-01-01T00:00:00Z',
receipt_url: '/api/v1/billing/transactions/1/receipt',
},
],
} as any);
vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any);
vi.mocked(api.getEvent).mockResolvedValue(null as any);
});
it('shows current event scoped entitlements separately from tenant history', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: {
id: 201,
name: 'Event Paket',
price: 19,
purchased_at: '2024-01-01T00:00:00Z',
expires_at: '2024-02-01T00:00:00Z',
branding_allowed: true,
watermark_allowed: true,
features: [],
},
capabilities: {
ai_styling: false,
ai_styling_granted_by: null,
ai_styling_required_feature: null,
ai_styling_addon_keys: [],
ai_styling_event_enabled: true,
ai_styling_allow_custom_prompt: true,
ai_styling_allowed_style_keys: [],
ai_styling_policy_message: null,
},
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getTenantAddonHistory)
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({
data: [
{
id: 302,
addon_key: 'extra_photos_100',
label: '+100 photos',
event: { id: 99, slug: 'fruehlingsfest', name: { de: 'Frühlingsfest' } },
amount: 9,
currency: 'EUR',
status: 'completed',
purchased_at: '2024-01-20T00:00:00Z',
extra_photos: 100,
extra_guests: 0,
extra_gallery_days: 0,
quantity: 1,
},
],
} as any);
render(<MobileBillingPage />);
await screen.findByText('Current event');
expect(screen.getByText('Frühlingsfest')).toBeInTheDocument();
expect(screen.getByText('Active for this event')).toBeInTheDocument();
expect(screen.getAllByText('+100 photos').length).toBeGreaterThan(0);
expect(api.getTenantAddonHistory).toHaveBeenCalledWith({ eventId: 99, perPage: 6, page: 1 });
});
it('marks add-ons purchased for another event in global history', async () => {
eventContext.activeEvent = {
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
};
vi.mocked(api.getEvent).mockResolvedValueOnce({
id: 99,
slug: 'fruehlingsfest',
name: { de: 'Frühlingsfest' },
event_date: null,
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
package: null,
addons: [],
limits: null,
member_permissions: [],
} as any);
vi.mocked(api.getTenantAddonHistory)
.mockResolvedValueOnce({
data: [
{
id: 400,
addon_key: 'ai_styling_unlock',
label: 'AI Magic Edits',
event: { id: 77, slug: 'sommerfest', name: { de: 'Sommerfest' } },
amount: 12,
currency: 'EUR',
status: 'completed',
purchased_at: '2024-01-03T00:00:00Z',
extra_photos: 0,
extra_guests: 0,
extra_gallery_days: 0,
quantity: 1,
},
],
} as any)
.mockResolvedValueOnce({ data: [] } as any);
render(<MobileBillingPage />);
await screen.findByText('Purchased for another event');
});
it('shows linked event information for a package', async () => {
eventContext.activeEvent = null;
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 11,
@@ -207,6 +358,7 @@ describe('MobileBillingPage', () => {
});
it('shows only recent package history and can expand the rest', async () => {
eventContext.activeEvent = null;
vi.mocked(api.getTenantPackagesOverview).mockResolvedValueOnce({
activePackage: {
id: 1,
@@ -317,6 +469,7 @@ describe('MobileBillingPage', () => {
});
it('downloads receipts via the API helper', async () => {
eventContext.activeEvent = null;
render(<MobileBillingPage />);
const receiptLink = await screen.findByText('Beleg');

View File

@@ -24,6 +24,10 @@ const FEATURE_LABELS: Record<string, { key: string; fallback: string }> = {
key: 'mobileDashboard.packageSummary.feature.custom_branding',
fallback: 'Custom branding',
},
ai_styling: {
key: 'mobileDashboard.packageSummary.feature.ai_styling',
fallback: 'AI styling',
},
custom_tasks: {
key: 'mobileDashboard.packageSummary.feature.custom_tasks',
fallback: 'Custom tasks',