UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

617 lines (616 loc) 22.6 kB
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; 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 { usePaymentMethodContext } from "@blocklet/payment-react-headless"; import { getPrefix } from "../../libs/util.js"; import { formatTokenAmount, primaryContrastColor } from "../utils/format.js"; 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); } `; function useConfetti(containerRef, enabled) { const firedRef = useRef(false); useEffect(() => { if (!enabled || firedRef.current || !containerRef.current) return void 0; 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 void 0; const colors = ["#3b82f6", "#60a5fa", "#34d399", "#fbbf24", "#f472b6", "#a78bfa", "#f97316"]; const pieces = []; 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; 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(); } }; const timer = setTimeout(() => { frame = requestAnimationFrame(animate); }, 400); return () => { clearTimeout(timer); cancelAnimationFrame(frame); canvas.remove(); }; }, [enabled, containerRef]); } function getCustomMessage(session) { return session?.payment_link?.after_completion?.hosted_confirmation?.custom_message; } function getPayee(session) { const items = session?.line_items || []; for (const item of items) { if (item?.price?.product?.statement_descriptor) { return item.price.product.statement_descriptor; } } return session?.app_name || session?.payment_link?.app_name || ""; } function getVendorLabel(vendor, isFailed, t) { 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 }); } function HeroSuccessIcon() { return /* @__PURE__ */ jsxs( Box, { sx: { position: "relative", mb: 1, animation: `${scaleIn} 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both` }, children: [ /* @__PURE__ */ jsx( Box, { sx: { position: "absolute", inset: -24, background: (theme) => 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" } } ), /* @__PURE__ */ jsxs( Box, { sx: { position: "relative", width: { xs: 100, md: 120 }, height: { xs: 100, md: 120 }, borderRadius: { xs: "28px", md: "32px" }, background: (theme) => 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" }, children: [ /* @__PURE__ */ jsx( Box, { sx: { position: "absolute", inset: 0, borderRadius: "inherit", background: (theme) => 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" } } ), /* @__PURE__ */ jsx( VerifiedUserIcon, { sx: { fontSize: { xs: 52, md: 64 }, color: "primary.main", filter: (theme) => 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))" } } ) ] } ), /* @__PURE__ */ jsx( Box, { sx: { position: "absolute", bottom: { xs: -6, md: -8 }, right: { xs: -6, md: -8 }, width: { xs: 32, md: 38 }, height: { xs: 32, md: 38 }, borderRadius: { xs: "12px", md: "14px" }, bgcolor: "background.paper", boxShadow: "0 4px 12px rgba(0,0,0,0.1)", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(59,130,246,0.08)", display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsx(CheckCircleIcon, { sx: { fontSize: { xs: 20, md: 24 }, color: "success.main" } }) } ) ] } ); } function PaymentReceipt({ session, t }) { const { currencies } = usePaymentMethodContext(); const amountTotal = session?.amount_total; const currencyId = session?.currency_id; const subData = session?.subscription_data; if (Number(subData?.trial_period_days || 0) > 0) return null; if (!amountTotal || amountTotal === "0") return null; 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(); const fullText = t("payment.checkout.completed.summary.paid", { amount: amountStr }); const idx = fullText.indexOf(amountStr); return /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 15, md: 16 }, color: "text.secondary", fontWeight: 500, lineHeight: 1.6, textAlign: "center", maxWidth: 440, animation: `${fadeUp} 0.5s ease 0.3s both` }, children: idx >= 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [ fullText.slice(0, idx), /* @__PURE__ */ jsx(Box, { component: "span", sx: { fontWeight: 700, color: "text.primary" }, children: amountStr }), fullText.slice(idx + amountStr.length) ] }) : fullText } ); } function VendorProgressItemV2({ vendor, t }) { const [displayProgress, setDisplayProgress] = useState(0); const animationRef = useRef(); const startAnimation = useCallback(() => { const realProgress = vendor.progress || 0; let startTime; let startProgress; const animate = (currentTime) => { if (!startTime) { startTime = currentTime; startProgress = displayProgress; } const elapsed = currentTime - startTime; let newProgress; if (realProgress === 100) { newProgress = 100; } else if (realProgress === 0) { newProgress = Math.min(startProgress + elapsed / 1e3, 99); } else if (realProgress > startProgress) { const progress = Math.min(elapsed / 1e3, 1); newProgress = startProgress + (realProgress - startProgress) * progress; } else { newProgress = Math.min(startProgress + elapsed / 1e3, 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 /* @__PURE__ */ jsxs(Box, { sx: { mb: 1.5 }, children: [ /* @__PURE__ */ jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "center", sx: { mb: 0.75 }, children: [ /* @__PURE__ */ jsx(Skeleton, { variant: "rounded", height: 14, width: 150 }), /* @__PURE__ */ jsx(Skeleton, { variant: "rounded", height: 14, width: 50 }) ] }), /* @__PURE__ */ jsx(Skeleton, { variant: "rounded", height: 6, width: "100%" }) ] }); } return /* @__PURE__ */ jsxs(Box, { sx: { mb: 1.5 }, children: [ /* @__PURE__ */ jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "center", sx: { mb: 0.75 }, children: [ /* @__PURE__ */ jsxs( Typography, { sx: { fontSize: 13, fontWeight: 600, color: isFailed ? "error.main" : "text.secondary", display: "flex", alignItems: "center" }, children: [ nameText, isCompleted && !isFailed && /* @__PURE__ */ jsx(CheckIcon, { sx: { color: "success.main", ml: 0.5, fontSize: 16 } }) ] } ), !isCompleted && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 12, fontWeight: 600, color: isFailed ? "error.main" : "text.secondary" }, children: t("payment.checkout.vendor.progress", { progress: isFailed ? 0 : displayProgress }) }) ] }), /* @__PURE__ */ jsx( LinearProgress, { variant: "determinate", value: isFailed ? 100 : displayProgress || 0, sx: { height: 6, borderRadius: 3, bgcolor: (theme) => 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" } } } ) ] }); } function VendorProgressPanel({ vendorStatus, pageInfo = void 0, locale, t }) { return /* @__PURE__ */ jsxs( Box, { sx: { width: "100%", maxWidth: 420, p: 2.5, borderRadius: 3, bgcolor: (theme) => 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` }, children: [ (vendorStatus.vendors || []).map((vendor, idx) => /* @__PURE__ */ jsx(VendorProgressItemV2, { vendor, t }, vendor.title || `vendor-${idx}`)), vendorStatus.hasFailed && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 13, fontWeight: 600, color: "warning.main", mt: 1 }, children: t("payment.checkout.vendor.failedMsg") }), vendorStatus.isAllCompleted && pageInfo?.success_message?.[locale] && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 14, fontWeight: 600, color: "text.primary", mt: 1 }, children: pageInfo.success_message[locale] }) ] } ); } function SubscriptionLinks({ mode, subscriptions, subscriptionId = void 0, payee, prefix, t }) { if (!["subscription", "setup"].includes(mode)) return null; if (subscriptions.length > 1) { return /* @__PURE__ */ jsx( Box, { sx: { width: "100%", maxWidth: 420, borderRadius: 3, bgcolor: (theme) => 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` }, children: subscriptions.map((sub, idx) => /* @__PURE__ */ jsxs(Box, { children: [ idx > 0 && /* @__PURE__ */ jsx(Divider, {}), /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", sx: { px: 2.5, py: 1.5 }, children: [ /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 13, fontWeight: 500, color: "text.secondary", flex: 1, minWidth: 0 }, noWrap: true, children: sub.description || sub.id }), /* @__PURE__ */ jsx( Box, { sx: { flex: 1, borderBottom: "1px dashed", borderColor: "grey.300", mx: 2, minWidth: 20 } } ), /* @__PURE__ */ jsxs( Link, { href: joinURL(prefix, `/customer/subscription/${sub.id}`), underline: "none", sx: { display: "flex", alignItems: "center", gap: 0.5, fontSize: 13, fontWeight: 600, color: "primary.main", flexShrink: 0 }, children: [ t("payment.checkout.next.view"), /* @__PURE__ */ jsx(OpenInNewIcon, { sx: { fontSize: 14 } }) ] } ) ] }) ] }, sub.id)) } ); } if (subscriptionId) { return /* @__PURE__ */ jsx( Button, { variant: "contained", href: joinURL(prefix, `/customer/subscription/${subscriptionId}`), endIcon: /* @__PURE__ */ jsx(OpenInNewIcon, { sx: { fontSize: 16 } }), sx: { width: "100%", maxWidth: 320, height: 52, borderRadius: "16px", textTransform: "none", fontWeight: 700, fontSize: { xs: 16, md: 17 }, letterSpacing: "0.02em", color: (theme) => primaryContrastColor(theme), boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)", "&:hover": { boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)" }, animation: `${fadeUp} 0.5s ease 0.35s both` }, children: t("payment.checkout.next.subscription", { payee }) } ); } return null; } function InvoiceLink({ mode, invoiceId = void 0, prefix, t }) { if (mode !== "payment" || !invoiceId) return null; return /* @__PURE__ */ jsx( Button, { variant: "contained", href: joinURL(prefix, `/customer/invoice/${invoiceId}`), endIcon: /* @__PURE__ */ jsx(OpenInNewIcon, { sx: { fontSize: 16 } }), sx: { width: "100%", maxWidth: 320, height: 52, borderRadius: "16px", textTransform: "none", fontWeight: 700, fontSize: { xs: 16, md: 17 }, letterSpacing: "0.02em", color: (theme) => primaryContrastColor(theme), boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)", "&:hover": { boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)" }, animation: `${fadeUp} 0.5s ease 0.35s both` }, children: t("payment.checkout.next.invoice") } ); } export default function SuccessView({ submit, session }) { const { t, locale } = useLocaleContext(); const { vendorStatus } = submit; const payee = getPayee(session); const prefix = getPrefix(); const mode = session?.mode || "payment"; const resultSession = submit.result?.checkoutSession; const subscriptions = session?.subscriptions || []; const subscriptionId = session?.subscription_id || resultSession?.subscription_id; const invoiceId = session?.invoice_id || submit.result?.checkoutSession?.invoice_id || submit.result?.checkoutSession?.payment_intent?.invoice_id; const pageInfo = session?.metadata?.page_info; const customMessage = getCustomMessage(session); const submitType = session?.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; const containerRef = useRef(null); const showConfetti = !vendorStatus?.hasFailed; useConfetti(containerRef, showConfetti); return /* @__PURE__ */ jsx( Box, { ref: containerRef, sx: { position: "relative", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", flex: 1, minHeight: 400, p: { xs: 3, md: 4 }, textAlign: "center", overflow: "hidden" }, children: /* @__PURE__ */ jsxs(Stack, { spacing: 3, alignItems: "center", sx: { width: "100%", maxWidth: 440 }, children: [ vendorStatus?.hasFailed ? /* @__PURE__ */ jsx( ErrorOutlineIcon, { sx: { fontSize: 64, color: "warning.main", animation: `${scaleIn} 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both` } } ) : /* @__PURE__ */ jsx(HeroSuccessIcon, {}), /* @__PURE__ */ jsxs(Stack, { spacing: 1, alignItems: "center", sx: { animation: `${fadeUp} 0.5s ease 0.2s both` }, children: [ /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 28, md: 36 }, fontWeight: 800, color: "text.primary", lineHeight: 1.2, letterSpacing: "-0.02em" }, children: headline } ), payee && /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 14, md: 16 }, color: "text.secondary", fontWeight: 500, lineHeight: 1.6, maxWidth: 360 }, children: t("payment.checkout.completed.tip", { payee }) } ) ] }), /* @__PURE__ */ jsx(PaymentReceipt, { session: resultSession || session, t }), vendorStatus && /* @__PURE__ */ jsx(VendorProgressPanel, { vendorStatus, pageInfo, locale, t }), vendorStatus?.hasFailed && vendorStatus.error && /* @__PURE__ */ jsx(Alert, { severity: "warning", sx: { maxWidth: 420, borderRadius: 3, width: "100%" }, children: vendorStatus.error }), !isVendorProcessing && /* @__PURE__ */ jsx( SubscriptionLinks, { mode, subscriptions, subscriptionId, payee, prefix, t } ), !isVendorProcessing && /* @__PURE__ */ jsx(InvoiceLink, { mode, invoiceId, prefix, t }), !isVendorProcessing && /* @__PURE__ */ jsx( Button, { variant: "text", startIcon: /* @__PURE__ */ jsx(ArrowBackIcon, { sx: { fontSize: 16 } }), onClick: () => { if (window.history.length > 1) { window.history.back(); } else { window.location.href = "/"; } }, sx: { textTransform: "none", fontWeight: 600, fontSize: 14, color: "text.secondary", "&:hover": { bgcolor: "action.hover" }, animation: `${fadeUp} 0.5s ease 0.45s both` }, children: t("common.back") } ) ] }) } ); }