diff --git a/app/Http/Controllers/Api/Tenant/EventAddonController.php b/app/Http/Controllers/Api/Tenant/EventAddonController.php index 759aad51..bf2bf99b 100644 --- a/app/Http/Controllers/Api/Tenant/EventAddonController.php +++ b/app/Http/Controllers/Api/Tenant/EventAddonController.php @@ -4,10 +4,13 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\EventAddonCheckoutRequest; +use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest; use App\Models\Event; +use App\Models\EventPackageAddon; use App\Services\Addons\EventAddonCheckoutService; use App\Support\ApiError; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Arr; class EventAddonController extends Controller { @@ -48,4 +51,100 @@ class EventAddonController extends Controller 'expires_at' => $checkout['expires_at'] ?? null, ]); } + + public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return ApiError::response( + 'event_not_found', + 'Event not accessible', + __('Das Event konnte nicht gefunden werden.'), + 404, + ['event_slug' => $event->slug ?? null] + ); + } + + $validated = $request->validated(); + $addonIntent = trim((string) ($validated['addon_intent'] ?? '')); + $checkoutId = trim((string) ($validated['checkout_id'] ?? '')); + $addonKey = trim((string) ($validated['addon_key'] ?? '')); + + $baseQuery = EventPackageAddon::query() + ->where('tenant_id', $tenantId) + ->where('event_id', $event->id) + ->with(['event:id,name,slug']); + + $addon = null; + + if ($addonIntent !== '') { + $addon = (clone $baseQuery) + ->where('metadata->addon_intent', $addonIntent) + ->orderByDesc('created_at') + ->first(); + } + + if (! $addon && $checkoutId !== '') { + $addon = (clone $baseQuery) + ->where('checkout_id', $checkoutId) + ->orderByDesc('created_at') + ->first(); + } + + if (! $addon && $addonKey !== '') { + $addon = (clone $baseQuery) + ->where('addon_key', $addonKey) + ->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end") + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + } + + if (! $addon) { + $addon = (clone $baseQuery) + ->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end") + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + } + + if (! $addon) { + return ApiError::response( + 'addon_not_found', + 'Add-on purchase not found', + __('Der Add-on Kauf wurde nicht gefunden.'), + 404, + ['event_slug' => $event->slug ?? null] + ); + } + + $label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key; + + return response()->json([ + 'data' => [ + 'id' => $addon->id, + 'addon_key' => $addon->addon_key, + 'label' => $label, + 'quantity' => (int) ($addon->quantity ?? 1), + 'status' => $addon->status, + 'amount' => $addon->amount !== null ? (float) $addon->amount : null, + 'currency' => $addon->currency, + 'extra_photos' => (int) $addon->extra_photos, + 'extra_guests' => (int) $addon->extra_guests, + 'extra_gallery_days' => (int) $addon->extra_gallery_days, + 'purchased_at' => $addon->purchased_at?->toIso8601String(), + 'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'), + 'checkout_id' => $addon->checkout_id, + 'transaction_id' => $addon->transaction_id, + 'created_at' => $addon->created_at?->toIso8601String(), + 'addon_intent' => Arr::get($addon->metadata ?? [], 'addon_intent'), + 'event' => $addon->event ? [ + 'id' => $addon->event->id, + 'slug' => $addon->event->slug, + 'name' => $addon->event->name, + ] : null, + ], + ]); + } } diff --git a/app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php b/app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php new file mode 100644 index 00000000..305161b6 --- /dev/null +++ b/app/Http/Requests/Tenant/EventAddonPurchaseLookupRequest.php @@ -0,0 +1,22 @@ + ['nullable', 'string', 'max:191'], + 'checkout_id' => ['nullable', 'string', 'max:191'], + 'addon_key' => ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 49262601..4938fe01 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -86,6 +86,8 @@ class EventAddonCheckoutService $addonMetadata = $this->resolveAddonMetadata($addon); $entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata); $price = $this->catalog->resolvePrice($addonKey); + $successUrl = $this->appendQueryParam($payload['success_url'] ?? null, 'addon_intent', $addonIntent); + $cancelUrl = $this->appendQueryParam($payload['cancel_url'] ?? null, 'addon_intent', $addonIntent); $providerMetadata = array_filter([ 'tenant_id' => (string) $tenant->id, @@ -98,13 +100,13 @@ class EventAddonCheckoutService 'legal_version' => $this->resolveLegalVersion(), 'accepted_terms' => $acceptedTerms ? '1' : '0', 'accepted_waiver' => $acceptedWaiver ? '1' : '0', - 'success_url' => $payload['success_url'] ?? null, - 'cancel_url' => $payload['cancel_url'] ?? null, + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, ], static fn ($value) => $value !== null && $value !== ''); $response = $this->checkout->createVariantCheckout($variantId, $providerMetadata, [ - 'success_url' => $payload['success_url'] ?? null, - 'return_url' => $payload['cancel_url'] ?? null, + 'success_url' => $successUrl, + 'return_url' => $cancelUrl, 'customer_email' => $tenant->contact_email ?? $tenant->user?->email, ]); @@ -227,7 +229,9 @@ class EventAddonCheckoutService ]); $successUrl = $payload['success_url'] ?? null; + $successUrl = $this->appendQueryParam($successUrl, 'addon_intent', $addonIntent); $cancelUrl = $payload['cancel_url'] ?? $successUrl; + $cancelUrl = $this->appendQueryParam($cancelUrl, 'addon_intent', $addonIntent); $paypalReturnUrl = route('paypal.addon.return', absolute: true); try { @@ -312,6 +316,23 @@ class EventAddonCheckoutService return config('app.legal_version', now()->toDateString()); } + protected function appendQueryParam(?string $url, string $key, string $value): ?string + { + if (! is_string($url) || trim($url) === '') { + return null; + } + + [$base, $fragment] = array_pad(explode('#', $url, 2), 2, null); + $separator = str_contains($base, '?') ? '&' : '?'; + $next = $base.$separator.rawurlencode($key).'='.rawurlencode($value); + + if ($fragment === null) { + return $next; + } + + return $next.'#'.$fragment; + } + /** * @param array $addon */ diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 84be9761..94dfaee4 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -667,6 +667,13 @@ export type TenantAddonHistoryEntry = { receipt_url?: string | null; }; +export type EventAddonPurchaseSummary = TenantAddonHistoryEntry & { + checkout_id: string | null; + transaction_id: string | null; + created_at: string | null; + addon_intent?: string | null; +}; + export type TenantBillingAddonScope = { type: 'tenant' | 'event'; event: TenantAddonEventSummary | null; @@ -1435,6 +1442,18 @@ function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryE }; } +function normalizeEventAddonPurchaseSummary(entry: JsonValue): EventAddonPurchaseSummary { + const base = normalizeTenantAddonHistoryEntry(entry); + + return { + ...base, + checkout_id: typeof entry.checkout_id === 'string' ? entry.checkout_id : null, + transaction_id: typeof entry.transaction_id === 'string' ? entry.transaction_id : null, + created_at: typeof entry.created_at === 'string' ? entry.created_at : null, + addon_intent: typeof entry.addon_intent === 'string' ? entry.addon_intent : null, + }; +} + function normalizeTask(task: JsonValue): TenantTask { const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); @@ -2013,6 +2032,46 @@ export async function createEventAddonCheckout( ); } +export async function getEventAddonPurchase( + eventSlug: string, + options?: { + addonIntent?: string; + checkoutId?: string; + addonKey?: string; + } +): Promise { + const params = new URLSearchParams(); + + if (options?.addonIntent) { + params.set('addon_intent', options.addonIntent); + } + + if (options?.checkoutId) { + params.set('checkout_id', options.checkoutId); + } + + if (options?.addonKey) { + params.set('addon_key', options.addonKey); + } + + const query = params.toString(); + const response = await authorizedFetch( + `${eventEndpoint(eventSlug)}/addons/purchase${query ? `?${query}` : ''}` + ); + + if (response.status === 404) { + return null; + } + + const payload = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to load add-on purchase'); + + if (!payload.data || typeof payload.data !== 'object') { + return null; + } + + return normalizeEventAddonPurchaseSummary(payload.data); +} + export async function getAddonCatalog(): Promise { const response = await authorizedFetch('/api/v1/tenant/addons/catalog'); const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons'); diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 49561df4..5c4d82b7 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -36,6 +36,8 @@ 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)}/recap`); export const ADMIN_EVENT_ADDONS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons`); +export const ADMIN_EVENT_ADDON_SUCCESS_PATH = (slug: string): string => + adminPath(`/mobile/events/${encodeURIComponent(slug)}/addons/success`); 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/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 0593a314..173f4809 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -518,6 +518,30 @@ "gallery": "Galerie-Laufzeit", "bundle": "Bundles", "feature": "Feature-Freischaltungen" + }, + "success": { + "title": "Add-on-Kauf", + "loadFailed": "Kaufergebnis konnte nicht geladen werden.", + "notFound": "Dieser Kauf wird noch bestätigt.", + "statusUnknown": "Unbekannt", + "completedTitle": "Kauf abgeschlossen", + "completedBody": "Dein Event-Add-on ist jetzt aktiv, die Limits aktualisieren sich in Kürze.", + "pendingTitle": "Kauf in Bearbeitung", + "pendingBody": "Deine Zahlung wird noch bestätigt.", + "failedTitle": "Kauf fehlgeschlagen", + "failedBody": "Die Zahlung konnte nicht abgeschlossen werden. Du kannst es erneut versuchen.", + "summaryTitle": "Kaufübersicht", + "addonType": "Add-on", + "amount": "Betrag", + "quantity": "Menge", + "purchasedAt": "Gekauft am", + "checkoutId": "Checkout-ID", + "transactionId": "Transaktions-ID", + "nextTitle": "Was jetzt?", + "backToAddons": "Zurück zum Add-on-Manager", + "openControlRoom": "Control Room öffnen", + "openBilling": "Billing öffnen", + "backToEvent": "Zurück zum Event" } }, "form": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 59cf8ba6..d88ed20e 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -514,6 +514,30 @@ "gallery": "Gallery runtime", "bundle": "Bundles", "feature": "Feature unlocks" + }, + "success": { + "title": "Add-on purchase", + "loadFailed": "Purchase result could not be loaded.", + "notFound": "We are still confirming this purchase.", + "statusUnknown": "Unknown", + "completedTitle": "Purchase completed", + "completedBody": "Your event add-on is now active and limits update shortly.", + "pendingTitle": "Purchase in progress", + "pendingBody": "We are still processing your payment confirmation.", + "failedTitle": "Purchase failed", + "failedBody": "The payment could not be completed. You can try again.", + "summaryTitle": "Purchase summary", + "addonType": "Add-on", + "amount": "Amount", + "quantity": "Quantity", + "purchasedAt": "Purchased at", + "checkoutId": "Checkout ID", + "transactionId": "Transaction ID", + "nextTitle": "What next?", + "backToAddons": "Back to add-on manager", + "openControlRoom": "Open control room", + "openBilling": "Open billing", + "backToEvent": "Back to event" } }, "form": { diff --git a/resources/js/admin/mobile/EventAddonSuccessPage.tsx b/resources/js/admin/mobile/EventAddonSuccessPage.tsx new file mode 100644 index 00000000..8c55e22e --- /dev/null +++ b/resources/js/admin/mobile/EventAddonSuccessPage.tsx @@ -0,0 +1,306 @@ +import React from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, Clock3, AlertTriangle, ReceiptText, Sparkles } from 'lucide-react'; +import { YStack, XStack } from '@tamagui/stacks'; +import { SizableText as Text } from '@tamagui/text'; + +import { EventAddonPurchaseSummary, getEvent, getEventAddonPurchase, TenantEvent } from '../api'; +import { getApiErrorMessage } from '../lib/apiError'; +import { adminPath, ADMIN_BILLING_PATH, ADMIN_EVENT_ADDONS_PATH, ADMIN_EVENT_CONTROL_ROOM_PATH, 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 { resolveEventDisplayName } from '../lib/events'; + +function formatAmount(value: number | null, currency: string | null): string { + if (value === null || value === undefined) { + return '—'; + } + + const resolvedCurrency = currency ?? 'EUR'; + + try { + return new Intl.NumberFormat(undefined, { style: 'currency', currency: resolvedCurrency }).format(value); + } catch { + return `${value} ${resolvedCurrency}`; + } +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) { + return '—'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '—'; + } + + return date.toLocaleString(undefined, { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +async function launchConfetti(): Promise { + if (typeof window === 'undefined') { + return; + } + + const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches; + if (prefersReducedMotion) { + return; + } + + try { + const { default: confetti } = await import('canvas-confetti'); + + confetti({ + particleCount: 80, + spread: 70, + origin: { x: 0.5, y: 0.3 }, + ticks: 180, + scalar: 0.95, + }); + + setTimeout(() => { + confetti({ + particleCount: 50, + spread: 90, + origin: { x: 0.5, y: 0.2 }, + ticks: 140, + scalar: 0.8, + }); + }, 260); + } catch {} +} + +export default function MobileEventAddonSuccessPage() { + const { slug } = useParams<{ slug: string }>(); + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation('management'); + const { textStrong, text, muted, border, successText, warningText, danger, primary } = useAdminTheme(); + const back = useBackNavigation(slug ? ADMIN_EVENT_ADDONS_PATH(slug) : adminPath('/mobile/events')); + + const [event, setEvent] = React.useState(null); + const [purchase, setPurchase] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const confettiTriggeredRef = React.useRef(false); + + const query = React.useMemo(() => { + const params = new URLSearchParams(location.search); + + return { + addonIntent: params.get('addon_intent') ?? undefined, + checkoutId: params.get('checkout_id') ?? undefined, + addonKey: params.get('addon_key') ?? undefined, + }; + }, [location.search]); + + const load = React.useCallback(async () => { + if (!slug) { + return; + } + + setLoading(true); + + try { + const [eventData, purchaseData] = await Promise.all([ + getEvent(slug), + getEventAddonPurchase(slug, { + addonIntent: query.addonIntent, + checkoutId: query.checkoutId, + addonKey: query.addonKey, + }), + ]); + + setEvent(eventData); + setPurchase(purchaseData); + + if (!purchaseData) { + setError(t('events.addons.success.notFound', 'We are still confirming this purchase.')); + } else { + setError(null); + } + } catch (err) { + setError(getApiErrorMessage(err, t('events.addons.success.loadFailed', 'Purchase result could not be loaded.'))); + } finally { + setLoading(false); + } + }, [query.addonIntent, query.addonKey, query.checkoutId, slug, t]); + + React.useEffect(() => { + void load(); + }, [load]); + + React.useEffect(() => { + if (!purchase || purchase.status !== 'completed' || confettiTriggeredRef.current) { + return; + } + + confettiTriggeredRef.current = true; + void launchConfetti(); + }, [purchase]); + + const statusTone = purchase?.status === 'completed' ? 'success' : purchase?.status === 'pending' ? 'warning' : 'danger'; + const statusText = purchase + ? t(`mobileBilling.status.${purchase.status}`, purchase.status) + : t('events.addons.success.statusUnknown', 'Unknown'); + + const StatusIcon = purchase?.status === 'completed' ? CheckCircle2 : purchase?.status === 'pending' ? Clock3 : AlertTriangle; + const statusColor = purchase?.status === 'completed' ? successText : purchase?.status === 'pending' ? warningText : danger; + + if (loading) { + return ( + + + + + + + + ); + } + + return ( + + + + + + + {purchase?.status === 'completed' + ? t('events.addons.success.completedTitle', 'Purchase completed') + : purchase?.status === 'pending' + ? t('events.addons.success.pendingTitle', 'Purchase in progress') + : t('events.addons.success.failedTitle', 'Purchase failed')} + + + + {purchase?.status === 'completed' + ? t('events.addons.success.completedBody', 'Your event add-on is now active and limits update shortly.') + : purchase?.status === 'pending' + ? t('events.addons.success.pendingBody', 'We are still processing your payment confirmation.') + : t('events.addons.success.failedBody', 'The payment could not be completed. You can try again.')} + + {statusText} + + + + + {t('events.addons.event', 'Event')} + + + {resolveEventDisplayName(event)} + + + + + + + + {t('events.addons.success.summaryTitle', 'Purchase summary')} + + + + {error ? ( + + {error} + + ) : null} + + {purchase ? ( + + + + + + {purchase.checkout_id ? ( + + ) : null} + {purchase.transaction_id ? ( + + ) : null} + + ) : null} + + {purchase ? ( + + {purchase.extra_photos > 0 ? ( + + {t('mobileBilling.extra.photos', '+{{count}} photos', { count: purchase.extra_photos })} + + ) : null} + {purchase.extra_guests > 0 ? ( + + {t('mobileBilling.extra.guests', '+{{count}} guests', { count: purchase.extra_guests })} + + ) : null} + {purchase.extra_gallery_days > 0 ? ( + + {t('mobileBilling.extra.days', '+{{count}} days', { count: purchase.extra_gallery_days })} + + ) : null} + + ) : null} + + + + + + + {t('events.addons.success.nextTitle', 'What next?')} + + + + + navigate(slug ? ADMIN_EVENT_ADDONS_PATH(slug) : adminPath('/mobile/events'))} + /> + navigate(slug ? ADMIN_EVENT_CONTROL_ROOM_PATH(slug) : adminPath('/mobile/events'))} + /> + navigate(ADMIN_BILLING_PATH)} + /> + navigate(slug ? ADMIN_EVENT_VIEW_PATH(slug) : adminPath('/mobile/events'))} + /> + + + + + ); +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + const { textStrong, muted, border } = useAdminTheme(); + + return ( + + + {label} + + + {value} + + + ); +} diff --git a/resources/js/admin/mobile/EventAddonsPage.tsx b/resources/js/admin/mobile/EventAddonsPage.tsx index dfaff7e9..de88c119 100644 --- a/resources/js/admin/mobile/EventAddonsPage.tsx +++ b/resources/js/admin/mobile/EventAddonsPage.tsx @@ -17,7 +17,7 @@ import { } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { buildLimitWarnings } from '../lib/limitWarnings'; -import { adminPath, ADMIN_EVENT_VIEW_PATH } from '../constants'; +import { adminPath, ADMIN_EVENT_ADDON_SUCCESS_PATH, ADMIN_EVENT_VIEW_PATH } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { MobileShell } from './components/MobileShell'; @@ -312,11 +312,13 @@ export default function MobileEventAddonsPage() { const pagePath = adminPath(`/mobile/events/${slug}/addons`); const currentUrl = `${window.location.origin}${pagePath}`; + const successPath = `${ADMIN_EVENT_ADDON_SUCCESS_PATH(slug)}?addon_key=${encodeURIComponent(pendingAddonKey)}`; + const successUrl = `${window.location.origin}${successPath}`; try { const checkout = await createEventAddonCheckout(slug, { addon_key: pendingAddonKey, - success_url: `${currentUrl}?addon_success=1`, + success_url: successUrl, cancel_url: currentUrl, accepted_terms: consents.acceptedTerms, accepted_waiver: consents.acceptedWaiver, diff --git a/resources/js/admin/mobile/__tests__/EventAddonSuccessPage.test.tsx b/resources/js/admin/mobile/__tests__/EventAddonSuccessPage.test.tsx new file mode 100644 index 00000000..acb0bd32 --- /dev/null +++ b/resources/js/admin/mobile/__tests__/EventAddonSuccessPage.test.tsx @@ -0,0 +1,170 @@ +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: 77, + name: { de: 'Sommerfest', en: 'Summer Fest' }, + slug: 'summer-fest', + status: 'published', + event_date: '2026-07-01T10:00:00Z', + settings: {}, + }, + purchase: { + id: 901, + addon_key: 'extra_photos_500', + label: 'Extra photos 500', + quantity: 1, + status: 'completed', + amount: 12, + currency: 'EUR', + extra_photos: 500, + extra_guests: 0, + extra_gallery_days: 0, + purchased_at: '2026-02-07T12:00:00Z', + receipt_url: null, + checkout_id: 'ORDER-123', + transaction_id: 'TX-999', + created_at: '2026-02-07T11:59:00Z', + addon_intent: 'intent_abc', + event: { id: 77, slug: 'summer-fest', name: { de: 'Sommerfest', en: 'Summer Fest' } }, + }, +})); + +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, + useParams: () => ({ slug: fixtures.event.slug }), + useLocation: () => ({ pathname: '/event-admin/mobile/events/summer-fest/addons/success', search: '?addon_intent=intent_abc' }), +})); + +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('../../lib/events', () => ({ + resolveEventDisplayName: (event: any) => event?.name?.de ?? event?.name ?? 'Event', +})); + +vi.mock('../theme', () => ({ + useAdminTheme: () => ({ + text: '#111827', + muted: '#6b7280', + subtle: '#94a3b8', + border: '#e5e7eb', + primary: '#2563eb', + successText: '#16a34a', + warningText: '#f59e0b', + 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('@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('../../api', () => ({ + getEvent: vi.fn().mockResolvedValue(fixtures.event), + getEventAddonPurchase: vi.fn().mockResolvedValue(fixtures.purchase), +})); + +import MobileEventAddonSuccessPage from '../EventAddonSuccessPage'; +import { getEventAddonPurchase } from '../../api'; + +describe('MobileEventAddonSuccessPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ + matches: true, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); + }); + + it('loads purchase by addon intent and renders summary details', async () => { + const purchaseMock = vi.mocked(getEventAddonPurchase); + + render(); + + await waitFor(() => { + expect(purchaseMock).toHaveBeenCalledWith(fixtures.event.slug, { + addonIntent: 'intent_abc', + checkoutId: undefined, + addonKey: undefined, + }); + }); + + expect(await screen.findByText('Purchase summary')).toBeInTheDocument(); + expect(screen.getByText('Sommerfest')).toBeInTheDocument(); + expect(screen.getByText('Extra photos 500')).toBeInTheDocument(); + expect(screen.getByText('ORDER-123')).toBeInTheDocument(); + expect(screen.getByText('TX-999')).toBeInTheDocument(); + }); + + it('shows fallback error state when purchase is not found', async () => { + vi.mocked(getEventAddonPurchase).mockResolvedValue(null as any); + + render(); + + expect(await screen.findByText('We are still confirming this purchase.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Open billing' })); + expect(navigateMock).toHaveBeenCalledWith('/event-admin/mobile/billing'); + }); +}); diff --git a/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx index c8ced1ab..4b5db067 100644 --- a/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventAddonsPage.test.tsx @@ -186,7 +186,7 @@ describe('MobileEventAddonsPage', () => { 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`, + success_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons/success?addon_key=extra_photos_500`, cancel_url: `${window.location.origin}/event-admin/mobile/events/demo-event/addons`, accepted_terms: true, accepted_waiver: true, diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index f9179e72..114613d9 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -32,6 +32,7 @@ const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPag const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage')); const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage')); const MobileEventAddonsPage = React.lazy(() => import('./mobile/EventAddonsPage')); +const MobileEventAddonSuccessPage = React.lazy(() => import('./mobile/EventAddonSuccessPage')); const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage')); const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage')); const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage')); @@ -201,6 +202,7 @@ export const router = createBrowserRouter([ { 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/addons/success', element: `${ADMIN_EVENTS_PATH}/${slug}/addons/success`} /> }, { 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`} /> }, @@ -219,6 +221,7 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/live-show/settings', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/addons', element: }, + { path: 'mobile/events/:slug/addons/success', element: }, { path: 'mobile/events/:slug/analytics', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: }, diff --git a/routes/api.php b/routes/api.php index 695dd55b..f8384906 100644 --- a/routes/api.php +++ b/routes/api.php @@ -302,6 +302,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index'); Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store'); Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout'); + Route::get('addons/purchase', [EventAddonController::class, 'purchase'])->name('tenant.events.addons.purchase'); Route::prefix('live-show')->group(function () { Route::get('link', [LiveShowLinkController::class, 'show'])->name('tenant.events.live-show.link'); diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index b24d4821..08000090 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -67,6 +67,8 @@ class EventAddonCheckoutTest extends TenantTestCase $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ 'addon_key' => 'extra_photos_small', 'quantity' => 2, + 'success_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons/success?addon_key=extra_photos_small', + 'cancel_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons', 'accepted_terms' => true, 'accepted_waiver' => true, ]); @@ -87,6 +89,9 @@ class EventAddonCheckoutTest extends TenantTestCase $this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null); $this->assertSame(500, $addon->metadata['increments']['extra_photos'] ?? null); $this->assertNull($addon->metadata['price_eur'] ?? null); + $this->assertNotEmpty($addon->metadata['addon_intent'] ?? null); + $this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['success_url'] ?? '')); + $this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['cancel_url'] ?? '')); } public function test_paypal_checkout_creates_pending_addon_record(): void @@ -132,6 +137,8 @@ class EventAddonCheckoutTest extends TenantTestCase $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ 'addon_key' => 'extra_photos_small', 'quantity' => 2, + 'success_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons/success?addon_key=extra_photos_small', + 'cancel_url' => 'https://app.fotospiel.test/event-admin/mobile/events/test-event/addons', 'accepted_terms' => true, 'accepted_waiver' => true, ]); @@ -154,6 +161,9 @@ class EventAddonCheckoutTest extends TenantTestCase $this->assertSame(1000, $addon->extra_photos); $this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null); $this->assertSame(12.5, $addon->metadata['price_eur'] ?? null); + $this->assertNotEmpty($addon->metadata['addon_intent'] ?? null); + $this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['paypal_success_url'] ?? '')); + $this->assertStringContainsString('addon_intent=', (string) ($addon->metadata['paypal_cancel_url'] ?? '')); } public function test_ai_styling_checkout_persists_feature_entitlement_metadata(): void diff --git a/tests/Feature/Tenant/EventAddonPurchaseLookupTest.php b/tests/Feature/Tenant/EventAddonPurchaseLookupTest.php new file mode 100644 index 00000000..486a9bf6 --- /dev/null +++ b/tests/Feature/Tenant/EventAddonPurchaseLookupTest.php @@ -0,0 +1,87 @@ +endcustomer()->create([ + 'max_photos' => 100, + 'max_guests' => 50, + 'gallery_days' => 7, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'summer-party', + 'name' => ['de' => 'Sommerparty', 'en' => 'Summer Party'], + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subDay(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $addon = EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'extra_photos_500', + 'quantity' => 1, + 'extra_photos' => 500, + 'extra_guests' => 0, + 'extra_gallery_days' => 0, + 'checkout_id' => 'ORDER-ADDON-123', + 'transaction_id' => 'TX-ABC', + 'status' => 'completed', + 'amount' => 12.00, + 'currency' => 'EUR', + 'metadata' => [ + 'label' => 'Extra photos 500', + 'addon_intent' => 'intent_123', + ], + 'purchased_at' => now(), + 'receipt_payload' => ['receipt_url' => 'https://receipt.test/addon'], + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/addons/purchase?addon_intent=intent_123"); + + $response->assertOk(); + $response->assertJsonPath('data.id', $addon->id); + $response->assertJsonPath('data.addon_key', 'extra_photos_500'); + $response->assertJsonPath('data.label', 'Extra photos 500'); + $response->assertJsonPath('data.checkout_id', 'ORDER-ADDON-123'); + $response->assertJsonPath('data.transaction_id', 'TX-ABC'); + $response->assertJsonPath('data.status', 'completed'); + $response->assertJsonPath('data.amount', 12); + $response->assertJsonPath('data.currency', 'EUR'); + $response->assertJsonPath('data.extra_photos', 500); + $response->assertJsonPath('data.receipt_url', 'https://receipt.test/addon'); + $response->assertJsonPath('data.addon_intent', 'intent_123'); + $response->assertJsonPath('data.event.slug', 'summer-party'); + } + + public function test_tenant_cannot_load_purchase_for_event_of_other_tenant(): void + { + $otherTenant = Tenant::factory()->create(); + $otherEvent = Event::factory()->for($otherTenant)->create([ + 'slug' => 'foreign-event', + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$otherEvent->slug}/addons/purchase?addon_key=extra_photos_500"); + + $response->assertStatus(404); + $response->assertJsonPath('error.code', 'event_not_found'); + } +}