import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import CreditCardIcon from '@mui/icons-material/CreditCard'; import CurrencyBitcoinIcon from '@mui/icons-material/CurrencyBitcoin'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined'; import CloseIcon from '@mui/icons-material/Close'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import { Avatar, Box, Button, CircularProgress, Collapse, Divider, Drawer, MenuItem, Select, Skeleton, Stack, ToggleButton, ToggleButtonGroup, Tooltip, Typography, } from '@mui/material'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import { useCheckoutStatus, usePaymentMethodFeature, useCustomerFormFeature, useSubmitFeature, usePricingFeature, useSessionContext, useLineItems, usePromotion, useExchangeRate, } from '@blocklet/payment-react-headless'; import { joinURL } from 'ufo'; import { usePaymentContext } from '../../../contexts/payment'; import { useMobile } from '../../../hooks/mobile'; import { getPrefix, getStatementDescriptor } from '../../../libs/util'; import OverdueInvoicePayment from '../../../components/over-due-invoice-payment'; import { tSafe, whiteTooltipSx, primaryContrastColor } from '../../utils/format'; import CustomerInfoCard from '../../components/right/customer-info-card'; import SubscriptionDisclaimer from '../../components/right/subscription-disclaimer'; import StatusFeedback from '../../components/right/status-feedback'; import PromotionInput from '../../components/left/promotion-input'; export default function PaymentPanel() { const { t, locale } = useLocaleContext(); const { session, sessionData, subscription, refresh } = useSessionContext(); const { isDonation } = useCheckoutStatus(); const paymentMethod = usePaymentMethodFeature(); const form = useCustomerFormFeature(); const submit = useSubmitFeature(); const pricing = usePricingFeature(); const { session: didSession, connect, prefix: paymentKitPrefix } = usePaymentContext(); const { inventoryOk } = useLineItems(); const promotion = usePromotion(); const rate = useExchangeRate(); const { isMobile } = useMobile(); const isAmountLoading = paymentMethod.switching || (rate.hasDynamicPricing && rate.status === 'loading'); const { currency, types, isStripe, isCrypto } = paymentMethod; const mode = session?.mode || 'payment'; const discounts = (session as any)?.discounts || []; const isLoggedIn = !!didSession?.user; const actionLabel = isDonation ? t('payment.checkout.donate') : t(`payment.checkout.${mode}`); const buttonLabel = isLoggedIn ? actionLabel : t('payment.checkout.connect', { action: actionLabel }); const [customerLimited, setCustomerLimited] = useState(false); const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false); const [promoDrawerOpen, setPromoDrawerOpen] = useState(false); // Detect CUSTOMER_LIMITED error from submit hook useEffect(() => { if (submit.status === 'failed' && (submit.context as any)?.code === 'CUSTOMER_LIMITED') { setCustomerLimited(true); } }, [submit.status, submit.context]); const canSubmit = submit.status === 'idle' && session?.status === 'open' && inventoryOk; const isProcessing = ['submitting', 'waiting_did'].includes(submit.status); // Pre-login action handler (mirrors V1 form/index.tsx onAction) const handleAction = useCallback(() => { if (!canSubmit) return; // Lock config to prevent quantity/currency/method changes during submit flow submit.lock(); if (isLoggedIn || isDonation) { // Already logged in or donation mode — submit directly submit.execute(); return; } // Not logged in — initiate DID Connect login first didSession?.login?.(() => { // After login: refresh session data + re-fetch customer info → then submit Promise.all([refresh(true), form.refetchCustomer()]) .then(() => submit.execute()) .catch((err) => { console.error('Post-login refresh failed:', err); }); }); }, [canSubmit, isLoggedIn, isDonation, didSession, refresh, form, submit]); // When user logs in from top-right (not via handleAction button), refetch customer to fill form // Mirrors V1 form/index.tsx useEffect on session?.user useEffect(() => { if (didSession?.user && !submit.status.startsWith('submitting')) { form.refetchCustomer(); } }, [didSession?.user]); // eslint-disable-line react-hooks/exhaustive-deps // Group crypto currencies by network (method) const cryptoType = types.find((tp) => tp.type === 'crypto'); const cryptoMethods = useMemo( () => paymentMethod.available.filter((m) => m.type !== 'stripe'), [paymentMethod.available] ); const networks = useMemo(() => { if (!cryptoMethods.length) return []; return cryptoMethods.map((m) => ({ id: m.id, name: m.name, logo: m.logo || '', currencies: m.payment_currencies || [], })); }, [cryptoMethods]); // Current network for dropdown const currentMethodId = paymentMethod.current?.id || ''; // Enter key submit handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && canSubmit && submit.status === 'idle') { const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); if (tag === 'textarea') return; handleAction(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [canSubmit, submit.status, handleAction]); // DID Connect const didConnectOpenedRef = useRef(false); const didConnectSucceededRef = useRef(false); // Track latest submit.status for use inside DID Connect SDK callbacks (onClose/onError), // which fire asynchronously and may run after status has already advanced past waiting_did. // Reading submit.status directly would capture a stale closure. const submitStatusRef = useRef(submit.status); useEffect(() => { submitStatusRef.current = submit.status; }, [submit.status]); useEffect(() => { if (submit.status !== 'waiting_did') { didConnectOpenedRef.current = false; didConnectSucceededRef.current = false; return; } const ctx = submit.context; if (ctx?.type !== 'did_connect' || !connect) return; if (didConnectOpenedRef.current) return; didConnectOpenedRef.current = true; // Use absolute URL for DID Connect prefix — DID Connect SDK needs full URL // for status polling. paymentKitPrefix may be relative ('/' or ''), which can // cause polling to fail. getPrefix() from util.ts returns the full origin URL. const didPrefix = `${paymentKitPrefix || window.location.origin}/api/did`.replace(/([^:])\/\//g, '$1/'); connect.open({ locale: locale as any, action: ctx.action, prefix: didPrefix, saveConnect: false, extraParams: ctx.extraParams, onSuccess: async () => { didConnectSucceededRef.current = true; connect.close(); await submit.didConnectComplete(); }, onClose: () => { connect.close(); // If submit has already advanced past waiting_did (e.g. didConnectComplete is // polling, or completion/failure resolved), the modal close is a follow-up to a // successful flow — do not reset. Without this guard, the effect re-run after // setStatus('submitting') clears didConnectSucceededRef, and a late onClose call // would incorrectly tear down the in-flight polling. if (submitStatusRef.current !== 'waiting_did') return; if (!didConnectSucceededRef.current) { submit.reset(); } }, onError: (err: any) => { console.error('DID Connect error:', err); if (submitStatusRef.current !== 'waiting_did') return; submit.reset(); }, messages: { title: t('payment.checkout.connectModal.title', { action: buttonLabel }), scan: t('payment.checkout.connectModal.scan'), confirm: t('payment.checkout.connectModal.confirm'), } as any, }); // No cleanup needed — connect modal manages its own lifecycle. }, [submit.status, submit.context, connect, locale, t, buttonLabel]); // eslint-disable-line react-hooks/exhaustive-deps // Active payment type const activeType = types.find((tp) => tp.active)?.type || 'crypto'; const hasMultipleTypes = types.length > 1; return ( {/* Main content — scrollable when overflows */} {/* PAYMENT METHOD header */} {t('payment.checkout.paymentDetails')} {/* Card / Crypto toggle */} {hasMultipleTypes && ( v && paymentMethod.setType(v)} fullWidth size="small" sx={{ mb: 2.5, bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'grey.100'), borderRadius: '10px', p: 0.5, '& .MuiToggleButton-root': { textTransform: 'none', borderRadius: '8px !important', py: 0.75, border: 'none', fontSize: 14, fontWeight: 500, gap: 0.75, color: 'text.secondary', }, '& .Mui-selected': { bgcolor: 'background.paper !important', color: 'text.primary', fontWeight: 600, boxShadow: (theme) => theme.palette.mode === 'dark' ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.08)', '&:hover': { bgcolor: 'background.paper !important' }, }, }}> {types.map((tp) => ( {tp.type === 'stripe' ? ( ) : ( )} {tp.label || (tp.type === 'stripe' ? 'Card' : 'Crypto')} ))} )} {/* Crypto: Network + Asset selectors (side by side) */} {isCrypto && networks.length > 0 && ( {/* Network selector */} {networks.length > 1 && ( {t('common.network')} )} {/* Asset selector */} {t('common.currency')} )} {/* Card: show currency indicator — same style as crypto asset selector */} {isStripe && currency && ( {t('common.currency')} )} {/* Customer Info */} {/* end main content / scrollable area */} {/* Submit section — fixed on mobile, flows on desktop */} {/* Mobile: collapsible details (staking etc.) */} {isMobile && pricing.staking && ( <> setMobileDetailsOpen(!mobileDetailsOpen)} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', mb: 1 }}> {t('payment.checkout.orderSummary')} {t('payment.checkout.staking.title')} {isAmountLoading ? ( ) : ( +{pricing.staking} )} )} {/* Desktop: staking + promotion (together with Total Due) */} {!isMobile && ( <> {pricing.staking && ( {t('payment.checkout.staking.title')} {isAmountLoading ? ( ) : ( +{pricing.staking} )} )} )} {/* Total amount due */} {(() => { const totalStr = pricing.total || '0'; const parts = totalStr.split(/\s+/); const num = parts[0] || '0'; const sym = parts.slice(1).join(' ') || currency?.symbol || ''; const dotIdx = num.indexOf('.'); const intPart = dotIdx >= 0 ? num.slice(0, dotIdx) : num; const decPart = dotIdx >= 0 ? num.slice(dotIdx) : ''; return ( {t('common.totalDue')} {isAmountLoading ? ( ) : ( {intPart} {decPart && ( {decPart} )} {sym} )} {/* Row below Total Due: mobile promo (left) + USD estimate (right) */} {(pricing.usdEquivalent || (isMobile && promotion.active)) && !isAmountLoading && ( {/* Mobile: promo link or applied badge — left-aligned */} {(() => { if (isMobile && !promotion.applied && promotion.active) { return ( setPromoDrawerOpen(true)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, cursor: 'pointer' }}> {tSafe(t, 'payment.checkout.promotion.add', 'Add promo code')} ); } if (isMobile && promotion.applied) { return ( {promotion.code} {pricing.discount && ( (-{pricing.discount}) )} ); } return ; })()} {/* USD estimate — right-aligned */} {pricing.usdEquivalent && ( ≈ {pricing.usdEquivalent} )} )} ); })()} {/* Submit button */} {/* Mobile: SSL footer inside fixed bar, below button */} {isMobile && ( {tSafe(t, 'payment.checkout.ssl', 'SSL Secure')} · {tSafe(t, 'common.terms', 'Terms')} | {tSafe(t, 'common.privacy', 'Privacy')} )} {/* end submit fixed bar */} {/* Mobile: Promo code drawer */} {isMobile && ( setPromoDrawerOpen(false)} PaperProps={{ sx: { borderRadius: '16px 16px 0 0', p: 3, pb: 4, minHeight: '30vh', }, }}> {tSafe(t, 'payment.checkout.promotion.add', 'Add promo code')} ) => { const result = await promotion.apply(...args); if (result.success) setPromoDrawerOpen(false); return result; }, remove: promotion.remove, }} discounts={discounts} discountAmount={pricing.discount} currency={currency} /> )} {/* Disclaimer + footer — outside fixed bar, desktop only for SSL */} {!isMobile && ( {tSafe(t, 'payment.checkout.ssl', 'SSL Secure')} · {tSafe(t, 'common.terms', 'Terms')} | {tSafe(t, 'common.privacy', 'Privacy')} )} {/* Status Feedback (Toast errors only — dialogs handled by CheckoutDialogs) */} {/* Overdue Invoice Payment (CUSTOMER_LIMITED) */} {customerLimited && ( { setCustomerLimited(false); submit.retry(); }} alertMessage={t('payment.customer.pastDue.alert.customMessage')} detailLinkOptions={{ enabled: true, onClick: () => { setCustomerLimited(false); window.open( joinURL(getPrefix(), `/customer/invoice/past-due?referer=${encodeURIComponent(window.location.href)}`), '_self' ); }, }} dialogProps={{ open: customerLimited, onClose: () => { setCustomerLimited(false); submit.reset(); }, title: t('payment.customer.pastDue.alert.title'), }} /> )} ); }