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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user