@blocklet/payment-react
Version:
Reusable react components for payment kit v2
559 lines (558 loc) • 22.1 kB
JavaScript
import { jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from "@mui/material";
import { Add, Remove, LocalOffer } from "@mui/icons-material";
import { useEffect, useMemo, useState } from "react";
import { useRequest } from "ahooks";
import { BN, fromTokenToUnit } from "@ocap/util";
import Status from "../components/status.js";
import Switch from "../components/switch-button.js";
import {
findCurrency,
formatLineItemPricing,
formatNumber,
formatPrice,
formatQuantityInventory,
formatRecurring,
formatUpsellSaving,
formatAmount,
formatBNStr
} from "../libs/util.js";
import ProductCard from "./product-card.js";
import dayjs from "../libs/dayjs.js";
import { usePaymentContext } from "../contexts/payment.js";
const getRecommendedQuantityFromUrl = (priceId) => {
try {
const urlParams = new URLSearchParams(window.location.search);
const recommendedQuantity = urlParams.get(`qty_${priceId}`) || urlParams.get("qty");
return recommendedQuantity ? Math.max(1, parseInt(recommendedQuantity, 10)) : void 0;
} catch {
return void 0;
}
};
const getUserQuantityPreference = (userDid, priceId) => {
try {
const key = `quantity_preference_${userDid}_${priceId}`;
const stored = localStorage.getItem(key);
return stored ? Math.max(1, parseInt(stored, 10)) : void 0;
} catch {
return void 0;
}
};
const saveUserQuantityPreference = (userDid, priceId, quantity) => {
try {
const key = `quantity_preference_${userDid}_${priceId}`;
localStorage.setItem(key, quantity.toString());
} catch {
}
};
export default function ProductItem({
item,
items,
trialInDays,
trialEnd = 0,
currency,
mode = "normal",
children = null,
onUpsell,
onDownsell,
completed = false,
adjustableQuantity = { enabled: false },
onQuantityChange = () => {
},
showFeatures = false
}) {
const { t, locale } = useLocaleContext();
const { settings, setPayable, session, api } = usePaymentContext();
const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays }, locale);
const saving = formatUpsellSaving(items, currency);
const metered = item.price?.recurring?.usage_type === "metered" ? t("common.metered") : "";
const canUpsell = mode === "normal" && items.length === 1;
const isCreditProduct = item.price.product?.type === "credit" && item.price.metadata?.credit_config?.credit_amount;
const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
const creditCurrency = isCreditProduct ? findCurrency(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? "") : null;
const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || "days";
const userDid = session?.user?.did;
const { data: pendingAmount } = useRequest(
async () => {
if (!isCreditProduct || !userDid) return null;
try {
const { data } = await api.get("/api/meter-events/pending-amount", {
params: {
customer_id: userDid,
currency_id: creditCurrency?.id
}
});
return data?.[creditCurrency?.id || ""];
} catch (error) {
console.warn("Failed to fetch pending amount:", error);
return null;
}
},
{
refreshDeps: [isCreditProduct, userDid, creditCurrency?.id]
}
);
const getInitialQuantity = () => {
const urlQuantity = getRecommendedQuantityFromUrl(item.price.id);
if (urlQuantity && urlQuantity > 0) {
return urlQuantity;
}
if (userDid) {
const preferredQuantity = getUserQuantityPreference(userDid, item.price.id);
if (preferredQuantity && preferredQuantity > 0) {
return preferredQuantity;
}
}
return item.quantity;
};
const [localQuantity, setLocalQuantity] = useState(getInitialQuantity());
const canAdjustQuantity = adjustableQuantity.enabled && mode === "normal";
const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity;
const localQuantityNum = localQuantity || 0;
useEffect(() => {
const initialQuantity = getInitialQuantity();
if (initialQuantity !== item.quantity && initialQuantity && initialQuantity > 1) {
onQuantityChange(item.price_id, initialQuantity);
}
}, []);
const handleQuantityChange = (newQuantity) => {
if (!newQuantity) {
setLocalQuantity(void 0);
setPayable(false);
return;
}
setPayable(true);
if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
if (formatQuantityInventory(item.price, newQuantity, locale)) {
return;
}
setLocalQuantity(newQuantity);
onQuantityChange(item.price_id, newQuantity);
if (userDid && newQuantity > 0) {
saveUserQuantityPreference(userDid, item.price.id, newQuantity);
}
}
};
const handleQuantityIncrease = () => {
if (localQuantityNum < maxQuantity) {
handleQuantityChange(localQuantityNum + 1);
}
};
const handleQuantityDecrease = () => {
if (localQuantityNum > minQuantity) {
handleQuantityChange(localQuantityNum - 1);
}
};
const handleQuantityInputChange = (event) => {
const value = parseInt(event.target.value || "0", 10);
if (!Number.isNaN(value)) {
handleQuantityChange(value);
}
};
const formatCreditInfo = () => {
if (!isCreditProduct) return null;
const totalCreditStr = formatNumber(creditAmount * (localQuantity || 0));
const currencySymbol = creditCurrency?.symbol || "Credits";
const hasPendingAmount = pendingAmount && new BN(pendingAmount || "0").gt(new BN(0));
const isRecurring = item.price.type === "recurring";
const hasExpiry = validDuration && validDuration > 0;
const buildBaseParams = () => ({
amount: totalCreditStr,
symbol: currencySymbol,
...hasExpiry && {
duration: validDuration,
unit: t(`common.${validDurationUnit}`)
},
...isRecurring && {
period: formatRecurring(item.price.recurring, true, "per", locale)
}
});
const buildPendingParams = (pendingBN, availableAmount) => ({
amount: formatBNStr(pendingBN.toString(), creditCurrency?.decimal || 2),
symbol: currencySymbol,
totalAmount: totalCreditStr,
...availableAmount && {
availableAmount: formatBNStr(availableAmount, creditCurrency?.decimal || 2)
},
...hasExpiry && {
duration: validDuration,
unit: t(`common.${validDurationUnit}`)
},
...isRecurring && {
period: formatRecurring(item.price.recurring, true, "per", locale)
}
});
const getLocaleKey = (category, type2) => {
const suffix = hasExpiry ? "WithExpiry" : "";
return `payment.checkout.credit.${category}.${type2}${suffix}`;
};
if (!hasPendingAmount) {
const type2 = isRecurring ? "recurring" : "oneTime";
return t(getLocaleKey("normal", type2), buildBaseParams());
}
const pendingAmountBN = new BN(pendingAmount || "0");
const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2);
const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100);
const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0));
const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString();
if (!new BN(actualAvailable).gt(new BN(0))) {
return t("payment.checkout.credit.pending.notEnough", {
amount: formatBNStr(pendingAmountBN.toString(), creditCurrency?.decimal || 2),
symbol: currencySymbol,
quantity: formatNumber(minQuantityNeeded)
});
}
const type = isRecurring ? "recurringEnough" : "oneTimeEnough";
return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable));
};
const primaryText = useMemo(() => {
const price = item.upsell_price || item.price || {};
const isRecurring = price?.type === "recurring" && price?.recurring;
const trial = trialInDays > 0 || trialEnd > dayjs().unix();
if (isRecurring && !trial && price?.recurring?.usage_type !== "metered") {
return `${pricing.primary} ${price.recurring ? formatRecurring(price.recurring, false, "slash", locale) : ""}`;
}
return pricing.primary;
}, [trialInDays, trialEnd, pricing, item, locale]);
const quantityInventoryError = formatQuantityInventory(item.price, localQuantityNum, locale);
const features = item.price.product?.features || [];
return /* @__PURE__ */ jsxs(
Stack,
{
direction: "column",
spacing: 1,
className: "product-item",
sx: {
alignItems: "flex-start",
width: "100%"
},
children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "column",
className: "product-item-content",
sx: {
alignItems: "flex-start",
width: "100%"
},
children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 0.5,
sx: {
alignItems: "center",
flexWrap: "wrap",
justifyContent: "space-between",
width: "100%"
},
children: [
/* @__PURE__ */ jsx(
ProductCard,
{
logo: item.price.product?.images[0],
name: item.price.product?.name,
extra: /* @__PURE__ */ jsx(
Box,
{
sx: {
display: "flex",
alignItems: "center"
},
children: item.price.type === "recurring" && item.price.recurring ? [pricing.quantity, t("common.billed", { rule: `${formatRecurring(item.upsell_price?.recurring || item.price.recurring, true, "per", locale)} ${metered}` })].filter(Boolean).join(", ") : pricing.quantity
}
)
}
),
/* @__PURE__ */ jsxs(
Stack,
{
direction: "column",
sx: {
alignItems: "flex-end",
flex: 1
},
children: [
/* @__PURE__ */ jsx(Typography, { sx: { color: "text.primary", fontWeight: 500, whiteSpace: "nowrap" }, gutterBottom: true, children: primaryText }),
pricing.secondary && /* @__PURE__ */ jsx(Typography, { sx: { fontSize: "0.74375rem", color: "text.lighter" }, children: pricing.secondary })
]
}
)
]
}
),
item.discount_amounts && item.discount_amounts.length > 0 && /* @__PURE__ */ jsx(Stack, { direction: "row", spacing: 1, sx: { mt: 1, alignItems: "center" }, children: item.discount_amounts.map((discountAmount) => /* @__PURE__ */ jsx(
Chip,
{
icon: /* @__PURE__ */ jsx(LocalOffer, { sx: { fontSize: "0.8rem !important" } }),
label: /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.5 }, children: [
/* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: "0.75rem", fontWeight: "medium" }, children: discountAmount.promotion_code?.code || "DISCOUNT" }),
/* @__PURE__ */ jsxs(Typography, { component: "span", sx: { fontSize: "0.75rem" }, children: [
"(-",
formatAmount(discountAmount.amount || "0", currency.decimal),
" ",
currency.symbol,
")"
] })
] }),
size: "small",
variant: "filled",
sx: {
height: 20,
"& .MuiChip-icon": {
color: "warning.main"
},
"& .MuiChip-label": {
px: 1
}
}
},
discountAmount.promotion_code
)) }),
showFeatures && features.length > 0 && /* @__PURE__ */ jsx(
Box,
{
sx: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
flexWrap: "wrap",
gap: 1.5,
width: "100%",
mt: 1.5
},
children: features.map((feature) => /* @__PURE__ */ jsxs(
Box,
{
sx: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 0.5,
px: 1,
py: 0.5,
bgcolor: "action.hover",
borderRadius: 1,
border: "1px solid",
borderColor: "divider"
},
children: [
/* @__PURE__ */ jsx(
Box,
{
sx: {
bgcolor: "success.main",
width: 4,
height: 4,
borderRadius: "50%"
}
}
),
/* @__PURE__ */ jsx(
Typography,
{
component: "span",
variant: "caption",
sx: {
color: "text.primary",
fontSize: "0.75rem",
fontWeight: 500,
textTransform: "capitalize",
whiteSpace: "nowrap"
},
children: feature.name
}
)
]
},
feature.name
))
}
),
quantityInventoryError ? /* @__PURE__ */ jsx(
Status,
{
label: quantityInventoryError,
variant: "outlined",
sx: {
mt: 1,
borderColor: "chip.error.border",
backgroundColor: "chip.error.background",
color: "chip.error.text"
}
}
) : null,
canAdjustQuantity && !completed && /* @__PURE__ */ jsx(Box, { sx: { mt: 1, p: 1 }, children: /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
alignItems: "center"
},
children: [
/* @__PURE__ */ jsxs(
Typography,
{
variant: "body2",
sx: {
color: "text.secondary",
minWidth: "fit-content"
},
children: [
t("common.quantity"),
":"
]
}
),
/* @__PURE__ */ jsx(
IconButton,
{
size: "small",
onClick: handleQuantityDecrease,
disabled: localQuantityNum <= minQuantity,
sx: { minWidth: 32, width: 32, height: 32 },
children: /* @__PURE__ */ jsx(Remove, { fontSize: "small" })
}
),
/* @__PURE__ */ jsx(
TextField,
{
size: "small",
value: localQuantity,
onChange: handleQuantityInputChange,
type: "number",
slotProps: {
htmlInput: {
min: minQuantity,
max: maxQuantity,
style: { textAlign: "center", padding: "4px", minWidth: 80 }
}
}
}
),
/* @__PURE__ */ jsx(
IconButton,
{
size: "small",
onClick: handleQuantityIncrease,
disabled: localQuantityNum >= maxQuantity,
sx: { minWidth: 32, width: 32, height: 32 },
children: /* @__PURE__ */ jsx(Add, { fontSize: "small" })
}
)
]
}
) }),
isCreditProduct && /* @__PURE__ */ jsx(Alert, { severity: "info", sx: { mt: 1, fontSize: "0.875rem" }, icon: false, children: formatCreditInfo() }),
children
]
}
),
canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
className: "product-item-upsell",
sx: {
alignItems: "center",
justifyContent: "space-between",
width: "100%"
},
children: [
/* @__PURE__ */ jsxs(
Typography,
{
component: "label",
htmlFor: "upsell-switch",
sx: {
fontSize: 12,
cursor: "pointer",
color: "text.secondary"
},
children: [
/* @__PURE__ */ jsx(
Switch,
{
id: "upsell-switch",
sx: { mr: 1 },
variant: "success",
checked: false,
onChange: () => onUpsell(item.price_id, item.price.upsell?.upsells_to_id)
}
),
t("payment.checkout.upsell.save", {
recurring: formatRecurring(item.price.upsell.upsells_to.recurring, true, "per", locale)
}),
/* @__PURE__ */ jsx(
Status,
{
label: t("payment.checkout.upsell.off", { saving }),
variant: "outlined",
sx: {
ml: 1,
borderColor: "chip.warning.border",
backgroundColor: "chip.warning.background",
color: "chip.warning.text"
}
}
)
]
}
),
/* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 12 }, children: formatPrice(item.price.upsell.upsells_to, currency, item.price.product?.unit_label, 1, true, locale) })
]
}
),
canUpsell && item.upsell_price_id && /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
className: "product-item-upsell",
sx: {
alignItems: "center",
justifyContent: "space-between",
width: "100%"
},
children: [
/* @__PURE__ */ jsxs(
Typography,
{
component: "label",
htmlFor: "upsell-switch",
sx: {
fontSize: 12,
cursor: "pointer",
color: "text.secondary"
},
children: [
/* @__PURE__ */ jsx(
Switch,
{
id: "upsell-switch",
sx: { mr: 1 },
variant: "success",
checked: true,
onChange: () => onDownsell(item.upsell_price_id)
}
),
t("payment.checkout.upsell.revert", {
recurring: t(`common.${formatRecurring(item.price.recurring)}`)
})
]
}
),
/* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 12 }, children: formatPrice(item.price, currency, item.price.product?.unit_label, 1, true, locale) })
]
}
)
]
}
);
}