import React from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Image as ImageIcon, Sparkles, Users, Clock3, Layers } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import toast from 'react-hot-toast'; import { createEventAddonCheckout, EventAddonCatalogItem, getAddonCatalog, getEvent, getTenantAddonHistory, TenantAddonHistoryEntry, TenantEvent, } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { adminPath, ADMIN_EVENT_VIEW_PATH } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { MobileShell } from './components/MobileShell'; import { CTAButton, MobileCard, PillBadge, SkeletonCard } from './components/Primitives'; import { LegalConsentSheet } from './components/LegalConsentSheet'; import { scopeDefaults, selectAddonKeyForScope } from './addons'; type AddonScope = 'photos' | 'guests' | 'gallery' | 'bundle' | 'feature'; type ScopedAddon = EventAddonCatalogItem & { scope: AddonScope; price: number | null; currency: string; }; function translateLimits(t: any) { return (key: string, options?: Record) => t(`limits.${key}`, key, options); } function resolveAddonScope(addon: EventAddonCatalogItem): AddonScope { const increments = addon.increments ?? {}; const photos = Number(increments.extra_photos ?? 0); const guests = Number(increments.extra_guests ?? 0); const gallery = Number(increments.extra_gallery_days ?? 0); const count = Number(photos > 0) + Number(guests > 0) + Number(gallery > 0); if (count === 0 || addon.key.includes('ai')) { return 'feature'; } if (count > 1 || addon.key.includes('boost') || addon.key.includes('bundle')) { return 'bundle'; } if (photos > 0) { return 'photos'; } if (guests > 0) { return 'guests'; } return 'gallery'; } function normalizeAddon(addon: EventAddonCatalogItem): ScopedAddon { const payload = addon as EventAddonCatalogItem & { price?: number | null; currency?: string | null }; return { ...addon, scope: resolveAddonScope(addon), price: typeof payload.price === 'number' && Number.isFinite(payload.price) ? payload.price : null, currency: typeof payload.currency === 'string' && payload.currency ? payload.currency : 'EUR', }; } function formatAmount(value: number | null, currency: string): string { if (value === null) { return '—'; } try { return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value); } catch { return `${value} ${currency}`; } } function formatDate(value: string | null | undefined): string { if (!value) { return '—'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return '—'; } return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); } function scopeIcon(scope: AddonScope) { if (scope === 'photos') { return ImageIcon; } if (scope === 'guests') { return Users; } if (scope === 'gallery') { return Clock3; } return scope === 'bundle' ? Layers : Sparkles; } function scopeLabel(scope: AddonScope, t: (key: string, fallback: string) => string): string { if (scope === 'photos') { return t('events.addons.groups.photos', 'Photo capacity'); } if (scope === 'guests') { return t('events.addons.groups.guests', 'Guest capacity'); } if (scope === 'gallery') { return t('events.addons.groups.gallery', 'Gallery runtime'); } if (scope === 'bundle') { return t('events.addons.groups.bundle', 'Bundles'); } return t('events.addons.groups.feature', 'Feature unlocks'); } function resolveScopeFromQuery(input: string | null): 'photos' | 'guests' | 'gallery' | 'ai' | null { if (!input) { return null; } if (input === 'photos' || input === 'guests' || input === 'gallery' || input === 'ai') { return input; } return null; } export default function MobileEventAddonsPage() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('management'); const { textStrong, text, muted, border, primary, danger } = useAdminTheme(); const back = useBackNavigation(slug ? ADMIN_EVENT_VIEW_PATH(slug) : adminPath('/mobile/events')); const [event, setEvent] = React.useState(null); const [catalog, setCatalog] = React.useState([]); const [history, setHistory] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [consentOpen, setConsentOpen] = React.useState(false); const [checkoutBusy, setCheckoutBusy] = React.useState(false); const [pendingAddonKey, setPendingAddonKey] = React.useState(null); const queryScope = React.useMemo(() => { const params = new URLSearchParams(location.search); return resolveScopeFromQuery(params.get('scope')); }, [location.search]); const queryAddonKey = React.useMemo(() => { const params = new URLSearchParams(location.search); return params.get('addon'); }, [location.search]); const load = React.useCallback(async () => { if (!slug) { return; } setLoading(true); try { const [eventData, catalogData] = await Promise.all([ getEvent(slug), getAddonCatalog(), ]); setEvent(eventData); setCatalog(catalogData.map(normalizeAddon)); const historyResult = await getTenantAddonHistory({ eventId: eventData.id, perPage: 20, page: 1, }).catch(() => ({ data: [] as TenantAddonHistoryEntry[] })); setHistory(historyResult.data ?? []); setError(null); } catch (err) { setError(getApiErrorMessage(err, 'Add-ons konnten nicht geladen werden.')); } finally { setLoading(false); } }, [slug]); React.useEffect(() => { void load(); }, [load]); React.useEffect(() => { if (!slug) { return; } const params = new URLSearchParams(location.search); if (!params.get('addon_success')) { return; } toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.')); params.delete('addon_success'); navigate( { pathname: adminPath(`/mobile/events/${slug}/addons`), search: params.toString(), }, { replace: true }, ); void load(); }, [load, location.search, navigate, slug, t]); const warnings = React.useMemo(() => buildLimitWarnings(event?.limits ?? null, translateLimits(t as any)), [event?.limits, t]); const suggestedKeys = React.useMemo(() => { const keys = new Set(); if (queryAddonKey) { keys.add(queryAddonKey); } if (queryScope === 'photos' || queryScope === 'guests' || queryScope === 'gallery') { keys.add(selectAddonKeyForScope(catalog, queryScope)); } if (queryScope === 'ai') { const aiAddon = catalog.find((addon) => addon.scope === 'feature' && addon.key.includes('ai')); if (aiAddon) { keys.add(aiAddon.key); } } for (const warning of warnings) { if (warning.scope === 'photos' || warning.scope === 'guests' || warning.scope === 'gallery') { keys.add(selectAddonKeyForScope(catalog, warning.scope)); } } return keys; }, [catalog, queryAddonKey, queryScope, warnings]); const grouped = React.useMemo(() => { const scopes: AddonScope[] = ['photos', 'guests', 'gallery', 'bundle', 'feature']; return scopes.map((scope) => { const addons = catalog .filter((addon) => addon.scope === scope) .sort((left, right) => (left.price ?? Number.MAX_SAFE_INTEGER) - (right.price ?? Number.MAX_SAFE_INTEGER)); return { scope, addons }; }); }, [catalog]); const recommendedAddons = React.useMemo( () => catalog.filter((addon) => suggestedKeys.has(addon.key)), [catalog, suggestedKeys], ); const hasSellableAddons = catalog.some((addon) => Boolean(addon.price_id)); function openConsent(addonKey: string) { if (!slug || checkoutBusy) { return; } setPendingAddonKey(addonKey); setConsentOpen(true); } async function confirmCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) { if (!slug || !pendingAddonKey) { return; } setCheckoutBusy(true); const pagePath = adminPath(`/mobile/events/${slug}/addons`); const currentUrl = `${window.location.origin}${pagePath}`; try { const checkout = await createEventAddonCheckout(slug, { addon_key: pendingAddonKey, success_url: `${currentUrl}?addon_success=1`, cancel_url: currentUrl, accepted_terms: consents.acceptedTerms, accepted_waiver: consents.acceptedWaiver, }); if (checkout.checkout_url) { window.location.href = checkout.checkout_url; return; } toast.error(t('events.errors.checkoutMissing', 'Checkout could not be started.')); } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on checkout failed.'))); } finally { setCheckoutBusy(false); setConsentOpen(false); setPendingAddonKey(null); } } if (loading) { return ( ); } if (error || !event) { return ( {error || t('common.error', 'Something went wrong.')} ); } return ( {t('events.addons.event', 'Event')} {typeof event.name === 'string' ? event.name : t('events.placeholders.untitled', 'Untitled event')} {event.package?.name ? ( {event.package.name} {event.package.expires_at ? ( {t('events.addons.packageExpires', 'Gallery active until {{date}}', { date: formatDate(event.package.expires_at), })} ) : null} ) : ( {t('events.addons.noPackage', 'No package is currently assigned to this event.')} )} {recommendedAddons.length > 0 ? ( {t('events.addons.recommendedTitle', 'Recommended now')} {t('events.addons.recommendedBody', 'Based on your event usage, these add-ons are likely most relevant.')} {recommendedAddons.map((addon) => ( ))} ) : null} {!hasSellableAddons ? ( {t('events.recap.noAddonAvailable', 'No add-ons are currently available.')} ) : ( grouped.map((group) => { if (!group.addons.length) { return null; } const GroupIcon = scopeIcon(group.scope); return ( {scopeLabel(group.scope, t as any)} {group.addons.map((addon) => ( ))} ); }) )} {t('events.addons.historyTitle', 'Purchased add-ons for this event')} {history.length === 0 ? ( {t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')} ) : ( {history.map((entry) => ( {entry.label ?? entry.addon_key} {formatDate(entry.purchased_at)} {formatAmount(entry.amount, entry.currency)} {t(`mobileBilling.status.${entry.status}`, entry.status)} ))} )} { if (!checkoutBusy) { setConsentOpen(false); setPendingAddonKey(null); } }} onConfirm={confirmCheckout} t={t as any} /> ); } function AddonCard({ addon, textStrong, muted, onBuy, busy, isRecommended, t, }: { addon: ScopedAddon; textStrong: string; muted: string; onBuy: (addonKey: string) => void; busy: boolean; isRecommended: boolean; t: (key: string, fallback: string, options?: Record) => string; }) { return ( {addon.label || addon.key} {formatAmount(addon.price, addon.currency)} {isRecommended ? {t('events.addons.recommendedBadge', 'Recommended')} : null} {Number(addon.increments?.extra_photos ?? 0) > 0 ? ( {t('mobileBilling.extra.photos', '+{{count}} photos', { count: Number(addon.increments?.extra_photos ?? 0) })} ) : null} {Number(addon.increments?.extra_guests ?? 0) > 0 ? ( {t('mobileBilling.extra.guests', '+{{count}} guests', { count: Number(addon.increments?.extra_guests ?? 0) })} ) : null} {Number(addon.increments?.extra_gallery_days ?? 0) > 0 ? ( {t('mobileBilling.extra.days', '+{{count}} days', { count: Number(addon.increments?.extra_gallery_days ?? 0) })} ) : null} onBuy(addon.key)} loading={busy} /> ); }