/* eslint-disable @typescript-eslint/indent */ /* eslint-disable no-nested-ternary */ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import Toast from '@arcblock/ux/lib/Toast'; // eslint-disable-next-line import/no-extraneous-dependencies import Header from '@blocklet/ui-react/lib/Header'; import type { TCustomer, TLineItemExpanded, TPaymentCurrency, TPaymentMethodExpanded } from '@blocklet/payment-types'; import { ArrowBackOutlined, ArrowForwardOutlined, HelpOutlineOutlined } from '@mui/icons-material'; import { Box, Button, Divider, Stack, Typography, type BoxProps } from '@mui/material'; import { styled } from '@mui/system'; import { fromTokenToUnit } from '@ocap/util'; import { useMount, useRequest, useSetState } from 'ahooks'; import { useEffect, useState } from 'react'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import type { LiteralUnion } from 'type-fest'; import { joinURL } from 'ufo'; import { usePaymentContext } from '../contexts/payment'; import api from '../libs/api'; import { findCurrency, formatError, getPrefix, getQueryParams, getStatementDescriptor, isMobileSafari, isValidCountry, } from '../libs/util'; import type { CheckoutCallbacks, CheckoutContext, CheckoutFormData } from '../types'; import PaymentError from './error'; import PaymentForm from './form'; // import PaymentHeader from './header'; import PaymentSuccess from './success'; import { useMobile } from '../hooks/mobile'; import ProductDonation from './product-donation'; import ConfirmDialog from '../components/confirm'; import PaymentBeneficiaries, { TBeneficiary } from '../components/payment-beneficiaries'; import DonationSkeleton from './skeleton/donation'; import { formatPhone } from '../libs/phone-validator'; const getBenefits = async (id: string, url?: string) => { const { data } = await api.get(`/api/payment-links/${id}/benefits?${url ? `url=${url}` : ''}`); return data; }; // eslint-disable-next-line react/no-unused-prop-types type Props = CheckoutContext & CheckoutCallbacks & { completed?: boolean; error?: any; showCheckoutSummary?: boolean; formRender?: Record; id: string; }; function PaymentInner({ checkoutSession, paymentMethods, paymentLink, paymentIntent, customer, completed = false, mode, onPaid, onError, onChange, action, formRender = {}, benefits, }: MainProps) { const { t } = useLocaleContext(); const { settings, session } = usePaymentContext(); const { isMobile } = useMobile(); const [state, setState] = useSetState({ checkoutSession, submitting: false, paying: false, paid: false, paymentIntent, stripeContext: undefined, customer, customerLimited: false, stripePaying: false, }); const query = getQueryParams(window.location.href); const defaultCurrencyId = query.currencyId || state.checkoutSession.currency_id || state.checkoutSession.line_items[0]?.price.currency_id; const defaultMethodId = paymentMethods.find((m) => m.payment_currencies.some((c) => c.id === defaultCurrencyId))?.id; const items = state.checkoutSession.line_items; const donationSettings = paymentLink?.donation_settings; const [benefitsState, setBenefitsState] = useSetState({ open: false, amount: '0', }); 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 (!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); }; }, []); useEffect(() => { if (!methods || query.currencyId) { return; } if (state.checkoutSession.currency_id !== defaultCurrencyId) { methods.setValue('payment_currency', state.checkoutSession.currency_id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.checkoutSession, defaultCurrencyId, query.currencyId]); const currencyId = useWatch({ control: methods.control, name: 'payment_currency', defaultValue: defaultCurrencyId }); const currency = (findCurrency(paymentMethods as TPaymentMethodExpanded[], currencyId as string) as TPaymentCurrency) || settings.baseCurrency; useEffect(() => { if (onChange) { onChange(methods.getValues()); } }, [currencyId]); // eslint-disable-line const onChangeAmount = async ({ priceId, amount }: { priceId: string; amount: string }) => { const amountStr = fromTokenToUnit(amount, currency.decimal).toString(); setBenefitsState({ amount: amountStr }); try { if (!amountStr || Number(amountStr) === 0) { return; } const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/amount`, { priceId, amount: amountStr, }); setState({ checkoutSession: data }); } catch (err) { console.error(err); Toast.error(formatError(err)); } }; const handlePaid = (result: any) => { setState({ checkoutSession: result.checkoutSession }); onPaid(result); }; const renderBenefits = () => { if (!benefits) { return null; } if (benefits.length === 1) { return t('payment.checkout.donation.benefits.one', { name: benefits[0].name, }); } return t('payment.checkout.donation.benefits.multiple', { count: benefits.length, }); }; return ( {completed ? ( ) : ( <> {benefitsState.open && ( )} {t('payment.checkout.donation.tipAmount')} {!isMobile && donationSettings?.amount?.presets && donationSettings.amount.presets.length > 0 && ( Tab {t('payment.checkout.donation.tabHint')} )} {items.map((x: TLineItemExpanded) => ( ))} {benefits && benefits.length > 0 && (benefitsState.open ? ( setBenefitsState({ open: false })} sx={{ cursor: 'pointer', color: 'text.secondary', '&:hover': { color: 'text.primary', }, display: 'flex', alignItems: 'center', }}> {t('common.back')} ) : ( setBenefitsState({ open: true })} sx={{ display: 'flex', gap: 0.5, alignItems: 'center', color: 'text.secondary', cursor: 'pointer', '& .benefits-arrow': { opacity: 0, transform: 'translateX(-4px)', transition: 'all 0.2s', }, '&:hover': { color: 'text.primary', '& .benefits-arrow': { opacity: 1, transform: 'translateX(0)', }, }, }}> {renderBenefits()} ))} {benefitsState.open ? null : ( {formRender?.cancel} )} )} {state.customerLimited && ( window.open( joinURL(getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`), '_self' ) } onCancel={() => setState({ customerLimited: false })} confirm={t('payment.customer.pastDue.alert.confirm')} title={t('payment.customer.pastDue.alert.title')} message={t('payment.customer.pastDue.alert.description')} color="primary" /> )} ); } export default function DonationForm({ checkoutSession, paymentMethods, paymentIntent, paymentLink, customer, completed = false, error = null, mode, onPaid, onError, onChange, goBack, action, showCheckoutSummary = true, formRender = {}, id, }: Props) { const { t } = useLocaleContext(); const { refresh, livemode, setLivemode } = usePaymentContext(); const { isMobile } = useMobile(); const [delay, setDelay] = useState(false); const isMobileSafariEnv = isMobileSafari(); const paymentLinkId = id.startsWith('plink_') ? id : undefined; const { data: benefits, loading: benefitLoading } = useRequest( () => { if (paymentLinkId) { return getBenefits(paymentLinkId); } return Promise.resolve([]); }, { refreshDeps: [paymentLinkId || paymentLink?.id], ready: !!paymentLinkId || !!paymentLink?.id, } ); useMount(() => { setTimeout(() => { // 骨架屏 delay setDelay(true); }, 500); // eslint-di }); useEffect(() => { if (checkoutSession) { if (livemode !== checkoutSession.livemode) { setLivemode(checkoutSession.livemode); } } }, [checkoutSession, livemode, setLivemode, refresh]); const renderContent = () => { const footer = ( <> ); if (error) { return ( <> {footer} ); } if (!checkoutSession || !delay || !paymentLink || benefitLoading) { return ; } if (checkoutSession?.expires_at <= Math.round(Date.now() / 1000)) { return ( <> {footer} ); } if (!checkoutSession.line_items.length) { return ( <> {footer} ); } return ( ); }; return ( {mode === 'standalone' ? (
) : null} {goBack && ( )} {renderContent()} ); } type MainProps = CheckoutContext & CheckoutCallbacks & { completed?: boolean; showCheckoutSummary?: boolean; formRender?: Record; benefits: TBeneficiary[]; }; type RootProps = { mode: LiteralUnion<'standalone' | 'inline' | 'popup', string> } & BoxProps; export const Root: React.FC = 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: column; justify-content: center; position: relative; flex: 1; } .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-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; } } @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) { padding-top: 0; overflow: auto; .cko-container { flex-direction: column; justify-content: flex-start; gap: 0; overflow: visible; min-width: 200px; } .cko-overview { width: 100%; min-height: auto; flex: none; } .cko-footer { position: relative; margin-bottom: 4px; margin-top: 0; bottom: 0; left: 0; transform: translateX(0); } } `;