UNPKG

@blocklet/payment-react

Version:

Reusable react components for payment kit v2

735 lines (734 loc) 27.4 kB
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 } ) }) }) }) ] }) ] } ) }) }) } ); }