import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { Box, IconButton, Typography, InputBase, Stack } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import { useRequest } from 'ahooks'; import { BN, fromTokenToUnit } from '@ocap/util'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { useCheckoutStatus, useLineItems, useSessionContext, useProduct, useExchangeRate, useSlippage, usePaymentMethodContext, useSubmitFeature, } from '@blocklet/payment-react-headless'; import { usePaymentContext } from '../../../contexts/payment'; import api from '../../../libs/api'; import { formatNumber, formatBNStr, formatCreditForCheckout, formatCreditAmount } from '../../../libs/util'; import { tSafe } from '../../utils/format'; import ScenarioBadge from '../../components/shared/scenario-badge'; import ExchangeRateFooter from '../../components/shared/exchange-rate-footer'; export default function CreditTopupPanel() { const { t, locale } = useLocaleContext(); const { session, sessionData } = useSessionContext(); const { livemode } = useCheckoutStatus(); const { product } = useProduct(); const { currency, isStripe } = usePaymentMethodContext(); const lineItems = useLineItems(); const rate = useExchangeRate(); const slippage = useSlippage(); const { locked: configLocked } = useSubmitFeature(); const item = lineItems.items[0]; const activePrice: any = item ? (item as any).upsell_price || item.price : null; // Credit metadata const creditConfig = activePrice?.metadata?.credit_config; const creditAmount = creditConfig?.credit_amount ? Number(creditConfig.credit_amount) : 0; const creditCurrencyId = creditConfig?.currency_id; // DID session for login state and userDid; getCurrency from /api/settings (same as V1) const { session: didSession, getCurrency } = usePaymentContext(); // Credit currency: use getCurrency from /api/settings — same data source as V1 const creditCurrency = creditCurrencyId ? getCurrency(creditCurrencyId) : null; const userDid = didSession?.user?.did || (sessionData?.customer as any)?.did; const creditCurrencyDecimal = creditCurrency?.decimal; const creditCurrencySymbol = creditCurrency?.symbol || 'Credits'; const currencySymbol = 'Credits'; // Fetch meter data by meter_id from price metadata const meterId = activePrice?.metadata?.meter_id; const { data: meterData } = useRequest( async () => { if (!meterId) return null; try { const { data } = await api.get(`/api/meters/public/${meterId}`); return data; } catch { return null; } }, { refreshDeps: [meterId] } ); const creditName = meterData?.name || product?.name || 'Credits'; // Credit config details const validDuration = creditConfig?.valid_duration_value; const validDurationUnit = creditConfig?.valid_duration_unit || 'days'; const scheduleConfig = creditConfig?.schedule; const hasSchedule = scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== 'invoice'; const hasExpiry = validDuration && validDuration > 0; // Step size: each +/- changes by one pack's worth of credits const step = Math.max(creditAmount, 1); // Adjustable quantity config const adjustableQty = item?.adjustable_quantity; const canAdjust = adjustableQty?.enabled !== false; const minQuantity = Math.max(adjustableQty?.minimum || 1, 1); const quantityAvailable = Math.min( activePrice?.quantity_limit_per_checkout ?? Infinity, activePrice?.quantity_available ?? Infinity ); const maxQuantity = quantityAvailable ? Math.min(adjustableQty?.maximum || Infinity, quantityAvailable) : adjustableQty?.maximum || Infinity; const { data: pendingAmount } = useRequest( async () => { // Wait for credit currency decimal before fetching — calculations depend on correct decimal if (!creditConfig || !userDid || !creditCurrencyId || creditCurrencyDecimal == null) return null; try { const { data } = await api.get('/api/meter-events/pending-amount', { params: { customer_id: userDid, currency_id: creditCurrencyId }, }); return data?.[creditCurrencyId]; } catch { return null; } }, { refreshDeps: [creditConfig, userDid, creditCurrencyId, creditCurrencyDecimal] } ); // Min quantity needed to cover pending const minQtyForPending = useMemo(() => { if (!pendingAmount || !creditAmount || creditAmount <= 0) return null; const pendingBN = new BN(pendingAmount || '0'); if (!pendingBN.gt(new BN(0))) return null; // creditCurrencyDecimal is guaranteed non-null here (pending fetch depends on it) const creditBN = fromTokenToUnit(creditAmount, creditCurrencyDecimal!); if (!creditBN || creditBN.isZero()) return null; return Math.ceil(pendingBN.mul(new BN(100)).div(creditBN).toNumber() / 100); }, [pendingAmount, creditAmount, creditCurrencyDecimal]); // Credit info description (referencing V1 formatCreditInfo) const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0)); const pendingDisplayAmount = useMemo(() => { if (!hasPendingAmount) return ''; // creditCurrencyDecimal is guaranteed non-null here (pending fetch depends on it) return formatCreditForCheckout(formatBNStr(pendingAmount!, creditCurrencyDecimal!), creditCurrencySymbol, locale); }, [hasPendingAmount, pendingAmount, creditCurrencyDecimal, creditCurrencySymbol, locale]); // Min credits needed to cover pending (for user-facing display) const minCreditsForPending = minQtyForPending ? minQtyForPending * step : 0; const minCreditsForPendingFormatted = minCreditsForPending ? formatCreditForCheckout(formatNumber(minCreditsForPending), creditCurrencySymbol, locale) : ''; // Credit info text: schedule or product description (no fallback "Purchase X") const creditInfoText = useMemo(() => { if (hasSchedule && scheduleConfig) { const intervalUnit = scheduleConfig.interval_unit; const intervalValue = scheduleConfig.interval_value; let amountPerGrant: number; if (scheduleConfig.amount_per_grant) { amountPerGrant = Number(scheduleConfig.amount_per_grant); } else { amountPerGrant = creditAmount; } const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol); const intervalDisplay = intervalValue === 1 ? t(`common.${intervalUnit}`) : `${intervalValue} ${t(`common.${intervalUnit}s` as any)}`; return scheduleConfig.expire_with_next_grant ? t('payment.checkout.credit.schedule.withRefresh', { amount: formattedAmount, interval: intervalDisplay }) : t('payment.checkout.credit.schedule.periodic', { amount: formattedAmount, interval: intervalDisplay }); } return ''; }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, t]); // Validity text: "Credits are valid for X days after purchase." const validityText = useMemo(() => { if (!hasExpiry) return ''; return t('payment.checkout.creditTopup.validFor', { duration: validDuration, unit: t(`common.${validDurationUnit}`), }); }, [hasExpiry, validDuration, validDurationUnit, t]); const hasSubtitle = !!creditInfoText; // ── State: user inputs desired credits, we compute packs ── const currentQty = item?.quantity || 1; const [localQty, setLocalQty] = useState(currentQty); // pack quantity (source of truth for API) const [desiredCredits, setDesiredCredits] = useState(currentQty * step); // what user sees/types const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); const inputRef = useRef(null); // Sync when item quantity changes from backend (skip if locked or already completed) useEffect(() => { if (configLocked || session?.status === 'complete') return; if (item?.quantity && item.quantity !== localQty) { setLocalQty(item.quantity); setDesiredCredits(item.quantity * step); } }, [item?.quantity]); // eslint-disable-line react-hooks/exhaustive-deps // Enforce min quantity for pending — only once on initial load. // After user confirms quantity (e.g. clicks "Connect and Pay"), don't override their choice. const pendingEnforcedRef = useRef(false); useEffect(() => { if (configLocked || session?.status === 'complete') return; if (pendingEnforcedRef.current) return; if (minQtyForPending && minQtyForPending > localQty) { pendingEnforcedRef.current = true; const newQty = Math.min(Math.max(minQtyForPending, minQuantity), maxQuantity); setLocalQty(newQty); setDesiredCredits(newQty * step); if (item) lineItems.updateQuantity(item.price_id, newQty); } }, [minQtyForPending]); // eslint-disable-line react-hooks/exhaustive-deps // Max credits = maxQuantity packs * credits per pack const maxCredits = maxQuantity * step; const minCredits = minQuantity * step; // Debounce API calls — UI updates immediately, API fires after 400ms idle const debounceRef = useRef | null>(null); useEffect( () => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, [] ); // Commit desired credits → compute packs → update API (debounced) const commitCredits = useCallback( (credits: number) => { if (credits <= 0) return; // Clamp credits to valid range const clamped = Math.max(minCredits, Math.min(maxCredits, credits)); const packs = Math.ceil(clamped / step); const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs)); setLocalQty(clampedPacks); setDesiredCredits(clamped); if (item) { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { lineItems.updateQuantity(item.price_id, clampedPacks); }, 400); } }, [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems] ); // +/- buttons: step by one pack's worth of credits const handleStep = useCallback( (delta: number) => { const newCredits = desiredCredits + delta * step; if (newCredits < step * minQuantity || Math.ceil(newCredits / step) > maxQuantity) return; commitCredits(newCredits); }, [desiredCredits, step, minQuantity, maxQuantity, commitCredits] ); // Actual credits user will receive (always >= desired, rounded up to whole packs) const actualPacks = Math.max(minQuantity, Math.min(maxQuantity, Math.ceil(desiredCredits / step))); const actualCredits = actualPacks * step; const actualCreditsFormatted = formatNumber(actualCredits, 6, true, true); const desiredCreditsFormatted = formatNumber(desiredCredits, 6, true, true); const showReceiveSection = canAdjust && step > 1; // Pre-compute pending message to avoid deep ternary in JSX const pendingMessage = useMemo(() => { if (!hasPendingAmount) return ''; if (actualCredits >= minCreditsForPending) { return t('payment.checkout.creditTopup.pendingEnough', { pendingAmount: pendingDisplayAmount, availableAmount: formatCreditForCheckout( formatNumber(actualCredits - minCreditsForPending), creditCurrencySymbol, locale ), }); } return t('payment.checkout.creditTopup.pendingWarning', { pendingAmount: pendingDisplayAmount, minCredits: minCreditsForPendingFormatted, }); }, [ hasPendingAmount, actualCredits, minCreditsForPending, pendingDisplayAmount, creditCurrencySymbol, locale, minCreditsForPendingFormatted, t, ]); // Editing handlers — user types desired credit amount const startEditing = useCallback(() => { if (!canAdjust) return; setEditValue(String(desiredCredits)); setIsEditing(true); setTimeout(() => inputRef.current?.select(), 0); }, [canAdjust, desiredCredits]); const commitEdit = useCallback(() => { setIsEditing(false); const parsed = parseInt(editValue.replace(/,/g, ''), 10); if (!Number.isFinite(parsed) || parsed <= 0) return; commitCredits(parsed); }, [editValue, commitCredits]); const handleEditKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') commitEdit(); else if (e.key === 'Escape') setIsEditing(false); }, [commitEdit] ); // ── Shared styles ── const numberHeight = { xs: 48, md: 72 }; const numberFontSx = { fontSize: numberHeight, fontWeight: 800, lineHeight: 1, letterSpacing: '-0.03em', }; const circleBtnSx = { width: { xs: 40, md: 56 }, height: { xs: 40, md: 56 }, borderRadius: '50%', bgcolor: 'background.paper', border: '1px solid', borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'divider'), color: 'text.secondary', transition: 'all 0.2s ease', '&:hover': { borderColor: 'primary.main', color: 'primary.main', bgcolor: 'background.paper', }, '&.Mui-disabled': { borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'), color: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'), }, }; return ( {/* Main content — vertically centered */} {/* Badge */} {/* Title: Get Credits */} {t('payment.checkout.creditTopup.title', { name: creditName })} {/* Credit info (schedule/description) */} {hasSubtitle && ( {creditInfoText} )} {/* Pending/overdue warning */} {hasPendingAmount && ( (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.12)' : 'rgba(255,152,0,0.08)'), border: '1px solid', borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.25)' : 'rgba(255,152,0,0.2)'), }}> {pendingMessage} )} {canAdjust ? ( {/* Question label — spaced away from subtitle, close to input */} {t('payment.checkout.creditTopup.question', { symbol: currencySymbol })} {/* ── Credit input with +/- ── */} handleStep(-1)} disabled={actualPacks <= minQuantity} sx={circleBtnSx}> {isEditing ? ( setEditValue(e.target.value.replace(/\D/g, ''))} onBlur={commitEdit} onKeyDown={handleEditKeyDown} autoFocus inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} sx={{ width: '100%', bgcolor: 'transparent', '&.Mui-focused': { bgcolor: 'transparent' }, '& input': { textAlign: 'center', ...numberFontSx, p: 0, fontFamily: 'inherit', bgcolor: 'transparent', '&:focus': { bgcolor: 'transparent' }, }, }} /> ) : ( {desiredCreditsFormatted} )} {currencySymbol} handleStep(1)} disabled={actualPacks >= maxQuantity} sx={circleBtnSx}> + {/* Increment hint */} {step > 1 && ( {t('payment.checkout.creditTopup.increment', { step: formatNumber(step, 6, true, true), symbol: currencySymbol, })} )} {/* ── "You'll receive" result card — frosted glass ── */} {showReceiveSection && ( theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)', border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)', boxShadow: (theme) => theme.palette.mode === 'dark' ? '0 8px 32px rgba(59,130,246,0.06)' : '0 8px 32px rgba(59,130,246,0.04)', }}> {/* Main content */} {t('payment.checkout.creditTopup.willReceive')} {': '} {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)} {/* Pack info line */} {t('payment.checkout.creditTopup.packInfo', { packs: actualPacks, perPack: formatNumber(step, 6, true, true), })} {/* Auto match hint */} {t('payment.checkout.creditTopup.autoMatch')} {/* Validity footer */} {validityText && ( theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)', }}> {validityText} )} )} {/* Validity hint — shown below input when no receive box */} {!showReceiveSection && validityText && ( {validityText} )} ) : ( // ── Non-adjustable: show fixed credit amount in a styled card ── (theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)'), border: '1px solid', borderColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)', boxShadow: (theme) => theme.palette.mode === 'dark' ? '0 8px 32px rgba(59,130,246,0.06)' : '0 8px 32px rgba(59,130,246,0.04)', }}> {t('payment.checkout.creditTopup.willReceive')} {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)} {/* Validity footer */} {validityText && ( theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)', }}> {validityText} )} )} {/* End main content */} {/* Exchange rate — shown for all dynamic pricing scenarios */} {rate.hasDynamicPricing && rate.status === 'unavailable' && !isStripe && ( {t('payment.dynamicPricing.unavailable.title')} {t('payment.dynamicPricing.unavailable.retry')} )} ); }