UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

573 lines (572 loc) 20.2 kB
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button, Typography, Stack, Alert, useTheme } from "@mui/material"; import { useLocaleContext } from "@arcblock/ux/lib/Locale/context"; import useArcblockBrowser from "@arcblock/react-hooks/lib/useBrowser"; import Toast from "@arcblock/ux/lib/Toast"; import { joinURL } from "ufo"; import { useRequest } from "ahooks"; import pWaitFor from "p-wait-for"; import { Dialog } from "@arcblock/ux"; import { CheckCircle as CheckCircleIcon } from "@mui/icons-material"; import debounce from "lodash/debounce"; import { usePaymentContext } from "../contexts/payment.js"; import { formatAmount, formatError, getPrefix, isCrossOrigin } from "../libs/util.js"; import { useSubscription } from "../hooks/subscription.js"; import api from "../libs/api.js"; import LoadingButton from "./loading-button.js"; import StripePaymentAction from "./stripe-payment-action.js"; const fetchOverdueInvoices = async (params) => { if (!params.subscriptionId && !params.customerId) { throw new Error("Either subscriptionId or customerId must be provided"); } let url; if (params.subscriptionId) { url = `/api/subscriptions/${params.subscriptionId}/overdue/invoices`; } else { url = `/api/customers/${params.customerId}/overdue/invoices`; } const res = await api.get(params.authToken ? joinURL(url, `?authToken=${params.authToken}`) : url); return res.data; }; function OverdueInvoicePayment({ subscriptionId = void 0, customerId = void 0, mode = "default", dialogProps = { open: true }, children = void 0, onPaid = () => { }, detailLinkOptions = { enabled: true }, successToast = true, alertMessage = "", authToken = void 0 }) { const { t, locale } = useLocaleContext(); const browser = useArcblockBrowser(); const inArcSphere = browser?.arcSphere; const theme = useTheme(); const { connect, session } = usePaymentContext(); const [selectCurrencyId, setSelectCurrencyId] = useState(""); const [payLoading, setPayLoading] = useState(false); const [dialogOpen, setDialogOpen] = useState(dialogProps.open || false); const [processedCurrencies, setProcessedCurrencies] = useState({}); const [paymentStatus, setPaymentStatus] = useState( {} ); const [stripePaymentInProgress, setStripePaymentInProgress] = useState({}); const stripePaymentInProgressRef = useRef(stripePaymentInProgress); const sourceType = subscriptionId ? "subscription" : "customer"; const effectiveCustomerId = customerId || session?.user?.did; const sourceId = subscriptionId || effectiveCustomerId; const customerIdRef = useRef(effectiveCustomerId); useEffect(() => { stripePaymentInProgressRef.current = stripePaymentInProgress; }, [stripePaymentInProgress]); const { data = { summary: {}, invoices: [] }, error, loading, runAsync: refresh } = useRequest(() => fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken }), { ready: !!subscriptionId || !!effectiveCustomerId, onSuccess: (res) => { if (res.customer?.id && res.customer?.id !== customerIdRef.current) { customerIdRef.current = res.customer?.id; } } }); const detailUrl = useMemo(() => { if (subscriptionId) { return joinURL(getPrefix(), `/customer/subscription/${subscriptionId}`); } if (effectiveCustomerId) { return joinURL(getPrefix(), "/customer/invoice/past-due"); } return ""; }, [subscriptionId, effectiveCustomerId]); const summaryList = useMemo(() => { if (!data?.summary) { return []; } return Object.values(data.summary); }, [data?.summary]); const checkAndHandleInvoicePaid = async (currencyId) => { if (stripePaymentInProgressRef.current[currencyId]) { try { const checkData = await fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken }); const hasRemainingInvoices = checkData.invoices?.some((inv) => inv.currency_id === currencyId); if (hasRemainingInvoices) { return; } setStripePaymentInProgress((prev) => { const newState = { ...prev }; delete newState[currencyId]; return newState; }); setPaymentStatus((prev) => ({ ...prev, [currencyId]: "success" })); } catch (err) { console.error("Error checking Stripe payment completion:", err); return; } } if (successToast) { Toast.close(); Toast.success(t("payment.customer.invoice.paySuccess")); } setPayLoading(false); const res = await refresh(); if (res.invoices?.length === 0) { setDialogOpen(false); onPaid(sourceId, currencyId, sourceType); } }; const debouncedHandleInvoicePaid = debounce((currencyId) => checkAndHandleInvoicePaid(currencyId), 1e3, { leading: false, trailing: true, maxWait: 5e3 }); const isCrossOriginRequest = isCrossOrigin(); const subscription = useSubscription("events"); const waitForInvoicePaidByCurrency = async (currencyId) => { let isPaid = false; await pWaitFor( async () => { const checkData = await fetchOverdueInvoices({ subscriptionId, customerId: effectiveCustomerId, authToken }); const hasRemainingInvoices = checkData.invoices?.some((inv) => inv.currency_id === currencyId); isPaid = !hasRemainingInvoices; return isPaid; }, { interval: 2e3, timeout: 3 * 60 * 1e3 } ); return isPaid; }; const handleConnected = async (currencyId, isStripe = false) => { if (isCrossOriginRequest || inArcSphere) { try { const paid = await waitForInvoicePaidByCurrency(currencyId); if (paid) { setPaymentStatus((prev) => ({ ...prev, [currencyId]: "success" })); const finalRes = await refresh(); if (successToast) { Toast.close(); Toast.success(t("payment.customer.invoice.paySuccess")); } if (finalRes.invoices?.length === 0) { setDialogOpen(false); onPaid(sourceId, currencyId, sourceType); } } } catch (err) { console.error("Check payment status failed:", err); setPaymentStatus((prev) => ({ ...prev, [currencyId]: "error" })); } finally { setPayLoading(false); } } else if (isStripe) { setStripePaymentInProgress((prev) => ({ ...prev, [currencyId]: true })); setPaymentStatus((prev) => ({ ...prev, [currencyId]: "processing" })); } }; useEffect(() => { if (!subscription || isCrossOriginRequest || inArcSphere) { return void 0; } const handleInvoicePaid = ({ response }) => { const relevantId = subscriptionId || response.customer_id; const uniqueKey = `${relevantId}-${response.currency_id}`; if (subscriptionId && response.subscription_id === subscriptionId || effectiveCustomerId && effectiveCustomerId === response.customer_id || customerIdRef.current && customerIdRef.current === response.customer_id) { if (!processedCurrencies[uniqueKey]) { setProcessedCurrencies((prev) => ({ ...prev, [uniqueKey]: 1 })); debouncedHandleInvoicePaid(response.currency_id); } } }; subscription.on("invoice.paid", handleInvoicePaid); return () => { subscription.off("invoice.paid", handleInvoicePaid); }; }, [subscription, subscriptionId, effectiveCustomerId]); const handlePay = (item) => { const { currency, method } = item; if (method.type === "stripe") { Toast.error(t("payment.subscription.overdue.notSupport")); return; } if (payLoading) { return; } setSelectCurrencyId(currency.id); setPayLoading(true); setPaymentStatus((prev) => ({ ...prev, [currency.id]: "idle" })); if (["arcblock", "ethereum", "base"].includes(method.type)) { const extraParams = { currencyId: currency.id }; if (subscriptionId) { extraParams.subscriptionId = subscriptionId; } else if (effectiveCustomerId) { extraParams.customerId = effectiveCustomerId; } connect.open({ locale, containerEl: void 0, saveConnect: false, action: "collect-batch", prefix: joinURL(getPrefix(), "/api/did"), useSocket: !isCrossOriginRequest, extraParams, messages: { scan: t("common.connect.defaultScan"), title: t("payment.customer.invoice.payBatch"), confirm: t("common.connect.confirm") }, onSuccess: () => { connect.close(); handleConnected(currency.id); setPayLoading(false); setPaymentStatus((prev) => ({ ...prev, [currency.id]: "success" })); }, onClose: () => { connect.close(); setPayLoading(false); }, onError: (err) => { Toast.error(formatError(err)); setPaymentStatus((prev) => ({ ...prev, [currency.id]: "error" })); setPayLoading(false); } }); } }; const handleClose = () => { setDialogOpen(false); dialogProps.onClose?.(); }; const handleViewDetailClick = (e) => { if (detailLinkOptions.onClick) { e.preventDefault(); detailLinkOptions.onClick(e); } else if (!detailLinkOptions.enabled) { e.preventDefault(); handleClose(); } }; if (loading) { return null; } const getDetailLinkText = () => { if (detailLinkOptions.title) { return detailLinkOptions.title; } if (subscriptionId) { return t("payment.subscription.overdue.view"); } return t("payment.customer.pastDue.view"); }; const renderPayButton = (item, primaryButton = true, options = { variant: "contained" }) => { const { currency } = item; const inProcess = payLoading && selectCurrencyId === currency.id; const status = paymentStatus[currency.id] || "idle"; if (status === "success") { return /* @__PURE__ */ jsx( Button, { variant: options?.variant || "contained", size: "small", onClick: () => checkAndHandleInvoicePaid(currency.id), ...primaryButton ? {} : { color: "success", startIcon: /* @__PURE__ */ jsx(CheckCircleIcon, {}) }, children: t("payment.subscription.overdue.paid") } ); } if (item.method.type === "stripe") { let buttonText = t("payment.subscription.overdue.payNow"); if (status === "error") { buttonText = t("payment.subscription.overdue.retry"); } else if (status === "processing") { buttonText = t("payment.subscription.overdue.processing"); } return /* @__PURE__ */ jsx( StripePaymentAction, { subscriptionId, customerId: !subscriptionId ? effectiveCustomerId : void 0, currencyId: currency.id, paymentMethod: item.method, onSuccess: () => { handleConnected(currency.id, true); }, onError: () => { setPaymentStatus((prev) => ({ ...prev, [currency.id]: "error" })); setStripePaymentInProgress((prev) => ({ ...prev, [currency.id]: false })); }, children: (onPay, paying) => /* @__PURE__ */ jsx( LoadingButton, { variant: options?.variant || "contained", size: "small", disabled: paying || status === "processing", loading: paying || status === "processing", onClick: onPay, sx: options?.sx, children: buttonText } ) } ); } return /* @__PURE__ */ jsx( LoadingButton, { variant: options?.variant || "contained", size: "small", disabled: inProcess, loading: inProcess, onClick: () => handlePay(item), sx: options?.sx, children: status === "error" ? t("payment.subscription.overdue.retry") : t("payment.subscription.overdue.payNow") } ); }; const getMethodText = (method) => { if (method.name && method.type !== "arcblock") { return ` (${method.name})`; } return ""; }; const getOverdueTitle = () => { if (subscriptionId && data.subscription) { if (summaryList.length === 1) { return t("payment.subscription.overdue.title", { name: data.subscription?.description, count: data.invoices?.length, total: formatAmount(summaryList[0]?.amount, summaryList[0]?.currency?.decimal), symbol: summaryList[0]?.currency?.symbol, method: getMethodText(summaryList[0]?.method) }); } return t("payment.subscription.overdue.simpleTitle", { name: data.subscription?.description, count: data.invoices?.length }); } if (effectiveCustomerId) { let title = ""; if (summaryList.length === 1) { title = t("payment.customer.overdue.title", { subscriptionCount: data.subscriptionCount || 0, count: data.invoices?.length, total: formatAmount(summaryList[0]?.amount, summaryList[0]?.currency?.decimal), symbol: summaryList[0]?.currency?.symbol, method: getMethodText(summaryList[0]?.method) }); } else { title = t("payment.customer.overdue.simpleTitle", { subscriptionCount: data.subscriptionCount || 0, count: data.invoices?.length }); } if (alertMessage) { return `${title}${alertMessage}`; } return `${title}${t("payment.customer.overdue.defaultAlert")}`; } return ""; }; const getEmptyStateMessage = () => { if (subscriptionId && data.subscription) { return t("payment.subscription.overdue.empty", { name: data.subscription?.description }); } return t("payment.customer.overdue.empty"); }; if (mode === "custom" && children && typeof children === "function") { return /* @__PURE__ */ jsx(Stack, { children: children(handlePay, { subscription: data?.subscription, summary: data?.summary, invoices: data?.invoices, subscriptionCount: data?.subscriptionCount, detailUrl }) }); } return /* @__PURE__ */ jsx( Dialog, { PaperProps: { style: { minHeight: "auto" } }, ...dialogProps || {}, open: dialogOpen, title: dialogProps?.title || t("payment.subscription.overdue.pastDue"), sx: { "& .MuiDialogContent-root": { pt: 0 } }, onClose: handleClose, children: error ? /* @__PURE__ */ jsx(Alert, { severity: "error", children: error.message }) : /* @__PURE__ */ jsxs( Stack, { sx: { gap: 1 }, children: [ summaryList.length === 0 && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Alert, { severity: "success", children: getEmptyStateMessage() }), /* @__PURE__ */ jsx( Stack, { direction: "row", sx: { justifyContent: "flex-end", mt: 2 }, children: /* @__PURE__ */ jsx(Button, { variant: "outlined", color: "primary", onClick: handleClose, sx: { width: "fit-content" }, children: t("common.know") }) } ) ] }), summaryList.length === 1 && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsxs( Typography, { variant: "body1", sx: { color: "text.secondary" }, children: [ getOverdueTitle(), detailLinkOptions.enabled && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("br", {}), t("payment.subscription.overdue.description"), /* @__PURE__ */ jsx( "a", { href: detailUrl, target: "_blank", onClick: handleViewDetailClick, rel: "noreferrer", style: { color: theme.palette.text.link }, children: getDetailLinkText() } ) ] }) ] } ), /* @__PURE__ */ jsxs( Stack, { direction: "row", sx: { justifyContent: "flex-end", gap: 2, mt: 2 }, children: [ /* @__PURE__ */ jsx(Button, { variant: "outlined", color: "primary", onClick: handleClose, children: t("common.cancel") }), renderPayButton(summaryList[0]) ] } ) ] }), summaryList.length > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsxs( Typography, { variant: "body1", sx: { color: "text.secondary" }, children: [ getOverdueTitle(), detailLinkOptions.enabled && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("br", {}), t("payment.subscription.overdue.description"), /* @__PURE__ */ jsx( "a", { href: detailUrl, target: "_blank", rel: "noreferrer", onClick: handleViewDetailClick, style: { color: theme.palette.text.link }, children: getDetailLinkText() } ) ] }) ] } ), /* @__PURE__ */ jsx( Typography, { variant: "body1", sx: { color: "text.secondary" }, children: t("payment.subscription.overdue.list") } ), /* @__PURE__ */ jsx(Stack, { children: summaryList.map((item) => /* @__PURE__ */ jsxs( Stack, { direction: "row", sx: { justifyContent: "space-between", alignItems: "center", py: 1, px: 0.5, borderBottom: "1px solid", borderColor: "divider", "&:hover": { backgroundColor: () => theme.palette.grey[100] }, mt: 0 }, children: [ /* @__PURE__ */ jsx(Typography, { children: t("payment.subscription.overdue.total", { total: formatAmount(item?.amount, item?.currency?.decimal), currency: item?.currency?.symbol, method: getMethodText(item?.method) }) }), renderPayButton(item, false, { variant: "text", sx: { color: "text.link" } }) ] }, item?.currency?.id )) }) ] }) ] } ) } ); } export default OverdueInvoicePayment;