UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

572 lines (571 loc) 20.2 kB
import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { useLocaleContext } from "@arcblock/ux/lib/Locale/context"; import { HelpOutline, Close, LocalOffer } from "@mui/icons-material"; import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton, Button } 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 Status from "../components/status.js"; import api from "../libs/api.js"; import { formatAmount, formatCheckoutHeadlines, getPriceUintAmountByCurrency, formatCouponTerms, formatNumber, findCurrency } from "../libs/util.js"; import PaymentAmount from "./amount.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 LoadingButton from "../components/loading-button.js"; import PromotionCode from "../components/promotion-code.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, paymentMethods = [], onPromotionUpdate = noop, showFeatures = false, ...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 headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale); const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : "0"; const getAppliedPromotionCodes = () => { if (!sessionDiscounts?.length) return []; return sessionDiscounts.map((discount) => ({ id: discount.promotion_code || discount.coupon, code: discount.verification_data?.code || "APPLIED", discount_amount: discount.discount_amount })); }; 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 discountAmount = new BN(checkoutSession?.total_details?.amount_discount || "0"); const subtotalAmount = fromUnitToToken( new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)).add(new BN(staking)).toString(), discountCurrency?.decimal ); const totalAmount = fromUnitToToken( new BN(fromTokenToUnit(subtotalAmount, discountCurrency?.decimal)).sub(discountAmount).toString(), discountCurrency?.decimal ); 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, onUpsell: handleUpsell, onDownsell: handleDownsell, adjustableQuantity: x.adjustable_quantity, completed, showFeatures, onQuantityChange: handleQuantityChange, 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, 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") } ) ] } ) } ) } ) }) ] } ); 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, {}) ] } ), 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: headlines.amount }) ] } ), /* @__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: [ formatNumber(subtotalAmount), " ", discountCurrency.symbol ] }) ] } ), allowPromotionCodes && !hasDiscounts && /* @__PURE__ */ jsx(Box, { sx: { mt: 1 }, children: /* @__PURE__ */ jsx( PromotionCode, { checkoutSessionId: checkoutSession.id, initialAppliedCodes: getAppliedPromotionCodes(), disabled: completed, onUpdate: handlePromotionUpdate, currencyId: currency.id } ) }), hasDiscounts && /* @__PURE__ */ jsx(Box, { children: sessionDiscounts.map((discount) => { const promotionCodeInfo = discount.promotion_code_details; const couponInfo = discount.coupon_details; const discountDescription = couponInfo ? formatCouponTerms(couponInfo, discountCurrency, locale) : ""; const notSupported = discountDescription === t("payment.checkout.coupon.noDiscount"); return /* @__PURE__ */ jsxs(Stack, { children: [ /* @__PURE__ */ jsxs( Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center" }, children: [ /* @__PURE__ */ jsxs( Stack, { direction: "row", spacing: 1, sx: { alignItems: "center", backgroundColor: "grey.100", width: "fit-content", px: 1, py: 1, borderRadius: 1 }, children: [ /* @__PURE__ */ jsxs( Typography, { sx: { fontWeight: "medium", display: "flex", alignItems: "center", gap: 0.5 }, children: [ /* @__PURE__ */ jsx(LocalOffer, { sx: { color: "warning.main", fontSize: "small" } }), promotionCodeInfo?.code || discount.verification_data?.code || t("payment.checkout.discount") ] } ), !completed && /* @__PURE__ */ jsx( Button, { size: "small", disabled: paymentState.paying || paymentState.stripePaying, onClick: () => handleRemovePromotion(checkoutSessionId), sx: { minWidth: "auto", width: 16, height: 16, color: "text.secondary", "&.Mui-disabled": { color: "text.disabled" } }, children: /* @__PURE__ */ jsx(Close, { sx: { fontSize: 14 } }) } ) ] } ), /* @__PURE__ */ jsxs(Typography, { sx: { color: "text.secondary" }, children: [ "-", formatAmount(discount.discount_amount || "0", discountCurrency.decimal), " ", discountCurrency.symbol ] }) ] } ), discountDescription && /* @__PURE__ */ jsx( Typography, { sx: { fontSize: "small", color: notSupported ? "error.main" : "text.secondary", mt: 0.5 }, children: discountDescription } ) ] }, discount.promotion_code || discount.coupon || `discount-${discount.discount_amount}`); }) }), hasSubTotal && /* @__PURE__ */ jsx(Divider, { sx: { my: 1 } }), /* @__PURE__ */ jsxs( Stack, { sx: { display: "flex", justifyContent: "space-between", flexDirection: "row", alignItems: "center", width: "100%" }, children: [ /* @__PURE__ */ jsxs(Box, { className: "base-label", children: [ t("common.total"), " " ] }), /* @__PURE__ */ jsx(PaymentAmount, { amount: `${totalAmount} ${discountCurrency.symbol}`, sx: { fontSize: "16px" } }) ] } ), headlines.then && headlines.showThen && /* @__PURE__ */ jsx( Typography, { component: "div", sx: { fontSize: "0.7875rem", color: "text.lighter", textAlign: "right", margin: "-2px 0 8px" }, children: headlines.then } ) ] }) }); }