UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

624 lines (623 loc) 28 kB
import { jsx, jsxs } from "react/jsx-runtime"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { Box, IconButton, Typography, InputBase, Stack } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined"; import Inventory2OutlinedIcon from "@mui/icons-material/Inventory2Outlined"; import { useRequest } from "ahooks"; import { BN, fromTokenToUnit } from "@ocap/util"; import { useLocaleContext } from "@arcblock/ux/lib/Locale/context"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { useCheckoutStatus, useLineItems, useSessionContext, useProduct, useExchangeRate, useSlippage, usePaymentMethodContext, useSubmitFeature } from "@blocklet/payment-react-headless"; import { usePaymentContext } from "../../../contexts/payment.js"; import api from "../../../libs/api.js"; import { formatNumber, formatBNStr, formatCreditForCheckout, formatCreditAmount } from "../../../libs/util.js"; import { tSafe } from "../../utils/format.js"; import ScenarioBadge from "../../components/shared/scenario-badge.js"; import ExchangeRateFooter from "../../components/shared/exchange-rate-footer.js"; export default function CreditTopupPanel() { const { t, locale } = useLocaleContext(); const { session, sessionData } = useSessionContext(); const { livemode } = useCheckoutStatus(); const { product } = useProduct(); const { currency, isStripe } = usePaymentMethodContext(); const lineItems = useLineItems(); const rate = useExchangeRate(); const slippage = useSlippage(); const { locked: configLocked } = useSubmitFeature(); const item = lineItems.items[0]; const activePrice = item ? item.upsell_price || item.price : null; const creditConfig = activePrice?.metadata?.credit_config; const creditAmount = creditConfig?.credit_amount ? Number(creditConfig.credit_amount) : 0; const creditCurrencyId = creditConfig?.currency_id; const { session: didSession, getCurrency } = usePaymentContext(); const creditCurrency = creditCurrencyId ? getCurrency(creditCurrencyId) : null; const userDid = didSession?.user?.did || sessionData?.customer?.did; const creditCurrencyDecimal = creditCurrency?.decimal; const creditCurrencySymbol = creditCurrency?.symbol || "Credits"; const currencySymbol = "Credits"; const meterId = activePrice?.metadata?.meter_id; const { data: meterData } = useRequest( async () => { if (!meterId) return null; try { const { data } = await api.get(`/api/meters/public/${meterId}`); return data; } catch { return null; } }, { refreshDeps: [meterId] } ); const creditName = meterData?.name || product?.name || "Credits"; const validDuration = creditConfig?.valid_duration_value; const validDurationUnit = creditConfig?.valid_duration_unit || "days"; const scheduleConfig = creditConfig?.schedule; const hasSchedule = scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== "invoice"; const hasExpiry = validDuration && validDuration > 0; const step = Math.max(creditAmount, 1); const adjustableQty = item?.adjustable_quantity; const canAdjust = adjustableQty?.enabled !== false; const minQuantity = Math.max(adjustableQty?.minimum || 1, 1); const quantityAvailable = Math.min( activePrice?.quantity_limit_per_checkout ?? Infinity, activePrice?.quantity_available ?? Infinity ); const maxQuantity = quantityAvailable ? Math.min(adjustableQty?.maximum || Infinity, quantityAvailable) : adjustableQty?.maximum || Infinity; const { data: pendingAmount } = useRequest( async () => { if (!creditConfig || !userDid || !creditCurrencyId || creditCurrencyDecimal == null) return null; try { const { data } = await api.get("/api/meter-events/pending-amount", { params: { customer_id: userDid, currency_id: creditCurrencyId } }); return data?.[creditCurrencyId]; } catch { return null; } }, { refreshDeps: [creditConfig, userDid, creditCurrencyId, creditCurrencyDecimal] } ); const minQtyForPending = useMemo(() => { if (!pendingAmount || !creditAmount || creditAmount <= 0) return null; const pendingBN = new BN(pendingAmount || "0"); if (!pendingBN.gt(new BN(0))) return null; const creditBN = fromTokenToUnit(creditAmount, creditCurrencyDecimal); if (!creditBN || creditBN.isZero()) return null; return Math.ceil(pendingBN.mul(new BN(100)).div(creditBN).toNumber() / 100); }, [pendingAmount, creditAmount, creditCurrencyDecimal]); const hasPendingAmount = pendingAmount && new BN(pendingAmount || "0").gt(new BN(0)); const pendingDisplayAmount = useMemo(() => { if (!hasPendingAmount) return ""; return formatCreditForCheckout(formatBNStr(pendingAmount, creditCurrencyDecimal), creditCurrencySymbol, locale); }, [hasPendingAmount, pendingAmount, creditCurrencyDecimal, creditCurrencySymbol, locale]); const minCreditsForPending = minQtyForPending ? minQtyForPending * step : 0; const minCreditsForPendingFormatted = minCreditsForPending ? formatCreditForCheckout(formatNumber(minCreditsForPending), creditCurrencySymbol, locale) : ""; const creditInfoText = useMemo(() => { if (hasSchedule && scheduleConfig) { const intervalUnit = scheduleConfig.interval_unit; const intervalValue = scheduleConfig.interval_value; let amountPerGrant; if (scheduleConfig.amount_per_grant) { amountPerGrant = Number(scheduleConfig.amount_per_grant); } else { amountPerGrant = creditAmount; } const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol); const intervalDisplay = intervalValue === 1 ? t(`common.${intervalUnit}`) : `${intervalValue} ${t(`common.${intervalUnit}s`)}`; return scheduleConfig.expire_with_next_grant ? t("payment.checkout.credit.schedule.withRefresh", { amount: formattedAmount, interval: intervalDisplay }) : t("payment.checkout.credit.schedule.periodic", { amount: formattedAmount, interval: intervalDisplay }); } return ""; }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, t]); const validityText = useMemo(() => { if (!hasExpiry) return ""; return t("payment.checkout.creditTopup.validFor", { duration: validDuration, unit: t(`common.${validDurationUnit}`) }); }, [hasExpiry, validDuration, validDurationUnit, t]); const hasSubtitle = !!creditInfoText; const currentQty = item?.quantity || 1; const [localQty, setLocalQty] = useState(currentQty); const [desiredCredits, setDesiredCredits] = useState(currentQty * step); const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(""); const inputRef = useRef(null); useEffect(() => { if (configLocked || session?.status === "complete") return; if (item?.quantity && item.quantity !== localQty) { setLocalQty(item.quantity); setDesiredCredits(item.quantity * step); } }, [item?.quantity]); const pendingEnforcedRef = useRef(false); useEffect(() => { if (configLocked || session?.status === "complete") return; if (pendingEnforcedRef.current) return; if (minQtyForPending && minQtyForPending > localQty) { pendingEnforcedRef.current = true; const newQty = Math.min(Math.max(minQtyForPending, minQuantity), maxQuantity); setLocalQty(newQty); setDesiredCredits(newQty * step); if (item) lineItems.updateQuantity(item.price_id, newQty); } }, [minQtyForPending]); const maxCredits = maxQuantity * step; const minCredits = minQuantity * step; const debounceRef = useRef(null); useEffect( () => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, [] ); const commitCredits = useCallback( (credits) => { if (credits <= 0) return; const clamped = Math.max(minCredits, Math.min(maxCredits, credits)); const packs = Math.ceil(clamped / step); const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs)); setLocalQty(clampedPacks); setDesiredCredits(clamped); if (item) { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { lineItems.updateQuantity(item.price_id, clampedPacks); }, 400); } }, [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems] ); const handleStep = useCallback( (delta) => { const newCredits = desiredCredits + delta * step; if (newCredits < step * minQuantity || Math.ceil(newCredits / step) > maxQuantity) return; commitCredits(newCredits); }, [desiredCredits, step, minQuantity, maxQuantity, commitCredits] ); const actualPacks = Math.max(minQuantity, Math.min(maxQuantity, Math.ceil(desiredCredits / step))); const actualCredits = actualPacks * step; const actualCreditsFormatted = formatNumber(actualCredits, 6, true, true); const desiredCreditsFormatted = formatNumber(desiredCredits, 6, true, true); const showReceiveSection = canAdjust && step > 1; const pendingMessage = useMemo(() => { if (!hasPendingAmount) return ""; if (actualCredits >= minCreditsForPending) { return t("payment.checkout.creditTopup.pendingEnough", { pendingAmount: pendingDisplayAmount, availableAmount: formatCreditForCheckout( formatNumber(actualCredits - minCreditsForPending), creditCurrencySymbol, locale ) }); } return t("payment.checkout.creditTopup.pendingWarning", { pendingAmount: pendingDisplayAmount, minCredits: minCreditsForPendingFormatted }); }, [ hasPendingAmount, actualCredits, minCreditsForPending, pendingDisplayAmount, creditCurrencySymbol, locale, minCreditsForPendingFormatted, t ]); const startEditing = useCallback(() => { if (!canAdjust) return; setEditValue(String(desiredCredits)); setIsEditing(true); setTimeout(() => inputRef.current?.select(), 0); }, [canAdjust, desiredCredits]); const commitEdit = useCallback(() => { setIsEditing(false); const parsed = parseInt(editValue.replace(/,/g, ""), 10); if (!Number.isFinite(parsed) || parsed <= 0) return; commitCredits(parsed); }, [editValue, commitCredits]); const handleEditKeyDown = useCallback( (e) => { if (e.key === "Enter") commitEdit(); else if (e.key === "Escape") setIsEditing(false); }, [commitEdit] ); const numberHeight = { xs: 48, md: 72 }; const numberFontSx = { fontSize: numberHeight, fontWeight: 800, lineHeight: 1, letterSpacing: "-0.03em" }; const circleBtnSx = { width: { xs: 40, md: 56 }, height: { xs: 40, md: 56 }, borderRadius: "50%", bgcolor: "background.paper", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(255,255,255,0.12)" : "divider", color: "text.secondary", transition: "all 0.2s ease", "&:hover": { borderColor: "primary.main", color: "primary.main", bgcolor: "background.paper" }, "&.Mui-disabled": { borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", color: (theme) => theme.palette.mode === "dark" ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.12)" } }; return /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", flexDirection: "column", height: "100%" }, children: [ /* @__PURE__ */ jsxs(Box, { sx: { flex: 1, display: "flex", flexDirection: "column", justifyContent: "center" }, children: [ /* @__PURE__ */ jsx(ScenarioBadge, { livemode, label: tSafe(t, "payment.checkout.typeBadge.topup", "TOP-UP") }), /* @__PURE__ */ jsx( Typography, { sx: { fontWeight: 800, fontSize: { xs: 28, md: 48 }, lineHeight: 1.1, letterSpacing: "-0.03em", color: "text.primary", mb: { xs: 0.5, md: 1 } }, children: t("payment.checkout.creditTopup.title", { name: creditName }) } ), hasSubtitle && /* @__PURE__ */ jsx(Box, { sx: { display: "flex", alignItems: "flex-start", gap: 1, mb: { xs: 1.5, md: 2.5 } }, children: /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 13, md: 15 }, fontWeight: 500, color: "text.secondary", lineHeight: 1.5 }, children: creditInfoText } ) }), hasPendingAmount && /* @__PURE__ */ jsxs( Stack, { direction: "row", alignItems: "flex-start", spacing: 1.5, sx: { mb: { xs: 1.5, md: 2.5 }, p: { xs: 1.5, md: 2 }, borderRadius: "12px", bgcolor: (theme) => theme.palette.mode === "dark" ? "rgba(255,152,0,0.12)" : "rgba(255,152,0,0.08)", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(255,152,0,0.25)" : "rgba(255,152,0,0.2)" }, children: [ /* @__PURE__ */ jsx(WarningAmberIcon, { sx: { fontSize: 18, color: "warning.main", mt: 0.25, flexShrink: 0 } }), /* @__PURE__ */ jsx(Typography, { sx: { fontSize: { xs: 12, md: 13 }, fontWeight: 600, lineHeight: 1.5, color: "text.primary" }, children: pendingMessage }) ] } ), canAdjust ? /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", flexDirection: "column", maxWidth: 480 }, children: [ /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 14, md: 17 }, fontWeight: 700, color: "text.secondary", mt: { xs: 3, md: 5 }, mb: { xs: 2, md: 3 } }, children: t("payment.checkout.creditTopup.question", { symbol: currencySymbol }) } ), /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: { xs: 1.5, md: 2 } }, children: [ /* @__PURE__ */ jsx(IconButton, { onClick: () => handleStep(-1), disabled: actualPacks <= minQuantity, sx: circleBtnSx, children: /* @__PURE__ */ jsx(Box, { component: "span", sx: { fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1, mt: "-1px" }, children: "\u2212" }) }), /* @__PURE__ */ jsxs( Box, { sx: { flex: 1, minWidth: 0, textAlign: "center", cursor: canAdjust ? "text" : "default" }, onClick: startEditing, children: [ /* @__PURE__ */ jsx( Box, { sx: { height: numberHeight, display: "flex", alignItems: "center", justifyContent: "center" }, children: isEditing ? /* @__PURE__ */ jsx( InputBase, { inputRef, value: editValue, onChange: (e) => setEditValue(e.target.value.replace(/\D/g, "")), onBlur: commitEdit, onKeyDown: handleEditKeyDown, autoFocus: true, inputProps: { inputMode: "numeric", pattern: "[0-9]*" }, sx: { width: "100%", bgcolor: "transparent", "&.Mui-focused": { bgcolor: "transparent" }, "& input": { textAlign: "center", ...numberFontSx, p: 0, fontFamily: "inherit", bgcolor: "transparent", "&:focus": { bgcolor: "transparent" } } } } ) : /* @__PURE__ */ jsx(Typography, { sx: { ...numberFontSx, color: "text.primary", userSelect: "none" }, children: desiredCreditsFormatted }) } ), /* @__PURE__ */ jsx(Box, { sx: { display: "flex", alignItems: "center", justifyContent: "center", gap: 0.75, mt: 1 }, children: /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 12, md: 14 }, fontWeight: 700, color: "text.secondary", textTransform: "uppercase", letterSpacing: "0.05em" }, children: currencySymbol } ) }) ] } ), /* @__PURE__ */ jsx(IconButton, { onClick: () => handleStep(1), disabled: actualPacks >= maxQuantity, sx: circleBtnSx, children: /* @__PURE__ */ jsx(Box, { component: "span", sx: { fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1 }, children: "+" }) }) ] }), step > 1 && /* @__PURE__ */ jsx( Typography, { sx: { fontSize: 11, fontWeight: 600, color: "grey.500", textTransform: "uppercase", letterSpacing: "0.08em", textAlign: "center", mt: { xs: 1.5, md: 2 } }, children: t("payment.checkout.creditTopup.increment", { step: formatNumber(step, 6, true, true), symbol: currencySymbol }) } ), showReceiveSection && /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", flexDirection: "column", mt: { xs: 3, md: 4 }, borderRadius: "20px", backdropFilter: "blur(12px)", bgcolor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.08)" : "rgba(59,130,246,0.04)", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.18)" : "rgba(59,130,246,0.10)", boxShadow: (theme) => theme.palette.mode === "dark" ? "0 8px 32px rgba(59,130,246,0.06)" : "0 8px 32px rgba(59,130,246,0.04)" }, children: [ /* @__PURE__ */ jsxs(Box, { sx: { p: { xs: 2.5, md: 3.5 } }, children: [ /* @__PURE__ */ jsxs( Typography, { sx: { fontSize: { xs: 16, md: 20 }, fontWeight: 700, color: "primary.main", mb: 0.5 }, children: [ t("payment.checkout.creditTopup.willReceive"), ": ", /* @__PURE__ */ jsx(Box, { component: "span", sx: { fontWeight: 800 }, children: formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale) }) ] } ), /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.75, mb: 0.5 }, children: [ /* @__PURE__ */ jsx(Inventory2OutlinedIcon, { sx: { fontSize: 16, color: "text.secondary" } }), /* @__PURE__ */ jsx(Typography, { sx: { fontSize: { xs: 12, md: 14 }, fontWeight: 600, color: "text.secondary" }, children: t("payment.checkout.creditTopup.packInfo", { packs: actualPacks, perPack: formatNumber(step, 6, true, true) }) }) ] }), /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 12, md: 13 }, fontWeight: 500, color: "grey.500" }, children: t("payment.checkout.creditTopup.autoMatch") } ) ] }), validityText && /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", alignItems: "center", gap: 0.75, px: { xs: 2.5, md: 3.5 }, py: { xs: 1.5, md: 2 }, borderTop: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.15)" : "rgba(59,130,246,0.08)" }, children: [ /* @__PURE__ */ jsx(AccessTimeOutlinedIcon, { sx: { fontSize: 14, color: "grey.500" } }), /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 12, md: 13 }, fontWeight: 500, color: "grey.500" }, children: validityText } ) ] } ) ] } ), !showReceiveSection && validityText && /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.75, mt: { xs: 2.5, md: 3.5 } }, children: [ /* @__PURE__ */ jsx(InfoOutlinedIcon, { sx: { fontSize: 14, color: "grey.500" } }), /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 12, md: 13 }, fontWeight: 500, color: "grey.500" }, children: validityText } ) ] }) ] }) : ( // ── Non-adjustable: show fixed credit amount in a styled card ── /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", flexDirection: "column", mt: { xs: 2, md: 3 }, borderRadius: "20px", backdropFilter: "blur(12px)", bgcolor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.08)" : "rgba(59,130,246,0.04)", border: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.18)" : "rgba(59,130,246,0.10)", boxShadow: (theme) => theme.palette.mode === "dark" ? "0 8px 32px rgba(59,130,246,0.06)" : "0 8px 32px rgba(59,130,246,0.04)" }, children: [ /* @__PURE__ */ jsxs(Box, { sx: { p: { xs: 2.5, md: 3.5 } }, children: [ /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 13, md: 14 }, fontWeight: 600, color: "text.secondary", mb: 1 }, children: t("payment.checkout.creditTopup.willReceive") } ), /* @__PURE__ */ jsx(Box, { sx: { display: "flex", alignItems: "center", gap: 1.5 }, children: /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 32, md: 48 }, fontWeight: 800, lineHeight: 1, letterSpacing: "-0.03em", color: "primary.main" }, children: formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale) } ) }) ] }), validityText && /* @__PURE__ */ jsxs( Box, { sx: { display: "flex", alignItems: "center", gap: 0.75, px: { xs: 2.5, md: 3.5 }, py: { xs: 1.5, md: 2 }, borderTop: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? "rgba(59,130,246,0.15)" : "rgba(59,130,246,0.08)" }, children: [ /* @__PURE__ */ jsx(AccessTimeOutlinedIcon, { sx: { fontSize: 14, color: "grey.500" } }), /* @__PURE__ */ jsx( Typography, { sx: { fontSize: { xs: 12, md: 13 }, fontWeight: 500, color: "grey.500" }, children: validityText } ) ] } ) ] } ) ) ] }), /* @__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(session?.mode || "payment") } ) ] }) ] } ); }