@blocklet/payment-react
Version:
Reusable react components for payment kit v2
445 lines (444 loc) • 18.7 kB
JavaScript
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)
}
)
] })
] });
}