@blocklet/payment-react
Version:
Reusable react components for payment kit v2
619 lines (618 loc) • 23.5 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import Toast from "@arcblock/ux/lib/Toast";
import { HelpOutline } from "@mui/icons-material";
import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton } from "@mui/material";
import { BN, fromTokenToUnit, fromUnitToToken } from "@ocap/util";
import { useRequest, useSetState } from "ahooks";
import noop from "lodash/noop";
import useBus from "use-bus";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { styled } from "@mui/material/styles";
import { useEffect, useMemo, useRef } from "react";
import Status from "../components/status.js";
import api from "../libs/api.js";
import {
formatAmount,
formatCheckoutHeadlines,
getPriceUintAmountByCurrency,
formatDynamicPrice,
findCurrency,
formatError
} from "../libs/util.js";
import ProductDonation from "./product-donation.js";
import ProductItem from "./product-item.js";
import Livemode from "../components/livemode.js";
import { usePaymentContext } from "../contexts/payment.js";
import { useMobile } from "../hooks/mobile.js";
import { useDynamicPricing } from "../hooks/dynamic-pricing.js";
import LoadingButton from "../components/loading-button.js";
import DynamicPricingUnavailable from "../components/dynamic-pricing-unavailable.js";
import PromotionSection from "./summary-section/promotion-section.js";
import TotalSection from "./summary-section/total-section.js";
const ExpandMore = styled((props) => {
const { expand, ...other } = props;
return /* @__PURE__ */ jsx(IconButton, { ...other });
})(({ theme, expand }) => ({
transform: !expand ? "rotate(0deg)" : "rotate(180deg)",
marginLeft: "auto",
transition: theme.transitions.create("transform", {
duration: theme.transitions.duration.shortest
})
}));
async function fetchCrossSell(id, skipError = true) {
try {
const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell?skipError=${skipError}`);
if (!data.error) {
return data;
}
return null;
} catch (err) {
return null;
}
}
function getStakingSetup(items, currency, billingThreshold = 0) {
const staking = {
licensed: new BN(0),
metered: new BN(0)
};
const recurringItems = items.map((x) => x.upsell_price || x.price).filter((x) => x.type === "recurring" && x.recurring);
if (recurringItems.length > 0) {
if (+billingThreshold) {
return fromTokenToUnit(billingThreshold, currency.decimal).toString();
}
items.forEach((x) => {
const price = x.upsell_price || x.price;
const unit = getPriceUintAmountByCurrency(price, currency);
const amount = new BN(unit).mul(new BN(x.quantity));
if (price.type === "recurring" && price.recurring) {
if (price.recurring.usage_type === "licensed") {
staking.licensed = staking.licensed.add(amount);
}
if (price.recurring.usage_type === "metered") {
staking.metered = staking.metered.add(amount);
}
}
});
return staking.licensed.add(staking.metered).toString();
}
return "0";
}
export default function PaymentSummary({
items,
currency,
trialInDays,
billingThreshold,
onUpsell = noop,
onDownsell = noop,
onQuantityChange = noop,
onApplyCrossSell = noop,
onCancelCrossSell = noop,
onChangeAmount = noop,
checkoutSessionId = "",
crossSellBehavior = "",
showStaking = false,
donationSettings = void 0,
action = "",
trialEnd = 0,
completed = false,
checkoutSession = void 0,
paymentIntent = void 0,
paymentMethods = [],
onPromotionUpdate = noop,
showFeatures = false,
rateUnavailable = false,
isRateLoading = false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
rateError: _rateError = void 0,
// Technical errors are logged but not displayed to users
onQuoteExpired = void 0,
onRefreshRate = void 0,
onSlippageChange = void 0,
slippageConfig: slippageConfigProp = void 0,
liveRate = void 0,
liveQuoteSnapshot = void 0,
isStripePayment = false,
isSubscription: isSubscriptionProp = void 0,
...rest
}) {
const { t, locale } = useLocaleContext();
const { isMobile } = useMobile();
const { paymentState, ...settings } = usePaymentContext();
const [state, setState] = useSetState({ loading: false, shake: false, expanded: items?.length < 3 });
const { data, runAsync } = useRequest(
(skipError) => checkoutSessionId ? fetchCrossSell(checkoutSessionId, skipError) : Promise.resolve(null)
);
const sessionDiscounts = checkoutSession?.discounts || [];
const allowPromotionCodes = !!checkoutSession?.allow_promotion_codes;
const hasDiscounts = sessionDiscounts?.length > 0;
const discountCurrency = paymentMethods && checkoutSession ? findCurrency(
paymentMethods,
hasDiscounts ? checkoutSession?.currency_id || currency.id : currency.id
) || settings.settings?.baseCurrency : currency;
const slippageConfig = slippageConfigProp ?? checkoutSession?.metadata?.slippage;
const {
hasDynamicPricing,
isPriceLocked,
quoteMeta,
rateInfo,
quoteLockedAt,
currentSlippagePercent,
rateDisplay,
calculatedTokenAmount,
calculatedDiscountAmount,
calculateUsdDisplay,
buildQuoteDetailRows
} = useDynamicPricing({
items,
currency: discountCurrency,
liveRate,
liveQuoteSnapshot,
checkoutSession,
paymentIntent,
locale,
isStripePayment,
isSubscription: isSubscriptionProp,
slippageConfig,
trialInDays,
trialEnd,
discounts: sessionDiscounts
});
const headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale, {
exchangeRate: rateInfo.exchangeRate
});
const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : "0";
const effectiveHasDynamicPricing = hasDynamicPricing && !isStripePayment;
const hasRecurringItems = items.some((x) => (x.upsell_price || x.price)?.type === "recurring");
const isTrialScenario = headlines.actualAmount === "0" && hasRecurringItems;
const headlineAmountDisplay = useMemo(() => {
if (!effectiveHasDynamicPricing) {
return headlines.amount;
}
if (isTrialScenario || !headlines.amount.includes(discountCurrency.symbol)) {
return headlines.amount;
}
if (calculatedTokenAmount) {
const displayAmount = fromUnitToToken(calculatedTokenAmount, discountCurrency?.decimal);
const formatted2 = formatDynamicPrice(displayAmount, true, 6);
return `${formatted2} ${discountCurrency.symbol}`;
}
const formatted = formatDynamicPrice(headlines.actualAmount, true, 6);
return `${formatted} ${discountCurrency.symbol}`;
}, [
headlines.amount,
headlines.actualAmount,
discountCurrency.symbol,
discountCurrency?.decimal,
effectiveHasDynamicPricing,
calculatedTokenAmount,
isTrialScenario
]);
const discountAmount = useMemo(
() => new BN(checkoutSession?.total_details?.amount_discount || "0"),
[checkoutSession?.total_details?.amount_discount]
);
const subtotalAmountUnit = new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)).add(new BN(staking)).toString();
const subtotalAmount = fromUnitToToken(subtotalAmountUnit, discountCurrency?.decimal);
const totalAmountUnit = new BN(subtotalAmountUnit).sub(discountAmount).toString();
const totalAmountValue = fromUnitToToken(totalAmountUnit, discountCurrency?.decimal);
const subtotalDisplay = useMemo(() => {
if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
const dynamicSubtotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).toString();
const displayAmount = fromUnitToToken(dynamicSubtotalUnit, discountCurrency?.decimal);
return formatDynamicPrice(displayAmount, true, 6);
}
return formatDynamicPrice(subtotalAmount, effectiveHasDynamicPricing, 6);
}, [
effectiveHasDynamicPricing,
calculatedTokenAmount,
staking,
discountCurrency?.decimal,
subtotalAmount,
isTrialScenario
]);
const totalAmountDisplay = useMemo(() => {
if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount;
const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString();
const displayAmount = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal);
const numericValue = Number(displayAmount);
if (Number.isFinite(numericValue) && numericValue >= 0) {
return formatDynamicPrice(displayAmount, true, 6);
}
}
if (isStripePayment && calculatedDiscountAmount && !isTrialScenario) {
const effectiveDiscount = new BN(calculatedDiscountAmount);
const adjustedTotalUnit = new BN(subtotalAmountUnit).sub(effectiveDiscount).toString();
const displayAmount = fromUnitToToken(adjustedTotalUnit, discountCurrency?.decimal);
const numericValue = Number(displayAmount);
if (Number.isFinite(numericValue) && numericValue >= 0) {
return formatDynamicPrice(displayAmount, false, 6);
}
}
return formatDynamicPrice(totalAmountValue, effectiveHasDynamicPricing, 6);
}, [
effectiveHasDynamicPricing,
calculatedTokenAmount,
staking,
discountAmount,
calculatedDiscountAmount,
discountCurrency?.decimal,
totalAmountValue,
isTrialScenario,
isStripePayment,
subtotalAmountUnit
]);
const totalAmountText = totalAmountDisplay === "\u2014" ? "\u2014" : `${totalAmountDisplay} ${discountCurrency.symbol}`;
const totalUsdDisplay = useMemo(() => {
if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount;
const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString();
const dynamicTotalToken = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal);
return calculateUsdDisplay(dynamicTotalToken);
}
return calculateUsdDisplay(totalAmountValue);
}, [
effectiveHasDynamicPricing,
calculatedTokenAmount,
staking,
discountAmount,
calculatedDiscountAmount,
discountCurrency?.decimal,
totalAmountValue,
calculateUsdDisplay,
isTrialScenario
]);
const quoteDetailRows = buildQuoteDetailRows(t);
const isSubscription = isSubscriptionProp ?? (checkoutSession?.mode === "subscription" || checkoutSession?.mode === "setup");
const handlePromotionUpdate = () => {
onPromotionUpdate?.();
};
const handleRemovePromotion = async (sessionId) => {
if (paymentState.paying || paymentState.stripePaying) {
return;
}
try {
await api.delete(`/api/checkout-sessions/${sessionId}/remove-promotion`);
onPromotionUpdate?.();
} catch (err) {
console.error("Failed to remove promotion code:", err);
}
};
const expiredHandledRef = useRef(false);
useEffect(() => {
if (completed || expiredHandledRef.current) {
return;
}
if (!liveQuoteSnapshot?.expires_at && !quoteMeta?.expiresAt) {
return;
}
const currentTime = Math.floor(Date.now() / 1e3);
const effectiveExpiresAt = liveQuoteSnapshot?.expires_at ?? quoteMeta?.expiresAt;
const quoteRemaining = effectiveExpiresAt ? Math.max(0, effectiveExpiresAt - currentTime) : 0;
const lockRemaining = quoteLockedAt ? Math.max(0, quoteLockedAt + 180 - currentTime) : 0;
const hasExpiry = !!effectiveExpiresAt;
const lockActive = lockRemaining > 0;
if (hasExpiry && !lockActive && quoteRemaining <= 0) {
expiredHandledRef.current = true;
onQuoteExpired?.();
}
}, [liveQuoteSnapshot?.expires_at, quoteMeta?.expiresAt, quoteLockedAt, completed, onQuoteExpired]);
const handleSlippageChange = async (newSlippageConfig) => {
if (!onSlippageChange) {
return;
}
if (!checkoutSessionId) {
onSlippageChange(newSlippageConfig);
return;
}
try {
await api.put(`/api/checkout-sessions/${checkoutSessionId}/slippage`, {
slippage_config: newSlippageConfig
});
onSlippageChange(newSlippageConfig);
if (onQuoteExpired) {
await onQuoteExpired(true);
}
} catch (err) {
console.error("Failed to update slippage", err);
Toast.error(err.response?.data?.error || formatError(err));
}
};
useBus(
"error.REQUIRE_CROSS_SELL",
() => {
setState({ shake: true });
setTimeout(() => {
setState({ shake: false });
}, 1e3);
},
[]
);
const handleUpsell = async (from, to) => {
await onUpsell(from, to);
runAsync(false);
};
const handleQuantityChange = async (itemId, quantity) => {
await onQuantityChange(itemId, quantity);
runAsync(false);
};
const handleDownsell = async (from) => {
await onDownsell(from);
runAsync(false);
};
const handleApplyCrossSell = async () => {
if (data) {
try {
setState({ loading: true });
await onApplyCrossSell(data.id);
} catch (err) {
console.error(err);
} finally {
setState({ loading: false });
}
}
};
const handleCancelCrossSell = async () => {
try {
setState({ loading: true });
await onCancelCrossSell();
} catch (err) {
console.error(err);
} finally {
setState({ loading: false });
}
};
const hasSubTotal = +staking > 0 || allowPromotionCodes;
const ProductCardList = /* @__PURE__ */ jsxs(
Stack,
{
className: "cko-product-list",
sx: {
flex: "0 1 auto",
overflow: "auto"
},
children: [
/* @__PURE__ */ jsx(Stack, { spacing: { xs: 1, sm: 2 }, children: items.map(
(x) => x.price?.custom_unit_amount && onChangeAmount && donationSettings ? /* @__PURE__ */ jsx(
ProductDonation,
{
item: x,
settings: donationSettings,
onChange: onChangeAmount,
currency: discountCurrency
},
`${x.price_id}-${discountCurrency.id}`
) : /* @__PURE__ */ jsx(
ProductItem,
{
item: x,
items,
trialInDays,
trialEnd,
currency: discountCurrency,
exchangeRate: rateInfo.exchangeRate,
isStripePayment,
isPriceLocked,
isRateLoading,
onUpsell: handleUpsell,
onDownsell: handleDownsell,
adjustableQuantity: x.adjustable_quantity,
completed,
showFeatures,
onQuantityChange: handleQuantityChange,
discounts: sessionDiscounts,
calculatedDiscountAmount: calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null),
children: x.cross_sell && /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
sx: {
alignItems: "center",
justifyContent: "space-between",
width: 1
},
children: [
/* @__PURE__ */ jsx(Typography, {}),
/* @__PURE__ */ jsx(
LoadingButton,
{
size: "small",
loadingPosition: "end",
endIcon: null,
color: "error",
variant: "text",
loading: state.loading,
onClick: handleCancelCrossSell,
children: t("payment.checkout.cross_sell.remove")
}
)
]
}
)
},
`${x.price_id}-${discountCurrency.id}`
)
) }),
data && items.some((x) => x.price_id === data.id) === false && /* @__PURE__ */ jsx(Grow, { in: true, children: /* @__PURE__ */ jsx(Stack, { sx: { mt: 1 }, children: /* @__PURE__ */ jsx(
ProductItem,
{
item: { quantity: 1, price: data, price_id: data.id, cross_sell: true },
items,
trialInDays,
currency: discountCurrency,
trialEnd,
exchangeRate: rateInfo.exchangeRate,
isStripePayment,
isPriceLocked,
isRateLoading,
onUpsell: noop,
onDownsell: noop,
children: /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
sx: {
alignItems: "center",
justifyContent: "space-between",
width: 1
},
children: [
/* @__PURE__ */ jsx(Typography, { children: crossSellBehavior === "required" && /* @__PURE__ */ jsx(Status, { label: t("payment.checkout.required"), color: "info", variant: "outlined", sx: { mr: 1 } }) }),
/* @__PURE__ */ jsx(
LoadingButton,
{
size: "small",
loadingPosition: "end",
endIcon: null,
color: crossSellBehavior === "required" ? "info" : "info",
variant: crossSellBehavior === "required" ? "text" : "text",
loading: state.loading,
onClick: handleApplyCrossSell,
children: t("payment.checkout.cross_sell.add")
}
)
]
}
)
}
) }) })
]
}
);
if (!discountCurrency || !items?.length) {
return null;
}
return /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-product", direction: "column", ...rest, children: [
/* @__PURE__ */ jsxs(
Box,
{
sx: {
display: "flex",
alignItems: "center",
mb: 2.5
},
children: [
/* @__PURE__ */ jsx(
Typography,
{
title: t("payment.checkout.orderSummary"),
sx: {
color: "text.primary",
fontSize: { xs: "18px", md: "24px" },
fontWeight: "700",
lineHeight: "32px"
},
children: action || t("payment.checkout.orderSummary")
}
),
!settings.livemode && /* @__PURE__ */ jsx(Livemode, {})
]
}
),
effectiveHasDynamicPricing && rateUnavailable && /* @__PURE__ */ jsx(DynamicPricingUnavailable, { sx: { mb: 2 }, onRetry: onRefreshRate }),
isMobile && !donationSettings ? /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsxs(
Stack,
{
onClick: () => setState({ expanded: !state.expanded }),
sx: {
justifyContent: "space-between",
flexDirection: "row",
alignItems: "center",
mb: 1.5
},
children: [
/* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.productListTotal", { total: items.length }) }),
/* @__PURE__ */ jsx(ExpandMore, { expand: state.expanded, "aria-expanded": state.expanded, "aria-label": "show more", children: /* @__PURE__ */ jsx(ExpandMoreIcon, {}) })
]
}
),
/* @__PURE__ */ jsx(Collapse, { in: state.expanded || !isMobile, timeout: "auto", unmountOnExit: true, children: ProductCardList })
] }) : ProductCardList,
/* @__PURE__ */ jsx(Divider, { sx: { mt: 2.5, mb: 2.5 } }),
+staking > 0 && /* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center" }, children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, sx: { alignItems: "center" }, children: [
/* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary" }, children: t("payment.checkout.paymentRequired") }),
/* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.stakingConfirm"), placement: "top", sx: { maxWidth: "150px" }, children: /* @__PURE__ */ jsx(HelpOutline, { fontSize: "small", sx: { color: "text.lighter" } }) })
] }),
/* @__PURE__ */ jsx(Typography, { children: headlineAmountDisplay })
] }),
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 1, sx: { justifyContent: "space-between", alignItems: "center" }, children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 0.5, sx: { alignItems: "center" }, children: [
/* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary" }, children: t("payment.checkout.staking.title") }),
/* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.staking.tooltip"), placement: "top", sx: { maxWidth: "150px" }, children: /* @__PURE__ */ jsx(HelpOutline, { fontSize: "small", sx: { color: "text.lighter" } }) })
] }),
/* @__PURE__ */ jsxs(Typography, { children: [
formatAmount(staking, discountCurrency.decimal),
" ",
discountCurrency.symbol
] })
] })
] }),
(allowPromotionCodes || hasDiscounts) && /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
justifyContent: "space-between",
alignItems: "center",
...+staking > 0 && {
borderTop: "1px solid",
borderColor: "divider",
pt: 1,
mt: 1
}
},
children: [
/* @__PURE__ */ jsx(Typography, { className: "base-label", children: t("common.subtotal") }),
/* @__PURE__ */ jsxs(Typography, { children: [
subtotalDisplay,
" ",
discountCurrency.symbol
] })
]
}
),
/* @__PURE__ */ jsx(
PromotionSection,
{
checkoutSessionId: checkoutSession?.id || checkoutSessionId,
currency: discountCurrency,
currencyId: currency.id,
discounts: sessionDiscounts,
allowPromotionCodes,
completed,
disabled: paymentState.paying || paymentState.stripePaying,
onPromotionUpdate: handlePromotionUpdate,
onRemovePromotion: handleRemovePromotion,
calculatedDiscountAmount: calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null),
isRateLoading
}
),
hasSubTotal && /* @__PURE__ */ jsx(Divider, { sx: { my: 1 } }),
/* @__PURE__ */ jsx(
TotalSection,
{
totalAmountText,
totalUsdDisplay,
currency: discountCurrency,
hasDynamicPricing,
rateDisplay,
rateInfo,
quoteDetailRows,
currentSlippagePercent,
slippageConfig,
isPriceLocked,
isSubscription,
completed,
onSlippageChange: onSlippageChange ? handleSlippageChange : void 0,
isStripePayment,
isRateLoading,
thenInfo: headlines.thenValue && headlines.showThen ? headlines.thenValue : void 0
}
)
] }) });
}