UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

684 lines (675 loc) 21.1 kB
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); } } `;