@blocklet/payment-react
Version:
Reusable react components for payment kit v2
690 lines (677 loc) • 21.7 kB
JavaScript
import { jsx, jsxs } from "react/jsx-runtime";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import Toast from "@arcblock/ux/lib/Toast";
import Header from "@blocklet/ui-react/lib/Header";
import { ArrowBackOutlined } from "@mui/icons-material";
import { Box, Fade, Stack } from "@mui/material";
import { styled } from "@mui/system";
import { fromTokenToUnit } from "@ocap/util";
import { useSetState } from "ahooks";
import { useEffect, useState, useMemo } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import trim from "lodash/trim";
import { usePaymentContext } from "../contexts/payment.js";
import api from "../libs/api.js";
import {
findCurrency,
formatError,
getQueryParams,
getStatementDescriptor,
isMobileSafari,
isValidCountry,
showStaking
} from "../libs/util.js";
import PaymentError from "./error.js";
import CheckoutFooter from "./footer.js";
import PaymentForm, { hasDidWallet } from "./form/index.js";
import OverviewSkeleton from "./skeleton/overview.js";
import PaymentSkeleton from "./skeleton/payment.js";
import PaymentSuccess from "./success.js";
import PaymentSummary from "./summary.js";
import { useMobile } from "../hooks/mobile.js";
import { formatPhone } from "../libs/phone-validator.js";
import { getCurrencyPreference } from "../libs/currency.js";
function PaymentInner({
checkoutSession,
paymentMethods,
paymentLink,
paymentIntent,
customer,
completed = false,
mode,
onPaid,
onError,
onChange,
action,
showCheckoutSummary = true
}) {
const { t } = useLocaleContext();
const { settings, session } = usePaymentContext();
const { isMobile } = useMobile();
const [state, setState] = useSetState({ checkoutSession });
const query = getQueryParams(window.location.href);
const availableCurrencyIds = useMemo(() => {
const currencyIds = /* @__PURE__ */ new Set();
paymentMethods.forEach((method2) => {
method2.payment_currencies.forEach((currency2) => {
if (currency2.active) {
currencyIds.add(currency2.id);
}
});
});
return Array.from(currencyIds);
}, [paymentMethods]);
const defaultCurrencyId = useMemo(() => {
if (query.currencyId && availableCurrencyIds.includes(query.currencyId)) {
return query.currencyId;
}
if (session?.user && !hasDidWallet(session.user)) {
const stripeCurrencyId = paymentMethods.find((m) => m.type === "stripe")?.payment_currencies.find((c) => c.active)?.id;
if (stripeCurrencyId) {
return stripeCurrencyId;
}
}
const savedPreference = getCurrencyPreference(session?.user?.did, availableCurrencyIds);
if (savedPreference) {
return savedPreference;
}
if (state.checkoutSession.currency_id && availableCurrencyIds.includes(state.checkoutSession.currency_id)) {
return state.checkoutSession.currency_id;
}
return availableCurrencyIds?.[0];
}, [query.currencyId, availableCurrencyIds, session?.user, state.checkoutSession.currency_id, paymentMethods]);
const defaultMethodId = paymentMethods.find((m) => m.payment_currencies.some((c) => c.id === defaultCurrencyId))?.id;
const hideSummaryCard = mode.endsWith("-minimal") || !showCheckoutSummary;
const methods = useForm({
defaultValues: {
customer_name: customer?.name || session?.user?.fullName || "",
customer_email: customer?.email || session?.user?.email || "",
customer_phone: formatPhone(customer?.phone || session?.user?.phone || ""),
payment_method: defaultMethodId,
payment_currency: defaultCurrencyId,
billing_address: Object.assign(
{
country: session?.user?.address?.country || "",
state: session?.user?.address?.province || "",
city: session?.user?.address?.city || "",
line1: session?.user?.address?.line1 || "",
line2: session?.user?.address?.line2 || "",
postal_code: session?.user?.address?.postalCode || ""
},
customer?.address || {},
{
country: isValidCountry(customer?.address?.country || session?.user?.address?.country || "") ? customer?.address?.country : "us"
}
)
}
});
useEffect(() => {
if (defaultCurrencyId) {
methods.setValue("payment_currency", defaultCurrencyId);
}
if (defaultMethodId) {
methods.setValue("payment_method", defaultMethodId);
}
}, [defaultCurrencyId, defaultMethodId]);
useEffect(() => {
if (!isMobileSafari()) {
return () => {
};
}
let scrollTop = 0;
const focusinHandler = () => {
scrollTop = window.scrollY;
};
const focusoutHandler = () => {
window.scrollTo(0, scrollTop);
};
document.body.addEventListener("focusin", focusinHandler);
document.body.addEventListener("focusout", focusoutHandler);
return () => {
document.body.removeEventListener("focusin", focusinHandler);
document.body.removeEventListener("focusout", focusoutHandler);
};
}, []);
const currencyId = useWatch({ control: methods.control, name: "payment_currency", defaultValue: defaultCurrencyId });
const currency = findCurrency(paymentMethods, currencyId) || settings.baseCurrency;
const method = paymentMethods.find((x) => x.id === currency.payment_method_id);
const recalculatePromotion = () => {
if (state.checkoutSession?.discounts?.length) {
api.post(`/api/checkout-sessions/${state.checkoutSession.id}/recalculate-promotion`, {
currency_id: currencyId
}).then(() => {
onPromotionUpdate();
});
}
};
useEffect(() => {
if (onChange) {
onChange(methods.getValues());
}
recalculatePromotion();
}, [currencyId]);
const onUpsell = async (from, to) => {
try {
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/upsell`, { from, to });
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onDownsell = async (from) => {
try {
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/downsell`, { from });
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onApplyCrossSell = async (to) => {
try {
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`, { to });
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onQuantityChange = async (itemId, quantity) => {
try {
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/adjust-quantity`, {
itemId,
quantity
});
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onCancelCrossSell = async () => {
try {
const { data } = await api.delete(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`);
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onChangeAmount = async ({ priceId, amount }) => {
try {
const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/amount`, {
priceId,
amount: fromTokenToUnit(amount, currency.decimal).toString()
});
if (data.discounts?.length) {
recalculatePromotion();
return;
}
setState({ checkoutSession: data });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const onPromotionUpdate = async () => {
try {
const { data } = await api.get(`/api/checkout-sessions/retrieve/${state.checkoutSession.id}`);
setState({ checkoutSession: data.checkoutSession });
} catch (err) {
console.error(err);
Toast.error(formatError(err));
}
};
const handlePaid = (result) => {
setState({ checkoutSession: result.checkoutSession });
onPaid(result);
};
let trialInDays = Number(state.checkoutSession?.subscription_data?.trial_period_days || 0);
let trialEnd = Number(state.checkoutSession?.subscription_data?.trial_end || 0);
const trialCurrencyIds = (state.checkoutSession?.subscription_data?.trial_currency || "").split(",").map(trim).filter(Boolean);
if (trialCurrencyIds.length > 0 && trialCurrencyIds.includes(currencyId) === false) {
trialInDays = 0;
trialEnd = 0;
}
const showFeatures = `${paymentLink?.metadata?.show_product_features}` === "true";
return /* @__PURE__ */ jsx(FormProvider, { ...methods, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: [
!hideSummaryCard && /* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "base-card cko-overview", direction: "column", children: [
/* @__PURE__ */ jsx(
PaymentSummary,
{
items: state.checkoutSession.line_items,
trialInDays,
trialEnd,
billingThreshold: Math.max(
state.checkoutSession.subscription_data?.billing_threshold_amount || 0,
// @ts-ignore
state.checkoutSession.subscription_data?.min_stake_amount || 0
),
showStaking: showStaking(method, currency, !!state.checkoutSession.subscription_data?.no_stake),
currency,
onUpsell,
onDownsell,
onQuantityChange,
onApplyCrossSell,
onCancelCrossSell,
onChangeAmount,
checkoutSessionId: state.checkoutSession.id,
crossSellBehavior: state.checkoutSession.cross_sell_behavior,
donationSettings: paymentLink?.donation_settings,
action,
completed,
checkoutSession: state.checkoutSession,
onPromotionUpdate,
paymentMethods,
showFeatures
}
),
mode === "standalone" && !isMobile && /* @__PURE__ */ jsx(CheckoutFooter, { className: "cko-footer", sx: { color: "text.lighter" } })
] }) }),
/* @__PURE__ */ jsxs(
Stack,
{
className: "base-card cko-payment",
direction: "column",
spacing: {
xs: 2,
sm: 3
},
children: [
completed && /* @__PURE__ */ jsx(
PaymentSuccess,
{
mode,
pageInfo: state.checkoutSession.metadata?.page_info,
vendorCount: state.checkoutSession.line_items.reduce((total, item) => {
return total + (item.price?.product?.vendor_config?.length || 0);
}, 0),
sessionId: state.checkoutSession.id,
payee: getStatementDescriptor(state.checkoutSession.line_items),
action: state.checkoutSession.mode,
invoiceId: state.checkoutSession.invoice_id,
subscriptionId: state.checkoutSession.subscription_id,
subscriptions: state.checkoutSession.subscriptions,
message: paymentLink?.after_completion?.hosted_confirmation?.custom_message || t(
`payment.checkout.completed.${state.checkoutSession.submit_type === "donate" ? "donate" : state.checkoutSession.mode}`
)
}
),
!completed && /* @__PURE__ */ jsx(
PaymentForm,
{
currencyId,
checkoutSession: state.checkoutSession,
paymentMethods,
paymentIntent,
paymentLink,
customer,
onPaid: handlePaid,
onError,
mode,
action
}
)
]
}
),
mode === "standalone" && isMobile && /* @__PURE__ */ jsx(CheckoutFooter, { className: "cko-footer", sx: { color: "text.lighter" } })
] }) });
}
export default function Payment({
checkoutSession,
paymentMethods,
paymentIntent,
paymentLink,
customer,
completed = false,
error = null,
mode,
onPaid,
onError,
onChange,
goBack,
action,
showCheckoutSummary = true
}) {
const { t } = useLocaleContext();
const { refresh, livemode, setLivemode } = usePaymentContext();
const [delay, setDelay] = useState(false);
const { isMobile } = useMobile();
const hideSummaryCard = mode.endsWith("-minimal") || !showCheckoutSummary;
const isMobileSafariEnv = isMobileSafari();
useEffect(() => {
setTimeout(() => {
setDelay(true);
}, 500);
}, []);
useEffect(() => {
if (checkoutSession) {
if (livemode !== checkoutSession.livemode) {
setLivemode(checkoutSession.livemode);
}
}
}, [checkoutSession, livemode, setLivemode, refresh]);
const renderContent = () => {
if (error) {
return /* @__PURE__ */ jsx(PaymentError, { mode, title: "Oops", description: formatError(error) });
}
if (!checkoutSession || !delay) {
return /* @__PURE__ */ jsxs(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: [
!hideSummaryCard && /* @__PURE__ */ jsx(Stack, { className: "base-card cko-overview", children: /* @__PURE__ */ jsx(Box, { className: "cko-product", children: /* @__PURE__ */ jsx(OverviewSkeleton, {}) }) }),
/* @__PURE__ */ jsx(Stack, { className: "base-card cko-payment", children: /* @__PURE__ */ jsx(PaymentSkeleton, {}) })
] });
}
if (checkoutSession.expires_at <= Math.round(Date.now() / 1e3)) {
return /* @__PURE__ */ jsx(
PaymentError,
{
mode,
title: t("payment.checkout.expired.title"),
description: t("payment.checkout.expired.description")
}
);
}
if (!checkoutSession.line_items.length) {
return /* @__PURE__ */ jsx(
PaymentError,
{
mode,
title: t("payment.checkout.emptyItems.title"),
description: t("payment.checkout.emptyItems.description")
}
);
}
return /* @__PURE__ */ jsx(
PaymentInner,
{
checkoutSession,
paymentMethods,
paymentLink,
paymentIntent,
completed: completed || checkoutSession.status === "complete",
customer,
onPaid,
onError,
onChange,
goBack,
mode,
action,
showCheckoutSummary
}
);
};
return /* @__PURE__ */ jsxs(
Stack,
{
sx: {
display: "flex",
flexDirection: "column",
height: mode === "standalone" ? "100vh" : "auto",
overflow: isMobileSafariEnv ? "visible" : "hidden"
},
children: [
mode === "standalone" ? /* @__PURE__ */ jsx(
Header,
{
meta: void 0,
addons: void 0,
sessionManagerProps: void 0,
homeLink: void 0,
theme: void 0,
hideNavMenu: void 0,
maxWidth: false,
sx: { borderBottom: "1px solid", borderColor: "divider" }
}
) : null,
/* @__PURE__ */ jsxs(
Root,
{
mode,
sx: {
flex: 1,
overflow: {
xs: isMobileSafariEnv ? "visible" : "auto",
md: "hidden"
},
...isMobile && mode === "standalone" ? {
".cko-payment-submit-btn": {
position: "fixed",
bottom: 20,
left: 0,
right: 0,
zIndex: 999,
backgroundColor: "background.paper",
padding: "10px",
textAlign: "center",
button: {
maxWidth: 542
}
},
".cko-footer": {
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 999,
backgroundColor: "background.paper",
marginBottom: 0
},
".cko-payment": {
paddingBottom: "100px"
}
} : {}
},
children: [
goBack && /* @__PURE__ */ jsx(
ArrowBackOutlined,
{
sx: { mr: 0.5, color: "text.secondary", alignSelf: "flex-start", margin: "16px 0", cursor: "pointer" },
onClick: goBack,
fontSize: "medium"
}
),
/* @__PURE__ */ jsx(Stack, { className: "cko-container", sx: { gap: { sm: mode === "standalone" ? 0 : mode === "inline" ? 4 : 8 } }, children: renderContent() })
]
}
)
]
}
);
}
export const Root = styled(Box)`
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
position: relative;
.cko-container {
overflow: hidden;
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
position: relative;
flex: 1;
padding: 1px;
}
.base-card {
border: none;
border-radius: 0;
padding: ${(props) => props.mode === "standalone" ? "100px 40px 20px" : "20px 0"};
box-shadow: none;
flex: 1;
max-width: 582px;
}
.cko-overview {
position: relative;
flex-direction: column;
display: ${(props) => props.mode.endsWith("-minimal") ? "none" : "flex"};
background: ${({ theme }) => theme.palette.background.default};
min-height: 'auto';
}
.cko-header {
left: 0;
margin-bottom: 0;
position: absolute;
right: 0;
top: 0;
transition:
background-color 0.15s ease,
box-shadow 0.15s ease-out;
}
.cko-product {
flex: 1;
overflow: hidden;
}
.cko-product-summary {
width: 100%;
}
.cko-ellipsis {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cko-payment {
width: 502px;
padding-left: ${(props) => props.mode === "standalone" ? "40px" : "20px"};
position: relative;
&:before {
-webkit-animation-fill-mode: both;
content: '';
height: 100%;
position: absolute;
left: 0px;
top: 0px;
transform-origin: left center;
width: 8px;
box-shadow: -4px 0px 8px 0px rgba(2, 7, 19, 0.04);
}
}
.cko-payment-contact {
overflow: hidden;
}
.cko-footer {
display: ${(props) => props.mode.endsWith("-minimal") ? "none" : "block"};
text-align: center;
margin-top: 20px;
}
.cko-payment-form {
.MuiFormLabel-root {
color: ${({ theme }) => theme.palette.grey.A700};
font-weight: 500;
margin-top: 12px;
margin-bottom: 4px;
}
.MuiBox-root {
margin: 0;
}
.MuiFormHelperText-root {
margin-left: 14px;
}
}
.cko-payment-methods {
}
.cko-payment-submit {
.MuiButtonBase-root {
font-size: 1.3rem;
position: relative;
}
.cko-submit-progress {
position: absolute;
top: 0;
width: 100%;
height: 100%;
opacity: 0.3;
}
}
.cko-header {
}
.product-item {
border-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
border: 1px solid;
border-color: ${({ theme }) => theme.palette.divider};
.product-item-content {
padding: 16px;
background: ${({ theme }) => theme.palette.grey[50]};
border-top-left-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
border-top-right-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
}
.product-item-upsell {
margin-top: 0;
padding: 16px;
background: ${({ theme }) => theme.palette.grey[100]};
border-bottom-left-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
border-bottom-right-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
}
.product-item-content:only-child {
border-radius: ${({ theme }) => `${2 * theme.shape.borderRadius}px`};
}
}
@media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
padding-top: 0;
overflow: auto;
&:before {
display: none;
}
.cko-container {
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0;
overflow: visible;
min-width: 200px;
}
.cko-overview {
width: 100%;
min-height: auto;
flex: none;
}
.cko-payment {
width: 100%;
height: fit-content;
flex: none;
border-top: 1px solid;
border-color: ${({ theme }) => theme.palette.divider};
&:before {
display: none;
}
}
.cko-footer {
position: relative;
margin-bottom: 4px;
margin-top: 0;
bottom: 0;
left: 0;
transform: translateX(0);
}
.base-card {
box-shadow: none;
border-radius: 0;
padding: 20px;
}
}
`;