import { useEffect, useRef, useState } from 'react'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import TrendingDownIcon from '@mui/icons-material/TrendingDown'; import { Avatar, Box, Button, Collapse, Stack, Typography } from '@mui/material'; import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import Toast from '@arcblock/ux/lib/Toast'; import { useCheckoutStatus, useLineItems, useBillingInterval, usePricingFeature, useExchangeRate, useSlippage, useSessionContext, usePaymentMethodContext, useProduct, } from '@blocklet/payment-react-headless'; import { useMobile } from '../../../hooks/mobile'; import { INTERVAL_LOCALE_KEY, formatTrialText, getSessionHeaderMeta, tSafe, primaryContrastColor, } from '../../utils/format'; import ProductItemCard from '../../components/left/product-item-card'; import BillingToggle from '../../components/left/billing-toggle'; import CrossSellCard from '../../components/left/cross-sell-card'; import ExchangeRateFooter from '../../components/shared/exchange-rate-footer'; import TrialInfo from '../../components/left/trial-info'; export default function CompositePanel() { const { t } = useLocaleContext(); const { session } = useSessionContext(); const { currency, isStripe, switching: currencySwitching } = usePaymentMethodContext(); const { livemode } = useCheckoutStatus(); const { product, pageInfo } = useProduct(); const lineItems = useLineItems(); const billingInterval = useBillingInterval(); const pricing = usePricingFeature(); const rate = useExchangeRate(); const slippage = useSlippage(); const { isMobile } = useMobile(); const mode = session?.mode || 'payment'; const discounts = (session as any)?.discounts || []; const appName = (session as any)?.app_name || (session as any)?.payment_link?.app_name || ''; const appLogo = (session as any)?.app_logo || (session as any)?.payment_link?.app_logo || ''; const showItemsCollapse = isMobile || lineItems.items.length >= 4; const [itemsExpanded, setItemsExpanded] = useState( isMobile ? lineItems.items.length <= 1 : lineItems.items.length < 4 ); // Detect cross-sell not yet added const crossSellNotAdded = lineItems.crossSellItem && !lineItems.items.some((i: any) => i.price_id === lineItems.crossSellItem?.id); // ── Find the single item with upsell → promote toggle to top ── // Backend rejects upsell when line_items > 1 (only cross-sell items are auto-removable). // So upsell is only possible when all other items are cross-sell. const nonCrossSellItems = lineItems.items.filter((i: any) => !i.cross_sell); const itemsWithUpsell = lineItems.items.filter((i: any) => (i.price as any)?.upsell?.upsells_to); const upsellPrimaryItem = itemsWithUpsell.length === 1 ? itemsWithUpsell[0] : null; const upsellTarget = upsellPrimaryItem ? (upsellPrimaryItem.price as any)?.upsell?.upsells_to : null; const canUpsell = nonCrossSellItems.length <= 1; const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ['subscription', 'setup'].includes(mode); const isUpselled = !!(upsellPrimaryItem as any)?.upsell_price; const [upsellSwitching, setUpsellSwitching] = useState(false); // Optimistic: track which tab the user clicked so highlight switches immediately const [pendingUpsell, setPendingUpsell] = useState(null); const visualIsUpselled = pendingUpsell !== null ? pendingUpsell : isUpselled; // Intervals for capsule toggle const currentInterval = hasTopUpsell ? (upsellPrimaryItem!.price as any)?.recurring?.interval : null; const upsellInterval = hasTopUpsell ? upsellTarget?.recurring?.interval : null; // Savings % let upsellSavings = 0; if (hasTopUpsell && currentInterval && upsellInterval) { const fromAmt = parseFloat( (upsellPrimaryItem!.price as any)?.base_amount || (upsellPrimaryItem!.price as any)?.unit_amount || '0' ); const toAmt = parseFloat(upsellTarget?.base_amount || upsellTarget?.unit_amount || '0'); const yearMap: Record = { day: 365, week: 52, month: 12, year: 1 }; const fromY = fromAmt * (yearMap[currentInterval] || 1); const toY = toAmt * (yearMap[upsellInterval] || 1); if (fromY > toY && fromY > 0) upsellSavings = Math.round(((fromY - toY) / fromY) * 100); } // Toast on rate failure (only once per transition to 'unavailable') const prevRateStatusRef = useRef(rate.status); useEffect(() => { if (prevRateStatusRef.current !== 'unavailable' && rate.status === 'unavailable' && !isStripe) { Toast.error(t('payment.dynamicPricing.unavailable.message')); } prevRateStatusRef.current = rate.status; }, [rate.status, isStripe, t]); // Structured header meta: badge, title, subtitle const headerMeta = getSessionHeaderMeta(t, session, product, lineItems.items); const isMultiItem = lineItems.items.length > 1; // Capsule button sx helper const activeSx = { bgcolor: 'primary.main', color: (theme: any) => primaryContrastColor(theme), boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)', }; const inactiveSx = { color: 'text.secondary', '&:hover': { color: 'text.primary' }, }; const capsuleBtnSx = (active: boolean) => ({ px: 3.5, py: 1, borderRadius: '9999px', cursor: 'pointer', transition: 'all 0.3s ease', userSelect: 'none' as const, ...(active ? activeSx : inactiveSx), }); return ( {/* App branding — desktop only (mobile uses compact inline) */} {!isMobile && (appName || appLogo) && ( {appLogo && ( )} {appName && {appName}} )} {/* Top spacer — balances bottom spacer to vertically center main content (desktop only) */} {!isMobile && } {/* ── Top: header content (fixed, does not scroll) ── */} {/* Header section: type badge + title + subtitle */} {/* Mobile: compact branding row */} {isMobile && (appName || appLogo) && ( {appLogo && } {appName && ( {appName} )} )} {/* Type badge + trial tag + TEST MODE */} theme.palette.mode === 'dark' ? `${theme.palette.primary.main}1A` : `${theme.palette.primary.main}0D`, px: 1, py: 0.5, borderRadius: '4px', }}> {headerMeta.badgeLabel} {pricing.trial.active && pricing.trial.days > 0 && ( theme.palette.mode === 'dark' ? `${theme.palette.primary.main}1A` : `${theme.palette.primary.main}0D`, px: 1, py: 0.5, borderRadius: '4px', }}> {formatTrialText(t, pricing.trial.days, pricing.trial.afterTrialInterval || 'day')} )} {!livemode && ( (theme.palette.mode === 'dark' ? theme.palette.grey[500] : theme.palette.grey[400]), bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : theme.palette.grey[100], px: 1, py: 0.5, borderRadius: '4px', }}> {t('common.livemode')} )} {/* Title: product name (single item) or "Order Summary" (multi item) */} {isMultiItem ? tSafe(t, 'payment.checkout.orderSummary', 'Order Summary') : headerMeta.title} {/* Subtitle */} {(isMultiItem || headerMeta.subtitle) && ( {isMultiItem ? tSafe(t, 'payment.checkout.orderSummarySubtitle', 'Items included in this purchase') : headerMeta.subtitle} )} {/* ── Top-level upsell toggle (single subscription) ── */} {hasTopUpsell && ( theme.palette.mode === 'dark' ? '0 1px 2px 0 rgba(0,0,0,0.3)' : '0 1px 2px 0 rgba(0,0,0,0.05)', }}> {/* Current interval */} { if (isUpselled && !upsellSwitching) { setPendingUpsell(false); setUpsellSwitching(true); try { await lineItems.downsell( (upsellPrimaryItem as any).upsell_price?.id || upsellPrimaryItem!.price_id ); } catch (err: any) { setPendingUpsell(null); Toast.error(err?.response?.data?.error || err?.message || 'Failed'); } finally { setUpsellSwitching(false); setPendingUpsell(null); } } }} sx={capsuleBtnSx(!visualIsUpselled)}> {t(INTERVAL_LOCALE_KEY[currentInterval!] || '')} {/* Upsell interval */} { if (!isUpselled && !upsellSwitching) { setPendingUpsell(true); setUpsellSwitching(true); try { await lineItems.upsell(upsellPrimaryItem!.price_id, upsellTarget.id); } catch (err: any) { setPendingUpsell(null); Toast.error(err?.response?.data?.error || err?.message || 'Failed'); } finally { setUpsellSwitching(false); setPendingUpsell(null); } } }} sx={capsuleBtnSx(visualIsUpselled)}> {t(INTERVAL_LOCALE_KEY[upsellInterval!] || '')} {/* SAVE badge */} {upsellSavings > 0 && ( (theme.palette.mode === 'dark' ? 'rgba(18,184,134,0.1)' : '#ebfef5'), color: '#12b886', fontSize: 11, fontWeight: 700, borderRadius: '9999px', border: '1px solid', borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(18,184,134,0.2)' : '#d3f9e8'), textTransform: 'uppercase', letterSpacing: '0.05em', }}> SAVE {upsellSavings}% )} )} {/* Billing interval toggle (multi-interval, not upsell) */} {/* end top section */} {/* ── Middle: items area — shrinks & scrolls only when content exceeds one screen ── */} {/* Items collapse header */} {showItemsCollapse && ( setItemsExpanded(!itemsExpanded)} sx={{ cursor: 'pointer', mb: 1.5 }}> {t('payment.checkout.productListTotal', { total: lineItems.items.length })} )} {/* Line items — individual cards with spacing */} {lineItems.items.map((item: any) => ( {/* Cross-sell remove button inside card */} {item.cross_sell && ( )} ))} {/* Cross-sell card — collapses together with line items */} {crossSellNotAdded && ( )} {/* end middle section */} {/* TrialInfo — follows items directly, no gap */} {/* Spacer — fills remaining space so exchange rate is pushed to bottom */} {/* Exchange rate — always at the very bottom */} {/* Rate unavailable — inline hint with retry */} {rate.hasDynamicPricing && rate.status === 'unavailable' && !isStripe && ( {t('payment.dynamicPricing.unavailable.title')} {t('payment.dynamicPricing.unavailable.retry')} )} ); }