UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

559 lines (558 loc) 22.1 kB
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) }) ] } ) ] } ); }