Files
fotospiel-app/resources/js/admin/mobile/EventAddonSuccessPage.tsx
Codex Agent c0c082975e
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add event addon purchase success page with confetti
2026-02-07 17:04:14 +01:00

307 lines
11 KiB
TypeScript

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>
);
}