@blocklet/payment-react
Version:
Reusable react components for payment kit v2
735 lines (734 loc) • 27.4 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { useMemo, useState } from "react";
import {
Box,
Typography,
Stack,
Button,
FormControlLabel,
Alert,
CircularProgress,
InputAdornment,
MenuItem,
Avatar,
Select,
TextField
} from "@mui/material";
import { AccountBalanceWalletOutlined, AddOutlined, CreditCard, SwapHoriz } from "@mui/icons-material";
import { useForm, FormProvider, Controller } from "react-hook-form";
import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
import Toast from "@arcblock/ux/lib/Toast";
import Dialog from "@arcblock/ux/lib/Dialog";
import { useRequest, useSetState } from "ahooks";
import { useNavigate } from "react-router-dom";
import { joinURL } from "ufo";
import pWaitFor from "p-wait-for";
import DidAddress from "@arcblock/ux/lib/DID";
import Switch from "../switch-button.js";
import api from "../../libs/api.js";
import { formatError, flattenPaymentMethods, getPrefix, formatBNStr } from "../../libs/util.js";
import { usePaymentContext } from "../../contexts/payment.js";
import { createLink, handleNavigation } from "../../libs/navigation.js";
import Collapse from "../collapse.js";
import FormInput from "../input.js";
import StripeCheckout from "../../payment/form/stripe/index.js";
import AutoTopupProductCard from "./product-card.js";
import FormLabel from "../label.js";
const fetchConfig = async (customerId, currencyId) => {
const { data } = await api.get(`/api/auto-recharge-configs/customer/${customerId}`, {
params: { currency_id: currencyId }
});
return data;
};
const fetchCurrencyBalance = async (currencyId, payerAddress) => {
const { data } = await api.get("/api/customers/payer-token", {
params: { currencyId, payerAddress }
});
return data;
};
const DEFAULT_VALUES = {
enabled: false,
threshold: "100",
quantity: 1,
payment_method_id: "",
recharge_currency_id: "",
price_id: "",
daily_max_amount: 0,
daily_max_attempts: 0
};
export const waitForAutoRechargeComplete = async (configId) => {
let result;
await pWaitFor(
async () => {
const { data } = await api.get(`/api/auto-recharge-configs/retrieve/${configId}`);
result = data;
return !!result.payment_settings?.payment_method_options?.[result.paymentMethod.type]?.payer;
},
{ interval: 2e3, timeout: 3 * 60 * 1e3 }
);
return result;
};
function PaymentMethodDisplay({
config,
onChangePaymentMethod,
paymentMethod,
currency
}) {
const { t } = useLocaleContext();
const [changePaymentMethod, setChangePaymentMethod] = useState(false);
const navigate = useNavigate();
const paymentInfo = config?.payment_settings?.payment_method_options?.[paymentMethod.type || ""];
const { data: balanceInfo, loading: balanceLoading } = useRequest(
async () => {
if (paymentMethod.type === "stripe") {
return null;
}
const result = await fetchCurrencyBalance(currency.id, paymentInfo?.payer);
return result;
},
{
refreshDeps: [currency.id, paymentInfo?.payer],
ready: !!currency.id && !!paymentInfo?.payer
}
);
const handleChangeToggle = () => {
const newChange = !changePaymentMethod;
setChangePaymentMethod(newChange);
onChangePaymentMethod(newChange);
};
if (!paymentInfo) {
return null;
}
const handleRecharge = (e) => {
const url = joinURL(getPrefix(), `/customer/recharge/${currency.id}?rechargeAddress=${paymentInfo?.payer}`);
const link = createLink(url, true);
handleNavigation(e, link, navigate);
};
const renderPaymentMethodInfo = () => {
if (paymentMethod.type === "stripe") {
return /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
alignItems: "center"
},
children: [
/* @__PURE__ */ jsx(CreditCard, { fontSize: "small", color: "primary" }),
/* @__PURE__ */ jsxs(Typography, { variant: "body2", children: [
"**** **** **** ",
paymentInfo?.card_last4 || "****"
] }),
/* @__PURE__ */ jsx(
Typography,
{
variant: "body2",
sx: {
color: "text.secondary",
textTransform: "uppercase"
},
children: paymentInfo?.card_brand || "CARD"
}
),
paymentInfo?.exp_time && /* @__PURE__ */ jsx(
Typography,
{
variant: "body2",
sx: {
color: "text.secondary",
borderLeft: "1px solid",
borderColor: "divider",
pl: 1
},
children: paymentInfo?.exp_time
}
)
]
}
);
}
return /* @__PURE__ */ jsxs(
Stack,
{
spacing: 1,
sx: {
borderRadius: 1,
backgroundColor: (theme) => theme.palette.mode === "dark" ? "grey.100" : "grey.50",
p: 2
},
children: [
/* @__PURE__ */ jsx(DidAddress, { did: paymentInfo?.payer, responsive: false, compact: true, copyable: false }),
(balanceInfo || balanceLoading) && /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
spacing: 1,
sx: {
alignItems: "center"
},
children: [
balanceLoading ? /* @__PURE__ */ jsx(CircularProgress, { size: 14, sx: { mr: 0.5 } }) : /* @__PURE__ */ jsxs(Box, { sx: { display: "flex", alignItems: "center", gap: 1 }, children: [
/* @__PURE__ */ jsx(AccountBalanceWalletOutlined, { fontSize: "small", sx: { color: "text.lighter" } }),
/* @__PURE__ */ jsxs(Typography, { variant: "body2", sx: { color: "text.primary" }, children: [
/* @__PURE__ */ jsxs("strong", { children: [
formatBNStr(balanceInfo?.token || "0", currency?.decimal),
" "
] }),
currency?.symbol || ""
] })
] }),
/* @__PURE__ */ jsxs(
Button,
{
size: "small",
variant: "text",
onClick: handleRecharge,
sx: { fontSize: "smaller", color: "primary.main" },
children: [
/* @__PURE__ */ jsx(AddOutlined, { fontSize: "small" }),
t("payment.autoTopup.addFunds")
]
}
)
]
}
)
]
}
);
};
return /* @__PURE__ */ jsx(
Box,
{
sx: {
p: 2,
border: "1px solid",
borderColor: changePaymentMethod ? "primary.main" : "divider",
borderRadius: 1,
bgcolor: changePaymentMethod ? "primary.50" : "background.paper"
},
children: /* @__PURE__ */ jsxs(Stack, { spacing: 2, children: [
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "flex-start", sm: "center" },
justifyContent: { xs: "flex-start", sm: "space-between" }
},
children: [
/* @__PURE__ */ jsx(Typography, { variant: "subtitle2", sx: { display: "flex", alignItems: "center", gap: 1, whiteSpace: "nowrap" }, children: t("payment.autoTopup.currentPaymentMethod") }),
/* @__PURE__ */ jsx(
Button,
{
size: "small",
startIcon: /* @__PURE__ */ jsx(SwapHoriz, {}),
onClick: handleChangeToggle,
variant: "text",
sx: {
color: "primary.main",
whiteSpace: "nowrap",
alignSelf: { xs: "flex-end", sm: "center" }
},
children: changePaymentMethod ? t("payment.autoTopup.keepCurrent") : t("payment.autoTopup.changePaymentMethod")
}
)
]
}
),
changePaymentMethod ? /* @__PURE__ */ jsx(
Typography,
{
variant: "body2",
sx: {
color: "text.secondary"
},
children: t("payment.autoTopup.changePaymentMethodTip")
}
) : renderPaymentMethodInfo()
] })
}
);
}
export default function AutoTopup({
open,
onClose,
currencyId,
onSuccess = () => {
},
onError = () => {
},
defaultEnabled = void 0
}) {
const { t, locale } = useLocaleContext();
const { session, connect, settings } = usePaymentContext();
const [changePaymentMethod, setChangePaymentMethod] = useState(false);
const [state, setState] = useSetState({
loading: false,
submitting: false,
authorizationRequired: false,
stripeContext: {
client_secret: "",
intent_type: "",
status: "",
public_key: "",
customer: {}
}
});
const currencies = flattenPaymentMethods(settings.paymentMethods);
const methods = useForm({
defaultValues: {
enabled: defaultEnabled || DEFAULT_VALUES.enabled,
threshold: DEFAULT_VALUES.threshold,
quantity: DEFAULT_VALUES.quantity,
payment_method_id: DEFAULT_VALUES.payment_method_id,
recharge_currency_id: DEFAULT_VALUES.recharge_currency_id,
price_id: DEFAULT_VALUES.price_id,
daily_limits: {
max_attempts: DEFAULT_VALUES.daily_max_attempts,
max_amount: DEFAULT_VALUES.daily_max_amount
}
}
});
const { handleSubmit, setValue, watch } = methods;
const enabled = watch("enabled");
const quantity = watch("quantity");
const rechargeCurrencyId = watch("recharge_currency_id");
const handleClose = () => {
setState({
loading: false,
submitting: false,
authorizationRequired: false
});
onClose();
};
const { data: config } = useRequest(() => fetchConfig(session?.user?.did, currencyId), {
refreshDeps: [session?.user?.did, currencyId],
ready: !!session?.user?.did && !!currencyId,
onError: (error) => {
Toast.error(formatError(error));
},
onSuccess: (data) => {
setValue("enabled", defaultEnabled || data.enabled);
setValue("threshold", data.threshold);
setValue("quantity", data.quantity);
setValue("payment_method_id", data.payment_method_id);
setValue("recharge_currency_id", data.recharge_currency_id || data.price?.currency_id);
setValue("price_id", data.price_id);
setValue("daily_limits", {
max_amount: data.daily_limits?.max_amount || 0,
max_attempts: data.daily_limits?.max_attempts || 0
});
}
});
const filterCurrencies = useMemo(() => {
return currencies.filter((c) => config?.price?.currency_options?.find((o) => o.currency_id === c.id));
}, [currencies, config]);
const handleConnected = async () => {
try {
const result = await waitForAutoRechargeComplete(config?.id);
if (result) {
setState({ submitting: false, authorizationRequired: false });
onSuccess?.(config);
handleClose();
Toast.success(t("payment.autoTopup.saveSuccess"));
}
} catch (err) {
Toast.error(formatError(err));
} finally {
setState({ submitting: false, authorizationRequired: false });
}
};
const handleDisable = async () => {
try {
const submitData = {
...config,
enabled: false
};
if (!config?.enabled) {
return;
}
const { data } = await api.post("/api/auto-recharge-configs/submit", submitData);
onSuccess?.(data);
Toast.success(t("payment.autoTopup.disableSuccess"));
} catch (error) {
Toast.error(formatError(error));
onError?.(error);
}
};
const handleEnableChange = async (checked) => {
setValue("enabled", checked);
if (!checked) {
await handleDisable();
}
};
const handleAuthorizationRequired = (authData) => {
setState({ authorizationRequired: true });
if (authData.stripeContext) {
setState({
stripeContext: {
client_secret: authData.stripeContext.client_secret,
intent_type: authData.stripeContext.intent_type,
status: authData.stripeContext.status,
public_key: authData.paymentMethod.settings.stripe.publishable_key,
customer: authData.customer
}
});
} else if (authData.delegation) {
handleDidConnect();
}
};
const handleDidConnect = () => {
try {
setState({ submitting: true });
connect.open({
containerEl: void 0,
saveConnect: false,
locale,
action: "auto-recharge-auth",
prefix: joinURL(getPrefix(), "/api/did"),
extraParams: {
autoRechargeConfigId: config?.id
},
messages: {
scan: t("payment.autoTopup.authTip"),
title: t("payment.autoTopup.authTitle"),
confirm: t("common.connect.confirm")
},
onSuccess: async () => {
connect.close();
await handleConnected();
},
onClose: () => {
connect.close();
setState({ submitting: false, authorizationRequired: false });
},
onError: (err) => {
setState({ submitting: false, authorizationRequired: false });
Toast.error(formatError(err));
}
});
} catch (error) {
setState({ submitting: false, authorizationRequired: false });
Toast.error(formatError(error));
}
};
const handleFormSubmit = async (formData) => {
setState({ submitting: true });
try {
const submitData = {
customer_id: session?.user?.did,
enabled: formData.enabled,
threshold: formData.threshold,
currency_id: currencyId,
recharge_currency_id: formData.recharge_currency_id,
price_id: formData.price_id,
quantity: formData.quantity,
daily_limits: {
max_attempts: formData.daily_limits.max_attempts || 0,
max_amount: formData.daily_limits.max_amount || "0"
},
change_payment_method: changePaymentMethod
};
const { data } = await api.post("/api/auto-recharge-configs/submit", submitData);
if (data.balanceResult && !data.balanceResult.sufficient) {
await handleAuthorizationRequired({
...data.balanceResult,
paymentMethod: data.paymentMethod,
customer: data.customer
});
return;
}
setState({
submitting: false,
authorizationRequired: false
});
onSuccess?.(data);
handleClose();
Toast.success(t("payment.autoTopup.saveSuccess"));
} catch (error) {
setState({ submitting: false, authorizationRequired: false });
Toast.error(formatError(error));
onError?.(error);
}
};
const onSubmit = (formData) => {
handleFormSubmit(formData);
};
const rechargeCurrency = filterCurrencies.find((c) => c.id === rechargeCurrencyId);
const selectedMethod = settings.paymentMethods.find((method) => {
return method.payment_currencies.find((c) => c.id === rechargeCurrencyId);
});
const showStripeForm = state.authorizationRequired && selectedMethod?.type === "stripe";
const onStripeConfirm = async () => {
await handleConnected();
};
const onStripeCancel = () => {
setState({ submitting: false, authorizationRequired: false });
};
return /* @__PURE__ */ jsx(
Dialog,
{
open,
onClose: handleClose,
maxWidth: "sm",
fullWidth: true,
className: "base-dialog",
title: t("payment.autoTopup.title"),
actions: enabled ? /* @__PURE__ */ jsxs(Stack, { direction: "row", spacing: 2, children: [
/* @__PURE__ */ jsx(Button, { variant: "outlined", onClick: handleClose, disabled: state.submitting, children: t("common.cancel") }),
/* @__PURE__ */ jsxs(
Button,
{
variant: "contained",
onClick: () => handleSubmit(onSubmit)(),
disabled: state.loading || state.authorizationRequired || state.submitting,
children: [
state.submitting && /* @__PURE__ */ jsx(CircularProgress, { size: 20, sx: { mr: 1 } }),
t("payment.autoTopup.saveConfiguration")
]
}
)
] }) : null,
children: /* @__PURE__ */ jsx(FormProvider, { ...methods, children: /* @__PURE__ */ jsx(Box, { component: "form", onSubmit: handleSubmit(onSubmit), children: /* @__PURE__ */ jsxs(
Stack,
{
sx: {
gap: 2
},
children: [
/* @__PURE__ */ jsx(Alert, { severity: "info", children: t("payment.autoTopup.tip") }),
/* @__PURE__ */ jsxs(
Stack,
{
spacing: 2,
direction: "row",
sx: {
alignItems: "center"
},
children: [
/* @__PURE__ */ jsx(Typography, { variant: "subtitle1", children: t("payment.autoTopup.enableLabel") }),
/* @__PURE__ */ jsx(
FormControlLabel,
{
control: /* @__PURE__ */ jsx(Switch, { checked: enabled, onChange: (e) => handleEnableChange(e.target.checked) }),
label: ""
}
)
]
}
),
enabled && /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(Box, { sx: { pt: 1 }, children: /* @__PURE__ */ jsxs(Stack, { spacing: 2.5, children: [
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
gap: 2,
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "flex-start", sm: "center" },
justifyContent: { xs: "flex-start", sm: "space-between" },
".MuiTextField-root": {
width: {
xs: "100%",
sm: "auto"
}
}
},
children: [
/* @__PURE__ */ jsx(FormLabel, { boxSx: { width: "fit-content", whiteSpace: "nowrap", marginBottom: 0 }, children: t("payment.autoTopup.triggerThreshold") }),
/* @__PURE__ */ jsx(
Controller,
{
name: "threshold",
control: methods.control,
rules: {
required: t("payment.checkout.required"),
min: { value: 0, message: t("payment.autoTopup.thresholdMinError") }
},
render: ({ field }) => /* @__PURE__ */ jsx(
TextField,
{
...field,
type: "number",
placeholder: t("payment.autoTopup.thresholdPlaceholder"),
sx: {
input: {
minWidth: 80
}
},
slotProps: {
input: {
endAdornment: /* @__PURE__ */ jsx(InputAdornment, { position: "end", children: config?.currency?.name })
},
htmlInput: {
min: 0,
step: 0.01
}
}
}
)
}
)
]
}
),
/* @__PURE__ */ jsxs(
Stack,
{
sx: {
gap: 2,
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "flex-start", sm: "center" },
justifyContent: { xs: "flex-start", sm: "space-between" }
},
children: [
/* @__PURE__ */ jsx(FormLabel, { boxSx: { width: "fit-content", whiteSpace: "nowrap", marginBottom: 0 }, children: t("payment.autoTopup.purchaseBelow") }),
/* @__PURE__ */ jsx(
Controller,
{
name: "recharge_currency_id",
control: methods.control,
render: ({ field }) => /* @__PURE__ */ jsx(
Select,
{
...field,
value: field.value,
onChange: (e) => field.onChange(e.target.value),
size: "small",
children: filterCurrencies.map((x) => /* @__PURE__ */ jsx(MenuItem, { value: x?.id, children: /* @__PURE__ */ jsxs(
Stack,
{
direction: "row",
sx: {
alignItems: "center",
gap: 1
},
children: [
/* @__PURE__ */ jsx(Avatar, { src: x?.logo, sx: { width: 20, height: 20 }, alt: x?.symbol }),
/* @__PURE__ */ jsxs(
Typography,
{
sx: {
color: "text.secondary"
},
children: [
x?.symbol,
" (",
x?.method?.name,
")"
]
}
)
]
}
) }, x?.id))
}
)
}
)
]
}
),
config?.price?.product && /* @__PURE__ */ jsx(
AutoTopupProductCard,
{
product: config.price.product,
price: config.price,
creditCurrency: config.currency,
currency: filterCurrencies.find((c) => c.id === rechargeCurrencyId) || filterCurrencies[0],
quantity,
onQuantityChange: (newQuantity) => setValue("quantity", newQuantity),
maxQuantity: 9999,
minQuantity: 1
}
),
config && rechargeCurrency && /* @__PURE__ */ jsx(
PaymentMethodDisplay,
{
config,
onChangePaymentMethod: setChangePaymentMethod,
currency: rechargeCurrency,
paymentMethod: selectedMethod
}
),
/* @__PURE__ */ jsx(Collapse, { trigger: t("payment.autoTopup.advanced"), children: /* @__PURE__ */ jsxs(
Stack,
{
sx: {
gap: 2,
pl: 2,
pr: 1
},
children: [
/* @__PURE__ */ jsx(
FormInput,
{
name: "daily_limits.max_amount",
label: t("payment.autoTopup.dailyLimits.maxAmount"),
type: "number",
placeholder: t("payment.autoTopup.dailyLimits.maxAmountPlaceholder"),
tooltip: t("payment.autoTopup.dailyLimits.maxAmountDescription"),
inputProps: {
min: 0,
step: 0.01
},
slotProps: {
input: {
endAdornment: /* @__PURE__ */ jsx(InputAdornment, { position: "end", children: filterCurrencies.find((c) => c.id === rechargeCurrencyId)?.symbol || "" })
}
},
sx: {
maxWidth: {
xs: "100%",
sm: "220px"
}
},
layout: "horizontal"
}
),
/* @__PURE__ */ jsx(
FormInput,
{
name: "daily_limits.max_attempts",
label: t("payment.autoTopup.dailyLimits.maxAttempts"),
type: "number",
placeholder: t("payment.autoTopup.dailyLimits.maxAttemptsPlaceholder"),
tooltip: t("payment.autoTopup.dailyLimits.maxAttemptsDescription"),
inputProps: {
min: 0,
step: 1
},
sx: {
maxWidth: {
xs: "100%",
sm: "220px"
}
},
layout: "horizontal"
}
)
]
}
) })
] }) }),
/* @__PURE__ */ jsx(Stack, { spacing: 2, children: showStripeForm && /* @__PURE__ */ jsx(Box, { sx: { mt: 2 }, children: state.stripeContext && /* @__PURE__ */ jsx(Box, { sx: { mt: 2 }, children: /* @__PURE__ */ jsx(
StripeCheckout,
{
clientSecret: state.stripeContext.client_secret,
intentType: state.stripeContext.intent_type,
publicKey: state.stripeContext.public_key,
customer: state.stripeContext.customer,
mode: "setup",
onConfirm: onStripeConfirm,
onCancel: onStripeCancel
}
) }) }) })
] })
]
}
) }) })
}
);
}