307 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|