@blocklet/payment-react
Version:
Reusable react components for payment kit v2
1,327 lines (1,326 loc) • 56.2 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import "react-international-phone/style.css";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import { FlagEmoji } from "react-international-phone";
import Toast from "@arcblock/ux/lib/Toast";
import { Box, Button, CircularProgress, Divider, Fade, Stack, Tooltip, Typography } from "@mui/material";
import { useMemoizedFn, useSetState } from "ahooks";
import pWaitFor from "p-wait-for";
import { useEffect, useMemo, useRef } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { joinURL } from "ufo";
import { dispatch } from "use-bus";
import isEmail from "validator/es/lib/isEmail";
import { BN, fromTokenToUnit, fromUnitToToken } from "@ocap/util";
import DID from "@arcblock/ux/lib/DID";
import isEmpty from "lodash/isEmpty";
import { HelpOutline, OpenInNew } from "@mui/icons-material";
import { ReactGA } from "@arcblock/ux/lib/withTracker";
import trim from "lodash/trim";
import FormInput from "../../components/input.js";
import FormLabel from "../../components/label.js";
import { usePaymentContext } from "../../contexts/payment.js";
import { useSubscription } from "../../hooks/subscription.js";
import api from "../../libs/api.js";
import {
flattenPaymentMethods,
formatError,
formatQuantityInventory,
getPrefix,
getQueryParams,
getStatementDescriptor,
getTokenBalanceLink,
isCrossOrigin,
getCheckoutAmount,
formatNumber,
formatUsdAmount,
getUsdAmountFromBaseAmount,
getUsdAmountFromTokenUnits,
formatAmount
} from "../../libs/util.js";
import AddressForm from "./address.js";
import CurrencySelector from "./currency.js";
import PhoneInput from "./phone.js";
import StripeCheckout from "./stripe/index.js";
import { useMobile } from "../../hooks/mobile.js";
import { formatPhone, validatePhoneNumber } from "../../libs/phone-validator.js";
import LoadingButton from "../../components/loading-button.js";
import OverdueInvoicePayment from "../../components/over-due-invoice-payment.js";
import { saveCurrencyPreference } from "../../libs/currency.js";
import ConfirmDialog from "../../components/confirm.js";
import ServiceSuspendedDialog from "../../components/service-suspended-dialog.js";
import PriceChangeConfirm from "../../components/price-change-confirm.js";
import { getFieldValidation, validatePostalCode } from "../../libs/validator.js";
const generateIdempotencyKey = (sessionId, currencyId) => {
return `${sessionId}-${currencyId}-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
};
export const waitForCheckoutComplete = async (sessionId) => {
let result;
await pWaitFor(
async () => {
const { data } = await api.get(`/api/checkout-sessions/retrieve/${sessionId}`);
if (data.paymentIntent && data.paymentIntent.status === "requires_action" && data.paymentIntent.last_payment_error) {
throw new Error(data.paymentIntent.last_payment_error.message);
}
result = data;
return (
// eslint-disable-next-line @typescript-eslint/return-await
data.checkoutSession?.status === "complete" && ["paid", "no_payment_required"].includes(data.checkoutSession?.payment_status)
);
},
{ interval: 2e3, timeout: 3 * 60 * 1e3 }
);
return result;
};
export const hasDidWallet = (user) => {
const connected = user?.connectedAccounts || user?.extraConfigs?.connectedAccounts || [];
return connected.some((x) => x.provider === "wallet");
};
const setUserFormValues = (userInfo, currentValues, setValue, options = {}) => {
const { preferExisting = true, shouldValidate = false } = options;
const basicFields = {
customer_name: userInfo.name || userInfo.fullName,
customer_email: userInfo.email,
customer_phone: formatPhone(userInfo.phone)
};
const addressFields = {
"billing_address.state": userInfo.address?.state || userInfo.address?.province,
"billing_address.line1": userInfo.address?.line1,
"billing_address.line2": userInfo.address?.line2,
"billing_address.city": userInfo.address?.city,
"billing_address.postal_code": userInfo.address?.postal_code || userInfo.address?.postalCode,
"billing_address.country": userInfo.address?.country || "us"
};
if (options.showPhone) {
addressFields["billing_address.country"] = userInfo.metadata?.phone?.country || userInfo.address?.country;
}
const allFields = { ...addressFields, ...basicFields };
const updatedFields = {
...currentValues,
billing_address: {
...currentValues.billing_address
}
};
Object.entries(allFields).forEach(([field, value]) => {
if (!preferExisting || !currentValues[field.split(".")[0]]) {
setValue(field, value, { shouldValidate });
if (field.startsWith("billing_address.")) {
const addressKey = field.replace("billing_address.", "");
updatedFields.billing_address[addressKey] = value;
} else {
updatedFields[field] = value;
}
}
});
return updatedFields;
};
export default function PaymentForm({
checkoutSession,
paymentMethods,
paymentIntent,
paymentLink,
customer,
onPaid,
onError,
onQuoteUpdated = void 0,
onPaymentIntentUpdate = void 0,
// mode,
action,
onlyShowBtn = false,
isDonation = false,
rateUnavailable = false
}) {
const { t, locale } = useLocaleContext();
const { isMobile } = useMobile();
const { session, connect, payable, setPaymentState } = usePaymentContext();
const subscription = useSubscription("events");
const formErrorPosition = "bottom";
const {
control,
getValues,
setValue,
handleSubmit,
formState: { errors },
trigger
} = useFormContext();
const errorRef = useRef(null);
const processingRef = useRef(false);
const idempotencyKeyRef = useRef("");
const sessionFingerprintRef = useRef("");
const quantityInventoryStatus = useMemo(() => {
let status = true;
for (const item of checkoutSession.line_items) {
if (formatQuantityInventory(item.price, item.quantity)) {
status = false;
break;
}
}
return status;
}, [checkoutSession]);
const [state, setState] = useSetState({
submitting: false,
paying: false,
paid: false,
paymentIntent,
stripeContext: void 0,
customer,
customerLimited: false,
serviceSuspended: false,
stripePaying: false,
fastCheckoutInfo: null,
creditInsufficientInfo: null,
showEditForm: false,
priceChangeConfirm: null
});
const currencies = flattenPaymentMethods(paymentMethods);
const searchParams = getQueryParams(window.location.href);
const onCheckoutComplete = useMemoizedFn(async ({ response }) => {
if (response.id === checkoutSession.id && state.paid === false) {
await handleConnected();
}
});
useEffect(() => {
if (subscription) {
subscription.on("checkout.session.completed", onCheckoutComplete);
}
}, [subscription]);
useEffect(() => {
setPaymentState({
paying: state.submitting || state.paying,
stripePaying: state.stripePaying
});
}, [state.submitting, state.paying, state.stripePaying]);
const mergeUserInfo = (customerInfo, userInfo) => {
return {
...userInfo || {},
name: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName,
fullName: customerInfo?.name || customerInfo?.fullName || userInfo?.name || userInfo?.fullName,
email: customerInfo?.email || userInfo?.email,
phone: customerInfo?.phone || userInfo?.phone,
address: {
...userInfo?.address || {},
...customerInfo?.address || {},
country: customerInfo?.address?.country || userInfo?.address?.country,
state: customerInfo?.address?.state || userInfo?.address?.province,
line1: customerInfo?.address?.line1 || userInfo?.address?.line1,
line2: customerInfo?.address?.line2 || userInfo?.address?.line2,
city: customerInfo?.address?.city || userInfo?.address?.city,
postal_code: customerInfo?.address?.postal_code || userInfo?.address?.postalCode
},
metadata: {
...userInfo?.metadata || {},
phone: {
country: customerInfo?.address?.country || userInfo?.metadata?.phone?.country,
phoneNumber: customerInfo?.phone || userInfo?.metadata?.phone?.phoneNumber
}
}
};
};
useEffect(() => {
const initUserInfo = async () => {
if (session?.user) {
const values = getValues();
let userInfo = session.user;
try {
const { data: customerInfo } = await api.get("/api/customers/me?skipSummary=1&fallback=1");
userInfo = mergeUserInfo(customerInfo, userInfo);
} catch (err) {
userInfo = mergeUserInfo(customer || {}, userInfo);
console.error(err);
}
const formValues = setUserFormValues(userInfo, values, setValue, {
preferExisting: false,
showPhone: checkoutSession.phone_number_collection?.enabled
});
const isValid = validateUserInfo(formValues);
setState({ showEditForm: !isValid });
} else {
setUserFormValues(
{
name: "",
email: "",
phone: "",
address: {
state: "",
line1: "",
line2: "",
city: "",
postal_code: ""
}
},
{},
setValue,
{ preferExisting: false, showPhone: checkoutSession.phone_number_collection?.enabled }
);
}
};
if (state.submitting) {
return;
}
initUserInfo();
}, [session?.user, checkoutSession.phone_number_collection?.enabled]);
const paymentMethod = useWatch({ control, name: "payment_method" });
const paymentCurrencyId = useWatch({ control, name: "payment_currency" });
const afterUserLoggedIn = useMemoizedFn(() => {
handleSubmit(onFormSubmit, onFormError)();
});
const payee = getStatementDescriptor(checkoutSession.line_items);
let buttonText = "";
if (paymentLink?.donation_settings) {
if (action) {
buttonText = action;
} else {
buttonText = t("payment.checkout.donate");
}
} else {
buttonText = t(`payment.checkout.${checkoutSession.mode}`);
}
buttonText = session?.user || isDonation ? buttonText : t("payment.checkout.connect", { action: buttonText });
const method = paymentMethods.find((x) => x.id === paymentMethod);
const paymentCurrency = currencies.find((x) => x.id === paymentCurrencyId);
const showStake = method.type === "arcblock" && !checkoutSession.subscription_data?.no_stake;
const hasDynamicPricing = useMemo(
() => (checkoutSession.line_items || []).some((item) => {
const price = item.upsell_price || item.price;
return price && price?.pricing_type === "dynamic";
}),
[checkoutSession.line_items]
);
const rateUnavailableForDynamic = hasDynamicPricing && rateUnavailable;
const canPay = payable && !rateUnavailableForDynamic;
const isDonationMode = checkoutSession?.submit_type === "donate" && isDonation;
const [priceUpdateInfo, setPriceUpdateInfo] = useSetState({
open: false,
total: "",
usd: null,
hasQuotes: false,
baseCurrency: "USD",
oldTotal: "",
reason: "recalculated"
});
const normalizeExchangeRate = useMemoizedFn((rate) => {
if (!rate) {
return null;
}
const value = Number(rate);
if (!Number.isFinite(value)) {
return null;
}
return value.toFixed(8);
});
const getExchangeRateFromSession = useMemoizedFn((sessionData) => {
if (!sessionData?.line_items?.length) {
return null;
}
for (const item of sessionData.line_items) {
const rate = item?.exchange_rate;
if (rate) {
return rate;
}
}
return null;
});
const quoteAutoRetryRef = useRef(false);
const lastRetryKeyRef = useRef("");
const buildRetryKey = useMemoizedFn((sessionData) => {
if (!sessionData?.line_items?.length) {
return "";
}
return sessionData.line_items.map((item) => {
const priceId = item.price_id || item.price?.id || "";
const quoteId = item?.quote_id || "";
const quotedAmount = item?.quoted_amount || "";
const exchangeRate = item?.exchange_rate || "";
return `${priceId}:${quoteId}:${quotedAmount}:${exchangeRate}`;
}).join("|");
});
const buildPriceUpdateSummary = useMemoizedFn((sessionData) => {
if (!paymentCurrency) {
return { total: "", usd: null, hasQuotes: false, baseCurrency: "USD", totalUnit: null };
}
const lineItems = sessionData.line_items || [];
let baseCurrency = "USD";
for (const item of lineItems) {
const price = item.upsell_price || item.price;
const base = price?.base_currency;
if (base) {
baseCurrency = base;
break;
}
}
const hasQuotes = lineItems.some((item) => item?.quoted_amount && item?.exchange_rate);
if (!hasQuotes) {
return { total: "", usd: null, hasQuotes: false, baseCurrency, totalUnit: null };
}
let trialInDays = Number(sessionData?.subscription_data?.trial_period_days || 0);
const trialCurrencyIds = (sessionData?.subscription_data?.trial_currency || "").split(",").map(trim).filter(Boolean);
if (trialCurrencyIds.length > 0 && paymentCurrencyId && trialCurrencyIds.includes(paymentCurrencyId) === false) {
trialInDays = 0;
}
const { total } = getCheckoutAmount(lineItems, paymentCurrency, trialInDays > 0);
const discountAmount = new BN(sessionData.total_details?.amount_discount || "0");
const totalUnit = new BN(total).sub(discountAmount);
const normalizedTotalUnit = totalUnit.isNeg() ? new BN(0) : totalUnit;
const totalDisplay = `${formatNumber(
fromUnitToToken(normalizedTotalUnit.toString(), paymentCurrency.decimal),
6
)} ${paymentCurrency.symbol}`;
const itemUsdReferences = lineItems.map((item) => {
const price = item.upsell_price || item.price;
const baseAmount = price?.base_amount;
const hasBaseAmount = baseAmount !== void 0 && baseAmount !== null;
if (hasBaseAmount) {
return getUsdAmountFromBaseAmount(baseAmount, item.quantity || 0);
}
const exchangeRate = item?.exchange_rate;
const quotedAmount = item?.quoted_amount;
if (!exchangeRate || !quotedAmount) {
return null;
}
return getUsdAmountFromTokenUnits(new BN(quotedAmount), paymentCurrency.decimal, exchangeRate);
});
const usdValues = itemUsdReferences.filter((value) => Boolean(value));
if (!usdValues.length) {
return { total: totalDisplay, usd: null, hasQuotes, baseCurrency, totalUnit: normalizedTotalUnit };
}
const sumUnit = usdValues.reduce((acc, value) => acc.add(new BN(fromTokenToUnit(value, 8))), new BN(0));
const totalUsdReference = fromUnitToToken(sumUnit.toString(), 8);
return {
total: totalDisplay,
usd: formatUsdAmount(totalUsdReference, locale),
hasQuotes,
baseCurrency,
totalUnit: normalizedTotalUnit
};
});
const compareTotals = useMemoizedFn((prevSession, nextSession) => {
const prev = buildPriceUpdateSummary(prevSession);
const next = buildPriceUpdateSummary(nextSession);
if (!prev.totalUnit || !next.totalUnit) {
return { changed: false, prev, next };
}
const diff = next.totalUnit.sub(prev.totalUnit).abs();
const epsilon = new BN(1);
return { changed: diff.gt(epsilon), prev, next };
});
const applyQuoteUpdate = useMemoizedFn(
(payload, options = {}) => {
if (!payload?.checkoutSession) {
return;
}
onQuoteUpdated?.({
checkoutSession: payload.checkoutSession,
quotes: payload.quotes,
rateUnavailable: payload.rateUnavailable,
rateError: payload.rateError
});
const { changed, prev, next } = compareTotals(checkoutSession, payload.checkoutSession);
const previousRate = normalizeExchangeRate(getExchangeRateFromSession(checkoutSession));
const nextRate = normalizeExchangeRate(getExchangeRateFromSession(payload.checkoutSession));
const rateChanged = !!(previousRate && nextRate && previousRate !== nextRate);
const shouldShowModal = (options.forceConfirm || changed) && next.hasQuotes;
if (shouldShowModal) {
setPriceUpdateInfo({
open: true,
total: next.total,
usd: next.usd,
hasQuotes: next.hasQuotes,
baseCurrency: next.baseCurrency,
oldTotal: prev.total,
reason: options.reason || (rateChanged ? "rateChanged" : "recalculated")
});
return;
}
setPriceUpdateInfo({ open: false });
const retryKey = buildRetryKey(payload.checkoutSession);
if (retryKey && retryKey !== lastRetryKeyRef.current) {
lastRetryKeyRef.current = retryKey;
quoteAutoRetryRef.current = true;
}
}
);
const validateUserInfo = (values) => {
if (!values) {
return false;
}
const fieldValidation = checkoutSession.metadata?.page_info?.field_validation;
const customerName2 = values.customer_name;
if (!customerName2 || customerName2.trim() === "") {
return false;
}
const customerEmail2 = values.customer_email;
if (!customerEmail2 || !isEmail(customerEmail2)) {
return false;
}
const nameValidation = getFieldValidation("customer_name", fieldValidation, locale);
if (nameValidation.pattern) {
const pattern = nameValidation.pattern.value;
if (!pattern.test(customerName2)) {
return false;
}
}
const emailValidation = getFieldValidation("customer_email", fieldValidation, locale);
if (emailValidation.pattern) {
const pattern = emailValidation.pattern.value;
if (!pattern.test(customerEmail2)) {
return false;
}
}
const billingAddress2 = values.billing_address || {};
const { postal_code: postalCode, country, state: stateValue, line1, city } = billingAddress2;
if (!postalCode || !validatePostalCode(postalCode, country)) {
return false;
}
const postalCodeValidation = getFieldValidation("billing_address.postal_code", fieldValidation, locale);
if (postalCodeValidation.pattern) {
const pattern = postalCodeValidation.pattern.value;
if (!pattern.test(postalCode)) {
return false;
}
}
if (!stateValue) {
return false;
}
const stateValidation = getFieldValidation("billing_address.state", fieldValidation, locale);
if (stateValidation.pattern) {
const pattern = stateValidation.pattern.value;
if (!pattern.test(stateValue)) {
return false;
}
}
if (checkoutSession.phone_number_collection?.enabled) {
const customerPhone2 = values.customer_phone;
if (!customerPhone2 || customerPhone2.trim() === "") {
return false;
}
const phoneValidation = getFieldValidation("customer_phone", fieldValidation, locale);
if (phoneValidation.pattern) {
const pattern = phoneValidation.pattern.value;
if (!pattern.test(customerPhone2)) {
return false;
}
}
}
const addressMode = checkoutSession.billing_address_collection;
if (addressMode === "required") {
if (!country || !stateValue || !line1 || !city || !postalCode) {
return false;
}
const line1Validation = getFieldValidation("billing_address.line1", fieldValidation, locale);
if (line1Validation.pattern) {
const pattern = line1Validation.pattern.value;
if (!pattern.test(line1)) {
return false;
}
}
const cityValidation = getFieldValidation("billing_address.city", fieldValidation, locale);
if (cityValidation.pattern) {
const pattern = cityValidation.pattern.value;
if (!pattern.test(city)) {
return false;
}
}
}
return true;
};
const customerName = useWatch({ control, name: "customer_name" });
const customerEmail = useWatch({ control, name: "customer_email" });
const customerPhone = useWatch({ control, name: "customer_phone" });
const billingAddress = useWatch({ control, name: "billing_address" });
const showForm = session?.user ? state.showEditForm : false;
useEffect(() => {
if (!quoteAutoRetryRef.current) {
return;
}
if (state.submitting || state.paying) {
return;
}
quoteAutoRetryRef.current = false;
onAction();
}, [state.submitting, state.paying]);
const handleConnected = async () => {
if (processingRef.current) {
return;
}
processingRef.current = true;
setState({ paying: true });
try {
const result = await waitForCheckoutComplete(checkoutSession.id);
if (state.paid === false) {
setState({ paid: true, paying: false });
onPaid(result);
}
} catch (err) {
const errorMessage = formatError(err);
const payFailedEvent = {
action: "payFailed",
// @ts-ignore 后续升级的话就会报错了,移除这个 lint 即可
mode: checkoutSession.mode,
errorMessage,
success: false
};
ReactGA.event(payFailedEvent.action, payFailedEvent);
Toast.error(errorMessage);
} finally {
setState({ paying: false });
processingRef.current = false;
}
};
useEffect(() => {
if (errorRef.current && !isEmpty(errors) && isMobile) {
errorRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [errors, isMobile]);
const onUserLoggedIn = async () => {
const { data: profile } = await api.get("/api/customers/me?fallback=1&skipSummary=1");
if (profile) {
const values = getValues();
const userInfo = mergeUserInfo(profile, session?.user);
const formValues = setUserFormValues(userInfo, values, setValue, {
preferExisting: false,
showPhone: checkoutSession.phone_number_collection?.enabled,
shouldValidate: true
});
const isValid = validateUserInfo(formValues);
setState({ showEditForm: !isValid });
await trigger();
}
};
const handleFastCheckoutConfirm = async () => {
if (!state.fastCheckoutInfo) return;
setState({
fastCheckoutInfo: {
...state.fastCheckoutInfo,
loading: true
}
});
try {
const result = await api.post(`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`, {});
if (result.data.paymentIntent) {
onPaymentIntentUpdate?.(result.data.paymentIntent);
}
if (result.data.fastPaid) {
setState({
fastCheckoutInfo: null,
paying: true
});
await handleConnected();
} else {
Toast.error(t("payment.checkout.fastPay.failed"));
setState({
fastCheckoutInfo: null,
paying: true
});
openConnect();
}
} catch (err) {
console.error(err);
const errorCode = err?.response?.data?.code;
if ([
"QUOTE_LOCK_EXPIRED",
"QUOTE_AMOUNT_MISMATCH",
"QUOTE_EXPIRED_OR_USED",
"QUOTE_NOT_FOUND",
"QUOTE_REQUIRED"
].includes(errorCode)) {
try {
const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
params: { forceRefresh: "1" }
});
if (refreshed?.checkoutSession) {
applyQuoteUpdate(refreshed, { reason: "rateChanged" });
Toast.info(t("payment.checkout.quote.updated.pleaseRetry") || "Price updated, please resubmit");
}
} catch (refreshError) {
console.error(refreshError);
Toast.error(formatError(refreshError));
} finally {
setState({ fastCheckoutInfo: null });
}
return;
}
if (errorCode === "QUOTE_UPDATED") {
const payload = err?.response?.data;
if (payload?.checkoutSession) {
applyQuoteUpdate(payload);
}
setState({ fastCheckoutInfo: null });
return;
}
if (errorCode === "RATE_UNAVAILABLE") {
const payload = err?.response?.data;
if (payload?.checkoutSession) {
onQuoteUpdated?.({
checkoutSession: payload.checkoutSession,
quotes: payload.quotes,
rateUnavailable: payload.rateUnavailable,
rateError: payload.rateError
});
}
setState({ fastCheckoutInfo: null });
return;
}
Toast.error(formatError(err));
setState({
fastCheckoutInfo: null
});
}
};
const handleFastCheckoutCancel = () => {
setState({ fastCheckoutInfo: null });
};
const handleCreditInsufficientClose = () => {
setState({ creditInsufficientInfo: null });
};
const handlePriceUpdateConfirm = () => {
setPriceUpdateInfo({ open: false });
quoteAutoRetryRef.current = true;
};
const handlePriceUpdateCancel = () => {
setPriceUpdateInfo({ open: false });
};
const handlePriceChangeConfirm = () => {
const formData = state.priceChangeConfirm?.formData;
setState({ priceChangeConfirm: null });
if (formData) {
onFormSubmit(formData);
}
};
const handlePriceChangeCancel = () => {
setState({ priceChangeConfirm: null });
};
const openConnect = () => {
try {
if (!["arcblock", "ethereum", "base"].includes(method.type)) {
return;
}
setState({ paying: true });
connect.open({
locale,
containerEl: void 0,
action: checkoutSession.mode,
prefix: joinURL(getPrefix(), "/api/did"),
saveConnect: false,
useSocket: isCrossOrigin() === false,
extraParams: { checkoutSessionId: checkoutSession.id, sessionUserDid: session?.user?.did },
onSuccess: async () => {
connect.close();
await handleConnected();
},
onClose: () => {
connect.close();
setState({ submitting: false, paying: false });
},
onError: (err) => {
console.error(err);
setState({ submitting: false, paying: false });
onError(err);
},
messages: {
title: t("payment.checkout.connectModal.title", { action: buttonText }),
scan: t("payment.checkout.connectModal.scan"),
confirm: t("payment.checkout.connectModal.confirm"),
cancel: t("payment.checkout.connectModal.cancel")
}
});
} catch (err) {
Toast.error(formatError(err));
}
};
const onFormSubmit = async (data) => {
if (state.submitting) {
return;
}
const userInfo = session.user;
if (!userInfo.sourceAppPid) {
const hasVendorConfig = checkoutSession.line_items?.some(
(item) => !!item?.price?.product?.vendor_config?.length
);
if (hasVendorConfig) {
Toast.error(t("payment.checkout.vendor.accountRequired"));
return;
}
}
setState({ submitting: true });
try {
let result;
const previewRate = checkoutSession.line_items?.find((item) => item?.exchange_rate)?.exchange_rate || void 0;
const items = checkoutSession.line_items || [];
const itemsSig = items.map((i) => `${i.upsell_price_id || i.price_id}:${i.quantity}`).join("|");
const fingerprint = `${checkoutSession.id}-${paymentCurrency?.id}-${itemsSig}`;
if (fingerprint !== sessionFingerprintRef.current || !idempotencyKeyRef.current) {
sessionFingerprintRef.current = fingerprint;
idempotencyKeyRef.current = generateIdempotencyKey(checkoutSession.id, paymentCurrency?.id || "");
}
const payload = {
...data,
// Final Freeze: Include these for new quote creation at submit
idempotency_key: idempotencyKeyRef.current,
preview_rate: previewRate || void 0,
price_confirmed: state.priceChangeConfirm?.formData ? true : void 0
};
if (isDonationMode) {
result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/donate-submit`, payload);
} else {
result = await api.put(`/api/checkout-sessions/${checkoutSession.id}/submit`, payload);
}
setState({
paymentIntent: result.data.paymentIntent,
stripeContext: result.data.stripeContext,
customer: result.data.customer,
submitting: false,
customerLimited: false
});
if (result.data.paymentIntent) {
onPaymentIntentUpdate?.(result.data.paymentIntent);
}
if (["arcblock", "ethereum", "base"].includes(method.type)) {
if (result.data.noPaymentRequired) {
try {
const confirmResult = await api.post(
`/api/checkout-sessions/${checkoutSession.id}/fast-checkout-confirm`,
{}
);
if (confirmResult.data.paymentIntent) {
onPaymentIntentUpdate?.(confirmResult.data.paymentIntent);
}
if (confirmResult.data.fastPaid || confirmResult.data.checkoutSession?.status === "complete") {
setState({ paying: true });
await handleConnected();
} else {
openConnect();
}
} catch (confirmErr) {
console.error("noPaymentRequired confirm failed", confirmErr);
openConnect();
}
return;
}
if (paymentCurrency?.type === "credit") {
if (result.data.creditSufficient === true) {
setState({
fastCheckoutInfo: {
open: true,
loading: false,
sourceType: "credit",
amount: result.data.fastPayInfo?.amount || "0",
payer: result.data.fastPayInfo?.payer,
availableCredit: result.data.fastPayInfo?.amount || "0",
balance: result.data.fastPayInfo?.token?.balance || "0"
}
});
} else {
setState({
creditInsufficientInfo: {
open: true
}
});
}
} else if ((result.data.balance?.sufficient || result.data.delegation?.sufficient) && !isDonationMode && result.data.fastPayInfo) {
setState({
fastCheckoutInfo: {
open: true,
loading: false,
sourceType: result.data.fastPayInfo.type,
amount: result.data.fastPayInfo.amount,
payer: result.data.fastPayInfo.payer
}
});
} else {
openConnect();
}
}
if (["stripe"].includes(method.type)) {
if (result.data.stripeContext?.status === "succeeded") {
setState({ paying: true });
} else {
setTimeout(() => {
setState({ stripePaying: true });
}, 200);
}
}
} catch (err) {
console.error(err);
let shouldToast = true;
const errorCode = err.response?.data?.code;
if (errorCode) {
if (![
"QUOTE_UPDATED",
"RATE_UNAVAILABLE",
"QUOTE_LOCK_EXPIRED",
"QUOTE_AMOUNT_MISMATCH",
"QUOTE_EXPIRED_OR_USED",
"QUOTE_NOT_FOUND",
"QUOTE_REQUIRED",
"QUOTE_MAX_PAYABLE_EXCEEDED"
].includes(errorCode)) {
dispatch(`error.${errorCode}`);
}
if ([
"QUOTE_LOCK_EXPIRED",
"QUOTE_AMOUNT_MISMATCH",
"QUOTE_EXPIRED_OR_USED",
"QUOTE_NOT_FOUND",
"QUOTE_REQUIRED",
"QUOTE_MAX_PAYABLE_EXCEEDED",
"quote_validation_failed"
].includes(errorCode)) {
shouldToast = false;
try {
const { data: refreshed } = await api.get(`/api/checkout-sessions/retrieve/${checkoutSession.id}`, {
params: { forceRefresh: "1" }
});
if (refreshed?.checkoutSession) {
applyQuoteUpdate(refreshed, { reason: "rateChanged" });
Toast.info(t("payment.checkout.quote.updated.pleaseRetry") || "Price updated, please resubmit");
}
} catch (refreshError) {
console.error(refreshError);
Toast.error(formatError(refreshError));
}
}
if (errorCode === "QUOTE_UPDATED") {
shouldToast = false;
const payload = err.response?.data;
if (payload?.checkoutSession) {
applyQuoteUpdate(payload);
}
}
if (errorCode === "RATE_UNAVAILABLE") {
shouldToast = false;
const payload = err.response?.data;
if (payload?.checkoutSession) {
onQuoteUpdated?.({
checkoutSession: payload.checkoutSession,
quotes: payload.quotes,
rateUnavailable: payload.rateUnavailable,
rateError: payload.rateError
});
}
}
if (errorCode === "PRICE_UNAVAILABLE") {
shouldToast = false;
Toast.error(
t("payment.checkout.priceChange.unavailable", {
fallback: "Unable to fetch exchange rate. Please try again later."
})
);
}
if (errorCode === "PRICE_UNSTABLE") {
shouldToast = false;
Toast.error(
t("payment.checkout.priceChange.unstable", {
fallback: "Price is volatile. Please try again later."
})
);
}
if (errorCode === "PRICE_CHANGED") {
shouldToast = false;
const errorData = err.response?.data;
setState({
priceChangeConfirm: {
open: true,
changePercent: errorData?.change_percent || 0,
formData: data
// Save form data for retry
}
});
}
if (errorCode === "UNIFIED_APP_REQUIRED") {
shouldToast = false;
Toast.error(t("payment.checkout.vendor.accountRequired"));
}
if (errorCode === "CUSTOMER_LIMITED") {
shouldToast = false;
setState({ customerLimited: true });
}
if (errorCode === "STOP_ACCEPTING_ORDERS") {
shouldToast = false;
setState({ serviceSuspended: true });
}
}
if (shouldToast) {
Toast.error(formatError(err));
}
} finally {
setState({ submitting: false });
}
};
const onFormError = (err) => {
if (err) {
console.error(err);
}
setState({ submitting: false });
};
const onAction = () => {
if (state.submitting || state.paying || !canPay) {
return;
}
if (errorRef.current && !isEmpty(errors) && isMobile) {
errorRef.current.scrollIntoView({ behavior: "smooth" });
}
if (session?.user) {
handleSubmit(onFormSubmit, onFormError)();
} else {
if (isDonationMode) {
handleSubmit(onFormSubmit, onFormError)();
return;
}
session?.login(() => {
setState({ submitting: true });
onUserLoggedIn().then(afterUserLoggedIn).catch((err) => {
console.error(err);
setState({ submitting: false });
});
});
}
};
const onStripeConfirm = async () => {
setState({ stripePaying: false, paying: true });
await handleConnected();
};
const onStripeCancel = async () => {
try {
await api.post(`/api/checkout-sessions/${checkoutSession.id}/abort-stripe`);
} catch (err) {
console.error(err);
Toast.error(formatError(err));
} finally {
setState({ stripePaying: false });
}
};
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === "Enter" && !state.submitting && !state.paying && !state.stripePaying && quantityInventoryStatus && canPay) {
onAction();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, canPay]);
const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || "");
const FastCheckoutConfirmDialog = state.fastCheckoutInfo && /* @__PURE__ */ jsx(
ConfirmDialog,
{
onConfirm: handleFastCheckoutConfirm,
onCancel: handleFastCheckoutCancel,
title: state.fastCheckoutInfo.sourceType === "credit" ? t("payment.checkout.fastPay.credit.title") : t("payment.checkout.fastPay.title"),
message: state.fastCheckoutInfo.sourceType === "credit" ? /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.credit.meteringSubscriptionMessage", {
available: `${formatAmount(state.fastCheckoutInfo?.balance || "0", paymentCurrency?.decimal || 18)} ${paymentCurrency?.symbol}`
}) }) : /* @__PURE__ */ jsxs(Stack, { children: [
/* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.autoPaymentReason") }),
/* @__PURE__ */ jsx(Divider, { sx: { mt: 1.5, mb: 1.5 } }),
/* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between"
},
children: [
/* @__PURE__ */ jsx(
Typography,
{
sx: {
color: "text.primary",
whiteSpace: "nowrap"
},
children: t("payment.checkout.fastPay.payer")
}
),
/* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 0.5 }, children: [
/* @__PURE__ */ jsx(DID, { did: state.fastCheckoutInfo.payer || "", compact: true, responsive: false }),
balanceLink && /* @__PURE__ */ jsx(Tooltip, { title: t("payment.checkout.fastPay.balanceLink"), placement: "top", children: /* @__PURE__ */ jsx(
OpenInNew,
{
sx: {
color: "text.lighter",
fontSize: "0.85rem",
cursor: "pointer",
"&:hover": { color: "text.primary" }
},
onClick: () => {
window.open(balanceLink, "_blank");
}
}
) })
] })
]
}
),
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between"
},
children: [
/* @__PURE__ */ jsx(
Typography,
{
sx: {
color: "text.primary"
},
children: t("payment.checkout.fastPay.amount")
}
),
/* @__PURE__ */ jsxs(Typography, { children: [
formatAmount(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18),
" ",
paymentCurrency?.symbol
] })
]
}
)
] })
] }),
loading: state.fastCheckoutInfo.loading,
color: "primary"
}
);
const CreditInsufficientDialog = state.creditInsufficientInfo && /* @__PURE__ */ jsx(
ConfirmDialog,
{
onConfirm: handleCreditInsufficientClose,
onCancel: handleCreditInsufficientClose,
title: t("payment.checkout.fastPay.credit.insufficientTitle"),
message: /* @__PURE__ */ jsx(Typography, { children: t("payment.checkout.fastPay.credit.insufficientMessage") }),
confirm: t("common.confirm")
}
);
const PriceUpdatedDialog = priceUpdateInfo.open && /* @__PURE__ */ jsx(
ConfirmDialog,
{
onConfirm: handlePriceUpdateConfirm,
onCancel: handlePriceUpdateCancel,
title: t("payment.checkout.quote.priceUpdatedTitle"),
message: /* @__PURE__ */ jsxs(Stack, { spacing: 1, children: [
/* @__PURE__ */ jsx(Typography, { children: t(
priceUpdateInfo.reason === "rateChanged" ? "payment.checkout.quote.priceUpdatedDescriptionRate" : "payment.checkout.quote.priceUpdatedDescriptionRecalc"
) }),
priceUpdateInfo.hasQuotes && /* @__PURE__ */ jsxs(Stack, { spacing: 0.25, children: [
/* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary", fontSize: "0.7875rem" }, children: t("payment.checkout.quote.priceUpdatedNewTotalLabel") }),
/* @__PURE__ */ jsx(Typography, { sx: { fontWeight: 600 }, children: priceUpdateInfo.total }),
priceUpdateInfo.usd && /* @__PURE__ */ jsxs(Typography, { sx: { color: "text.secondary" }, children: [
"\u2248 ",
priceUpdateInfo.usd,
" ",
priceUpdateInfo.baseCurrency
] }),
priceUpdateInfo.oldTotal && /* @__PURE__ */ jsx(Typography, { sx: { color: "text.secondary", fontSize: "0.75rem" }, children: t("payment.checkout.quote.priceUpdatedOldTotal", { total: priceUpdateInfo.oldTotal }) })
] })
] }),
confirm: t("payment.checkout.quote.priceUpdatedConfirm"),
cancel: t("common.cancel"),
color: "primary"
}
);
const getRedirectUrl = () => {
if (searchParams.redirect) {
return decodeURIComponent(searchParams.redirect);
}
if (checkoutSession.success_url) {
return checkoutSession.success_url;
}
if (paymentLink?.after_completion?.redirect?.url) {
return paymentLink.after_completion.redirect.url;
}
return void 0;
};
if (onlyShowBtn) {
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(Box, { className: "cko-payment-submit-btn", children: /* @__PURE__ */ jsxs(
Button,
{
variant: "contained",
color: "primary",
size: "large",
className: "cko-submit-button",
onClick: () => {
if (state.submitting || state.paying || !canPay) {
return;
}
onAction();
},
fullWidth: true,
disabled: state.stripePaying || !quantityInventoryStatus || !canPay,
children: [
(state.submitting || state.paying) && /* @__PURE__ */ jsx(CircularProgress, { size: 16, sx: { mr: 0.5, color: "primary.contrastText" } }),
state.submitting || state.paying ? t("payment.checkout.processing") : buttonText
]
}
) }),
state.customerLimited && /* @__PURE__ */ jsx(
OverdueInvoicePayment,
{
customerId: customer?.id || session?.user?.did,
onPaid: () => {
setState({ customerLimited: false });
onAction();
},
alertMessage: t("payment.customer.pastDue.alert.customMessage"),
detailLinkOptions: {
enabled: true,
onClick: () => {
setState({ customerLimited: false });
window.open(
joinURL(
getPrefix(),
`/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`
),
"_self"
);
}
},
dialogProps: {
open: state.customerLimited,
onClose: () => setState({ customerLimited: false }),
title: t("payment.customer.pastDue.alert.title")
}
}
),
state.serviceSuspended && /* @__PURE__ */ jsx(ServiceSuspendedDialog, { open: true, onClose: () => setState({ serviceSuspended: false }) }),
FastCheckoutConfirmDialog,
CreditInsufficientDialog,
PriceUpdatedDialog,
state.priceChangeConfirm?.open && /* @__PURE__ */ jsx(
PriceChangeConfirm,
{
open: true,
changePercent: state.priceChangeConfirm.changePercent,
onConfirm: handlePriceChangeConfirm,
onCancel: handlePriceChangeCancel,
loading: state.submitting
}
)
] });
}
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(Stack, { className: "cko-payment-contact", children: [
/* @__PURE__ */ jsx(
Typography,
{
title: t("payment.checkout.paymentDetails"),
sx: {
color: "text.primary",
fontSize: {
xs: "18px",
md: "24px"
},
fontWeight: "700",
lineHeight: "32px",
mb: 2.5
},
children: t("payment.checkout.paymentDetails")
}
),
/* @__PURE__ */ jsx(Fade, { in: true, children: /* @__PURE__ */ jsxs(
Stack,
{
direction: "column",
className: "cko-payment-methods",
sx: {
alignItems: "flex-start"
},
children: [
/* @__PURE__ */ jsx(Stack, { direction: "row", sx: { width: "100%" }, children: /* @__PURE__ */ jsx(
Controller,
{
name: "payment_currency",
control,
render: ({ field }) => /* @__PURE__ */ jsx(
CurrencySelector,
{
value: field.value,
currencies,
onChange: async (id, methodId) => {
const oldCurrencyId = field.value;
field.onChange(id);
setValue("payment_method", methodId);
saveCurrencyPreference(id, session?.user?.did);
if (oldCurrencyId && oldCurrencyId !== id) {
try {
const { data } = await api.put(
`/api/checkout-sessions/${checkoutSession.id}/switch-currency`,
{
currency_id: id,
payment_method_id: methodId
}
);
if (data.currency_changed && onQuoteUpdated) {
onQuoteUpdated({ checkoutSession: data, quotes: data.quotes });
}
} catch (err) {
console.error("Failed to switch currency:", err);
}
}
}
}
)
}
) }),
state.stripePaying && state.stripeContext && /* @__PURE__ */ jsx(
StripeCheckout,
{
clientSecret: state.stripeContext.client_secret,
intentType: state.stripeContext.intent_type,
publicKey: method.settings.stripe?.publishable_key,
customer: state.customer,
mode: checkoutSession.mode,
onConfirm: onStripeConfirm,
onCancel: onStripeCancel,
returnUrl: getRedirectUrl()
}
)
]
}
) }),
!showForm && session?.user && /* @__PURE__ */ jsxs(
Stack,
{
spacing: 1.25,
sx: {
mt: 2,
p: 2,
pt: 1,
backgroundColor: "background.paper",
borderRadius: 1,
border: "1px solid",
borderColor: "divider"
},
children: [
/* @__PURE__ */ jsxs(Stack, { direction: "row", justifyContent: "space-between", alignItems: "center", sx: { mb: 0.25 }, children: [
/* @__PURE__ */ jsx(Typography, { variant: "subtitle2", sx: { color: "text.primary", fontSize: "0.875rem" }, children: t("payment.checkout.customerInfo") }),
/* @__PURE__ */ jsx(
Button,
{
size: "small",
variant: "text",
onClick: () => setState({ showEditForm: true }),
sx: { minWidth: 0 },
children: t("common.edit")
}
)
] }),
/* @__PURE__ */ jsxs(Stack, { spacing: 0.5, children: [
/* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { color: "text.primary", fontWeight: 600, fontSize: "0.9375rem" }, children: customerName }),
/* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { color: "text.secondary", fontSize: "0.8125rem" }, children: customerEmail }),
checkoutSession.phone_number_collection?.enabled && customerPhone && /* @__PURE__ */ jsx(Typography, { variant: "body2", sx: { color: "text.secondary", fontSize: "0.8125rem" }, children: customerPhone }),
billingAddress && /* @__PURE__ */ jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.75, children: [
billingAddress.country && /* @__PURE__ */ jsx(FlagEmoji, { iso2: billingAddress.country.toLowerCase(), style: { width: 18, height: 14 } }),
/* @__PURE__ */ jsxs(Typography, { variant: "body2", sx: { color: "text.secondary", fontSize: "0.8125rem" }, children: [
checkoutSession.billing_address_collection === "required" ? [billingAddress.line1, billingAddress.city, billingAddress.state].filter(Boolean).join(", ") : billingAddress.state || "",
billingAddress.postal_code && ` [ ${t("payment.checkout.billing.postal_code")}: ${billingAddress.postal_code} ]`
] })
] })
] })
]
}
),
showForm && /* @__PURE__ */ jsxs(