Add event addon purchase success page with confetti
This commit is contained in:
306
resources/js/admin/mobile/EventAddonSuccessPage.tsx
Normal file
306
resources/js/admin/mobile/EventAddonSuccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user