feat: implement AI styling foundation and billing scope rework
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
229
resources/js/guest-v2/__tests__/AiMagicEditSheet.test.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const fetchGuestAiStylesMock = vi.fn();
|
||||
const createGuestAiEditMock = vi.fn();
|
||||
const fetchGuestAiEditStatusMock = vi.fn();
|
||||
|
||||
const translate = (key: string, options?: unknown, fallback?: string) => {
|
||||
if (typeof fallback === 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof options === 'string') {
|
||||
return options;
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
vi.mock('../services/aiEditsApi', () => ({
|
||||
fetchGuestAiStyles: (...args: unknown[]) => fetchGuestAiStylesMock(...args),
|
||||
createGuestAiEdit: (...args: unknown[]) => createGuestAiEditMock(...args),
|
||||
fetchGuestAiEditStatus: (...args: unknown[]) => fetchGuestAiEditStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/shared/guest/i18n/useTranslation', () => ({
|
||||
useTranslation: () => ({
|
||||
t: translate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/guestTheme', () => ({
|
||||
useGuestThemeVariant: () => ({ isDark: false }),
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Copy: () => <span>copy</span>,
|
||||
Download: () => <span>download</span>,
|
||||
Loader2: () => <span>loader</span>,
|
||||
MessageSquare: () => <span>message</span>,
|
||||
RefreshCcw: () => <span>refresh</span>,
|
||||
Share2: () => <span>share</span>,
|
||||
Sparkles: () => <span>sparkles</span>,
|
||||
Wand2: () => <span>wand</span>,
|
||||
X: () => <span>x</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/sheet', () => {
|
||||
const Sheet = ({ open, children }: { open?: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null);
|
||||
|
||||
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Frame = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
Sheet.Handle = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
||||
|
||||
return { Sheet };
|
||||
});
|
||||
|
||||
vi.mock('@tamagui/stacks', () => ({
|
||||
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/button', () => ({
|
||||
Button: ({ children, onPress, ...rest }: { children: React.ReactNode; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
|
||||
describe('AiMagicEditSheet', () => {
|
||||
const originalOnLine = navigator.onLine;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchGuestAiStylesMock.mockReset();
|
||||
createGuestAiEditMock.mockReset();
|
||||
fetchGuestAiEditStatusMock.mockReset();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: originalOnLine,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads styles and creates an ai edit request', async () => {
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
key: 'ghibli-soft',
|
||||
name: 'Ghibli Soft',
|
||||
description: 'Soft shading style',
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 15,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 99, provider_url: 'https://example.com/ai.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(screen.getByText('AI result')).toBeInTheDocument());
|
||||
expect(screen.getByText('Copy link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error when style loading fails', async () => {
|
||||
fetchGuestAiStylesMock.mockRejectedValue(new Error('Styles not reachable'));
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Styles not reachable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pauses polling while offline and resumes after reconnect', async () => {
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
|
||||
fetchGuestAiStylesMock.mockResolvedValue({
|
||||
data: [{ id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' }],
|
||||
meta: {},
|
||||
});
|
||||
createGuestAiEditMock.mockResolvedValue({
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'processing',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [],
|
||||
},
|
||||
});
|
||||
fetchGuestAiEditStatusMock.mockResolvedValue({
|
||||
data: {
|
||||
id: 22,
|
||||
event_id: 2,
|
||||
photo_id: 7,
|
||||
status: 'succeeded',
|
||||
style: { id: 1, key: 'ghibli-soft', name: 'Ghibli Soft' },
|
||||
outputs: [{ id: 7, provider_url: 'https://example.com/generated.jpg', is_primary: true }],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AiMagicEditSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
eventToken="event-token"
|
||||
photoId={7}
|
||||
originalImageUrl="/storage/original.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Ghibli Soft')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Generate AI edit'));
|
||||
|
||||
await waitFor(() => expect(createGuestAiEditMock).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('You are offline. Status updates resume automatically when connection is back.')).toBeInTheDocument();
|
||||
|
||||
vi.useFakeTimers();
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(0);
|
||||
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(fetchGuestAiEditStatusMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const setSearchParamsMock = vi.fn();
|
||||
const pushGuestToastMock = vi.fn();
|
||||
const mockEventData = {
|
||||
token: 'demo',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -11,7 +15,7 @@ vi.mock('react-router-dom', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'demo', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
@@ -73,6 +77,10 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/toast', () => ({
|
||||
pushGuestToast: (...args: unknown[]) => pushGuestToastMock(...args),
|
||||
}));
|
||||
@@ -115,6 +123,8 @@ describe('GalleryScreen', () => {
|
||||
pushGuestToastMock.mockClear();
|
||||
fetchGalleryMock.mockReset();
|
||||
fetchPhotoMock.mockReset();
|
||||
mockEventData.token = 'demo';
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -160,4 +170,33 @@ describe('GalleryScreen', () => {
|
||||
expect(setSearchParamsMock).not.toHaveBeenCalled();
|
||||
expect(pushGuestToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show ai magic edit action when ai styling is not entitled', async () => {
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
fetchGalleryMock.mockResolvedValue({
|
||||
data: [{ id: 123, thumbnail_url: '/storage/demo.jpg', likes_count: 2 }],
|
||||
});
|
||||
fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }));
|
||||
|
||||
render(<GalleryScreen />);
|
||||
|
||||
await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const mockEventData = {
|
||||
token: 'token',
|
||||
event: { name: 'Demo Event', capabilities: { ai_styling: false } },
|
||||
};
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ photoId: '123' }),
|
||||
useNavigate: () => vi.fn(),
|
||||
@@ -36,8 +41,12 @@ vi.mock('../components/ShareSheet', () => ({
|
||||
default: () => <div>ShareSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AiMagicEditSheet', () => ({
|
||||
default: () => <div>AiMagicEditSheet</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../context/EventDataContext', () => ({
|
||||
useEventData: () => ({ token: 'token', event: { name: 'Demo Event' } }),
|
||||
useEventData: () => mockEventData,
|
||||
}));
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
@@ -66,9 +75,18 @@ import PhotoLightboxScreen from '../screens/PhotoLightboxScreen';
|
||||
|
||||
describe('PhotoLightboxScreen', () => {
|
||||
it('renders lightbox layout', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: false } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(await screen.findByText('Gallery')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Like')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps ai magic edit action hidden while rollout flag is disabled', async () => {
|
||||
mockEventData.event = { name: 'Demo Event', capabilities: { ai_styling: true } };
|
||||
render(<PhotoLightboxScreen />);
|
||||
|
||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
642
resources/js/guest-v2/components/AiMagicEditSheet.tsx
Normal file
642
resources/js/guest-v2/components/AiMagicEditSheet.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import React from 'react';
|
||||
import { Sheet } from '@tamagui/sheet';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Copy, Download, Loader2, MessageSquare, RefreshCcw, Share2, Sparkles, Wand2, X } from 'lucide-react';
|
||||
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import {
|
||||
createGuestAiEdit,
|
||||
fetchGuestAiEditStatus,
|
||||
fetchGuestAiStyles,
|
||||
type GuestAiEditRequest,
|
||||
type GuestAiStyle,
|
||||
} from '../services/aiEditsApi';
|
||||
import type { ApiError } from '../services/apiClient';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
|
||||
type AiMagicEditSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
eventToken: string | null;
|
||||
photoId: number | null;
|
||||
originalImageUrl: string | null;
|
||||
};
|
||||
|
||||
const POLLABLE_STATUSES = new Set(['queued', 'processing']);
|
||||
const MAX_POLL_ATTEMPTS = 72;
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
|
||||
function resolveErrorMessage(error: unknown, fallback: string): string {
|
||||
const apiError = error as ApiError;
|
||||
|
||||
if (typeof apiError?.message === 'string' && apiError.message.trim() !== '') {
|
||||
return apiError.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildIdempotencyKey(photoId: number): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `guest-ai-${photoId}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
return `guest-ai-${photoId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function resolveOutputUrl(request: GuestAiEditRequest | null): string | null {
|
||||
if (!request || !Array.isArray(request.outputs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizeStorageUrl = (storagePath?: string | null): string | null => {
|
||||
if (!storagePath || typeof storagePath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^https?:/i.test(storagePath)) {
|
||||
return storagePath;
|
||||
}
|
||||
|
||||
const cleanPath = storagePath.replace(/^\/+/g, '');
|
||||
if (cleanPath.startsWith('storage/')) {
|
||||
return `/${cleanPath}`;
|
||||
}
|
||||
|
||||
return `/storage/${cleanPath}`;
|
||||
};
|
||||
|
||||
const primary = request.outputs.find(
|
||||
(output) =>
|
||||
output.is_primary
|
||||
&& (
|
||||
(typeof output.provider_url === 'string' && output.provider_url)
|
||||
|| (typeof output.storage_path === 'string' && output.storage_path)
|
||||
)
|
||||
);
|
||||
if (primary?.provider_url) {
|
||||
return primary.provider_url;
|
||||
}
|
||||
if (primary?.storage_path) {
|
||||
return normalizeStorageUrl(primary.storage_path);
|
||||
}
|
||||
|
||||
const first = request.outputs.find(
|
||||
(output) =>
|
||||
(typeof output.provider_url === 'string' && output.provider_url)
|
||||
|| (typeof output.storage_path === 'string' && output.storage_path)
|
||||
);
|
||||
|
||||
if (first?.provider_url) {
|
||||
return first.provider_url;
|
||||
}
|
||||
|
||||
return normalizeStorageUrl(first?.storage_path);
|
||||
}
|
||||
|
||||
export default function AiMagicEditSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
eventToken,
|
||||
photoId,
|
||||
originalImageUrl,
|
||||
}: AiMagicEditSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
|
||||
const [styles, setStyles] = React.useState<GuestAiStyle[]>([]);
|
||||
const [stylesLoading, setStylesLoading] = React.useState(false);
|
||||
const [stylesError, setStylesError] = React.useState<string | null>(null);
|
||||
const [selectedStyleKey, setSelectedStyleKey] = React.useState<string | null>(null);
|
||||
const [request, setRequest] = React.useState<GuestAiEditRequest | null>(null);
|
||||
const [requestError, setRequestError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [isOnline, setIsOnline] = React.useState<boolean>(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return navigator.onLine;
|
||||
});
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
|
||||
const selectedStyle = React.useMemo(() => {
|
||||
if (!selectedStyleKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return styles.find((style) => style.key === selectedStyleKey) ?? null;
|
||||
}, [selectedStyleKey, styles]);
|
||||
|
||||
const outputUrl = React.useMemo(() => resolveOutputUrl(request), [request]);
|
||||
|
||||
const resetRequestState = React.useCallback(() => {
|
||||
setRequest(null);
|
||||
setRequestError(null);
|
||||
setSubmitting(false);
|
||||
pollAttemptsRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const loadStyles = React.useCallback(async () => {
|
||||
if (!eventToken || !photoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStylesLoading(true);
|
||||
setStylesError(null);
|
||||
|
||||
try {
|
||||
const payload = await fetchGuestAiStyles(eventToken);
|
||||
const nextStyles = Array.isArray(payload.data) ? payload.data : [];
|
||||
setStyles(nextStyles);
|
||||
if (nextStyles.length > 0) {
|
||||
setSelectedStyleKey(nextStyles[0]?.key ?? null);
|
||||
} else {
|
||||
setSelectedStyleKey(null);
|
||||
setStylesError(t('galleryPage.lightbox.aiMagicEditNoStyles', 'No AI styles are currently available.'));
|
||||
}
|
||||
} catch (error) {
|
||||
setStyles([]);
|
||||
setSelectedStyleKey(null);
|
||||
setStylesError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStylesFailed', 'AI styles could not be loaded.')));
|
||||
} finally {
|
||||
setStylesLoading(false);
|
||||
}
|
||||
}, [eventToken, photoId, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
resetRequestState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventToken || !photoId) {
|
||||
setStyles([]);
|
||||
setSelectedStyleKey(null);
|
||||
setStylesError(t('galleryPage.lightbox.aiMagicEditUnavailable', 'AI Magic Edit is currently unavailable.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resetRequestState();
|
||||
void loadStyles();
|
||||
}, [eventToken, loadStyles, open, photoId, resetRequestState, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setIsOnline(false);
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !eventToken || !request || !POLLABLE_STATUSES.has(request.status) || !isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setRequestError(t('galleryPage.lightbox.aiMagicEditPollingTimeout', 'AI generation took too long. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
pollAttemptsRef.current += 1;
|
||||
|
||||
try {
|
||||
const payload = await fetchGuestAiEditStatus(eventToken, request.id);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRequest(payload.data);
|
||||
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
|
||||
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
setIsOnline(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStatusFailed', 'AI status could not be refreshed.')));
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [eventToken, isOnline, open, request, t]);
|
||||
|
||||
const startAiEdit = React.useCallback(async () => {
|
||||
if (!eventToken || !photoId || !selectedStyleKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setRequestError(null);
|
||||
|
||||
try {
|
||||
const payload = await createGuestAiEdit(eventToken, photoId, {
|
||||
style_key: selectedStyleKey,
|
||||
idempotency_key: buildIdempotencyKey(photoId),
|
||||
metadata: {
|
||||
client: 'guest-v2',
|
||||
entrypoint: 'lightbox',
|
||||
},
|
||||
});
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
setRequest(payload.data);
|
||||
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
|
||||
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
|
||||
}
|
||||
} catch (error) {
|
||||
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStartFailed', 'AI edit could not be started.')));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [eventToken, photoId, selectedStyleKey, t]);
|
||||
|
||||
const downloadGenerated = React.useCallback(() => {
|
||||
if (!outputUrl || !request?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = outputUrl;
|
||||
link.download = `ai-edit-${request.id}.jpg`;
|
||||
link.rel = 'noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [outputUrl, request?.id]);
|
||||
|
||||
const copyGeneratedLink = React.useCallback(async () => {
|
||||
if (!outputUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard?.writeText(outputUrl);
|
||||
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
|
||||
} catch (error) {
|
||||
console.error('Copy generated link failed', error);
|
||||
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
|
||||
}
|
||||
}, [outputUrl, t]);
|
||||
|
||||
const shareGeneratedNative = React.useCallback(() => {
|
||||
if (!outputUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shareData: ShareData = {
|
||||
title: t('galleryPage.lightbox.aiMagicEditShareTitle', 'AI Magic Edit'),
|
||||
text: t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'),
|
||||
url: outputUrl,
|
||||
};
|
||||
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||
navigator.share(shareData).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
void copyGeneratedLink();
|
||||
}, [copyGeneratedLink, outputUrl, t]);
|
||||
|
||||
const shareGeneratedWhatsApp = React.useCallback(() => {
|
||||
if (!outputUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
|
||||
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${text} ${outputUrl}`)}`;
|
||||
window.open(waUrl, '_blank', 'noopener');
|
||||
}, [outputUrl, t]);
|
||||
|
||||
const shareGeneratedMessages = React.useCallback(() => {
|
||||
if (!outputUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
|
||||
const smsUrl = `sms:?&body=${encodeURIComponent(`${text} ${outputUrl}`)}`;
|
||||
window.open(smsUrl, '_blank', 'noopener');
|
||||
}, [outputUrl, t]);
|
||||
|
||||
const isProcessing = Boolean(request && POLLABLE_STATUSES.has(request.status));
|
||||
const isDone = request?.status === 'succeeded' && Boolean(outputUrl);
|
||||
|
||||
const content = (
|
||||
<YStack gap="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Wand2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<YStack>
|
||||
<Text fontSize="$4" fontWeight="$8">
|
||||
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={() => onOpenChange(false)}
|
||||
aria-label={t('common.actions.close', 'Close')}
|
||||
>
|
||||
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{stylesLoading ? (
|
||||
<XStack alignItems="center" gap="$2" paddingVertical="$2">
|
||||
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{stylesError ? (
|
||||
<YStack gap="$2" padding="$3" borderRadius="$card" backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
||||
<Text fontSize="$2" color="#FCA5A5">{stylesError}</Text>
|
||||
<XStack gap="$2">
|
||||
<Button onPress={() => void loadStyles()} backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">{t('common.actions.retry', 'Retry')}</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{!stylesLoading && !stylesError && !request ? (
|
||||
<YStack gap="$3">
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')}
|
||||
</Text>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{styles.map((style) => {
|
||||
const selected = style.key === selectedStyleKey;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={style.key}
|
||||
size="$3"
|
||||
backgroundColor={selected ? '$primary' : mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={selected ? '$primary' : mutedBorder}
|
||||
onPress={() => setSelectedStyleKey(style.key)}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6" color={selected ? '#FFFFFF' : undefined}>
|
||||
{style.name}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
{selectedStyle?.description ? (
|
||||
<Text fontSize="$1" color="$color" opacity={0.75}>
|
||||
{selectedStyle.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
{originalImageUrl ? (
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')}
|
||||
</Text>
|
||||
<img
|
||||
src={originalImageUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 180,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
backgroundColor="$primary"
|
||||
onPress={() => void startAiEdit()}
|
||||
disabled={!selectedStyleKey || submitting}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
{submitting ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : <Sparkles size={14} color="#FFFFFF" />}
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{submitting
|
||||
? t('galleryPage.lightbox.aiMagicEditStarting', 'Starting...')
|
||||
: t('galleryPage.lightbox.aiMagicEditGenerate', 'Generate AI edit')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{request ? (
|
||||
<YStack gap="$3">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{isProcessing
|
||||
? t('galleryPage.lightbox.aiMagicEditProcessing', 'Generating your AI edit...')
|
||||
: request.status === 'succeeded'
|
||||
? t('galleryPage.lightbox.aiMagicEditReady', 'Your AI edit is ready.')
|
||||
: t('galleryPage.lightbox.aiMagicEditFailed', 'AI edit could not be completed.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
{isProcessing ? (
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
{isProcessing && !isOnline ? (
|
||||
<Text fontSize="$2" color="$color" opacity={0.8}>
|
||||
{t(
|
||||
'galleryPage.lightbox.aiMagicEditOfflineHint',
|
||||
'You are offline. Status updates resume automatically when connection is back.'
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{isDone && originalImageUrl ? (
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<YStack flex={1} minWidth={150} gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')}
|
||||
</Text>
|
||||
<img
|
||||
src={originalImageUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
<YStack flex={1} minWidth={150} gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')}
|
||||
</Text>
|
||||
<img
|
||||
src={outputUrl}
|
||||
alt={t('galleryPage.lightbox.aiMagicEditGeneratedAlt', 'AI generated photo')}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: 220,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${mutedBorder}`,
|
||||
}}
|
||||
/>
|
||||
</YStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{requestError ? (
|
||||
<Text fontSize="$2" color="#FCA5A5">{requestError}</Text>
|
||||
) : null}
|
||||
|
||||
{(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? (
|
||||
<Text fontSize="$2" color="#FCA5A5">{request.failure_message}</Text>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={resetRequestState}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.aiMagicEditTryAnother', 'Try another style')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
{isDone ? (
|
||||
<Button backgroundColor="$primary" onPress={downloadGenerated}>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Download size={14} color="#FFFFFF" />
|
||||
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
||||
{t('common.actions.download', 'Download')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
{isDone ? (
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedNative}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.button', 'Share')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedWhatsApp}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.whatsapp', 'WhatsApp')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={shareGeneratedMessages}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.imessage', 'Messages')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={mutedSurface}
|
||||
borderWidth={1}
|
||||
borderColor={mutedBorder}
|
||||
onPress={() => void copyGeneratedLink()}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Copy size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('share.copyLink', 'Copy link')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} snapPoints={[88]} position={open ? 0 : -1} modal>
|
||||
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.35)' } as any)} />
|
||||
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
|
||||
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
||||
<YStack style={{ maxHeight: '82vh', overflowY: 'auto' }}>
|
||||
{content}
|
||||
</YStack>
|
||||
</Sheet.Frame>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
1
resources/js/guest-v2/lib/featureFlags.ts
Normal file
1
resources/js/guest-v2/lib/featureFlags.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const GUEST_AI_MAGIC_EDITS_ENABLED = false;
|
||||
@@ -6,6 +6,7 @@ import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sp
|
||||
import AppShell from '../components/AppShell';
|
||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
||||
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||
@@ -17,6 +18,7 @@ import { buildEventPath } from '../lib/routes';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { usePollStats } from '../hooks/usePollStats';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
|
||||
|
||||
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
@@ -111,6 +113,7 @@ export default function GalleryScreen() {
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const [deleteBusy, setDeleteBusy] = React.useState(false);
|
||||
const [deleteConfirmMounted, setDeleteConfirmMounted] = React.useState(false);
|
||||
@@ -294,6 +297,7 @@ export default function GalleryScreen() {
|
||||
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
||||
const lightboxOpen = Boolean(selectedPhotoId);
|
||||
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
|
||||
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
||||
@@ -340,6 +344,7 @@ export default function GalleryScreen() {
|
||||
[searchParams, setSearchParams, token]
|
||||
);
|
||||
const closeLightbox = React.useCallback(() => {
|
||||
setAiMagicEditOpen(false);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('photo');
|
||||
setSearchParams(next, { replace: true });
|
||||
@@ -769,6 +774,14 @@ export default function GalleryScreen() {
|
||||
document.body.removeChild(link);
|
||||
}, []);
|
||||
|
||||
const openAiMagicEdit = React.useCallback(() => {
|
||||
if (!lightboxPhoto || !hasAiStylingAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiMagicEditOpen(true);
|
||||
}, [hasAiStylingAccess, lightboxPhoto]);
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent) => {
|
||||
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||
};
|
||||
@@ -1313,6 +1326,17 @@ export default function GalleryScreen() {
|
||||
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
) : null}
|
||||
{lightboxPhoto && hasAiStylingAccess ? (
|
||||
<Button
|
||||
unstyled
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$1.5"
|
||||
onPress={openAiMagicEdit}
|
||||
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
|
||||
>
|
||||
<Sparkles size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
) : null}
|
||||
{lightboxPhoto && canDelete ? (
|
||||
<Button
|
||||
unstyled
|
||||
@@ -1454,6 +1478,15 @@ export default function GalleryScreen() {
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
variant="inline"
|
||||
/>
|
||||
{hasAiStylingAccess ? (
|
||||
<AiMagicEditSheet
|
||||
open={aiMagicEditOpen}
|
||||
onOpenChange={setAiMagicEditOpen}
|
||||
eventToken={token ?? null}
|
||||
photoId={lightboxPhoto?.id ?? null}
|
||||
originalImageUrl={lightboxPhoto?.imageUrl ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2 } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Heart, Share2, Sparkles } from 'lucide-react';
|
||||
import { useGesture } from '@use-gesture/react';
|
||||
import { animated, to, useSpring } from '@react-spring/web';
|
||||
import AppShell from '../components/AppShell';
|
||||
import SurfaceCard from '../components/SurfaceCard';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import AiMagicEditSheet from '../components/AiMagicEditSheet';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchGallery, fetchPhoto, likePhoto, createPhotoShareLink } from '../services/photosApi';
|
||||
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
||||
@@ -16,6 +17,7 @@ import { useLocale } from '@/shared/guest/i18n/LocaleContext';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
|
||||
|
||||
type LightboxPhoto = {
|
||||
id: number;
|
||||
@@ -85,6 +87,7 @@ export default function PhotoLightboxScreen() {
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
const [aiMagicEditOpen, setAiMagicEditOpen] = React.useState(false);
|
||||
const zoomContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const zoomImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
const baseSizeRef = React.useRef({ width: 0, height: 0 });
|
||||
@@ -100,6 +103,7 @@ export default function PhotoLightboxScreen() {
|
||||
}));
|
||||
|
||||
const selected = selectedIndex !== null ? photos[selectedIndex] : null;
|
||||
const hasAiStylingAccess = GUEST_AI_MAGIC_EDITS_ENABLED && Boolean(event?.capabilities?.ai_styling);
|
||||
|
||||
const loadPage = React.useCallback(
|
||||
async (nextCursor?: string | null, replace = false) => {
|
||||
@@ -381,6 +385,14 @@ export default function PhotoLightboxScreen() {
|
||||
document.body.removeChild(link);
|
||||
}, []);
|
||||
|
||||
const openAiMagicEdit = React.useCallback(() => {
|
||||
if (!selected || !hasAiStylingAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAiMagicEditOpen(true);
|
||||
}, [hasAiStylingAccess, selected]);
|
||||
|
||||
const bind = useGesture(
|
||||
{
|
||||
onDrag: ({ down, movement: [mx, my], offset: [ox, oy], last, event }) => {
|
||||
@@ -642,6 +654,22 @@ export default function PhotoLightboxScreen() {
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
{hasAiStylingAccess ? (
|
||||
<Button
|
||||
unstyled
|
||||
onPress={openAiMagicEdit}
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
aria-label={t('galleryPage.lightbox.aiMagicEditAria', 'AI Magic Edit')}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
@@ -667,6 +695,15 @@ export default function PhotoLightboxScreen() {
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
variant="inline"
|
||||
/>
|
||||
{hasAiStylingAccess ? (
|
||||
<AiMagicEditSheet
|
||||
open={aiMagicEditOpen}
|
||||
onOpenChange={setAiMagicEditOpen}
|
||||
eventToken={token ?? null}
|
||||
photoId={selected?.id ?? null}
|
||||
originalImageUrl={selected?.imageUrl ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
</YStack>
|
||||
</AppShell>
|
||||
|
||||
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
81
resources/js/guest-v2/services/__tests__/aiEditsApi.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchJsonMock = vi.fn();
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
fetchJson: (...args: unknown[]) => fetchJsonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/device', () => ({
|
||||
getDeviceId: () => 'device-123',
|
||||
}));
|
||||
|
||||
import { createGuestAiEdit, fetchGuestAiEditStatus, fetchGuestAiStyles } from '../aiEditsApi';
|
||||
|
||||
describe('aiEditsApi', () => {
|
||||
beforeEach(() => {
|
||||
fetchJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads guest ai styles with device header', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
data: [{ id: 10, key: 'style-a', name: 'Style A' }],
|
||||
meta: { allow_custom_prompt: false },
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await fetchGuestAiStyles('token-abc');
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/ai-styles', {
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
},
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data).toHaveLength(1);
|
||||
expect(payload.data[0]?.key).toBe('style-a');
|
||||
expect(payload.meta.allow_custom_prompt).toBe(false);
|
||||
});
|
||||
|
||||
it('creates guest ai edit with json payload', async () => {
|
||||
fetchJsonMock.mockResolvedValue({
|
||||
data: {
|
||||
duplicate: false,
|
||||
data: {
|
||||
id: 55,
|
||||
event_id: 1,
|
||||
photo_id: 9,
|
||||
status: 'queued',
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = await createGuestAiEdit('token-abc', 9, {
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
});
|
||||
|
||||
expect(fetchJsonMock).toHaveBeenCalledWith('/api/v1/events/token-abc/photos/9/ai-edits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Device-Id': 'device-123',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
style_key: 'style-a',
|
||||
idempotency_key: 'demo-key',
|
||||
}),
|
||||
noStore: true,
|
||||
});
|
||||
expect(payload.data.id).toBe(55);
|
||||
expect(payload.data.status).toBe('queued');
|
||||
});
|
||||
|
||||
it('throws when status payload is malformed', async () => {
|
||||
fetchJsonMock.mockResolvedValue({ data: null });
|
||||
|
||||
await expect(fetchGuestAiEditStatus('token-abc', 55)).rejects.toThrow('AI edit status response is invalid.');
|
||||
});
|
||||
});
|
||||
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
142
resources/js/guest-v2/services/aiEditsApi.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { fetchJson } from './apiClient';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export type GuestAiStyle = {
|
||||
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 GuestAiStylesMeta = {
|
||||
required_feature?: string | null;
|
||||
addon_keys?: string[] | null;
|
||||
allow_custom_prompt?: boolean;
|
||||
allowed_style_keys?: string[] | null;
|
||||
policy_message?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditOutput = {
|
||||
id: number;
|
||||
storage_disk?: string | null;
|
||||
storage_path?: string | null;
|
||||
provider_url?: string | null;
|
||||
mime_type?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
is_primary?: boolean;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
generated_at?: string | null;
|
||||
};
|
||||
|
||||
export type GuestAiEditRequest = {
|
||||
id: number;
|
||||
event_id: number;
|
||||
photo_id: number;
|
||||
style?: {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
} | null;
|
||||
provider?: string | null;
|
||||
provider_model?: string | null;
|
||||
status: 'queued' | 'processing' | 'succeeded' | 'failed' | 'blocked' | 'canceled' | string;
|
||||
safety_state?: string | null;
|
||||
safety_reasons?: string[];
|
||||
failure_code?: string | null;
|
||||
failure_message?: string | null;
|
||||
queued_at?: string | null;
|
||||
started_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
outputs: GuestAiEditOutput[];
|
||||
};
|
||||
|
||||
export type GuestAiStylesResponse = {
|
||||
data: GuestAiStyle[];
|
||||
meta: GuestAiStylesMeta;
|
||||
};
|
||||
|
||||
export type GuestAiEditEnvelope = {
|
||||
message?: string;
|
||||
duplicate?: boolean;
|
||||
data: GuestAiEditRequest;
|
||||
};
|
||||
|
||||
function deviceHeaders(): Record<string, string> {
|
||||
return {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGuestAiStyles(eventToken: string): Promise<GuestAiStylesResponse> {
|
||||
const response = await fetchJson<GuestAiStylesResponse>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-styles`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
const payload = response.data;
|
||||
|
||||
return {
|
||||
data: Array.isArray(payload?.data) ? payload.data : [],
|
||||
meta: payload?.meta && typeof payload.meta === 'object' ? payload.meta : {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGuestAiEdit(
|
||||
eventToken: string,
|
||||
photoId: number,
|
||||
payload: {
|
||||
style_key?: string;
|
||||
prompt?: string;
|
||||
negative_prompt?: string;
|
||||
provider_model?: string;
|
||||
idempotency_key?: string;
|
||||
session_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<GuestAiEditEnvelope> {
|
||||
const response = await fetchJson<GuestAiEditEnvelope>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/ai-edits`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...deviceHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit request response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchGuestAiEditStatus(eventToken: string, requestId: number): Promise<{ data: GuestAiEditRequest }> {
|
||||
const response = await fetchJson<{ data: GuestAiEditRequest }>(
|
||||
`/api/v1/events/${encodeURIComponent(eventToken)}/ai-edits/${requestId}`,
|
||||
{
|
||||
headers: deviceHeaders(),
|
||||
noStore: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || typeof response.data !== 'object' || !response.data.data) {
|
||||
throw new Error('AI edit status response is invalid.');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
@@ -71,6 +71,13 @@ export interface EventData {
|
||||
live_show?: {
|
||||
moderation_mode?: 'off' | 'manual' | 'trusted_only';
|
||||
};
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"feature_no_watermark": "Fotospiel-Wasserzeichen entfernen",
|
||||
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
||||
"feature_advanced_analytics": "Erweiterte Analytics",
|
||||
"feature_ai_styling": "AI-Styling",
|
||||
"feature_priority_support": "Priorisierter Support",
|
||||
"feature_limited_sharing": "Begrenztes Teilen",
|
||||
"feature_no_branding": "Kein Branding",
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'feature_no_watermark' => 'Fotospiel-Wasserzeichen entfernen',
|
||||
'feature_custom_tasks' => 'Benutzerdefinierte Tasks',
|
||||
'feature_advanced_analytics' => 'Erweiterte Analytics',
|
||||
'feature_ai_styling' => 'AI-Styling',
|
||||
'feature_priority_support' => 'Priorisierter Support',
|
||||
'feature_limited_sharing' => 'Begrenztes Teilen',
|
||||
'feature_no_branding' => 'Kein Branding',
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"feature_no_watermark": "Remove Fotospiel watermark",
|
||||
"feature_custom_tasks": "Custom Tasks",
|
||||
"feature_advanced_analytics": "Advanced Analytics",
|
||||
"feature_ai_styling": "AI Styling",
|
||||
"feature_priority_support": "Priority Support",
|
||||
"feature_limited_sharing": "Limited Sharing",
|
||||
"feature_no_branding": "No Branding",
|
||||
|
||||
@@ -45,6 +45,7 @@ return [
|
||||
'feature_no_watermark' => 'Remove Fotospiel watermark',
|
||||
'feature_custom_tasks' => 'Custom Tasks',
|
||||
'feature_advanced_analytics' => 'Advanced Analytics',
|
||||
'feature_ai_styling' => 'AI Styling',
|
||||
'feature_priority_support' => 'Priority Support',
|
||||
'feature_limited_sharing' => 'Limited Sharing',
|
||||
'feature_no_branding' => 'No Branding',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@php
|
||||
use Filament\Support\Enums\GridDirection;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$stacked = (new ComponentAttributeBag())->grid(['default' => 1], GridDirection::Column);
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section heading="AI Editing Settings" :icon="Heroicon::Sparkles">
|
||||
<div {{ $stacked }}>
|
||||
{{ $this->form }}
|
||||
<x-filament::button wire:click="save" color="primary" :icon="Heroicon::CheckCircle">
|
||||
Save settings
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
|
||||
Reference in New Issue
Block a user