import { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Typography, Stack, Alert, Button, Link, Divider, LinearProgress, Skeleton, keyframes, } from '@mui/material'; import CheckIcon from '@mui/icons-material/Check'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { joinURL } from 'ufo'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import type { TCheckoutSessionExpanded } from '@blocklet/payment-types'; import { usePaymentMethodContext } from '@blocklet/payment-react-headless'; import { getPrefix } from '../../libs/util'; import { formatTokenAmount, primaryContrastColor } from '../utils/format'; // ── Animations ── const scaleIn = keyframes` from { transform: scale(0); opacity: 0; } 60% { transform: scale(1.15); } to { transform: scale(1); opacity: 1; } `; const fadeUp = keyframes` from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } `; // ── Confetti ── function useConfetti(containerRef: React.RefObject, enabled: boolean) { const firedRef = useRef(false); useEffect(() => { if (!enabled || firedRef.current || !containerRef.current) return undefined; firedRef.current = true; const container = containerRef.current; const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:10;'; canvas.width = container.offsetWidth; canvas.height = container.offsetHeight; container.appendChild(canvas); const ctx = canvas.getContext('2d'); if (!ctx) return undefined; const colors = ['#3b82f6', '#60a5fa', '#34d399', '#fbbf24', '#f472b6', '#a78bfa', '#f97316']; const pieces: Array<{ x: number; y: number; vx: number; vy: number; w: number; h: number; color: string; rot: number; rv: number; opacity: number; }> = []; for (let i = 0; i < 80; i++) { pieces.push({ x: canvas.width / 2 + (Math.random() - 0.5) * 60, y: canvas.height * 0.35, vx: (Math.random() - 0.5) * 12, vy: -Math.random() * 14 - 4, w: Math.random() * 8 + 4, h: Math.random() * 6 + 2, color: colors[Math.floor(Math.random() * colors.length)], rot: Math.random() * Math.PI * 2, rv: (Math.random() - 0.5) * 0.3, opacity: 1, }); } let frame: number; const gravity = 0.25; const friction = 0.99; const animate = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); let alive = false; for (const p of pieces) { p.vy += gravity; p.vx *= friction; p.x += p.vx; p.y += p.vy; p.rot += p.rv; if (p.y > canvas.height * 0.6) { p.opacity -= 0.02; } if (p.opacity > 0) { alive = true; ctx.save(); ctx.globalAlpha = p.opacity; ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.restore(); } } if (alive) { frame = requestAnimationFrame(animate); } else { canvas.remove(); } }; // Small delay so the icon animation plays first const timer = setTimeout(() => { frame = requestAnimationFrame(animate); }, 400); return () => { clearTimeout(timer); cancelAnimationFrame(frame); canvas.remove(); }; }, [enabled, containerRef]); } // ── Types ── interface VendorInfo { success: boolean; status: 'delivered' | 'pending' | 'failed'; progress: number; message: string; appUrl?: string; title?: string; name?: string; vendorType: string; } interface SuccessViewProps { submit: { vendorStatus: { payment_status: string; session_status: string; vendors: VendorInfo[]; error: string | null; isAllCompleted: boolean; hasFailed: boolean; } | null; result: { checkoutSession: TCheckoutSessionExpanded; } | null; }; session: TCheckoutSessionExpanded | null | undefined; } // ── Helpers ── function getCustomMessage(session: TCheckoutSessionExpanded | null | undefined): string | undefined { return (session as any)?.payment_link?.after_completion?.hosted_confirmation?.custom_message; } function getPayee(session: TCheckoutSessionExpanded | null | undefined): string { const items = (session as any)?.line_items || []; for (const item of items) { if (item?.price?.product?.statement_descriptor) { return item.price.product.statement_descriptor; } } return (session as any)?.app_name || (session as any)?.payment_link?.app_name || ''; } function getVendorLabel(vendor: VendorInfo, isFailed: boolean, t: (key: string, params?: any) => string): string { const name = vendor.name || vendor.title; const isCompleted = vendor.status === 'delivered'; if (vendor.vendorType === 'didnames') { if (isFailed) return t('payment.checkout.vendor.didnames.failed', { name }); if (isCompleted) return t('payment.checkout.vendor.didnames.completed', { name }); return t('payment.checkout.vendor.didnames.processing', { name }); } if (isFailed) return t('payment.checkout.vendor.launcher.failed', { name }); if (isCompleted) return t('payment.checkout.vendor.launcher.completed', { name }); return t('payment.checkout.vendor.launcher.processing', { name }); } // ── Hero Icon ── function HeroSuccessIcon() { return ( theme.palette.mode === 'dark' ? 'radial-gradient(circle, rgba(59,130,246,0.12) 0%, transparent 70%)' : 'radial-gradient(circle, rgba(59,130,246,0.08) 0%, transparent 70%)', borderRadius: '50%', pointerEvents: 'none', }} /> theme.palette.mode === 'dark' ? 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, rgba(255,255,255,0.04) 100%)' : 'linear-gradient(135deg, #eff6ff 0%, #ffffff 100%)', border: '1px solid', borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.2)' : 'rgba(59,130,246,0.12)'), boxShadow: (theme) => theme.palette.mode === 'dark' ? '0 10px 30px -5px rgba(0,0,0,0.3)' : '0 10px 30px -5px rgba(59,130,246,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}> theme.palette.mode === 'dark' ? 'linear-gradient(to top-right, rgba(59,130,246,0.1), transparent)' : 'linear-gradient(to top-right, rgba(59,130,246,0.06), transparent)', pointerEvents: 'none', }} /> theme.palette.mode === 'dark' ? 'drop-shadow(0 0 12px rgba(59,130,246,0.4))' : 'drop-shadow(0 0 12px rgba(59,130,246,0.2))', }} /> (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(59,130,246,0.08)'), display: 'flex', alignItems: 'center', justifyContent: 'center', }}> ); } // ── Payment Receipt (mobile only) ── function PaymentReceipt({ session, t, }: { session: TCheckoutSessionExpanded | null | undefined; t: (key: string, params?: any) => string; }) { const { currencies } = usePaymentMethodContext(); const amountTotal = (session as any)?.amount_total; const currencyId = (session as any)?.currency_id; // Skip for trials or no amount const subData = (session as any)?.subscription_data; if (Number(subData?.trial_period_days || 0) > 0) return null; if (!amountTotal || amountTotal === '0') return null; // Find currency object from available currencies const currency = currencies.find((c) => c.id === currencyId) || null; if (!currency) return null; const formatted = formatTokenAmount(amountTotal, currency); if (!formatted || formatted === '0') return null; const amountStr = `${formatted} ${currency.symbol || ''}`.trim(); // Split the translated sentence around the amount to bold it const fullText = t('payment.checkout.completed.summary.paid', { amount: amountStr }); const idx = fullText.indexOf(amountStr); return ( {idx >= 0 ? ( <> {fullText.slice(0, idx)} {amountStr} {fullText.slice(idx + amountStr.length)} ) : ( fullText )} ); } // ── Vendor Progress Item ── function VendorProgressItemV2({ vendor, t }: { vendor: VendorInfo; t: (key: string, params?: any) => string }) { const [displayProgress, setDisplayProgress] = useState(0); const animationRef = useRef(); const startAnimation = useCallback(() => { const realProgress = vendor.progress || 0; let startTime: number; let startProgress: number; const animate = (currentTime: number) => { if (!startTime) { startTime = currentTime; startProgress = displayProgress; } const elapsed = currentTime - startTime; let newProgress: number; if (realProgress === 100) { newProgress = 100; } else if (realProgress === 0) { newProgress = Math.min(startProgress + elapsed / 1000, 99); } else if (realProgress > startProgress) { const progress = Math.min(elapsed / 1000, 1); newProgress = startProgress + (realProgress - startProgress) * progress; } else { newProgress = Math.min(startProgress + elapsed / 1000, 99); } newProgress = Math.round(newProgress); setDisplayProgress((pre) => Math.min(pre > newProgress ? pre : newProgress, 100)); if (realProgress === 100) return; if (newProgress < 99 && realProgress < 100) { animationRef.current = requestAnimationFrame(animate); } }; if (animationRef.current) cancelAnimationFrame(animationRef.current); animationRef.current = requestAnimationFrame(animate); }, [vendor.progress, displayProgress]); useEffect(() => { startAnimation(); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); }; }, [startAnimation]); const isCompleted = displayProgress >= 100; const isFailed = vendor.status === 'failed'; const nameText = getVendorLabel(vendor, isFailed, t); if (!vendor.name && !vendor.title) { return ( ); } return ( {nameText} {isCompleted && !isFailed && } {!isCompleted && ( {t('payment.checkout.vendor.progress', { progress: isFailed ? 0 : displayProgress })} )} (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'grey.200'), '& .MuiLinearProgress-bar': { borderRadius: 3, // eslint-disable-next-line no-nested-ternary bgcolor: isFailed ? 'error.main' : isCompleted ? 'success.main' : 'primary.main', transition: 'background-color 0.3s linear', }, }} /> ); } // ── Vendor Progress Panel ── function VendorProgressPanel({ vendorStatus, pageInfo = undefined, locale, t, }: { vendorStatus: NonNullable; pageInfo?: any; locale: string; t: (key: string, params?: any) => string; }) { return ( (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'grey.50'), border: '1px solid', borderColor: 'divider', animation: `${fadeUp} 0.5s ease 0.3s both`, }}> {(vendorStatus.vendors || []).map((vendor, idx) => ( ))} {vendorStatus.hasFailed && ( {t('payment.checkout.vendor.failedMsg')} )} {vendorStatus.isAllCompleted && pageInfo?.success_message?.[locale] && ( {pageInfo.success_message[locale]} )} ); } // ── Subscription Links ── function SubscriptionLinks({ mode, subscriptions, subscriptionId = undefined, payee, prefix, t, }: { mode: string; subscriptions: any[]; subscriptionId?: string; payee: string; prefix: string; t: (key: string, params?: any) => string; }) { if (!['subscription', 'setup'].includes(mode)) return null; if (subscriptions.length > 1) { return ( (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.04)' : 'grey.50'), border: '1px solid', borderColor: 'divider', overflow: 'hidden', animation: `${fadeUp} 0.5s ease 0.35s both`, }}> {subscriptions.map((sub: any, idx: number) => ( {idx > 0 && } {sub.description || sub.id} {t('payment.checkout.next.view')} ))} ); } if (subscriptionId) { return ( ); } return null; } // ── Invoice Link ── function InvoiceLink({ mode, invoiceId = undefined, prefix, t, }: { mode: string; invoiceId?: string; prefix: string; t: (key: string, params?: any) => string; }) { if (mode !== 'payment' || !invoiceId) return null; return ( ); } // ── Main Component ── export default function SuccessView({ submit, session }: SuccessViewProps) { const { t, locale } = useLocaleContext(); const { vendorStatus } = submit; const payee = getPayee(session); const prefix = getPrefix(); const mode = session?.mode || 'payment'; const resultSession = submit.result?.checkoutSession as any; const subscriptions = (session as any)?.subscriptions || []; const subscriptionId = (session as any)?.subscription_id || resultSession?.subscription_id; const invoiceId = (session as any)?.invoice_id || (submit.result?.checkoutSession as any)?.invoice_id || (submit.result?.checkoutSession as any)?.payment_intent?.invoice_id; const pageInfo = (session as any)?.metadata?.page_info; const customMessage = getCustomMessage(session); const submitType = (session as any)?.submit_type; const messageKey = submitType === 'donate' ? 'payment.checkout.completed.donate' : `payment.checkout.completed.${mode}`; const headline = customMessage || t(messageKey); const isVendorProcessing = vendorStatus && !vendorStatus.isAllCompleted && !vendorStatus.hasFailed; // Confetti const containerRef = useRef(null); const showConfetti = !vendorStatus?.hasFailed; useConfetti(containerRef, showConfetti); return ( {/* Hero icon */} {vendorStatus?.hasFailed ? ( ) : ( )} {/* Title + subtitle */} {headline} {payee && ( {t('payment.checkout.completed.tip', { payee })} )} {/* Payment receipt — trial/subscription summary */} {/* Vendor progress panel */} {vendorStatus && } {/* Vendor failed warning */} {vendorStatus?.hasFailed && vendorStatus.error && ( {vendorStatus.error} )} {/* Subscription links */} {!isVendorProcessing && ( )} {/* Invoice link */} {!isVendorProcessing && } {/* Back button */} {!isVendorProcessing && ( )} ); }