import { useState, useEffect, useRef, useCallback } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CheckIcon from '@mui/icons-material/Check'; import LocalOfferIcon from '@mui/icons-material/LocalOffer'; import RemoveIcon from '@mui/icons-material/Remove'; import ShoppingCartCheckoutIcon from '@mui/icons-material/ShoppingCartCheckout'; import { Avatar, Box, Chip, Collapse, IconButton, Skeleton, Stack, Switch, TextField, Typography, useMediaQuery, useTheme, } from '@mui/material'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import type { TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types'; import { getPriceUnitAmountByCurrency } from '@blocklet/payment-react-headless'; import Toast from '@arcblock/ux/lib/Toast'; import { INTERVAL_LOCALE_KEY, formatDynamicUnitPrice, formatTokenAmount, formatTrialText, primaryContrastColor, } from '../../utils/format'; interface ProductItemCardProps { item: TLineItemExpanded & { adjustable_quantity?: { enabled: boolean; minimum?: number; maximum?: number } }; currency: TPaymentCurrency | null; discounts: any[]; exchangeRate: string | null; onQuantityChange: (itemId: string, qty: number) => Promise; onUpsell: (fromId: string, toId: string) => Promise; onDownsell: (priceId: string) => Promise; trialActive: boolean; trialDays: number; t: (key: string, params?: any) => string; recommended?: boolean; hideUpsell?: boolean; isRateLoading?: boolean; showFeatures?: boolean; children?: React.ReactNode; } export default function ProductItemCard({ item, currency, discounts, exchangeRate, onQuantityChange, onUpsell, onDownsell, trialActive, trialDays, t, recommended = false, hideUpsell = false, isRateLoading = false, showFeatures = true, children = undefined, }: ProductItemCardProps) { const activePrice: any = (item as any).upsell_price || item.price; const product = activePrice?.product; const name = product?.name || 'Item'; const logo = product?.images?.[0] || ''; const features: Array<{ name: string; icon?: string }> = product?.features || []; const recurring = activePrice?.recurring; const quantity = item.quantity || 1; const isMetered = recurring?.usage_type === 'metered'; const metered = isMetered ? ` ${t('common.metered')}` : ''; const perUnitFormatted = formatDynamicUnitPrice(activePrice, currency, exchangeRate); // Item type badge: just "SUBSCRIPTION" or "ONE-TIME" (interval is shown with price) const isSubscription = !!recurring; const typeBadgeText = isSubscription ? t('payment.checkout.typeBadge.subscription') : t('payment.checkout.typeBadge.oneTime'); // Billing interval suffix for price display: "/ month" const priceIntervalSuffix = recurring?.interval ? ` / ${t(`common.${recurring.interval}`)}` : ''; // Subtitle: only quantity breakdown (interval is now in the type badge) const subtitleText = (() => { if (quantity > 1 && perUnitFormatted && currency) { return `${quantity} × ${perUnitFormatted} ${currency.symbol}`; } if (isMetered) return metered.trim(); return ''; })(); // Item total const itemTotal = (() => { // custom_amount is backend-quoted in a specific currency — only use when quote_currency_id is present and matches // (when quote_currency_id is absent, custom_amount denomination is unknown — skip to base_amount path) const quoteCurrencyId = (item as any).quote_currency_id as string | undefined; if ((item as any).custom_amount && quoteCurrencyId && quoteCurrencyId === currency?.id) { return formatTokenAmount((item as any).custom_amount, currency); } if (activePrice?.pricing_type === 'dynamic' && exchangeRate && activePrice.base_amount) { const rate = Number(exchangeRate); if (rate > 0 && Number.isFinite(rate)) { const baseUsd = Number(activePrice.base_amount); if (baseUsd > 0 && Number.isFinite(baseUsd)) { const tokenAmount = (baseUsd * quantity) / rate; const abs = Math.abs(tokenAmount); const precision = abs > 0 && abs < 0.01 ? 6 : 2; return ( tokenAmount .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }) .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, '') || '0' ); } } } // Fiat/Stripe fallback: use base_amount when no exchange rate // (unit_amount may be in crypto denomination, not fiat cents) if (!exchangeRate && activePrice?.base_amount != null) { const baseUsd = Number(activePrice.base_amount); if (baseUsd >= 0 && Number.isFinite(baseUsd)) { const total = baseUsd * quantity; return total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } } // Fallback: look up unit_amount from currency_options for the selected currency if (activePrice && currency) { const unitAmount = getPriceUnitAmountByCurrency(activePrice, currency); if (unitAmount && unitAmount !== '0') { const totalUnits = BigInt(unitAmount) * BigInt(quantity); return formatTokenAmount(totalUnits.toString(), currency); } } return '0'; })(); // Per-item discount const discount = discounts?.[0]; const discountCode = discount?.promotion_code_details?.code || discount?.verification_data?.code || discount?.promotion_code || ''; const perItemDiscount = (() => { if (!discountCode || !discount) return null; const couponDetails = discount?.coupon_details; if (couponDetails?.percent_off > 0) { const numericTotal = parseFloat(String(itemTotal).replace(/,/g, '')); if (!Number.isNaN(numericTotal) && numericTotal > 0) { const discAmount = (numericTotal * couponDetails.percent_off) / 100; const abs = Math.abs(discAmount); const precision = abs > 0 && abs < 0.01 ? 6 : 2; return `${ discAmount .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }) .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, '') || '0' } ${currency?.symbol || ''}`; } } if ((item as any).discount_amounts?.length > 0 && currency) { return `${formatTokenAmount((item as any).discount_amounts[0].amount, currency)} ${currency.symbol}`; } return null; })(); // Quantity controls const adjustable = item.adjustable_quantity?.enabled; const min = item.adjustable_quantity?.minimum || 1; const max = item.adjustable_quantity?.maximum; const [qtyInput, setQtyInput] = useState(String(quantity)); const [isEditing, setIsEditing] = useState(false); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const [featuresOpen, setFeaturesOpen] = useState(!isMobile); useEffect(() => { if (!isEditing) setQtyInput(String(quantity)); }, [quantity, isEditing]); // Debounce quantity API calls — UI updates immediately, API fires after 400ms idle const debounceRef = useRef | null>(null); const debouncedQuantityChange = useCallback( (priceId: string, qty: number) => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { onQuantityChange(priceId, qty); }, 400); }, [onQuantityChange] ); useEffect( () => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, [] ); // Upsell info const canUpsell = !!(item.price as any)?.upsell?.upsells_to; const isUpselled = !!(item as any).upsell_price; const upsellTo = (item.price as any)?.upsell?.upsells_to; let savingsPercent = 0; if (canUpsell && upsellTo) { const fromAmount = parseFloat((item.price as any)?.base_amount || (item.price as any)?.unit_amount || '0'); const toAmount = parseFloat(upsellTo?.base_amount || upsellTo?.unit_amount || '0'); const fromInterval = (item.price as any)?.recurring?.interval; const toInterval = upsellTo?.recurring?.interval; if (fromAmount > 0 && toAmount > 0 && fromInterval && toInterval) { const monthsMap: Record = { day: 365, week: 52, month: 12, year: 1 }; const fromYearly = fromAmount * (monthsMap[fromInterval] || 1); const toYearly = toAmount * (monthsMap[toInterval] || 1); if (fromYearly > toYearly) { savingsPercent = Math.round(((fromYearly - toYearly) / fromYearly) * 100); } } } // Upsell price display const upsellInterval = upsellTo?.recurring?.interval; const upsellPrice = (() => { if (!upsellTo) return ''; const slashText = upsellInterval ? t('common.slash', { interval: t(`common.${upsellInterval}`) }) : ''; if (upsellTo.pricing_type === 'dynamic' && upsellTo.base_amount && exchangeRate) { const rate = Number(exchangeRate); if (rate > 0 && Number.isFinite(rate)) { const baseUsd = parseFloat(upsellTo.base_amount); if (baseUsd > 0 && Number.isFinite(baseUsd)) { const tokenAmount = baseUsd / rate; const abs = Math.abs(tokenAmount); const precision = abs > 0 && abs < 0.01 ? 6 : 2; const formatted = tokenAmount .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }) .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, '') || '0'; return `${formatted} ${currency?.symbol || ''} ${slashText}`; } } } if (upsellTo.pricing_type === 'dynamic' && upsellTo.base_amount && upsellTo.base_currency === 'USD') { const baseUsd = parseFloat(upsellTo.base_amount); const formattedUsd = baseUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `$${formattedUsd} ${slashText}`; } const upsellUnitFormatted = formatDynamicUnitPrice(upsellTo, currency, exchangeRate); return upsellUnitFormatted ? `${upsellUnitFormatted} ${currency?.symbol || ''} ${slashText}` : ''; })(); // Downsell price display const originalInterval = (item.price as any)?.recurring?.interval; const downsellPrice = (() => { if (!item.price || !isUpselled) return ''; const originalPrice: any = item.price; const originalSlash = originalInterval ? t('common.slash', { interval: t(`common.${originalInterval}`) }) : ''; if (originalPrice.pricing_type === 'dynamic' && originalPrice.base_amount && exchangeRate) { const rate = Number(exchangeRate); if (rate > 0 && Number.isFinite(rate)) { const baseUsd = parseFloat(originalPrice.base_amount); if (baseUsd > 0 && Number.isFinite(baseUsd)) { const tokenAmount = baseUsd / rate; const abs = Math.abs(tokenAmount); const precision = abs > 0 && abs < 0.01 ? 6 : 2; const formatted = tokenAmount .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }) .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, '') || '0'; return `${formatted} ${currency?.symbol || ''} ${originalSlash}`; } } } if ( originalPrice.pricing_type === 'dynamic' && originalPrice.base_amount && originalPrice.base_currency === 'USD' ) { const baseUsd = parseFloat(originalPrice.base_amount); const formattedUsd = baseUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `$${formattedUsd} ${originalSlash}`; } const unitFormatted = formatDynamicUnitPrice(originalPrice, currency, exchangeRate); return unitFormatted ? `${unitFormatted} ${currency?.symbol || ''} ${originalSlash}` : ''; })(); // Upsell toggle label const upsellToggleLabel = (() => { if (isUpselled) { const recurringLabel = originalInterval ? t(INTERVAL_LOCALE_KEY[originalInterval] || '') : ''; return t('payment.checkout.upsell.revert', { recurring: recurringLabel }); } const recurringLabel = upsellInterval ? t(INTERVAL_LOCALE_KEY[upsellInterval] || '') : ''; return t('payment.checkout.upsell.save', { recurring: recurringLabel }); })(); return ( {/* Recommended badge — top-right */} {recommended && ( primaryContrastColor(th), boxShadow: '0 4px 12px rgba(45,124,243,0.2)', '& .MuiChip-label': { px: 1.5 }, }} /> )} th.palette.mode === 'dark' ? '0 12px 40px -8px rgba(0,0,0,0.3)' : '0 12px 40px -8px rgba(0,0,0,0.06)', transition: 'all 0.3s ease', ...(canUpsell && !hideUpsell ? { borderRadius: { xs: '16px 16px 0 0', md: '24px 24px 0 0' } } : {}), '&:hover': { borderColor: (th) => (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(45,124,243,0.15)'), }, }}> {/* Product avatar */} {logo ? ( ) : ( (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : '#eff6ff'), flexShrink: 0, }}> )} {name} {/* Item type badge: subscription/one-time + interval */} th.palette.mode === 'dark' ? `${th.palette.primary.main}1A` : `${th.palette.primary.main}0D`, px: 0.75, py: 0.4, borderRadius: '3px', }}> {typeBadgeText} {subtitleText && ( {subtitleText} )} {/* eslint-disable-next-line no-nested-ternary */} {trialActive && isSubscription ? ( {formatTrialText(t, trialDays, recurring?.interval || 'day')} ) : isRateLoading ? ( ) : ( <> {itemTotal} {currency?.symbol} {priceIntervalSuffix} {exchangeRate && activePrice?.base_amount && ( ≈ ${(Number(activePrice.base_amount) * quantity).toFixed(2)} )} )} {/* Product features — collapsible, default expanded */} {showFeatures && features.length > 0 && ( (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'grey.100'), }}> setFeaturesOpen((v) => !v)} sx={{ cursor: 'pointer', userSelect: 'none', mb: featuresOpen ? 1.25 : 0 }}> {t('payment.checkout.planFeatures')} {features.map((feature) => ( th.palette.mode === 'dark' ? 'rgba(16,185,129,0.15)' : 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }}> {feature.name} ))} )} {/* Discount chip */} {discountCode && perItemDiscount && ( {isRateLoading ? ( ) : ( } label={`${discountCode} (-${perItemDiscount})`} size="small" sx={{ height: 22, borderRadius: '6px', '& .MuiChip-label': { fontSize: 12 } }} /> )} )} {/* Quantity controls */} {adjustable && ( {t('common.quantity')} { const cur = parseInt(qtyInput, 10) || quantity; const newQty = cur - 1; if (newQty < min) return; setQtyInput(String(newQty)); debouncedQuantityChange(item.price_id, newQty); }} disabled={(parseInt(qtyInput, 10) || quantity) <= min} sx={{ width: 36, height: 36, border: '1px solid', borderColor: 'divider', borderRadius: '50%', '&:hover': { bgcolor: 'action.hover' }, }}> setIsEditing(true)} onChange={(e) => setQtyInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); (e.target as HTMLInputElement).blur(); } }} onBlur={() => { setIsEditing(false); const v = parseInt(qtyInput, 10); if (!Number.isNaN(v) && v >= min && (!max || v <= max) && v !== quantity) { onQuantityChange(item.price_id, v); } else { setQtyInput(String(quantity)); } }} /> { const cur = parseInt(qtyInput, 10) || quantity; const newQty = cur + 1; if (max && newQty > max) return; setQtyInput(String(newQty)); debouncedQuantityChange(item.price_id, newQty); }} disabled={max ? (parseInt(qtyInput, 10) || quantity) >= max : false} sx={{ width: 36, height: 36, border: '1px solid', borderColor: 'divider', borderRadius: '50%', '&:hover': { bgcolor: 'action.hover' }, }}> )} {children} {/* Upsell toggle strip — seamless with card (hidden when promoted to top-level) */} {canUpsell && !hideUpsell && ( th.palette.mode === 'dark' ? '0 12px 40px -8px rgba(0,0,0,0.3)' : '0 12px 40px -8px rgba(0,0,0,0.06)', }}> {/* Subtle divider */} { try { if (isUpselled) { await onDownsell((item as any).upsell_price?.id || (item.price as any).id); } else { await onUpsell(item.price_id, upsellTo.id); } } catch (err: any) { Toast.error(err?.response?.data?.error || err?.message || 'Failed'); } }} size="small" sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: '#12b886' }, '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#12b886' }, }} /> { try { if (isUpselled) await onDownsell((item as any).upsell_price?.id || (item.price as any).id); else await onUpsell(item.price_id, upsellTo.id); } catch (err: any) { Toast.error(err?.response?.data?.error || err?.message || 'Failed'); } }}> {upsellToggleLabel} {!isUpselled && savingsPercent > 0 && ( (th.palette.mode === 'dark' ? 'rgba(18,184,134,0.1)' : '#ebfef5'), color: '#12b886', border: '1px solid', borderColor: (th) => (th.palette.mode === 'dark' ? 'rgba(18,184,134,0.2)' : '#d3f9e8'), borderRadius: '9999px', '& .MuiChip-label': { px: 1 }, }} /> )} {isUpselled ? downsellPrice : upsellPrice} )} ); }