@blocklet/payment-react
Version:
Reusable react components for payment kit v2
606 lines (605 loc) • 26.6 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useState, useEffect, useRef, useCallback } from "react";
import AddIcon from "@mui/icons-material/Add";
import CheckIcon from "@mui/icons-material/Check";
import LocalOfferIcon from "@mui/icons-material/LocalOffer";
import RemoveIcon from "@mui/icons-material/Remove";
import ShoppingCartCheckoutIcon from "@mui/icons-material/ShoppingCartCheckout";
import {
Avatar,
Box,
Chip,
Collapse,
IconButton,
Skeleton,
Stack,
Switch,
TextField,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { getPriceUnitAmountByCurrency } from "@blocklet/payment-react-headless";
import Toast from "@arcblock/ux/lib/Toast";
import {
INTERVAL_LOCALE_KEY,
formatDynamicUnitPrice,
formatTokenAmount,
formatTrialText,
primaryContrastColor
} from "../../utils/format.js";
export default function ProductItemCard({
item,
currency,
discounts,
exchangeRate,
onQuantityChange,
onUpsell,
onDownsell,
trialActive,
trialDays,
t,
recommended = false,
hideUpsell = false,
isRateLoading = false,
showFeatures = true,
children = void 0
}) {
const activePrice = item.upsell_price || item.price;
const product = activePrice?.product;
const name = product?.name || "Item";
const logo = product?.images?.[0] || "";
const features = product?.features || [];
const recurring = activePrice?.recurring;
const quantity = item.quantity || 1;
const isMetered = recurring?.usage_type === "metered";
const metered = isMetered ? ` ${t("common.metered")}` : "";
const perUnitFormatted = formatDynamicUnitPrice(activePrice, currency, exchangeRate);
const isSubscription = !!recurring;
const typeBadgeText = isSubscription ? t("payment.checkout.typeBadge.subscription") : t("payment.checkout.typeBadge.oneTime");
const priceIntervalSuffix = recurring?.interval ? ` / ${t(`common.${recurring.interval}`)}` : "";
const subtitleText = (() => {
if (quantity > 1 && perUnitFormatted && currency) {
return `${quantity} \xD7 ${perUnitFormatted} ${currency.symbol}`;
}
if (isMetered) return metered.trim();
return "";
})();
const itemTotal = (() => {
const quoteCurrencyId = item.quote_currency_id;
if (item.custom_amount && quoteCurrencyId && quoteCurrencyId === currency?.id) {
return formatTokenAmount(item.custom_amount, currency);
}
if (activePrice?.pricing_type === "dynamic" && exchangeRate && activePrice.base_amount) {
const rate = Number(exchangeRate);
if (rate > 0 && Number.isFinite(rate)) {
const baseUsd = Number(activePrice.base_amount);
if (baseUsd > 0 && Number.isFinite(baseUsd)) {
const tokenAmount = baseUsd * quantity / rate;
const abs = Math.abs(tokenAmount);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
return tokenAmount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
}
}
}
if (!exchangeRate && activePrice?.base_amount != null) {
const baseUsd = Number(activePrice.base_amount);
if (baseUsd >= 0 && Number.isFinite(baseUsd)) {
const total = baseUsd * quantity;
return total.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
}
if (activePrice && currency) {
const unitAmount = getPriceUnitAmountByCurrency(activePrice, currency);
if (unitAmount && unitAmount !== "0") {
const totalUnits = BigInt(unitAmount) * BigInt(quantity);
return formatTokenAmount(totalUnits.toString(), currency);
}
}
return "0";
})();
const discount = discounts?.[0];
const discountCode = discount?.promotion_code_details?.code || discount?.verification_data?.code || discount?.promotion_code || "";
const perItemDiscount = (() => {
if (!discountCode || !discount) return null;
const couponDetails = discount?.coupon_details;
if (couponDetails?.percent_off > 0) {
const numericTotal = parseFloat(String(itemTotal).replace(/,/g, ""));
if (!Number.isNaN(numericTotal) && numericTotal > 0) {
const discAmount = numericTotal * couponDetails.percent_off / 100;
const abs = Math.abs(discAmount);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
return `${discAmount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0"} ${currency?.symbol || ""}`;
}
}
if (item.discount_amounts?.length > 0 && currency) {
return `${formatTokenAmount(item.discount_amounts[0].amount, currency)} ${currency.symbol}`;
}
return null;
})();
const adjustable = item.adjustable_quantity?.enabled;
const min = item.adjustable_quantity?.minimum || 1;
const max = item.adjustable_quantity?.maximum;
const [qtyInput, setQtyInput] = useState(String(quantity));
const [isEditing, setIsEditing] = useState(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [featuresOpen, setFeaturesOpen] = useState(!isMobile);
useEffect(() => {
if (!isEditing) setQtyInput(String(quantity));
}, [quantity, isEditing]);
const debounceRef = useRef(null);
const debouncedQuantityChange = useCallback(
(priceId, qty) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onQuantityChange(priceId, qty);
}, 400);
},
[onQuantityChange]
);
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[]
);
const canUpsell = !!item.price?.upsell?.upsells_to;
const isUpselled = !!item.upsell_price;
const upsellTo = item.price?.upsell?.upsells_to;
let savingsPercent = 0;
if (canUpsell && upsellTo) {
const fromAmount = parseFloat(item.price?.base_amount || item.price?.unit_amount || "0");
const toAmount = parseFloat(upsellTo?.base_amount || upsellTo?.unit_amount || "0");
const fromInterval = item.price?.recurring?.interval;
const toInterval = upsellTo?.recurring?.interval;
if (fromAmount > 0 && toAmount > 0 && fromInterval && toInterval) {
const monthsMap = { day: 365, week: 52, month: 12, year: 1 };
const fromYearly = fromAmount * (monthsMap[fromInterval] || 1);
const toYearly = toAmount * (monthsMap[toInterval] || 1);
if (fromYearly > toYearly) {
savingsPercent = Math.round((fromYearly - toYearly) / fromYearly * 100);
}
}
}
const upsellInterval = upsellTo?.recurring?.interval;
const upsellPrice = (() => {
if (!upsellTo) return "";
const slashText = upsellInterval ? t("common.slash", { interval: t(`common.${upsellInterval}`) }) : "";
if (upsellTo.pricing_type === "dynamic" && upsellTo.base_amount && exchangeRate) {
const rate = Number(exchangeRate);
if (rate > 0 && Number.isFinite(rate)) {
const baseUsd = parseFloat(upsellTo.base_amount);
if (baseUsd > 0 && Number.isFinite(baseUsd)) {
const tokenAmount = baseUsd / rate;
const abs = Math.abs(tokenAmount);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
const formatted = tokenAmount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
return `${formatted} ${currency?.symbol || ""} ${slashText}`;
}
}
}
if (upsellTo.pricing_type === "dynamic" && upsellTo.base_amount && upsellTo.base_currency === "USD") {
const baseUsd = parseFloat(upsellTo.base_amount);
const formattedUsd = baseUsd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `$${formattedUsd} ${slashText}`;
}
const upsellUnitFormatted = formatDynamicUnitPrice(upsellTo, currency, exchangeRate);
return upsellUnitFormatted ? `${upsellUnitFormatted} ${currency?.symbol || ""} ${slashText}` : "";
})();
const originalInterval = item.price?.recurring?.interval;
const downsellPrice = (() => {
if (!item.price || !isUpselled) return "";
const originalPrice = item.price;
const originalSlash = originalInterval ? t("common.slash", { interval: t(`common.${originalInterval}`) }) : "";
if (originalPrice.pricing_type === "dynamic" && originalPrice.base_amount && exchangeRate) {
const rate = Number(exchangeRate);
if (rate > 0 && Number.isFinite(rate)) {
const baseUsd = parseFloat(originalPrice.base_amount);
if (baseUsd > 0 && Number.isFinite(baseUsd)) {
const tokenAmount = baseUsd / rate;
const abs = Math.abs(tokenAmount);
const precision = abs > 0 && abs < 0.01 ? 6 : 2;
const formatted = tokenAmount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
return `${formatted} ${currency?.symbol || ""} ${originalSlash}`;
}
}
}
if (originalPrice.pricing_type === "dynamic" && originalPrice.base_amount && originalPrice.base_currency === "USD") {
const baseUsd = parseFloat(originalPrice.base_amount);
const formattedUsd = baseUsd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return `$${formattedUsd} ${originalSlash}`;
}
const unitFormatted = formatDynamicUnitPrice(originalPrice, currency, exchangeRate);
return unitFormatted ? `${unitFormatted} ${currency?.symbol || ""} ${originalSlash}` : "";
})();
const upsellToggleLabel = (() => {
if (isUpselled) {
const recurringLabel2 = originalInterval ? t(INTERVAL_LOCALE_KEY[originalInterval] || "") : "";
return t("payment.checkout.upsell.revert", { recurring: recurringLabel2 });
}
const recurringLabel = upsellInterval ? t(INTERVAL_LOCALE_KEY[upsellInterval] || "") : "";
return t("payment.checkout.upsell.save", { recurring: recurringLabel });
})();
return /* @__PURE__ */ jsxs(Box, { sx: { position: "relative" }, children: [
recommended && /* @__PURE__ */ jsx(
Chip,
{
label: "RECOMMENDED",
size: "small",
sx: {
position: "absolute",
top: 0,
right: 40,
transform: "translateY(-50%)",
zIndex: 1,
height: 22,
fontSize: 9,
fontWeight: 900,
letterSpacing: "0.12em",
bgcolor: "primary.main",
color: (th) => primaryContrastColor(th),
boxShadow: "0 4px 12px rgba(45,124,243,0.2)",
"& .MuiChip-label": { px: 1.5 }
}
}
),
/* @__PURE__ */ jsxs(
Box,
{
sx: {
p: { xs: 2, md: 3 },
bgcolor: "background.paper",
borderRadius: { xs: "16px", md: "24px" },
border: "1px solid",
borderColor: "divider",
boxShadow: (th) => th.palette.mode === "dark" ? "0 12px 40px -8px rgba(0,0,0,0.3)" : "0 12px 40px -8px rgba(0,0,0,0.06)",
transition: "all 0.3s ease",
...canUpsell && !hideUpsell ? { borderRadius: { xs: "16px 16px 0 0", md: "24px 24px 0 0" } } : {},
"&:hover": {
borderColor: (th) => th.palette.mode === "dark" ? "rgba(255,255,255,0.12)" : "rgba(45,124,243,0.15)"
}
},
children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: { xs: 1.5, md: 2.5 }, sx: { alignItems: "center", width: "100%" }, children: [
logo ? /* @__PURE__ */ jsx(
Avatar,
{
src: logo,
alt: name,
variant: "rounded",
sx: {
width: { xs: 44, md: 64 },
height: { xs: 44, md: 64 },
borderRadius: { xs: "12px", md: "16px" },
flexShrink: 0
}
}
) : /* @__PURE__ */ jsx(
Avatar,
{
variant: "rounded",
sx: {
width: { xs: 44, md: 64 },
height: { xs: 44, md: 64 },
borderRadius: { xs: "12px", md: "16px" },
bgcolor: (th) => th.palette.mode === "dark" ? "rgba(255,255,255,0.06)" : "#eff6ff",
flexShrink: 0
},
children: /* @__PURE__ */ jsx(ShoppingCartCheckoutIcon, { sx: { fontSize: { xs: 22, md: 28 }, color: "primary.main", opacity: 0.45 } })
}
),
/* @__PURE__ */ jsx(Box, { sx: { flex: 1, minWidth: 0, overflow: "hidden" }, children: /* @__PURE__ */ jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "flex-start", sx: { mb: 0.25 }, children: [
/* @__PURE__ */ jsxs(Box, { sx: { minWidth: 0 }, children: [
/* @__PURE__ */ jsx(
Typography,
{
sx: { color: "text.primary", fontWeight: 800, fontSize: { xs: 15, md: 18 }, lineHeight: 1.3 },
children: name
}
),
/* @__PURE__ */ jsx(
Typography,
{
component: "span",
sx: {
display: "inline-block",
mt: 0.5,
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.08em",
lineHeight: 1,
textTransform: "uppercase",
color: "primary.main",
bgcolor: (th) => th.palette.mode === "dark" ? `${th.palette.primary.main}1A` : `${th.palette.primary.main}0D`,
px: 0.75,
py: 0.4,
borderRadius: "3px"
},
children: typeBadgeText
}
),
subtitleText && /* @__PURE__ */ jsx(Typography, { sx: { color: "text.disabled", fontSize: 13, fontWeight: 500, mt: 0.25 }, children: subtitleText })
] }),
/* @__PURE__ */ jsx(Stack, { alignItems: "flex-end", sx: { flexShrink: 0, ml: 1.5 }, children: trialActive && isSubscription ? /* @__PURE__ */ jsx(
Typography,
{
sx: { fontWeight: 800, color: "text.primary", whiteSpace: "nowrap", fontSize: { xs: 15, md: 18 } },
children: formatTrialText(t, trialDays, recurring?.interval || "day")
}
) : isRateLoading ? /* @__PURE__ */ jsx(Skeleton, { variant: "text", width: 100, height: 28 }) : /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsxs(
Typography,
{
sx: {
fontWeight: 800,
color: "text.primary",
whiteSpace: "nowrap",
fontSize: { xs: 15, md: 18 },
transition: "opacity 0.3s ease"
},
children: [
itemTotal,
" ",
currency?.symbol,
priceIntervalSuffix
]
}
),
exchangeRate && activePrice?.base_amount && /* @__PURE__ */ jsxs(Typography, { sx: { fontSize: 12, color: "text.disabled", fontWeight: 600, lineHeight: 1 }, children: [
"\u2248 $",
(Number(activePrice.base_amount) * quantity).toFixed(2)
] })
] }) })
] }) })
] }),
showFeatures && features.length > 0 && /* @__PURE__ */ jsxs(
Box,
{
sx: {
mt: { xs: 2, md: 2.5 },
pt: { xs: 2, md: 2.5 },
borderTop: "1px solid",
borderColor: (th) => th.palette.mode === "dark" ? "rgba(255,255,255,0.06)" : "grey.100"
},
children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
alignItems: "center",
justifyContent: "space-between",
onClick: () => setFeaturesOpen((v) => !v),
sx: { cursor: "pointer", userSelect: "none", mb: featuresOpen ? 1.25 : 0 },
children: [
/* @__PURE__ */ jsx(Typography, { sx: { fontSize: 14, fontWeight: 700, color: "text.primary" }, children: t("payment.checkout.planFeatures") }),
/* @__PURE__ */ jsx(
KeyboardArrowUpIcon,
{
sx: {
fontSize: 20,
color: "text.secondary",
transition: "transform 0.2s ease",
transform: featuresOpen ? "rotate(0deg)" : "rotate(180deg)"
}
}
)
]
}
),
/* @__PURE__ */ jsx(Collapse, { in: featuresOpen, children: /* @__PURE__ */ jsx(Stack, { spacing: 1.25, children: features.map((feature) => /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1.5, alignItems: "center", children: [
/* @__PURE__ */ jsx(
Box,
{
sx: {
width: 20,
height: 20,
borderRadius: "50%",
bgcolor: (th) => th.palette.mode === "dark" ? "rgba(16,185,129,0.15)" : "rgba(16,185,129,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0
},
children: /* @__PURE__ */ jsx(CheckIcon, { sx: { fontSize: 14, color: "success.main" } })
}
),
/* @__PURE__ */ jsx(Typography, { sx: { fontSize: 14, fontWeight: 500, color: "text.secondary" }, children: feature.name })
] }, feature.name)) }) })
]
}
),
discountCode && perItemDiscount && /* @__PURE__ */ jsx(Box, { sx: { mt: 1.5 }, children: isRateLoading ? /* @__PURE__ */ jsx(Skeleton, { variant: "rounded", width: 160, height: 22, sx: { borderRadius: "6px" } }) : /* @__PURE__ */ jsx(
Chip,
{
icon: /* @__PURE__ */ jsx(LocalOfferIcon, { sx: { color: "warning.main", fontSize: "small" } }),
label: `${discountCode} (-${perItemDiscount})`,
size: "small",
sx: { height: 22, borderRadius: "6px", "& .MuiChip-label": { fontSize: 12 } }
}
) }),
adjustable && /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", sx: { mt: 2 }, children: [
/* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary", minWidth: "fit-content", fontSize: 13, fontWeight: 500 }, children: t("common.quantity") }),
/* @__PURE__ */ jsx(
IconButton,
{
size: "small",
onClick: () => {
const cur = parseInt(qtyInput, 10) || quantity;
const newQty = cur - 1;
if (newQty < min) return;
setQtyInput(String(newQty));
debouncedQuantityChange(item.price_id, newQty);
},
disabled: (parseInt(qtyInput, 10) || quantity) <= min,
sx: {
width: 36,
height: 36,
border: "1px solid",
borderColor: "divider",
borderRadius: "50%",
"&:hover": { bgcolor: "action.hover" }
},
children: /* @__PURE__ */ jsx(RemoveIcon, { sx: { fontSize: 20 } })
}
),
/* @__PURE__ */ jsx(
TextField,
{
value: qtyInput,
size: "small",
type: "number",
slotProps: {
htmlInput: {
style: {
textAlign: "center",
padding: "6px 4px",
fontWeight: 700,
fontSize: 16,
MozAppearance: "textfield"
},
min,
max: max || void 0
}
},
sx: {
minWidth: 64,
"& input": { textAlign: "center" },
"& .MuiOutlinedInput-root": { borderRadius: "12px" },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0
},
"& input[type=number]": {
MozAppearance: "textfield"
}
},
onFocus: () => setIsEditing(true),
onChange: (e) => setQtyInput(e.target.value),
onKeyDown: (e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
e.target.blur();
}
},
onBlur: () => {
setIsEditing(false);
const v = parseInt(qtyInput, 10);
if (!Number.isNaN(v) && v >= min && (!max || v <= max) && v !== quantity) {
onQuantityChange(item.price_id, v);
} else {
setQtyInput(String(quantity));
}
}
}
),
/* @__PURE__ */ jsx(
IconButton,
{
size: "small",
onClick: () => {
const cur = parseInt(qtyInput, 10) || quantity;
const newQty = cur + 1;
if (max && newQty > max) return;
setQtyInput(String(newQty));
debouncedQuantityChange(item.price_id, newQty);
},
disabled: max ? (parseInt(qtyInput, 10) || quantity) >= max : false,
sx: {
width: 36,
height: 36,
border: "1px solid",
borderColor: "divider",
borderRadius: "50%",
"&:hover": { bgcolor: "action.hover" }
},
children: /* @__PURE__ */ jsx(AddIcon, { sx: { fontSize: 20 } })
}
)
] }),
children
]
}
),
canUpsell && !hideUpsell && /* @__PURE__ */ jsxs(
Box,
{
sx: {
px: { xs: 2, md: 3 },
py: 1.5,
bgcolor: "background.paper",
border: "1px solid",
borderTop: 0,
borderColor: "divider",
borderRadius: { xs: "0 0 16px 16px", md: "0 0 24px 24px" },
boxShadow: (th) => th.palette.mode === "dark" ? "0 12px 40px -8px rgba(0,0,0,0.3)" : "0 12px 40px -8px rgba(0,0,0,0.06)"
},
children: [
/* @__PURE__ */ jsx(Box, { sx: { borderTop: "1px solid", borderColor: "divider", mb: 1.5 } }),
/* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", justifyContent: "space-between", children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [
/* @__PURE__ */ jsx(
Switch,
{
checked: isUpselled,
onChange: async () => {
try {
if (isUpselled) {
await onDownsell(item.upsell_price?.id || item.price.id);
} else {
await onUpsell(item.price_id, upsellTo.id);
}
} catch (err) {
Toast.error(err?.response?.data?.error || err?.message || "Failed");
}
},
size: "small",
sx: {
"& .MuiSwitch-switchBase.Mui-checked": { color: "#12b886" },
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { bgcolor: "#12b886" }
}
}
),
/* @__PURE__ */ jsx(
Typography,
{
sx: { fontSize: 13, color: "text.secondary", cursor: "pointer", fontWeight: 600 },
onClick: async () => {
try {
if (isUpselled) await onDownsell(item.upsell_price?.id || item.price.id);
else await onUpsell(item.price_id, upsellTo.id);
} catch (err) {
Toast.error(err?.response?.data?.error || err?.message || "Failed");
}
},
children: upsellToggleLabel
}
),
!isUpselled && savingsPercent > 0 && /* @__PURE__ */ jsx(
Chip,
{
label: t("payment.checkout.upsell.off", { saving: savingsPercent }),
size: "small",
sx: {
height: 22,
fontSize: 11,
fontWeight: 700,
bgcolor: (th) => th.palette.mode === "dark" ? "rgba(18,184,134,0.1)" : "#ebfef5",
color: "#12b886",
border: "1px solid",
borderColor: (th) => th.palette.mode === "dark" ? "rgba(18,184,134,0.2)" : "#d3f9e8",
borderRadius: "9999px",
"& .MuiChip-label": { px: 1 }
}
}
)
] }),
/* @__PURE__ */ jsx(Typography, { sx: { fontSize: 13, color: "text.primary", whiteSpace: "nowrap", fontWeight: 700 }, children: isUpselled ? downsellPrice : upsellPrice })
] })
]
}
)
] });
}