Add event addon purchase success page with confetti
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 17:04:14 +01:00
parent 64b3bf3ed4
commit c0c082975e
15 changed files with 837 additions and 7 deletions

View File

@@ -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,
],
]);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class EventAddonPurchaseLookupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'addon_intent' => ['nullable', 'string', 'max:191'],
'checkout_id' => ['nullable', 'string', 'max:191'],
'addon_key' => ['nullable', 'string', 'max:191'],
];
}
}

View File

@@ -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<string, mixed> $addon
*/

View File

@@ -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<EventAddonPurchaseSummary | null> {
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<EventAddonCatalogItem[]> {
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');

View File

@@ -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 =>

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<void> {
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<TenantEvent | null>(null);
const [purchase, setPurchase] = React.useState<EventAddonPurchaseSummary | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<MobileShell title={t('events.addons.success.title', 'Add-on purchase')} activeTab="home" onBack={back}>
<YStack gap="$3">
<SkeletonCard height={130} />
<SkeletonCard height={210} />
<SkeletonCard height={170} />
</YStack>
</MobileShell>
);
}
return (
<MobileShell title={t('events.addons.success.title', 'Add-on purchase')} activeTab="home" onBack={back}>
<YStack gap="$3">
<MobileCard gap="$2" borderColor={statusColor}>
<XStack alignItems="center" gap="$2">
<StatusIcon size={18} color={statusColor} />
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{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')}
</Text>
</XStack>
<Text fontSize="$sm" color={text}>
{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.')}
</Text>
<PillBadge tone={statusTone}>{statusText}</PillBadge>
</MobileCard>
<MobileCard gap="$2">
<Text fontSize="$sm" color={muted}>
{t('events.addons.event', 'Event')}
</Text>
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
</MobileCard>
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<ReceiptText size={16} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.addons.success.summaryTitle', 'Purchase summary')}
</Text>
</XStack>
{error ? (
<Text fontSize="$sm" color={danger}>
{error}
</Text>
) : null}
{purchase ? (
<YStack gap="$1.5">
<SummaryRow label={t('events.addons.success.addonType', 'Add-on')} value={purchase.label ?? purchase.addon_key} />
<SummaryRow label={t('events.addons.success.amount', 'Amount')} value={formatAmount(purchase.amount, purchase.currency)} />
<SummaryRow label={t('events.addons.success.quantity', 'Quantity')} value={String(purchase.quantity)} />
<SummaryRow
label={t('events.addons.success.purchasedAt', 'Purchased at')}
value={formatDateTime(purchase.purchased_at ?? purchase.created_at)}
/>
{purchase.checkout_id ? (
<SummaryRow label={t('events.addons.success.checkoutId', 'Checkout ID')} value={purchase.checkout_id} />
) : null}
{purchase.transaction_id ? (
<SummaryRow label={t('events.addons.success.transactionId', 'Transaction ID')} value={purchase.transaction_id} />
) : null}
</YStack>
) : null}
{purchase ? (
<XStack gap="$2" flexWrap="wrap" marginTop="$1">
{purchase.extra_photos > 0 ? (
<PillBadge tone="muted">
{t('mobileBilling.extra.photos', '+{{count}} photos', { count: purchase.extra_photos })}
</PillBadge>
) : null}
{purchase.extra_guests > 0 ? (
<PillBadge tone="muted">
{t('mobileBilling.extra.guests', '+{{count}} guests', { count: purchase.extra_guests })}
</PillBadge>
) : null}
{purchase.extra_gallery_days > 0 ? (
<PillBadge tone="muted">
{t('mobileBilling.extra.days', '+{{count}} days', { count: purchase.extra_gallery_days })}
</PillBadge>
) : null}
</XStack>
) : null}
</MobileCard>
<MobileCard gap="$2">
<XStack alignItems="center" gap="$2">
<Sparkles size={16} color={primary} />
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{t('events.addons.success.nextTitle', 'What next?')}
</Text>
</XStack>
<YStack gap="$2">
<CTAButton
label={t('events.addons.success.backToAddons', 'Back to add-on manager')}
onPress={() => navigate(slug ? ADMIN_EVENT_ADDONS_PATH(slug) : adminPath('/mobile/events'))}
/>
<CTAButton
label={t('events.addons.success.openControlRoom', 'Open control room')}
tone="ghost"
onPress={() => navigate(slug ? ADMIN_EVENT_CONTROL_ROOM_PATH(slug) : adminPath('/mobile/events'))}
/>
<CTAButton
label={t('events.addons.success.openBilling', 'Open billing')}
tone="ghost"
onPress={() => navigate(ADMIN_BILLING_PATH)}
/>
<CTAButton
label={t('events.addons.success.backToEvent', 'Back to event')}
tone="ghost"
onPress={() => navigate(slug ? ADMIN_EVENT_VIEW_PATH(slug) : adminPath('/mobile/events'))}
/>
</YStack>
</MobileCard>
</YStack>
</MobileShell>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted, border } = useAdminTheme();
return (
<XStack alignItems="center" justifyContent="space-between" gap="$2" borderBottomWidth={1} borderColor={border} paddingBottom="$1.5">
<Text fontSize="$xs" color={muted}>
{label}
</Text>
<Text fontSize="$sm" color={textStrong} fontWeight="700" textAlign="right" flexShrink={1}>
{value}
</Text>
</XStack>
);
}

View File

@@ -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,

View File

@@ -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<string, unknown>, options?: Record<string, unknown>) => {
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 }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>{label}</button>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
SkeletonCard: () => <div>Loading...</div>,
}));
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('../../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(<MobileEventAddonSuccessPage />);
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(<MobileEventAddonSuccessPage />);
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');
});
});

View File

@@ -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,

View File

@@ -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: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/members`} /> },
{ path: 'events/:slug/tasks', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/tasks`} /> },
{ path: 'events/:slug/addons', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/addons`} /> },
{ path: 'events/:slug/addons/success', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/addons/success`} /> },
{ path: 'events/:slug/invites', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/qr`} /> },
{ path: 'events/:slug/branding', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/branding`} /> },
{ path: 'events/:slug/photobooth', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/photobooth`} /> },
@@ -219,6 +221,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/recap', element: <RequireAdminAccess><MobileEventRecapPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/addons', element: <RequireAdminAccess><MobileEventAddonsPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/addons/success', element: <RequireAdminAccess><MobileEventAddonSuccessPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/analytics', element: <RequireAdminAccess><MobileEventAnalyticsPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },

View File

@@ -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');

View File

@@ -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

View File

@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\Tenant;
class EventAddonPurchaseLookupTest extends TenantTestCase
{
public function test_tenant_can_load_event_addon_purchase_by_intent(): void
{
$package = Package::factory()->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');
}
}