@blocklet/payment-react
Version:
Reusable react components for payment kit v2
684 lines (675 loc) • 21.1 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import Toast from "@arcblock/ux/lib/Toast";
import Header from "@blocklet/ui-react/lib/Header";
import { ArrowBackOutlined, ArrowForwardOutlined, HelpOutlineOutlined } from "@mui/icons-material";
import { Box, Button, Divider, Stack, Typography } from "@mui/material";
import { styled } from "@mui/system";
import { fromTokenToUnit } from "@ocap/util";
import { useMount, useRequest, useSetState } from "ahooks";
import { useEffect, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { joinURL } from "ufo";
import { usePaymentContext } from "../contexts/payment.js";
import api from "../libs/api.js";
import {
findCurrency,
formatError,
getPrefix,
getQueryParams,
getStatementDescriptor,
isMobileSafari,
isValidCountry
} from "../libs/util.js";
import PaymentError from "./error.js";
import PaymentForm from "./form/index.js";
import PaymentSuccess from "./success.js";
import { useMobile } from "../hooks/mobile.js";
import ProductDonation from "./product-donation.js";
import ConfirmDialog from "../components/confirm.js";
import PaymentBeneficiaries from "../components/payment-beneficiaries.js";
import DonationSkeleton from "./skeleton/donation.js";
import { formatPhone } from "../libs/phone-validator.js";
const getBenefits = async (id, url) => {
const { data } = await api.get(`/api/payment-links/${id}/benefits?${url ? `url=${url}` : ""}`);
return data;
};
function PaymentInner({
checkoutSession,
paymentMethods,
paymentLink,
paymentIntent,
customer,
completed = false,
mode,
onPaid,
onError,
onChange,
action,
formRender = {},
benefits
}) {
const { t } = useLocaleContext();
const { settings, session } = usePaymentContext();
const { isMobile } = useMobile();
const [state, setState] = useSetState({
checkoutSession,
submitting: false,
paying: false,
paid: false,
paymentIntent,
stripeContext: void 0,
customer,
customerLimited: false,
stripePaying: false
});
const query = getQueryParams(window.location.href);
const defaultCurrencyId = query.currencyId || state.checkoutSession.currency_id || state.checkoutSession.line_items[0]?.price.currency_id;
const defaultMethodId = paymentMethods.find((m) => m.payment_currencies.some((c) => c.id === defaultCurrencyId))?.id;
const items = state.checkoutSession.line_items;
const donationSettings = paymentLink?.donation_settings;
const [benefitsState, setBenefitsState] = useSetState({
open: false,
amount: "0"
});
const methods = useForm({
defaultValues: {
customer_name: customer?.name || session?.user?.fullName || "",
customer_email: customer?.email || session?.user?.email || "",
customer_phone: formatPhone(customer?.phone || session?.user?.phone || ""),
payment_method: defaultMethodId,
payment_currency: defaultCurrencyId,
billing_address: Object.assign(
{
country: session?.user?.address?.country || "",
state: session?.user?.address?.province || "",
city: session?.user?.address?.city || "",
line1: session?.user?.address?.line1 || "",
line2: session?.user?.address?.line2 || "",
postal_code: session?.user?.address?.postalCode || ""
},
customer?.address || {},
{
country: isValidCountry(customer?.address?.country || session?.user?.address?.country || "") ? customer?.address?.country : "us"
}
)
}
});
useEffect(() => {
if (!isMobileSafari()) {
return () => {
};
}
let scrollTop = 0;
const focusinHandler = () => {
scrollTop = window.scrollY;
};
const focusoutHandler = () => {
window.scrollTo(0, scrollTop);
};
document.body.addEventListener("focusin", focusinHandler);
document.body.addEventListener("focusout", focusoutHandler);
return () => {
document.body.removeEventListener("focusin", focusinHandler);
document.body.removeEventListener("focusout", focusoutHandler);
};
}, []);
useEffect(() => {
if (!methods || query.currencyId) {
return;
}
if (state.checkoutSession.currency_id !== defaultCurrencyId) {
methods.setValue("payment_currency", state.checkoutSession.currency_id);
}
}, [state.checkoutSession, defaultCurrencyId, query.currencyId]);
const currencyId = useWatch({ control: methods.control, name: "payment_currency", defaultValue: defaultCurrencyId });
const currency = findCurrency(paymentMethods, currencyId) || settings.baseCurrency;
useEffect(() => {
if (onChange) {
onChange(methods.getValues());
}
}, [currencyId]);
const onChangeAmount = async ({ priceId, amount }) => {
const amountStr = fromTokenToUnit(amount, currency.decimal).toString();
setBenefitsState({ amount: amountStr });
try {
if (!amountStr || Number(amountStr) === 0) {
return;
}
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/amount`, {
priceId,
amount: amountStr
});
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const handlePaid = (result) => {
setState({ checkoutSession: result.checkoutSession });
onPaid(result);
};
const renderBenefits = () => {
if (!benefits) {
return null;
}
if (benefits.length === 1) {
return t("payment.checkout.donation.benefits.one", {
name: benefits[0].name
});
}
return t("payment.checkout.donation.benefits.multiple", {
count: benefits.length
});
};
return /* @__PURE__ */ jsx(FormProvider, { ...methods, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: [
completed ? /* @__PURE__ */ jsxs(Stack, { children: [
/* @__PURE__ */ jsx(
PaymentSuccess,
{
mode,
payee: getStatementDescriptor(state.checkoutSession.line_items),
action: state.checkoutSession.mode,
invoiceId: state.checkoutSession.invoice_id,
subscriptionId: state.checkoutSession.subscription_id,
message: paymentLink?.after_completion?.hosted_confirmation?.custom_message || t("payment.checkout.completed.donate")
}
),
/* @__PURE__ */ jsx(
Divider,
{
sx: {
mt: {
xs: "16px",
md: "-24px"
},
mb: {
xs: "16px",
md: "16px"
}
}
}
),
/* @__PURE__ */ jsx(
Stack,
{
direction: "row",
sx: {
justifyContent: "flex-end",
alignItems: "center",
flexWrap: "wrap",
gap: 1
},
children: /* @__PURE__ */ jsx(
Button,
{
variant: "outlined",
size: "large",
onClick: formRender?.onCancel,
sx: { width: "fit-content", minWidth: 120 },
children: t("common.close")
}
)
}
)
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
benefitsState.open && /* @__PURE__ */ jsx(PaymentBeneficiaries, { data: benefits, currency, totalAmount: benefitsState.amount }),
/* @__PURE__ */ jsxs(Stack, { sx: { display: benefitsState.open ? "none" : "block" }, children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
sx: {
justifyContent: "space-between",
alignItems: "center",
mb: 2
},
children: [
/* @__PURE__ */ jsx(
Typography,
{
title: t("payment.checkout.orderSummary"),
sx: {
color: "text.primary",
fontSize: "18px",
fontWeight: "500",
lineHeight: "24px"
},
children: t("payment.checkout.donation.tipAmount")
}
),
!isMobile && donationSettings?.amount?.presets && donationSettings.amount.presets.length > 0 && /* @__PURE__ */ jsxs(
Typography,
{
sx: {
color: "text.secondary",
fontSize: "13px",
display: "flex",
alignItems: "center",
gap: 0.5,
opacity: 0.8
},
children: [
/* @__PURE__ */ jsx(
Box,
{
component: "span",
sx: {
border: "1px solid",
borderColor: "divider",
borderRadius: 0.75,
px: 0.75,
py: 0.25,
fontSize: "12px",
lineHeight: 1,
color: "text.secondary",
fontWeight: "400",
bgcolor: "transparent"
},
children: "Tab"
}
),
t("payment.checkout.donation.tabHint")
]
}
)
]
}
),
items.map((x) => /* @__PURE__ */ jsx(
ProductDonation,
{
item: x,
settings: donationSettings,
onChange: onChangeAmount,
currency
},
`${x.price_id}-${currency.id}`
))
] }),
/* @__PURE__ */ jsx(
Divider,
{
sx: {
mt: {
xs: "32px",
md: "0"
},
mb: {
xs: "16px",
md: "-8px"
}
}
}
),
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
sx: {
justifyContent: "space-between",
alignItems: "center",
flexWrap: "wrap",
gap: 1
},
children: [
benefits && benefits.length > 0 && (benefitsState.open ? /* @__PURE__ */ jsxs(
Typography,
{
onClick: () => setBenefitsState({ open: false }),
sx: {
cursor: "pointer",
color: "text.secondary",
"&:hover": {
color: "text.primary"
},
display: "flex",
alignItems: "center"
},
children: [
/* @__PURE__ */ jsx(ArrowBackOutlined, { className: "benefits-arrow", sx: { fontSize: "18px", mr: 0.5 } }),
t("common.back")
]
}
) : /* @__PURE__ */ jsxs(
Box,
{
onClick: () => setBenefitsState({ open: true }),
sx: {
display: "flex",
gap: 0.5,
alignItems: "center",
color: "text.secondary",
cursor: "pointer",
"& .benefits-arrow": {
opacity: 0,
transform: "translateX(-4px)",
transition: "all 0.2s"
},
"&:hover": {
color: "text.primary",
"& .benefits-arrow": {
opacity: 1,
transform: "translateX(0)"
}
}
},
children: [
/* @__PURE__ */ jsx(HelpOutlineOutlined, { sx: { fontSize: "18px" } }),
/* @__PURE__ */ jsx(Typography, { variant: "body2", children: renderBenefits() }),
/* @__PURE__ */ jsx(ArrowForwardOutlined, { className: "benefits-arrow", sx: { fontSize: "18px" } })
]
}
)),
benefitsState.open ? null : /* @__PURE__ */ jsxs(
Box,
{
sx: {
display: "flex",
gap: 2,
flex: {
xs: 1,
md: "auto"
},
justifyContent: "flex-end",
whiteSpace: "nowrap"
},
children: [
formRender?.cancel,
/* @__PURE__ */ jsx(
PaymentForm,
{
currencyId,
checkoutSession: state.checkoutSession,
paymentMethods,
paymentIntent,
paymentLink,
customer,
onPaid: handlePaid,
onError,
mode,
action,
onlyShowBtn: true,
isDonation: true
}
)
]
}
)
]
}
)
] }),
state.customerLimited && /* @__PURE__ */ jsx(
ConfirmDialog,
{
onConfirm: () => window.open(
joinURL(getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`),
"_self"
),
onCancel: () => setState({ customerLimited: false }),
confirm: t("payment.customer.pastDue.alert.confirm"),
title: t("payment.customer.pastDue.alert.title"),
message: t("payment.customer.pastDue.alert.description"),
color: "primary"
}
)
] }) });
}
export default function DonationForm({
checkoutSession,
paymentMethods,
paymentIntent,
paymentLink,
customer,
completed = false,
error = null,
mode,
onPaid,
onError,
onChange,
goBack,
action,
showCheckoutSummary = true,
formRender = {},
id
}) {
const { t } = useLocaleContext();
const { refresh, livemode, setLivemode } = usePaymentContext();
const { isMobile } = useMobile();
const [delay, setDelay] = useState(false);
const isMobileSafariEnv = isMobileSafari();
const paymentLinkId = id.startsWith("plink_") ? id : void 0;
const { data: benefits, loading: benefitLoading } = useRequest(
() => {
if (paymentLinkId) {
return getBenefits(paymentLinkId);
}
return Promise.resolve([]);
},
{
refreshDeps: [paymentLinkId || paymentLink?.id],
ready: !!paymentLinkId || !!paymentLink?.id
}
);
useMount(() => {
setTimeout(() => {
setDelay(true);
}, 500);
});
useEffect(() => {
if (checkoutSession) {
if (livemode !== checkoutSession.livemode) {
setLivemode(checkoutSession.livemode);
}
}
}, [checkoutSession, livemode, setLivemode, refresh]);
const renderContent = () => {
const footer = /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(Divider, { sx: { mt: { xs: "16px", md: "-24px" }, mb: { xs: "16px", md: "-16px" } } }),
/* @__PURE__ */ jsx(
Stack,
{
direction: "row",
sx: {
justifyContent: "flex-end",
alignItems: "center",
flexWrap: "wrap",
gap: 1
},
children: /* @__PURE__ */ jsx(Button, { variant: "outlined", size: "large", onClick: formRender?.onCancel, children: t("common.cancel") })
}
)
] });
if (error) {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(PaymentError, { mode, title: "Oops", description: formatError(error), button: null }),
footer
] });
}
if (!checkoutSession || !delay || !paymentLink || benefitLoading) {
return /* @__PURE__ */ jsx(DonationSkeleton, {});
}
if (checkoutSession?.expires_at <= Math.round(Date.now() / 1e3)) {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
PaymentError,
{
mode,
title: t("payment.checkout.expired.title"),
description: t("payment.checkout.expired.description"),
button: null
}
),
footer
] });
}
if (!checkoutSession.line_items.length) {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
PaymentError,
{
mode,
title: t("payment.checkout.emptyItems.title"),
description: t("payment.checkout.emptyItems.description"),
button: null
}
),
footer
] });
}
return /* @__PURE__ */ jsx(
PaymentInner,
{
formRender,
checkoutSession,
paymentMethods,
paymentLink,
paymentIntent,
completed: completed || checkoutSession.status === "complete",
customer,
onPaid,
onError,
onChange,
goBack,
mode,
action,
showCheckoutSummary,
benefits
}
);
};
return /* @__PURE__ */ jsxs(
Stack,
{
sx: {
display: "flex",
flexDirection: "column",
height: mode === "standalone" ? "100vh" : "auto",
overflow: isMobileSafariEnv ? "visible" : "hidden"
},
children: [
mode === "standalone" ? /* @__PURE__ */ jsx(
Header,
{
meta: void 0,
addons: void 0,
sessionManagerProps: void 0,
homeLink: void 0,
theme: void 0,
hideNavMenu: void 0,
maxWidth: false,
sx: { borderBottom: "1px solid", borderColor: "divider" }
}
) : null,
/* @__PURE__ */ jsxs(
Root,
{
mode,
sx: {
flex: 1,
overflow: {
xs: isMobileSafariEnv ? "visible" : "auto",
md: "hidden"
},
...isMobile && mode === "standalone" ? {
".cko-payment-submit-btn": {
position: "fixed",
bottom: 20,
left: 0,
right: 0,
zIndex: 999,
backgroundColor: "background.paper",
padding: "10px",
textAlign: "center",
button: {
maxWidth: 542
}
}
} : {}
},
children: [
goBack && /* @__PURE__ */ jsx(
ArrowBackOutlined,
{
sx: { mr: 0.5, color: "text.secondary", alignSelf: "flex-start", margin: "16px 0", cursor: "pointer" },
onClick: goBack,
fontSize: "medium"
}
),
/* @__PURE__ */ jsx(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: renderContent() })
]
}
)
]
}
);
}
export const Root = styled(Box)`
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
position: relative;
.cko-container {
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
flex: 1;
}
.cko-overview {
position: relative;
flex-direction: column;
display: ${(props) => props.mode.endsWith("-minimal") ? "none" : "flex"};
background: ${({ theme }) => theme.palette.background.default};
min-height: 'auto';
}
.cko-footer {
display: ${(props) => props.mode.endsWith("-minimal") ? "none" : "block"};
text-align: center;
margin-top: 20px;
}
.cko-payment-form {
.MuiFormLabel-root {
color: ${({ theme }) => theme.palette.grey.A700};
font-weight: 500;
margin-top: 12px;
margin-bottom: 4px;
}
.MuiBox-root {
margin: 0;
}
.MuiFormHelperText-root {
margin-left: 14px;
}
}
@media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
padding-top: 0;
overflow: auto;
.cko-container {
flex-direction: column;
justify-content: flex-start;
gap: 0;
overflow: visible;
min-width: 200px;
}
.cko-overview {
width: 100%;
min-height: auto;
flex: none;
}
.cko-footer {
position: relative;
margin-bottom: 4px;
margin-top: 0;
bottom: 0;
left: 0;
transform: translateX(0);
}
}
`;