@blocklet/payment-react
Version:
Reusable react components for payment kit v2
572 lines (571 loc) • 20.2 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import { HelpOutline, Close, LocalOffer } from "@mui/icons-material";
import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton, Button } 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 Status from "../components/status.js";
import api from "../libs/api.js";
import {
formatAmount,
formatCheckoutHeadlines,
getPriceUintAmountByCurrency,
formatCouponTerms,
formatNumber,
findCurrency
} from "../libs/util.js";
import PaymentAmount from "./amount.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 LoadingButton from "../components/loading-button.js";
import PromotionCode from "../components/promotion-code.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,
paymentMethods = [],
onPromotionUpdate = noop,
showFeatures = false,
...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 headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale);
const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : "0";
const getAppliedPromotionCodes = () => {
if (!sessionDiscounts?.length) return [];
return sessionDiscounts.map((discount) => ({
id: discount.promotion_code || discount.coupon,
code: discount.verification_data?.code || "APPLIED",
discount_amount: discount.discount_amount
}));
};
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 discountAmount = new BN(checkoutSession?.total_details?.amount_discount || "0");
const subtotalAmount = fromUnitToToken(
new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)).add(new BN(staking)).toString(),
discountCurrency?.decimal
);
const totalAmount = fromUnitToToken(
new BN(fromTokenToUnit(subtotalAmount, discountCurrency?.decimal)).sub(discountAmount).toString(),
discountCurrency?.decimal
);
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,
onUpsell: handleUpsell,
onDownsell: handleDownsell,
adjustableQuantity: x.adjustable_quantity,
completed,
showFeatures,
onQuantityChange: handleQuantityChange,
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,
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")
}
)
]
}
)
}
)
}
) })
]
}
);
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, {})
]
}
),
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: headlines.amount })
]
}
),
/* @__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: [
formatNumber(subtotalAmount),
" ",
discountCurrency.symbol
] })
]
}
),
allowPromotionCodes && !hasDiscounts && /* @__PURE__ */ jsx(Box, { sx: { mt: 1 }, children: /* @__PURE__ */ jsx(
PromotionCode,
{
checkoutSessionId: checkoutSession.id,
initialAppliedCodes: getAppliedPromotionCodes(),
disabled: completed,
onUpdate: handlePromotionUpdate,
currencyId: currency.id
}
) }),
hasDiscounts && /* @__PURE__ */ jsx(Box, { children: sessionDiscounts.map((discount) => {
const promotionCodeInfo = discount.promotion_code_details;
const couponInfo = discount.coupon_details;
const discountDescription = couponInfo ? formatCouponTerms(couponInfo, discountCurrency, locale) : "";
const notSupported = discountDescription === t("payment.checkout.coupon.noDiscount");
return /* @__PURE__ */ jsxs(Stack, { children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
justifyContent: "space-between",
alignItems: "center"
},
children: [
/* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
alignItems: "center",
backgroundColor: "grey.100",
width: "fit-content",
px: 1,
py: 1,
borderRadius: 1
},
children: [
/* @__PURE__ */ jsxs(
Typography,
{
sx: {
fontWeight: "medium",
display: "flex",
alignItems: "center",
gap: 0.5
},
children: [
/* @__PURE__ */ jsx(LocalOffer, { sx: { color: "warning.main", fontSize: "small" } }),
promotionCodeInfo?.code || discount.verification_data?.code || t("payment.checkout.discount")
]
}
),
!completed && /* @__PURE__ */ jsx(
Button,
{
size: "small",
disabled: paymentState.paying || paymentState.stripePaying,
onClick: () => handleRemovePromotion(checkoutSessionId),
sx: {
minWidth: "auto",
width: 16,
height: 16,
color: "text.secondary",
"&.Mui-disabled": {
color: "text.disabled"
}
},
children: /* @__PURE__ */ jsx(Close, { sx: { fontSize: 14 } })
}
)
]
}
),
/* @__PURE__ */ jsxs(Typography, { sx: { color: "text.secondary" }, children: [
"-",
formatAmount(discount.discount_amount || "0", discountCurrency.decimal),
" ",
discountCurrency.symbol
] })
]
}
),
discountDescription && /* @__PURE__ */ jsx(
Typography,
{
sx: {
fontSize: "small",
color: notSupported ? "error.main" : "text.secondary",
mt: 0.5
},
children: discountDescription
}
)
] }, discount.promotion_code || discount.coupon || `discount-${discount.discount_amount}`);
}) }),
hasSubTotal && /* @__PURE__ */ jsx(Divider, { sx: { my: 1 } }),
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
display: "flex",
justifyContent: "space-between",
flexDirection: "row",
alignItems: "center",
width: "100%"
},
children: [
/* @__PURE__ */ jsxs(Box, { className: "base-label", children: [
t("common.total"),
" "
] }),
/* @__PURE__ */ jsx(PaymentAmount, { amount: `${totalAmount} ${discountCurrency.symbol}`, sx: { fontSize: "16px" } })
]
}
),
headlines.then && headlines.showThen && /* @__PURE__ */ jsx(
Typography,
{
component: "div",
sx: { fontSize: "0.7875rem", color: "text.lighter", textAlign: "right", margin: "-2px 0 8px" },
children: headlines.then
}
)
] }) });
}