@blocklet/payment-react
Version:
Reusable react components for payment kit v2
573 lines (572 loc) • 20.2 kB
JavaScript
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;