UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

619 lines (618 loc) 23.5 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 { HelpOutline } from "@mui/icons-material"; import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton } from "@mui/material"; import { BN, fromTokenToUnit, fromUnitToToken } from "@ocap/util"; import { useRequest, useSetState } from "ahooks"; import noop from "lodash/noop"; import useBus from "use-bus"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { styled } from "@mui/material/styles"; import { useEffect, useMemo, useRef } from "react"; import Status from "../components/status.js"; import api from "../libs/api.js"; import { formatAmount, formatCheckoutHeadlines, getPriceUintAmountByCurrency, formatDynamicPrice, findCurrency, formatError } from "../libs/util.js"; import ProductDonation from "./product-donation.js"; import ProductItem from "./product-item.js"; import Livemode from "../components/livemode.js"; import { usePaymentContext } from "../contexts/payment.js"; import { useMobile } from "../hooks/mobile.js"; import { useDynamicPricing } from "../hooks/dynamic-pricing.js"; import LoadingButton from "../components/loading-button.js"; import DynamicPricingUnavailable from "../components/dynamic-pricing-unavailable.js"; import PromotionSection from "./summary-section/promotion-section.js"; import TotalSection from "./summary-section/total-section.js"; const ExpandMore = styled((props) => { const { expand, ...other } = props; return /* @__PURE__ */ jsx(IconButton, { ...other }); })(({ theme, expand }) => ({ transform: !expand ? "rotate(0deg)" : "rotate(180deg)", marginLeft: "auto", transition: theme.transitions.create("transform", { duration: theme.transitions.duration.shortest }) })); async function fetchCrossSell(id, skipError = true) { try { const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell?skipError=${skipError}`); if (!data.error) { return data; } return null; } catch (err) { return null; } } function getStakingSetup(items, currency, billingThreshold = 0) { const staking = { licensed: new BN(0), metered: new BN(0) }; const recurringItems = items.map((x) => x.upsell_price || x.price).filter((x) => x.type === "recurring" && x.recurring); if (recurringItems.length > 0) { if (+billingThreshold) { return fromTokenToUnit(billingThreshold, currency.decimal).toString(); } items.forEach((x) => { const price = x.upsell_price || x.price; const unit = getPriceUintAmountByCurrency(price, currency); const amount = new BN(unit).mul(new BN(x.quantity)); if (price.type === "recurring" && price.recurring) { if (price.recurring.usage_type === "licensed") { staking.licensed = staking.licensed.add(amount); } if (price.recurring.usage_type === "metered") { staking.metered = staking.metered.add(amount); } } }); return staking.licensed.add(staking.metered).toString(); } return "0"; } export default function PaymentSummary({ items, currency, trialInDays, billingThreshold, onUpsell = noop, onDownsell = noop, onQuantityChange = noop, onApplyCrossSell = noop, onCancelCrossSell = noop, onChangeAmount = noop, checkoutSessionId = "", crossSellBehavior = "", showStaking = false, donationSettings = void 0, action = "", trialEnd = 0, completed = false, checkoutSession = void 0, paymentIntent = void 0, paymentMethods = [], onPromotionUpdate = noop, showFeatures = false, rateUnavailable = false, isRateLoading = false, // eslint-disable-next-line @typescript-eslint/no-unused-vars rateError: _rateError = void 0, // Technical errors are logged but not displayed to users onQuoteExpired = void 0, onRefreshRate = void 0, onSlippageChange = void 0, slippageConfig: slippageConfigProp = void 0, liveRate = void 0, liveQuoteSnapshot = void 0, isStripePayment = false, isSubscription: isSubscriptionProp = void 0, ...rest }) { const { t, locale } = useLocaleContext(); const { isMobile } = useMobile(); const { paymentState, ...settings } = usePaymentContext(); const [state, setState] = useSetState({ loading: false, shake: false, expanded: items?.length < 3 }); const { data, runAsync } = useRequest( (skipError) => checkoutSessionId ? fetchCrossSell(checkoutSessionId, skipError) : Promise.resolve(null) ); const sessionDiscounts = checkoutSession?.discounts || []; const allowPromotionCodes = !!checkoutSession?.allow_promotion_codes; const hasDiscounts = sessionDiscounts?.length > 0; const discountCurrency = paymentMethods && checkoutSession ? findCurrency( paymentMethods, hasDiscounts ? checkoutSession?.currency_id || currency.id : currency.id ) || settings.settings?.baseCurrency : currency; const slippageConfig = slippageConfigProp ?? checkoutSession?.metadata?.slippage; const { hasDynamicPricing, isPriceLocked, quoteMeta, rateInfo, quoteLockedAt, currentSlippagePercent, rateDisplay, calculatedTokenAmount, calculatedDiscountAmount, calculateUsdDisplay, buildQuoteDetailRows } = useDynamicPricing({ items, currency: discountCurrency, liveRate, liveQuoteSnapshot, checkoutSession, paymentIntent, locale, isStripePayment, isSubscription: isSubscriptionProp, slippageConfig, trialInDays, trialEnd, discounts: sessionDiscounts }); const headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale, { exchangeRate: rateInfo.exchangeRate }); const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : "0"; const effectiveHasDynamicPricing = hasDynamicPricing && !isStripePayment; const hasRecurringItems = items.some((x) => (x.upsell_price || x.price)?.type === "recurring"); const isTrialScenario = headlines.actualAmount === "0" && hasRecurringItems; const headlineAmountDisplay = useMemo(() => { if (!effectiveHasDynamicPricing) { return headlines.amount; } if (isTrialScenario || !headlines.amount.includes(discountCurrency.symbol)) { return headlines.amount; } if (calculatedTokenAmount) { const displayAmount = fromUnitToToken(calculatedTokenAmount, discountCurrency?.decimal); const formatted2 = formatDynamicPrice(displayAmount, true, 6); return `${formatted2} ${discountCurrency.symbol}`; } const formatted = formatDynamicPrice(headlines.actualAmount, true, 6); return `${formatted} ${discountCurrency.symbol}`; }, [ headlines.amount, headlines.actualAmount, discountCurrency.symbol, discountCurrency?.decimal, effectiveHasDynamicPricing, calculatedTokenAmount, isTrialScenario ]); const discountAmount = useMemo( () => new BN(checkoutSession?.total_details?.amount_discount || "0"), [checkoutSession?.total_details?.amount_discount] ); const subtotalAmountUnit = new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)).add(new BN(staking)).toString(); const subtotalAmount = fromUnitToToken(subtotalAmountUnit, discountCurrency?.decimal); const totalAmountUnit = new BN(subtotalAmountUnit).sub(discountAmount).toString(); const totalAmountValue = fromUnitToToken(totalAmountUnit, discountCurrency?.decimal); const subtotalDisplay = useMemo(() => { if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { const dynamicSubtotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).toString(); const displayAmount = fromUnitToToken(dynamicSubtotalUnit, discountCurrency?.decimal); return formatDynamicPrice(displayAmount, true, 6); } return formatDynamicPrice(subtotalAmount, effectiveHasDynamicPricing, 6); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountCurrency?.decimal, subtotalAmount, isTrialScenario ]); const totalAmountDisplay = useMemo(() => { if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount; const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString(); const displayAmount = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal); const numericValue = Number(displayAmount); if (Number.isFinite(numericValue) && numericValue >= 0) { return formatDynamicPrice(displayAmount, true, 6); } } if (isStripePayment && calculatedDiscountAmount && !isTrialScenario) { const effectiveDiscount = new BN(calculatedDiscountAmount); const adjustedTotalUnit = new BN(subtotalAmountUnit).sub(effectiveDiscount).toString(); const displayAmount = fromUnitToToken(adjustedTotalUnit, discountCurrency?.decimal); const numericValue = Number(displayAmount); if (Number.isFinite(numericValue) && numericValue >= 0) { return formatDynamicPrice(displayAmount, false, 6); } } return formatDynamicPrice(totalAmountValue, effectiveHasDynamicPricing, 6); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountAmount, calculatedDiscountAmount, discountCurrency?.decimal, totalAmountValue, isTrialScenario, isStripePayment, subtotalAmountUnit ]); const totalAmountText = totalAmountDisplay === "\u2014" ? "\u2014" : `${totalAmountDisplay} ${discountCurrency.symbol}`; const totalUsdDisplay = useMemo(() => { if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) { const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount; const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString(); const dynamicTotalToken = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal); return calculateUsdDisplay(dynamicTotalToken); } return calculateUsdDisplay(totalAmountValue); }, [ effectiveHasDynamicPricing, calculatedTokenAmount, staking, discountAmount, calculatedDiscountAmount, discountCurrency?.decimal, totalAmountValue, calculateUsdDisplay, isTrialScenario ]); const quoteDetailRows = buildQuoteDetailRows(t); const isSubscription = isSubscriptionProp ?? (checkoutSession?.mode === "subscription" || checkoutSession?.mode === "setup"); const handlePromotionUpdate = () => { onPromotionUpdate?.(); }; const handleRemovePromotion = async (sessionId) => { if (paymentState.paying || paymentState.stripePaying) { return; } try { await api.delete(`/api/checkout-sessions/${sessionId}/remove-promotion`); onPromotionUpdate?.(); } catch (err) { console.error("Failed to remove promotion code:", err); } }; const expiredHandledRef = useRef(false); useEffect(() => { if (completed || expiredHandledRef.current) { return; } if (!liveQuoteSnapshot?.expires_at && !quoteMeta?.expiresAt) { return; } const currentTime = Math.floor(Date.now() / 1e3); const effectiveExpiresAt = liveQuoteSnapshot?.expires_at ?? quoteMeta?.expiresAt; const quoteRemaining = effectiveExpiresAt ? Math.max(0, effectiveExpiresAt - currentTime) : 0; const lockRemaining = quoteLockedAt ? Math.max(0, quoteLockedAt + 180 - currentTime) : 0; const hasExpiry = !!effectiveExpiresAt; const lockActive = lockRemaining > 0; if (hasExpiry && !lockActive && quoteRemaining <= 0) { expiredHandledRef.current = true; onQuoteExpired?.(); } }, [liveQuoteSnapshot?.expires_at, quoteMeta?.expiresAt, quoteLockedAt, completed, onQuoteExpired]); const handleSlippageChange = async (newSlippageConfig) => { if (!onSlippageChange) { return; } if (!checkoutSessionId) { onSlippageChange(newSlippageConfig); return; } try { await api.put(`/api/checkout-sessions/${checkoutSessionId}/slippage`, { slippage_config: newSlippageConfig }); onSlippageChange(newSlippageConfig); if (onQuoteExpired) { await onQuoteExpired(true); } } catch (err) { console.error("Failed to update slippage", err); Toast.error(err.response?.data?.error || formatError(err)); } }; useBus( "error.REQUIRE_CROSS_SELL", () => { setState({ shake: true }); setTimeout(() => { setState({ shake: false }); }, 1e3); }, [] ); const handleUpsell = async (from, to) => { await onUpsell(from, to); runAsync(false); }; const handleQuantityChange = async (itemId, quantity) => { await onQuantityChange(itemId, quantity); runAsync(false); }; const handleDownsell = async (from) => { await onDownsell(from); runAsync(false); }; const handleApplyCrossSell = async () => { if (data) { try { setState({ loading: true }); await onApplyCrossSell(data.id); } catch (err) { console.error(err); } finally { setState({ loading: false }); } } }; const handleCancelCrossSell = async () => { try { setState({ loading: true }); await onCancelCrossSell(); } catch (err) { console.error(err); } finally { setState({ loading: false }); } }; const hasSubTotal = +staking > 0 || allowPromotionCodes; const ProductCardList = /* @__PURE__ */ jsxs( Stack, { className: "cko-product-list", sx: { flex: "0 1 auto", overflow: "auto" }, children: [ /* @__PURE__ */ jsx(Stack, { spacing: { xs: 1, sm: 2 }, children: items.map( (x) => x.price?.custom_unit_amount && onChangeAmount && donationSettings ? /* @__PURE__ */ jsx( ProductDonation, { item: x, settings: donationSettings, onChange: onChangeAmount, currency: discountCurrency }, `${x.price_id}-${discountCurrency.id}` ) : /* @__PURE__ */ jsx( ProductItem, { item: x, items, trialInDays, trialEnd, currency: discountCurrency, exchangeRate: rateInfo.exchangeRate, isStripePayment, isPriceLocked, isRateLoading, onUpsell: handleUpsell, onDownsell: handleDownsell, adjustableQuantity: x.adjustable_quantity, completed, showFeatures, onQuantityChange: handleQuantityChange, discounts: sessionDiscounts, calculatedDiscountAmount: calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null), children: x.cross_sell && /* @__PURE__ */ jsxs( Stack, { direction: "row", sx: { alignItems: "center", justifyContent: "space-between", width: 1 }, children: [ /* @__PURE__ */ jsx(Typography, {}), /* @__PURE__ */ jsx( LoadingButton, { size: "small", loadingPosition: "end", endIcon: null, color: "error", variant: "text", loading: state.loading, onClick: handleCancelCrossSell, children: t("payment.checkout.cross_sell.remove") } ) ] } ) }, `${x.price_id}-${discountCurrency.id}` ) ) }), data && items.some((x) => x.price_id === data.id) === false && /* @__PURE__ */ jsx(Grow, { in: true, children: /* @__PURE__ */ jsx(Stack, { sx: { mt: 1 }, children: /* @__PURE__ */ jsx( ProductItem, { item: { quantity: 1, price: data, price_id: data.id, cross_sell: true }, items, trialInDays, currency: discountCurrency, trialEnd, exchangeRate: rateInfo.exchangeRate, isStripePayment, isPriceLocked, isRateLoading, onUpsell: noop, onDownsell: noop, children: /* @__PURE__ */ jsxs( Stack, { direction: "row", sx: { alignItems: "center", justifyContent: "space-between", width: 1 }, children: [ /* @__PURE__ */ jsx(Typography, { children: crossSellBehavior === "required" && /* @__PURE__ */ jsx(Status, { label: t("payment.checkout.required"), color: "info", variant: "outlined", sx: { mr: 1 } }) }), /* @__PURE__ */ jsx( LoadingButton, { size: "small", loadingPosition: "end", endIcon: null, color: crossSellBehavior === "required" ? "info" : "info", variant: crossSellBehavior === "required" ? "text" : "text", loading: state.loading, onClick: handleApplyCrossSell, children: t("payment.checkout.cross_sell.add") } ) ] } ) } ) }) }) ] } ); if (!discountCurrency || !items?.length) { return null; } return /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-product", direction: "column", ...rest, children: [ /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", alignItems: "center", mb: 2.5 }, children: [ /* @__PURE__ */ jsx( Typography, { title: t("payment.checkout.orderSummary"), sx: { color: "text.primary", fontSize: { xs: "18px", md: "24px" }, fontWeight: "700", lineHeight: "32px" }, children: action || t("payment.checkout.orderSummary") } ), !settings.livemode && /* @__PURE__ */ jsx(Livemode, {}) ] } ), effectiveHasDynamicPricing && rateUnavailable && /* @__PURE__ */ jsx(DynamicPricingUnavailable, { sx: { mb: 2 }, onRetry: onRefreshRate }), isMobile && !donationSettings ? /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsxs( Stack, { onClick: () => setState({ expanded: !state.expanded }), sx: { justifyContent: "space-between", flexDirection: "row", alignItems: "center", mb: 1.5 }, children: [ /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.productListTotal", { total: items.length }) }), /* @__PURE__ */ jsx(ExpandMore, { expand: state.expanded, "aria-expanded": state.expanded, "aria-label": "show more", children: /* @__PURE__ */ jsx(ExpandMoreIcon, {}) }) ] } ), /* @__PURE__ */ jsx(Collapse, { in: state.expanded || !isMobile, timeout: "auto", unmountOnExit: true, children: ProductCardList }) ] }) : ProductCardList, /* @__PURE__ */ jsx(Divider, { sx: { mt: 2.5, mb: 2.5 } }), +staking > 0 && /* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center" }, children: [ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, sx: { alignItems: "center" }, children: [ /* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary" }, children: t("payment.checkout.paymentRequired") }), /* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.stakingConfirm"), placement: "top", sx: { maxWidth: "150px" }, children: /* @__PURE__ */ jsx(HelpOutline, { fontSize: "small", sx: { color: "text.lighter" } }) }) ] }), /* @__PURE__ */ jsx(Typography, { children: headlineAmountDisplay }) ] }), /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center" }, children: [ /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, sx: { alignItems: "center" }, children: [ /* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary" }, children: t("payment.checkout.staking.title") }), /* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.staking.tooltip"), placement: "top", sx: { maxWidth: "150px" }, children: /* @__PURE__ */ jsx(HelpOutline, { fontSize: "small", sx: { color: "text.lighter" } }) }) ] }), /* @__PURE__ */ jsxs(Typography, { children: [ formatAmount(staking, discountCurrency.decimal), " ", discountCurrency.symbol ] }) ] }) ] }), (allowPromotionCodes || hasDiscounts) && /* @__PURE__ */ jsxs( Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center", ...+staking > 0 && { borderTop: "1px solid", borderColor: "divider", pt: 1, mt: 1 } }, children: [ /* @__PURE__ */ jsx(Typography, { className: "base-label", children: t("common.subtotal") }), /* @__PURE__ */ jsxs(Typography, { children: [ subtotalDisplay, " ", discountCurrency.symbol ] }) ] } ), /* @__PURE__ */ jsx( PromotionSection, { checkoutSessionId: checkoutSession?.id || checkoutSessionId, currency: discountCurrency, currencyId: currency.id, discounts: sessionDiscounts, allowPromotionCodes, completed, disabled: paymentState.paying || paymentState.stripePaying, onPromotionUpdate: handlePromotionUpdate, onRemovePromotion: handleRemovePromotion, calculatedDiscountAmount: calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null), isRateLoading } ), hasSubTotal && /* @__PURE__ */ jsx(Divider, { sx: { my: 1 } }), /* @__PURE__ */ jsx( TotalSection, { totalAmountText, totalUsdDisplay, currency: discountCurrency, hasDynamicPricing, rateDisplay, rateInfo, quoteDetailRows, currentSlippagePercent, slippageConfig, isPriceLocked, isSubscription, completed, onSlippageChange: onSlippageChange ? handleSlippageChange : void 0, isStripePayment, isRateLoading, thenInfo: headlines.thenValue && headlines.showThen ? headlines.thenValue : void 0 } ) ] }) }); }