UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

445 lines (444 loc) 18.7 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { useEffect, useRef, useState } from "react"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import TrendingDownIcon from "@mui/icons-material/TrendingDown"; import { Avatar, Box, Button, Collapse, Stack, Typography } from "@mui/material"; import { useLocaleContext } from "@arcblock/ux/lib/Locale/context"; import Toast from "@arcblock/ux/lib/Toast"; import { useCheckoutStatus, useLineItems, useBillingInterval, usePricingFeature, useExchangeRate, useSlippage, useSessionContext, usePaymentMethodContext, useProduct } from "@blocklet/payment-react-headless"; import { useMobile } from "../../../hooks/mobile.js"; import { INTERVAL_LOCALE_KEY, formatTrialText, getSessionHeaderMeta, tSafe, primaryContrastColor } from "../../utils/format.js"; import ProductItemCard from "../../components/left/product-item-card.js"; import BillingToggle from "../../components/left/billing-toggle.js"; import CrossSellCard from "../../components/left/cross-sell-card.js"; import ExchangeRateFooter from "../../components/shared/exchange-rate-footer.js"; import TrialInfo from "../../components/left/trial-info.js"; export default function CompositePanel() { const { t } = useLocaleContext(); const { session } = useSessionContext(); const { currency, isStripe, switching: currencySwitching } = usePaymentMethodContext(); const { livemode } = useCheckoutStatus(); const { product, pageInfo } = useProduct(); const lineItems = useLineItems(); const billingInterval = useBillingInterval(); const pricing = usePricingFeature(); const rate = useExchangeRate(); const slippage = useSlippage(); const { isMobile } = useMobile(); const mode = session?.mode || "payment"; const discounts = session?.discounts || []; const appName = session?.app_name || session?.payment_link?.app_name || ""; const appLogo = session?.app_logo || session?.payment_link?.app_logo || ""; const showItemsCollapse = isMobile || lineItems.items.length >= 4; const [itemsExpanded, setItemsExpanded] = useState( isMobile ? lineItems.items.length <= 1 : lineItems.items.length < 4 ); const crossSellNotAdded = lineItems.crossSellItem && !lineItems.items.some((i) => i.price_id === lineItems.crossSellItem?.id); const nonCrossSellItems = lineItems.items.filter((i) => !i.cross_sell); const itemsWithUpsell = lineItems.items.filter((i) => i.price?.upsell?.upsells_to); const upsellPrimaryItem = itemsWithUpsell.length === 1 ? itemsWithUpsell[0] : null; const upsellTarget = upsellPrimaryItem ? upsellPrimaryItem.price?.upsell?.upsells_to : null; const canUpsell = nonCrossSellItems.length <= 1; const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ["subscription", "setup"].includes(mode); const isUpselled = !!upsellPrimaryItem?.upsell_price; const [upsellSwitching, setUpsellSwitching] = useState(false); const [pendingUpsell, setPendingUpsell] = useState(null); const visualIsUpselled = pendingUpsell !== null ? pendingUpsell : isUpselled; const currentInterval = hasTopUpsell ? upsellPrimaryItem.price?.recurring?.interval : null; const upsellInterval = hasTopUpsell ? upsellTarget?.recurring?.interval : null; let upsellSavings = 0; if (hasTopUpsell && currentInterval && upsellInterval) { const fromAmt = parseFloat( upsellPrimaryItem.price?.base_amount || upsellPrimaryItem.price?.unit_amount || "0" ); const toAmt = parseFloat(upsellTarget?.base_amount || upsellTarget?.unit_amount || "0"); const yearMap = { day: 365, week: 52, month: 12, year: 1 }; const fromY = fromAmt * (yearMap[currentInterval] || 1); const toY = toAmt * (yearMap[upsellInterval] || 1); if (fromY > toY && fromY > 0) upsellSavings = Math.round((fromY - toY) / fromY * 100); } const prevRateStatusRef = useRef(rate.status); useEffect(() => { if (prevRateStatusRef.current !== "unavailable" && rate.status === "unavailable" && !isStripe) { Toast.error(t("payment.dynamicPricing.unavailable.message")); } prevRateStatusRef.current = rate.status; }, [rate.status, isStripe, t]); const headerMeta = getSessionHeaderMeta(t, session, product, lineItems.items); const isMultiItem = lineItems.items.length > 1; const activeSx = { bgcolor: "primary.main", color: (theme) => primaryContrastColor(theme), boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)" }; const inactiveSx = { color: "text.secondary", "&:hover": { color: "text.primary" } }; const capsuleBtnSx = (active) => ({ px: 3.5, py: 1, borderRadius: "9999px", cursor: "pointer", transition: "all 0.3s ease", userSelect: "none", ...active ? activeSx : inactiveSx }); return /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", flexDirection: "column", flex: 1 }, children: [ !isMobile && (appName || appLogo) && /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 5 }, children: [ appLogo && /* @__PURE__ */ jsx( Avatar, { src: appLogo, alt: appName, sx: { width: 48, height: 48, borderRadius: "16px", bgcolor: "background.paper", boxShadow: 1, border: "1px solid", borderColor: "divider" } } ), appName && /* @__PURE__ */ jsx(Typography, { sx: { fontWeight: 600, fontSize: 15, color: "text.primary" }, children: appName }) ] }), !isMobile && /* @__PURE__ */ jsx(Box, { sx: { flexGrow: 1, flexShrink: 1, flexBasis: 0 } }), /* @__PURE__ */ jsxs(Box, { sx: { flexShrink: 0 }, children: [ /* @__PURE__ */ jsxs(Box, { sx: { mb: { xs: 3, md: 4 } }, children: [ isMobile && (appName || appLogo) && /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1 }, children: [ appLogo && /* @__PURE__ */ jsx(Avatar, { src: appLogo, alt: appName, sx: { width: 24, height: 24, borderRadius: "6px" } }), appName && /* @__PURE__ */ jsx(Typography, { sx: { fontWeight: 600, fontSize: 13, color: "text.secondary" }, children: appName }) ] }), /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1.5 }, children: [ /* @__PURE__ */ jsx( Typography, { component: "span", sx: { fontSize: 10, fontWeight: 700, letterSpacing: "0.1em", lineHeight: 1, textTransform: "uppercase", color: "primary.main", bgcolor: (theme) => theme.palette.mode === "dark" ? `${theme.palette.primary.main}1A` : `${theme.palette.primary.main}0D`, px: 1, py: 0.5, borderRadius: "4px" }, children: headerMeta.badgeLabel } ), pricing.trial.active && pricing.trial.days > 0 && /* @__PURE__ */ jsx( Typography, { component: "span", sx: { fontSize: 10, fontWeight: 700, letterSpacing: "0.1em", lineHeight: 1, textTransform: "uppercase", color: "primary.main", bgcolor: (theme) => theme.palette.mode === "dark" ? `${theme.palette.primary.main}1A` : `${theme.palette.primary.main}0D`, px: 1, py: 0.5, borderRadius: "4px" }, children: formatTrialText(t, pricing.trial.days, pricing.trial.afterTrialInterval || "day") } ), !livemode && /* @__PURE__ */ jsx( Typography, { component: "span", sx: { fontSize: 10, fontWeight: 700, letterSpacing: "0.1em", lineHeight: 1, textTransform: "uppercase", color: (theme) => theme.palette.mode === "dark" ? theme.palette.grey[500] : theme.palette.grey[400], bgcolor: (theme) => theme.palette.mode === "dark" ? "rgba(255,255,255,0.06)" : theme.palette.grey[100], px: 1, py: 0.5, borderRadius: "4px" }, children: t("common.livemode") } ) ] }), /* @__PURE__ */ jsx( Typography, { sx: { fontWeight: 800, fontSize: { xs: 24, md: 36 }, lineHeight: 1.1, letterSpacing: "-0.03em", color: "text.primary", mb: 0.75 }, children: isMultiItem ? tSafe(t, "payment.checkout.orderSummary", "Order Summary") : headerMeta.title } ), (isMultiItem || headerMeta.subtitle) && /* @__PURE__ */ jsx( Typography, { sx: { color: "text.secondary", fontSize: { xs: 14, md: 16 }, fontWeight: 500, lineHeight: 1.5 }, children: isMultiItem ? tSafe(t, "payment.checkout.orderSummarySubtitle", "Items included in this purchase") : headerMeta.subtitle } ), hasTopUpsell && /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 2, sx: { mt: 2.5 }, children: [ /* @__PURE__ */ jsxs( Stack, { direction: "row", alignItems: "center", sx: { bgcolor: "background.paper", borderRadius: "9999px", border: "1px solid", borderColor: "divider", p: "4px", display: "inline-flex", boxShadow: (theme) => theme.palette.mode === "dark" ? "0 1px 2px 0 rgba(0,0,0,0.3)" : "0 1px 2px 0 rgba(0,0,0,0.05)" }, children: [ /* @__PURE__ */ jsx( Box, { onClick: async () => { if (isUpselled && !upsellSwitching) { setPendingUpsell(false); setUpsellSwitching(true); try { await lineItems.downsell( upsellPrimaryItem.upsell_price?.id || upsellPrimaryItem.price_id ); } catch (err) { setPendingUpsell(null); Toast.error(err?.response?.data?.error || err?.message || "Failed"); } finally { setUpsellSwitching(false); setPendingUpsell(null); } } }, sx: capsuleBtnSx(!visualIsUpselled), children: /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 14, fontWeight: 700, color: "inherit", lineHeight: 1 }, children: t(INTERVAL_LOCALE_KEY[currentInterval] || "") }) } ), /* @__PURE__ */ jsx( Box, { onClick: async () => { if (!isUpselled && !upsellSwitching) { setPendingUpsell(true); setUpsellSwitching(true); try { await lineItems.upsell(upsellPrimaryItem.price_id, upsellTarget.id); } catch (err) { setPendingUpsell(null); Toast.error(err?.response?.data?.error || err?.message || "Failed"); } finally { setUpsellSwitching(false); setPendingUpsell(null); } } }, sx: capsuleBtnSx(visualIsUpselled), children: /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 14, fontWeight: 700, color: "inherit", lineHeight: 1 }, children: t(INTERVAL_LOCALE_KEY[upsellInterval] || "") }) } ) ] } ), upsellSavings > 0 && /* @__PURE__ */ jsxs( Stack, { direction: "row", alignItems: "center", spacing: 0.75, sx: { px: 1.5, py: 0.75, bgcolor: (theme) => theme.palette.mode === "dark" ? "rgba(18,184,134,0.1)" : "#ebfef5", color: "#12b886", fontSize: 11, fontWeight: 700, borderRadius: "9999px", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(18,184,134,0.2)" : "#d3f9e8", textTransform: "uppercase", letterSpacing: "0.05em" }, children: [ /* @__PURE__ */ jsx(TrendingDownIcon, { sx: { fontSize: 14 } }), /* @__PURE__ */ jsxs( Typography, { component: "span", sx: { fontSize: "inherit", fontWeight: "inherit", color: "inherit", lineHeight: 1 }, children: [ "SAVE ", upsellSavings, "%" ] } ) ] } ) ] }) ] }), /* @__PURE__ */ jsx(BillingToggle, { billingInterval }) ] }), /* @__PURE__ */ jsxs( Box, { sx: { flexGrow: 0, flexShrink: 1, flexBasis: "auto", minHeight: { md: 0 }, overflowY: { md: "auto" }, "&::-webkit-scrollbar": { display: "none" }, scrollbarWidth: "none" }, children: [ showItemsCollapse && /* @__PURE__ */ jsxs( Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", onClick: () => setItemsExpanded(!itemsExpanded), sx: { cursor: "pointer", mb: 1.5 }, children: [ /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 14, fontWeight: 600 }, children: t("payment.checkout.productListTotal", { total: lineItems.items.length }) }), /* @__PURE__ */ jsx(ExpandMoreIcon, { sx: { transform: itemsExpanded ? "rotate(180deg)" : "rotate(0deg)", transition: "0.3s" } }) ] } ), /* @__PURE__ */ jsxs(Collapse, { in: itemsExpanded || !showItemsCollapse, children: [ /* @__PURE__ */ jsx(Stack, { spacing: 2, sx: { mb: 2 }, children: lineItems.items.map((item) => /* @__PURE__ */ jsx( ProductItemCard, { item, currency, discounts, exchangeRate: rate.value, onQuantityChange: lineItems.updateQuantity, onUpsell: lineItems.upsell, onDownsell: lineItems.downsell, trialActive: pricing.trial.active, trialDays: pricing.trial.days, hideUpsell: hasTopUpsell || !canUpsell, isRateLoading: currencySwitching || rate.hasDynamicPricing && rate.status === "loading", showFeatures: pageInfo?.showProductFeatures ?? false, t, children: item.cross_sell && /* @__PURE__ */ jsx( Button, { size: "small", color: "error", variant: "text", onClick: lineItems.removeCrossSell, sx: { mt: 1, ml: -0.5, textTransform: "none", fontSize: 12 }, children: t("payment.checkout.cross_sell.remove") } ) }, item.id )) }), crossSellNotAdded && /* @__PURE__ */ jsx(Box, { sx: { mb: 2 }, children: /* @__PURE__ */ jsx( CrossSellCard, { crossSellItem: lineItems.crossSellItem, currency, exchangeRate: rate.value, crossSellRequired: lineItems.crossSellRequired, onAdd: lineItems.addCrossSell } ) }) ] }) ] } ), /* @__PURE__ */ jsx(Box, { sx: { flexShrink: 0 }, children: /* @__PURE__ */ jsx( TrialInfo, { trial: { active: pricing.trial.active, days: pricing.trial.days, afterTrialPrice: pricing.trial.afterTrialPrice, afterTrialInterval: pricing.trial.afterTrialInterval }, mode, items: lineItems.items } ) }), /* @__PURE__ */ jsx(Box, { sx: { flexGrow: 1, flexShrink: 1, flexBasis: 0 } }), /* @__PURE__ */ jsxs(Box, { sx: { flexShrink: 0 }, children: [ rate.hasDynamicPricing && rate.status === "unavailable" && !isStripe && /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.75, sx: { mb: 2 }, children: [ /* @__PURE__ */ jsx(Typography, { sx: { fontSize: 13, color: "text.secondary", fontWeight: 500 }, children: t("payment.dynamicPricing.unavailable.title") }), /* @__PURE__ */ jsx( Typography, { component: "span", onClick: rate.refresh, sx: { fontSize: 13, color: "primary.main", fontWeight: 600, cursor: "pointer", "&:hover": { textDecoration: "underline" } }, children: t("payment.dynamicPricing.unavailable.retry") } ) ] }), /* @__PURE__ */ jsx( ExchangeRateFooter, { hasDynamicPricing: rate.hasDynamicPricing, rate: { value: rate.value, display: rate.display, provider: rate.provider, providerDisplay: rate.providerDisplay, fetchedAt: rate.fetchedAt, status: rate.status }, slippage: { percent: slippage.percent, set: slippage.set }, currencySymbol: currency?.symbol || "", isSubscription: ["subscription", "setup"].includes(mode) } ) ] }) ] }); }