UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

690 lines (677 loc) 21.7 kB
import { 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 } from "@mui/icons-material"; import { Box, Fade, Stack } from "@mui/material"; import { styled } from "@mui/system"; import { fromTokenToUnit } from "@ocap/util"; import { useSetState } from "ahooks"; import { useEffect, useState, useMemo } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import trim from "lodash/trim"; import { usePaymentContext } from "../contexts/payment.js"; import api from "../libs/api.js"; import { findCurrency, formatError, getQueryParams, getStatementDescriptor, isMobileSafari, isValidCountry, showStaking } from "../libs/util.js"; import PaymentError from "./error.js"; import CheckoutFooter from "./footer.js"; import PaymentForm, { hasDidWallet } from "./form/index.js"; import OverviewSkeleton from "./skeleton/overview.js"; import PaymentSkeleton from "./skeleton/payment.js"; import PaymentSuccess from "./success.js"; import PaymentSummary from "./summary.js"; import { useMobile } from "../hooks/mobile.js"; import { formatPhone } from "../libs/phone-validator.js"; import { getCurrencyPreference } from "../libs/currency.js"; function PaymentInner({ checkoutSession, paymentMethods, paymentLink, paymentIntent, customer, completed = false, mode, onPaid, onError, onChange, action, showCheckoutSummary = true }) { const { t } = useLocaleContext(); const { settings, session } = usePaymentContext(); const { isMobile } = useMobile(); const [state, setState] = useSetState({ checkoutSession }); const query = getQueryParams(window.location.href); const availableCurrencyIds = useMemo(() => { const currencyIds = /* @__PURE__ */ new Set(); paymentMethods.forEach((method2) => { method2.payment_currencies.forEach((currency2) => { if (currency2.active) { currencyIds.add(currency2.id); } }); }); return Array.from(currencyIds); }, [paymentMethods]); const defaultCurrencyId = useMemo(() => { if (query.currencyId && availableCurrencyIds.includes(query.currencyId)) { return query.currencyId; } if (session?.user && !hasDidWallet(session.user)) { const stripeCurrencyId = paymentMethods.find((m) => m.type === "stripe")?.payment_currencies.find((c) => c.active)?.id; if (stripeCurrencyId) { return stripeCurrencyId; } } const savedPreference = getCurrencyPreference(session?.user?.did, availableCurrencyIds); if (savedPreference) { return savedPreference; } if (state.checkoutSession.currency_id && availableCurrencyIds.includes(state.checkoutSession.currency_id)) { return state.checkoutSession.currency_id; } return availableCurrencyIds?.[0]; }, [query.currencyId, availableCurrencyIds, session?.user, state.checkoutSession.currency_id, paymentMethods]); const defaultMethodId = paymentMethods.find((m) => m.payment_currencies.some((c) => c.id === defaultCurrencyId))?.id; const hideSummaryCard = mode.endsWith("-minimal") || !showCheckoutSummary; 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 (defaultCurrencyId) { methods.setValue("payment_currency", defaultCurrencyId); } if (defaultMethodId) { methods.setValue("payment_method", defaultMethodId); } }, [defaultCurrencyId, defaultMethodId]); 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); }; }, []); const currencyId = useWatch({ control: methods.control, name: "payment_currency", defaultValue: defaultCurrencyId }); const currency = findCurrency(paymentMethods, currencyId) || settings.baseCurrency; const method = paymentMethods.find((x) => x.id === currency.payment_method_id); const recalculatePromotion = () => { if (state.checkoutSession?.discounts?.length) { api.post(`/api/checkout-sessions/${state.checkoutSession.id}/recalculate-promotion`, { currency_id: currencyId }).then(() => { onPromotionUpdate(); }); } }; useEffect(() => { if (onChange) { onChange(methods.getValues()); } recalculatePromotion(); }, [currencyId]); const onUpsell = async (from, to) => { try { const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/upsell`, { from, to }); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onDownsell = async (from) => { try { const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/downsell`, { from }); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onApplyCrossSell = async (to) => { try { const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`, { to }); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onQuantityChange = async (itemId, quantity) => { try { const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/adjust-quantity`, { itemId, quantity }); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onCancelCrossSell = async () => { try { const { data } = await api.delete(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onChangeAmount = async ({ priceId, amount }) => { try { const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/amount`, { priceId, amount: fromTokenToUnit(amount, currency.decimal).toString() }); if (data.discounts?.length) { recalculatePromotion(); return; } setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const onPromotionUpdate = async () => { try { const { data } = await api.get(`/api/checkout-sessions/retrieve/${state.checkoutSession.id}`); setState({ checkoutSession: data.checkoutSession }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const handlePaid = (result) => { setState({ checkoutSession: result.checkoutSession }); onPaid(result); }; let trialInDays = Number(state.checkoutSession?.subscription_data?.trial_period_days || 0); let trialEnd = Number(state.checkoutSession?.subscription_data?.trial_end || 0); const trialCurrencyIds = (state.checkoutSession?.subscription_data?.trial_currency || "").split(",").map(trim).filter(Boolean); if (trialCurrencyIds.length > 0 && trialCurrencyIds.includes(currencyId) === false) { trialInDays = 0; trialEnd = 0; } const showFeatures = `${paymentLink?.metadata?.show_product_features}` === "true"; return /* @__PURE__ */ jsx(FormProvider, { ...methods, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: [ !hideSummaryCard && /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "base-card cko-overview", direction: "column", children: [ /* @__PURE__ */ jsx( PaymentSummary, { items: state.checkoutSession.line_items, trialInDays, trialEnd, billingThreshold: Math.max( state.checkoutSession.subscription_data?.billing_threshold_amount || 0, // @ts-ignore state.checkoutSession.subscription_data?.min_stake_amount || 0 ), showStaking: showStaking(method, currency, !!state.checkoutSession.subscription_data?.no_stake), currency, onUpsell, onDownsell, onQuantityChange, onApplyCrossSell, onCancelCrossSell, onChangeAmount, checkoutSessionId: state.checkoutSession.id, crossSellBehavior: state.checkoutSession.cross_sell_behavior, donationSettings: paymentLink?.donation_settings, action, completed, checkoutSession: state.checkoutSession, onPromotionUpdate, paymentMethods, showFeatures } ), mode === "standalone" && !isMobile && /* @__PURE__ */ jsx(CheckoutFooter, { className: "cko-footer", sx: { color: "text.lighter" } }) ] }) }), /* @__PURE__ */ jsxs( Stack, { className: "base-card cko-payment", direction: "column", spacing: { xs: 2, sm: 3 }, children: [ completed && /* @__PURE__ */ jsx( PaymentSuccess, { mode, pageInfo: state.checkoutSession.metadata?.page_info, vendorCount: state.checkoutSession.line_items.reduce((total, item) => { return total + (item.price?.product?.vendor_config?.length || 0); }, 0), sessionId: state.checkoutSession.id, payee: getStatementDescriptor(state.checkoutSession.line_items), action: state.checkoutSession.mode, invoiceId: state.checkoutSession.invoice_id, subscriptionId: state.checkoutSession.subscription_id, subscriptions: state.checkoutSession.subscriptions, message: paymentLink?.after_completion?.hosted_confirmation?.custom_message || t( `payment.checkout.completed.${state.checkoutSession.submit_type === "donate" ? "donate" : state.checkoutSession.mode}` ) } ), !completed && /* @__PURE__ */ jsx( PaymentForm, { currencyId, checkoutSession: state.checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, onPaid: handlePaid, onError, mode, action } ) ] } ), mode === "standalone" && isMobile && /* @__PURE__ */ jsx(CheckoutFooter, { className: "cko-footer", sx: { color: "text.lighter" } }) ] }) }); } export default function Payment({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, completed = false, error = null, mode, onPaid, onError, onChange, goBack, action, showCheckoutSummary = true }) { const { t } = useLocaleContext(); const { refresh, livemode, setLivemode } = usePaymentContext(); const [delay, setDelay] = useState(false); const { isMobile } = useMobile(); const hideSummaryCard = mode.endsWith("-minimal") || !showCheckoutSummary; const isMobileSafariEnv = isMobileSafari(); useEffect(() => { setTimeout(() => { setDelay(true); }, 500); }, []); useEffect(() => { if (checkoutSession) { if (livemode !== checkoutSession.livemode) { setLivemode(checkoutSession.livemode); } } }, [checkoutSession, livemode, setLivemode, refresh]); const renderContent = () => { if (error) { return /* @__PURE__ */ jsx(PaymentError, { mode, title: "Oops", description: formatError(error) }); } if (!checkoutSession || !delay) { return /* @__PURE__ */ jsxs(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: [ !hideSummaryCard && /* @__PURE__ */ jsx(Stack, { className: "base-card cko-overview", children: /* @__PURE__ */ jsx(Box, { className: "cko-product", children: /* @__PURE__ */ jsx(OverviewSkeleton, {}) }) }), /* @__PURE__ */ jsx(Stack, { className: "base-card cko-payment", children: /* @__PURE__ */ jsx(PaymentSkeleton, {}) }) ] }); } if (checkoutSession.expires_at <= Math.round(Date.now() / 1e3)) { return /* @__PURE__ */ jsx( PaymentError, { mode, title: t("payment.checkout.expired.title"), description: t("payment.checkout.expired.description") } ); } if (!checkoutSession.line_items.length) { return /* @__PURE__ */ jsx( PaymentError, { mode, title: t("payment.checkout.emptyItems.title"), description: t("payment.checkout.emptyItems.description") } ); } return /* @__PURE__ */ jsx( PaymentInner, { checkoutSession, paymentMethods, paymentLink, paymentIntent, completed: completed || checkoutSession.status === "complete", customer, onPaid, onError, onChange, goBack, mode, action, showCheckoutSummary } ); }; 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 } }, ".cko-footer": { position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 999, backgroundColor: "background.paper", marginBottom: 0 }, ".cko-payment": { paddingBottom: "100px" } } : {} }, 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: row; justify-content: center; position: relative; flex: 1; padding: 1px; } .base-card { border: none; border-radius: 0; padding: ${(props) => props.mode === "standalone" ? "100px 40px 20px" : "20px 0"}; box-shadow: none; flex: 1; max-width: 582px; } .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-header { left: 0; margin-bottom: 0; position: absolute; right: 0; top: 0; transition: background-color 0.15s ease, box-shadow 0.15s ease-out; } .cko-product { flex: 1; overflow: hidden; } .cko-product-summary { width: 100%; } .cko-ellipsis { width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cko-payment { width: 502px; padding-left: ${(props) => props.mode === "standalone" ? "40px" : "20px"}; position: relative; &:before { -webkit-animation-fill-mode: both; content: ''; height: 100%; position: absolute; left: 0px; top: 0px; transform-origin: left center; width: 8px; box-shadow: -4px 0px 8px 0px rgba(2, 7, 19, 0.04); } } .cko-payment-contact { overflow: hidden; } .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; } } .cko-payment-methods { } .cko-payment-submit { .MuiButtonBase-root { font-size: 1.3rem; position: relative; } .cko-submit-progress { position: absolute; top: 0; width: 100%; height: 100%; opacity: 0.3; } } .cko-header { } .product-item { border-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; border: 1px solid; border-color: ${({ theme }) => theme.palette.divider}; .product-item-content { padding: 16px; background: ${({ theme }) => theme.palette.grey[50]}; border-top-left-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; border-top-right-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; } .product-item-upsell { margin-top: 0; padding: 16px; background: ${({ theme }) => theme.palette.grey[100]}; border-bottom-left-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; border-bottom-right-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; } .product-item-content:only-child { border-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`}; } } @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) { padding-top: 0; overflow: auto; &:before { display: none; } .cko-container { flex-direction: column; align-items: center; justify-content: flex-start; gap: 0; overflow: visible; min-width: 200px; } .cko-overview { width: 100%; min-height: auto; flex: none; } .cko-payment { width: 100%; height: fit-content; flex: none; border-top: 1px solid; border-color: ${({ theme }) => theme.palette.divider}; &:before { display: none; } } .cko-footer { position: relative; margin-bottom: 4px; margin-top: 0; bottom: 0; left: 0; transform: translateX(0); } .base-card { box-shadow: none; border-radius: 0; padding: 20px; } } `;