UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

538 lines (537 loc) 20 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); module.exports = ProductItem; var _jsxRuntime = require("react/jsx-runtime"); var _context = require("@arcblock/ux/lib/Locale/context"); var _material = require("@mui/material"); var _iconsMaterial = require("@mui/icons-material"); var _react = require("react"); var _ahooks = require("ahooks"); var _util = require("@ocap/util"); var _status = _interopRequireDefault(require("../components/status")); var _switchButton = _interopRequireDefault(require("../components/switch-button")); var _util2 = require("../libs/util"); var _productCard = _interopRequireDefault(require("./product-card")); var _dayjs = _interopRequireDefault(require("../libs/dayjs")); var _payment = require("../contexts/payment"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } 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 {} }; 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 } = (0, _context.useLocaleContext)(); const { settings, setPayable, session, api } = (0, _payment.usePaymentContext)(); const pricing = (0, _util2.formatLineItemPricing)(item, currency, { trialEnd, trialInDays }, locale); const saving = (0, _util2.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 ? (0, _util2.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 } = (0, _ahooks.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] = (0, _react.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; (0, _react.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 ((0, _util2.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 = (0, _util2.formatNumber)(creditAmount * (localQuantity || 0)); const currencySymbol = creditCurrency?.symbol || "Credits"; const hasPendingAmount = pendingAmount && new _util.BN(pendingAmount || "0").gt(new _util.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: (0, _util2.formatRecurring)(item.price.recurring, true, "per", locale) }) }); const buildPendingParams = (pendingBN, availableAmount) => ({ amount: (0, _util2.formatBNStr)(pendingBN.toString(), creditCurrency?.decimal || 2), symbol: currencySymbol, totalAmount: totalCreditStr, ...(availableAmount && { availableAmount: (0, _util2.formatBNStr)(availableAmount, creditCurrency?.decimal || 2) }), ...(hasExpiry && { duration: validDuration, unit: t(`common.${validDurationUnit}`) }), ...(isRecurring && { period: (0, _util2.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 _util.BN(pendingAmount || "0"); const creditAmountBN = (0, _util.fromTokenToUnit)(creditAmount, creditCurrency?.decimal || 2); const minQuantityNeeded = Math.ceil(pendingAmountBN.mul(new _util.BN(100)).div(creditAmountBN).toNumber() / 100); const currentPurchaseCreditBN = creditAmountBN.mul(new _util.BN(localQuantity || 0)); const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString(); if (!new _util.BN(actualAvailable).gt(new _util.BN(0))) { return t("payment.checkout.credit.pending.notEnough", { amount: (0, _util2.formatBNStr)(pendingAmountBN.toString(), creditCurrency?.decimal || 2), symbol: currencySymbol, quantity: (0, _util2.formatNumber)(minQuantityNeeded) }); } const type = isRecurring ? "recurringEnough" : "oneTimeEnough"; return t(getLocaleKey("pending", type), buildPendingParams(pendingAmountBN, actualAvailable)); }; const primaryText = (0, _react.useMemo)(() => { const price = item.upsell_price || item.price || {}; const isRecurring = price?.type === "recurring" && price?.recurring; const trial = trialInDays > 0 || trialEnd > (0, _dayjs.default)().unix(); if (isRecurring && !trial && price?.recurring?.usage_type !== "metered") { return `${pricing.primary} ${price.recurring ? (0, _util2.formatRecurring)(price.recurring, false, "slash", locale) : ""}`; } return pricing.primary; }, [trialInDays, trialEnd, pricing, item, locale]); const quantityInventoryError = (0, _util2.formatQuantityInventory)(item.price, localQuantityNum, locale); const features = item.price.product?.features || []; return /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "column", spacing: 1, className: "product-item", sx: { alignItems: "flex-start", width: "100%" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "column", className: "product-item-content", sx: { alignItems: "flex-start", width: "100%" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "row", spacing: 0.5, sx: { alignItems: "center", flexWrap: "wrap", justifyContent: "space-between", width: "100%" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_productCard.default, { logo: item.price.product?.images[0], name: item.price.product?.name, extra: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, { sx: { display: "flex", alignItems: "center" }, children: item.price.type === "recurring" && item.price.recurring ? [pricing.quantity, t("common.billed", { rule: `${(0, _util2.formatRecurring)(item.upsell_price?.recurring || item.price.recurring, true, "per", locale)} ${metered}` })].filter(Boolean).join(", ") : pricing.quantity }) }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "column", sx: { alignItems: "flex-end", flex: 1 }, children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, { sx: { color: "text.primary", fontWeight: 500, whiteSpace: "nowrap" }, gutterBottom: true, children: primaryText }), pricing.secondary && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, { sx: { fontSize: "0.74375rem", color: "text.lighter" }, children: pricing.secondary })] })] }), item.discount_amounts && item.discount_amounts.length > 0 && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Stack, { direction: "row", spacing: 1, sx: { mt: 1, alignItems: "center" }, children: item.discount_amounts.map(discountAmount => /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Chip, { icon: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.LocalOffer, { sx: { fontSize: "0.8rem !important" } }), label: /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Box, { sx: { display: "flex", alignItems: "center", gap: 0.5 }, children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, { component: "span", sx: { fontSize: "0.75rem", fontWeight: "medium" }, children: discountAmount.promotion_code?.code || "DISCOUNT" }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, { component: "span", sx: { fontSize: "0.75rem" }, children: ["(-", (0, _util2.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__ */(0, _jsxRuntime.jsx)(_material.Box, { sx: { display: "flex", alignItems: "center", justifyContent: "flex-start", flexWrap: "wrap", gap: 1.5, width: "100%", mt: 1.5 }, children: features.map(feature => /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.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__ */(0, _jsxRuntime.jsx)(_material.Box, { sx: { bgcolor: "success.main", width: 4, height: 4, borderRadius: "50%" } }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.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__ */(0, _jsxRuntime.jsx)(_status.default, { label: quantityInventoryError, variant: "outlined", sx: { mt: 1, borderColor: "chip.error.border", backgroundColor: "chip.error.background", color: "chip.error.text" } }) : null, canAdjustQuantity && !completed && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, { sx: { mt: 1, p: 1 }, children: /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "row", spacing: 1, sx: { alignItems: "center" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, { variant: "body2", sx: { color: "text.secondary", minWidth: "fit-content" }, children: [t("common.quantity"), ":"] }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.IconButton, { size: "small", onClick: handleQuantityDecrease, disabled: localQuantityNum <= minQuantity, sx: { minWidth: 32, width: 32, height: 32 }, children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.Remove, { fontSize: "small" }) }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.TextField, { size: "small", value: localQuantity, onChange: handleQuantityInputChange, type: "number", slotProps: { htmlInput: { min: minQuantity, max: maxQuantity, style: { textAlign: "center", padding: "4px", minWidth: 80 } } } }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.IconButton, { size: "small", onClick: handleQuantityIncrease, disabled: localQuantityNum >= maxQuantity, sx: { minWidth: 32, width: 32, height: 32 }, children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.Add, { fontSize: "small" }) })] }) }), isCreditProduct && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.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__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "row", className: "product-item-upsell", sx: { alignItems: "center", justifyContent: "space-between", width: "100%" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, { component: "label", htmlFor: "upsell-switch", sx: { fontSize: 12, cursor: "pointer", color: "text.secondary" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_switchButton.default, { 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: (0, _util2.formatRecurring)(item.price.upsell.upsells_to.recurring, true, "per", locale) }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_status.default, { 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__ */(0, _jsxRuntime.jsx)(_material.Typography, { component: "span", sx: { fontSize: 12 }, children: (0, _util2.formatPrice)(item.price.upsell.upsells_to, currency, item.price.product?.unit_label, 1, true, locale) })] }), canUpsell && item.upsell_price_id && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, { direction: "row", className: "product-item-upsell", sx: { alignItems: "center", justifyContent: "space-between", width: "100%" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, { component: "label", htmlFor: "upsell-switch", sx: { fontSize: 12, cursor: "pointer", color: "text.secondary" }, children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_switchButton.default, { id: "upsell-switch", sx: { mr: 1 }, variant: "success", checked: true, onChange: () => onDownsell(item.upsell_price_id) }), t("payment.checkout.upsell.revert", { recurring: t(`common.${(0, _util2.formatRecurring)(item.price.recurring)}`) })] }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, { component: "span", sx: { fontSize: 12 }, children: (0, _util2.formatPrice)(item.price, currency, item.price.product?.unit_label, 1, true, locale) })] })] }); }