@blocklet/payment-react
Version:
Reusable react components for payment kit v2
624 lines (623 loc) • 28 kB
JavaScript
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")
}
)
] })
]
}
);
}