From 922da46331b7f286fac4e0fb7bbda2726b5ac21f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 7 Feb 2026 15:00:14 +0100 Subject: [PATCH] Add dedicated mobile event add-ons page --- resources/js/admin/api.ts | 2 + resources/js/admin/constants.ts | 3 +- resources/js/admin/mobile/BillingPage.tsx | 6 +- resources/js/admin/mobile/EventAddonsPage.tsx | 558 ++++++++++++++++++ .../js/admin/mobile/EventControlRoomPage.tsx | 106 +--- resources/js/admin/mobile/EventRecapPage.tsx | 111 ++-- .../mobile/__tests__/BillingPage.test.tsx | 7 +- .../mobile/__tests__/EventAddonsPage.test.tsx | 195 ++++++ .../mobile/__tests__/EventRecapPage.test.tsx | 45 +- resources/js/admin/router.tsx | 3 + 10 files changed, 847 insertions(+), 189 deletions(-) create mode 100644 resources/js/admin/mobile/EventAddonsPage.tsx create mode 100644 resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index c5c758b1..84be9761 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -352,6 +352,8 @@ export type EventAddonCatalogItem = { label: string; price_id: string | null; increments?: Record; + price?: number | null; + currency?: string | null; }; export type TenantFontVariant = { diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 664e68f2..49561df4 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -34,7 +34,8 @@ export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mo export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photobooth`); export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`); -export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); +export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/recap`); +export const ADMIN_EVENT_ADDONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons`); export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`); export const ADMIN_EVENT_LIVE_SHOW_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/live-show`); export const ADMIN_EVENT_LIVE_SHOW_SETTINGS_PATH = (slug: string): string => diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index ee8a7b1c..b8856a14 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -201,7 +201,7 @@ export default function MobileBillingPage() { const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null; const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null; const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]); - const eventRecapPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/recap`) : null; + const eventAddonsPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/addons`) : null; const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null; const scopedEventPackage = scopeEvent?.package ?? null; const scopedEventAddons = React.useMemo(() => { @@ -702,7 +702,7 @@ export default function MobileBillingPage() { navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))} + onPress={() => navigate(eventAddonsPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/addons`))} /> navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))} + onPress={() => navigate(eventAddonsPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/addons`))} /> ) : null} diff --git a/resources/js/admin/mobile/EventAddonsPage.tsx b/resources/js/admin/mobile/EventAddonsPage.tsx new file mode 100644 index 00000000..6f3f903f --- /dev/null +++ b/resources/js/admin/mobile/EventAddonsPage.tsx @@ -0,0 +1,558 @@ +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} + /> + + ); +} diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index e4d59608..d6388992 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Check, ChevronDown, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; @@ -21,7 +21,6 @@ import { clearLiveShowPhoto, ControlRoomSettings, ControlRoomUploaderRule, - createEventAddonCheckout, EventAddonCatalogItem, EventAiEditingSettings, EventLimitSummary, @@ -60,8 +59,6 @@ import { } from './lib/photoModerationQueue'; import { triggerHaptic } from './lib/haptics'; import { normalizeLiveStatus, resolveLiveShowApproveMode } from './lib/controlRoom'; -import { LegalConsentSheet } from './components/LegalConsentSheet'; -import { selectAddonKeyForScope } from './addons'; import { LimitWarnings } from './components/LimitWarnings'; type ModerationFilter = 'all' | 'featured' | 'hidden' | 'pending'; @@ -349,7 +346,6 @@ function isAiSettingsDirty(current: AiSettingsDraft, initial: AiSettingsDraft): export default function MobileEventControlRoomPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); - const location = useLocation(); const { t } = useTranslation('management'); const { activeEvent, selectEvent, refetch } = useEventContext(); const { user } = useAuth(); @@ -376,10 +372,6 @@ export default function MobileEventControlRoomPage() { }); const [limits, setLimits] = React.useState(null); const [catalogAddons, setCatalogAddons] = React.useState([]); - const [busyScope, setBusyScope] = React.useState(null); - const [consentOpen, setConsentOpen] = React.useState(false); - 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([]); const [liveStatusFilter, setLiveStatusFilter] = React.useState('pending'); @@ -918,21 +910,6 @@ export default function MobileEventControlRoomPage() { refreshCounts(); }, [refreshCounts]); - React.useEffect(() => { - if (!location.search || !slug) { - return; - } - const params = new URLSearchParams(location.search); - if (params.get('addon_success')) { - toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.')); - void refetch(); - setModerationPage(1); - void loadModeration(); - params.delete('addon_success'); - navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); - } - }, [location.search, slug, loadModeration, navigate, t, location.pathname, refetch]); - const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { replacePhotoQueue(queue); setQueuedActions(queue); @@ -1134,7 +1111,7 @@ export default function MobileEventControlRoomPage() { ], ); - function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) { + function resolveScope(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string): 'photos' | 'gallery' | 'guests' | 'ai' { const scope = scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' ? scopeOrKey @@ -1143,57 +1120,25 @@ export default function MobileEventControlRoomPage() { : scopeOrKey.includes('gallery') ? 'gallery' : scopeOrKey.includes('guest') - ? 'guests' + ? 'guests' : 'photos'; - const addonKey = - scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests' - ? selectAddonKeyForScope(catalogAddons, scope as 'photos' | 'gallery' | 'guests') - : scopeOrKey === 'ai' - ? aiStylingAddon?.key ?? 'ai_styling_unlock' - : scopeOrKey; - - return { scope, addonKey }; + return scope as 'photos' | 'gallery' | 'guests' | 'ai'; } - function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) { - if (!slug) return; - const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey); - setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests' | 'ai', addonKey }); - setConsentOpen(true); - } - - async function confirmAddonCheckout(consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) { - if (!slug || !consentTarget) return; - - const currentUrl = typeof window !== 'undefined' - ? `${window.location.origin}${adminPath(`/mobile/events/${slug}/control-room`)}` - : ''; - const successUrl = `${currentUrl}?addon_success=1`; - setBusyScope(consentTarget.scope); - setConsentBusy(true); - try { - const checkout = await createEventAddonCheckout(slug, { - addon_key: consentTarget.addonKey, - quantity: 1, - success_url: successUrl, - cancel_url: currentUrl, - accepted_terms: consents.acceptedTerms, - accepted_waiver: consents.acceptedWaiver, - }); - if (checkout.checkout_url) { - window.location.href = checkout.checkout_url; - } else { - 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 { - setConsentBusy(false); - setConsentOpen(false); - setConsentTarget(null); - setBusyScope(null); + function openAddonShop(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) { + if (!slug) { + return; } + + const scope = resolveScope(scopeOrKey); + const query = new URLSearchParams({ scope }); + + if (scopeOrKey !== 'photos' && scopeOrKey !== 'gallery' && scopeOrKey !== 'guests' && scopeOrKey !== 'ai') { + query.set('addon', scopeOrKey); + } + + navigate(adminPath(`/mobile/events/${slug}/addons?${query.toString()}`)); } async function handleApprove(photo: TenantPhoto) { @@ -1867,9 +1812,7 @@ export default function MobileEventControlRoomPage() { startAddonCheckout('ai')} - loading={busyScope === 'ai'} - disabled={consentBusy} + onPress={() => openAddonShop('ai')} /> ) : null @@ -1879,8 +1822,8 @@ export default function MobileEventControlRoomPage() { - { - if (consentBusy) return; - setConsentOpen(false); - setConsentTarget(null); - }} - onConfirm={confirmAddonCheckout} - busy={consentBusy} - t={t} - /> ); } diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index 096269c3..4c13bdb7 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -2,13 +2,14 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Share2, Sparkles, Trophy, Users, TrendingUp, Heart, Image as ImageIcon } from 'lucide-react'; +import { isPast, isSameDay, parseISO } from 'date-fns'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Switch } from '@tamagui/switch'; import { Tabs } from 'tamagui'; +import toast from 'react-hot-toast'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; -import { LegalConsentSheet } from './components/LegalConsentSheet'; import { DataExportsPanel } from './DataExportsPage'; import { getEvent, @@ -20,14 +21,10 @@ import { EventStats, EventQrInvite, EventEngagement, - EventAddonCatalogItem, - getAddonCatalog, - createEventAddonCheckout, } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { adminPath } from '../constants'; -import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; @@ -37,6 +34,25 @@ type GalleryCounts = { pending: number; }; +function canAccessRecap(event: TenantEvent): boolean { + if (event.status === 'archived') { + return true; + } + + if (!event.event_date) { + return false; + } + + const eventDate = parseISO(event.event_date); + const today = new Date(); + + if (isSameDay(today, eventDate)) { + return false; + } + + return isPast(eventDate); +} + export default function MobileEventRecapPage() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); @@ -48,30 +64,25 @@ export default function MobileEventRecapPage() { const [event, setEvent] = React.useState(null); const [stats, setEventStats] = React.useState(null); const [invites, setInvites] = React.useState([]); - const [addons, setAddons] = React.useState([]); const [engagement, setEngagement] = React.useState(null); const [engagementLoading, setEngagementLoading] = React.useState(false); const [engagementError, setEngagementError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const [consentOpen, setConsentOpen] = React.useState(false); - const [busyScope, setBusyScope] = React.useState(null); const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events')); const load = React.useCallback(async () => { if (!slug) return; setLoading(true); try { - const [eventData, statsData, invitesData, addonsData] = await Promise.all([ + const [eventData, statsData, invitesData] = await Promise.all([ getEvent(slug), getEventStats(slug), getEventQrInvites(slug), - getAddonCatalog(), ]); setEvent(eventData); setEventStats(statsData); setInvites(invitesData); - setAddons(addonsData); setError(null); } catch (err) { if (!isAuthError(err)) { @@ -130,39 +141,13 @@ export default function MobileEventRecapPage() { }; }, [event?.status, i18n.language, invites, t]); - const handleCheckout = (addonKey: string) => { - if (!slug || busyScope) { - return; + const recapAvailable = event ? canAccessRecap(event) : true; + + React.useEffect(() => { + if (event && !recapAvailable) { + navigate(adminPath(`/mobile/events/${event.slug}`), { replace: true }); } - - setBusyScope(addonKey); - setConsentOpen(true); - }; - - const handleConsentConfirm = async (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => { - if (!slug || !busyScope) return; - try { - const { checkout_url } = await createEventAddonCheckout(slug, { - addon_key: busyScope, - success_url: window.location.href, - cancel_url: window.location.href, - accepted_terms: consents.acceptedTerms, - accepted_waiver: consents.acceptedWaiver, - }); - if (checkout_url) { - window.location.href = checkout_url; - return; - } - - toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.')); - setBusyScope(null); - setConsentOpen(false); - } catch (err) { - toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.'))); - setBusyScope(null); - setConsentOpen(false); - } - }; + }, [event, navigate, recapAvailable]); if (loading) { return ( @@ -197,9 +182,10 @@ export default function MobileEventRecapPage() { const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null; const guestLink = activeInvite?.url ?? ''; - const galleryExtensionAddons = addons - .filter((addon) => addon.key.startsWith('extend_gallery_') || Number(addon.increments?.extra_gallery_days ?? 0) > 0) - .sort((left, right) => Number(left.increments?.extra_gallery_days ?? 0) - Number(right.increments?.extra_gallery_days ?? 0)); + + if (!recapAvailable) { + return null; + } return ( - {t('events.recap.addons', 'Galerie verlängern')} + {t('events.recap.addons', 'Manage add-ons')} - {t('events.recap.addonBody', 'Die Online-Zeit deiner Galerie neigt sich dem Ende? Hier kannst du sie verlängern.')} + {t('events.recap.addonBody', 'Need more runtime or capacity? Manage all event add-ons in one place.')} - - {galleryExtensionAddons.map((addon) => ( - handleCheckout(addon.key)} - loading={busyScope === addon.key} - /> - ))} - {galleryExtensionAddons.length === 0 ? ( - - {t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')} - - ) : null} - + navigate(adminPath(`/mobile/events/${event.slug}/addons?scope=gallery`))} + /> @@ -611,16 +586,6 @@ export default function MobileEventRecapPage() { - { - setConsentOpen(false); - setBusyScope(null); - }} - onConfirm={handleConsentConfirm} - busy={Boolean(busyScope)} - t={t as any} - /> ); } diff --git a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx index a1b3b543..75fb896a 100644 --- a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx @@ -515,6 +515,11 @@ describe('MobileBillingPage', () => { render(); - expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument(); + const buyAddonsButton = await screen.findByText('Buy add-ons for this event'); + expect(buyAddonsButton).toBeInTheDocument(); + + fireEvent.click(buyAddonsButton); + + expect(navigateMock).toHaveBeenCalledWith('/mobile/events/fruehlingsfest/addons'); }); }); diff --git a/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx new file mode 100644 index 00000000..e86dc617 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const navigateMock = vi.fn(); + +const fixtures = vi.hoisted(() => ({ + event: { + id: 42, + name: 'Demo Event', + slug: 'demo-event', + status: 'published', + event_date: '2026-02-01T12:00:00Z', + settings: {}, + package: { + id: 10, + name: 'Premium', + expires_at: '2026-03-01T00:00:00Z', + }, + limits: null, + }, + catalog: [ + { + key: 'extend_gallery_30d', + label: 'Extend gallery 30 days', + price_id: 'paypal', + increments: { extra_gallery_days: 30 }, + price: 4, + currency: 'EUR', + }, + { + key: 'extra_photos_500', + label: 'Extra photos 500', + price_id: 'paypal', + increments: { extra_photos: 500 }, + price: 5, + currency: 'EUR', + }, + ], + history: [ + { + id: 501, + addon_key: 'extend_gallery_30d', + label: 'Extend gallery 30 days', + amount: 4, + currency: 'EUR', + status: 'completed', + purchased_at: '2026-01-22T10:00:00Z', + extra_photos: 0, + extra_guests: 0, + extra_gallery_days: 30, + quantity: 1, + event: { id: 42, slug: 'demo-event', name: 'Demo Event' }, + }, + ], +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useParams: () => ({ slug: fixtures.event.slug }), + useLocation: () => ({ pathname: '/event-admin/mobile/events/demo-event/addons', search: '' }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record, options?: Record) => { + let value = typeof fallback === 'string' ? fallback : key; + if (options) { + Object.entries(options).forEach(([optionKey, optionValue]) => { + value = value.replaceAll(`{{${optionKey}}}`, String(optionValue)); + }); + } + return value; + }, + }), + initReactI18next: { + type: '3rdParty', + init: () => undefined, + }, +})); + +vi.mock('../hooks/useBackNavigation', () => ({ + useBackNavigation: () => undefined, +})); + +vi.mock('../../lib/apiError', () => ({ + getApiErrorMessage: (_err: unknown, fallback: string) => fallback, +})); + +vi.mock('../theme', () => ({ + useAdminTheme: () => ({ + text: '#111827', + muted: '#6b7280', + subtle: '#94a3b8', + border: '#e5e7eb', + primary: '#2563eb', + successText: '#16a34a', + danger: '#dc2626', + surface: '#ffffff', + surfaceMuted: '#f9fafb', + backdrop: '#111827', + accentSoft: '#eef2ff', + textStrong: '#0f172a', + }), +})); + +vi.mock('../components/MobileShell', () => ({ + MobileShell: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/Primitives', () => ({ + MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, + CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( + + ), + PillBadge: ({ children }: { children: React.ReactNode }) => {children}, + SkeletonCard: () =>
Loading...
, +})); + +vi.mock('../components/LegalConsentSheet', () => ({ + LegalConsentSheet: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void; + }) => (open ? : null), +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('react-hot-toast', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../api', () => ({ + getEvent: vi.fn().mockResolvedValue(fixtures.event), + getAddonCatalog: vi.fn().mockResolvedValue(fixtures.catalog), + getTenantAddonHistory: vi.fn().mockResolvedValue({ data: fixtures.history }), + createEventAddonCheckout: vi.fn().mockResolvedValue({ + checkout_url: null, + checkout_id: 'chk_123', + expires_at: null, + }), +})); + +import MobileEventAddonsPage from '../EventAddonsPage'; +import { createEventAddonCheckout } from '../../api'; + +describe('MobileEventAddonsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders add-ons and event-specific purchase history', async () => { + render(); + + expect((await screen.findAllByText('Extend gallery 30 days')).length).toBeGreaterThan(0); + expect(screen.getByText('Extra photos 500')).toBeInTheDocument(); + expect(screen.getByText('Purchased add-ons for this event')).toBeInTheDocument(); + }); + + it('requires legal consent and sends checkout payload', async () => { + const checkoutMock = vi.mocked(createEventAddonCheckout); + + render(); + + const buyButtons = await screen.findAllByRole('button', { name: 'Buy add-on' }); + fireEvent.click(buyButtons[0]); + + expect(checkoutMock).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' })); + + await waitFor(() => { + expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, { + addon_key: 'extra_photos_500', + success_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons?addon_success=1`, + cancel_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons`, + accepted_terms: true, + accepted_waiver: true, + }); + }); + }); +}); diff --git a/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx index 5a4bcd2b..530c9bdc 100644 --- a/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx @@ -2,13 +2,15 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +const navigateMock = vi.fn(); + const fixtures = vi.hoisted(() => ({ event: { id: 1, name: 'Demo Event', slug: 'demo-event', event_date: '2026-02-19', - status: 'published', + status: 'archived', settings: { guest_downloads_enabled: true, guest_sharing_enabled: false, @@ -34,7 +36,7 @@ const fixtures = vi.hoisted(() => ({ })); vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => navigateMock, useParams: () => ({ slug: fixtures.event.slug }), })); @@ -65,13 +67,7 @@ vi.mock('../../api', () => ({ getEvent: vi.fn().mockResolvedValue(fixtures.event), getEventStats: vi.fn().mockResolvedValue(fixtures.stats), getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites), - getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons), updateEvent: vi.fn().mockResolvedValue(fixtures.event), - createEventAddonCheckout: vi.fn().mockResolvedValue({ - checkout_url: null, - checkout_id: 'chk_123', - expires_at: null, - }), getEventEngagement: vi.fn().mockResolvedValue({ summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 }, leaderboards: { uploads: [], likes: [] }, @@ -185,10 +181,11 @@ vi.mock('../theme', () => ({ })); import MobileEventRecapPage from '../EventRecapPage'; -import { createEventAddonCheckout } from '../../api'; describe('MobileEventRecapPage', () => { beforeEach(() => { + fixtures.event.status = 'archived'; + fixtures.event.event_date = '2026-02-19'; fixtures.addons = [ { key: 'extend_gallery_30d', @@ -208,25 +205,25 @@ describe('MobileEventRecapPage', () => { expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument(); }); - it('requires consent and sends both legal acceptance flags for recap addon checkout', async () => { - const checkoutMock = vi.mocked(createEventAddonCheckout); + it('links to the dedicated add-on manager from recap', async () => { render(); - const checkoutButton = await screen.findByRole('button', { name: 'Galerie um 30 Tage verlängern' }); - fireEvent.click(checkoutButton); - - expect(checkoutMock).not.toHaveBeenCalled(); - - fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' })); + const openAddonsButton = await screen.findByRole('button', { name: 'Open add-on manager' }); + fireEvent.click(openAddonsButton); await waitFor(() => { - expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, { - addon_key: 'extend_gallery_30d', - success_url: window.location.href, - cancel_url: window.location.href, - accepted_terms: true, - accepted_waiver: true, - }); + expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/events/demo-event/addons?scope=gallery'); + }); + }); + + it('redirects to the event overview when recap is not available yet', async () => { + fixtures.event.status = 'published'; + fixtures.event.event_date = '2099-02-19'; + + render(); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/events/demo-event', { replace: true }); }); }); }); diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index d548013d..f9179e72 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -31,6 +31,7 @@ const MobileEventLiveShowSettingsPage = React.lazy(() => import('./mobile/EventL const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage')); const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage')); +const MobileEventAddonsPage = React.lazy(() => import('./mobile/EventAddonsPage')); const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage')); const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); @@ -199,6 +200,7 @@ export const router = createBrowserRouter([ { path: 'events/:slug/photos', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'events/:slug/members', element: `${ADMIN_EVENTS_PATH}/${slug}/members`} /> }, { path: 'events/:slug/tasks', element: `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> }, + { path: 'events/:slug/addons', element: `${ADMIN_EVENTS_PATH}/${slug}/addons`} /> }, { path: 'events/:slug/invites', element: `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> }, { path: 'events/:slug/branding', element: `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> }, { path: 'events/:slug/photobooth', element: `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> }, @@ -216,6 +218,7 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/live-show', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/live-show/settings', element: }, { path: 'mobile/events/:slug/recap', element: }, + { path: 'mobile/events/:slug/addons', element: }, { path: 'mobile/events/:slug/analytics', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: },