@blocklet/payment-react
Version:
Reusable react components for payment kit v2
617 lines (616 loc) • 22.6 kB
JavaScript
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")
}
)
] })
}
);
}