/* eslint-disable @typescript-eslint/indent */ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context'; import type { PriceRecurring, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types'; import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from '@mui/material'; import { Add, Remove, LocalOffer } from '@mui/icons-material'; import React, { useEffect, useMemo, useState } from 'react'; import { useRequest } from 'ahooks'; import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util'; import LoadingAmount from '../components/loading-amount'; import Status from '../components/status'; import Switch from '../components/switch-button'; import { findCurrency, formatLineItemPricing, formatNumber, formatPrice, formatQuantityInventory, formatRecurring, formatUpsellSaving, formatAmount, formatCreditForCheckout, formatCreditAmount, formatBNStr, getUsdAmountFromBaseAmount, formatUsdAmount, formatDynamicPrice, } from '../libs/util'; import ProductCard from './product-card'; import dayjs from '../libs/dayjs'; import { usePaymentContext } from '../contexts/payment'; interface DiscountInfo { promotion_code?: string; coupon?: string; discount_amount?: string; promotion_code_details?: { code?: string; }; coupon_details?: { percent_off?: number; amount_off?: string; currency_id?: string; currency_options?: Record; }; verification_data?: { code?: string; }; } type Props = { item: TLineItemExpanded; items: TLineItemExpanded[]; trialInDays: number; trialEnd?: number; currency: TPaymentCurrency; onUpsell: Function; onDownsell: Function; mode?: 'normal' | 'cross-sell'; children?: React.ReactNode; // 数量调整相关 adjustableQuantity?: { enabled: boolean; minimum?: number; maximum?: number; }; onQuantityChange?: (itemId: string, quantity: number) => void; completed?: boolean; showFeatures?: boolean; exchangeRate?: string | null; isStripePayment?: boolean; isPriceLocked?: boolean; isRateLoading?: boolean; // Discount display props discounts?: DiscountInfo[]; calculatedDiscountAmount?: string | null; }; const getRecommendedQuantityFromUrl = (priceId: string): number | undefined => { try { const urlParams = new URLSearchParams(window.location.search); const recommendedQuantity = urlParams.get(`qty_${priceId}`) || urlParams.get('qty'); return recommendedQuantity ? Math.max(1, parseInt(recommendedQuantity, 10)) : undefined; } catch { return undefined; } }; const getUserQuantityPreference = (userDid: string, priceId: string): number | undefined => { try { const key = `quantity_preference_${userDid}_${priceId}`; const stored = localStorage.getItem(key); return stored ? Math.max(1, parseInt(stored, 10)) : undefined; } catch { return undefined; } }; const saveUserQuantityPreference = (userDid: string, priceId: string, quantity: number): void => { try { const key = `quantity_preference_${userDid}_${priceId}`; localStorage.setItem(key, quantity.toString()); } catch { // Silently fail if localStorage is not available } }; export default function ProductItem({ item, items, trialInDays, trialEnd = 0, currency, mode = 'normal', children = null, onUpsell, onDownsell, completed = false, adjustableQuantity = { enabled: false }, onQuantityChange = () => {}, showFeatures = false, exchangeRate = null, isStripePayment = false, isPriceLocked = false, isRateLoading = false, discounts = [], calculatedDiscountAmount = null, }: Props) { const { t, locale } = useLocaleContext(); const { settings, setPayable, session, api } = usePaymentContext(); const pricingSource = item.upsell_price || item.price; const isDynamicPricing = (pricingSource as any)?.pricing_type === 'dynamic'; const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays, exchangeRate }, locale); const saving = formatUpsellSaving(items, currency); const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : ''; const canUpsell = mode === 'normal' && items.length === 1; const isTrial = trialInDays > 0 || trialEnd > dayjs().unix(); // Check if this is a credit product - be more lenient in detection const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config; const creditAmount = isCreditProduct && item.price.metadata?.credit_config?.credit_amount ? Number(item.price.metadata.credit_config.credit_amount) : 0; const creditCurrency = isCreditProduct && item.price.metadata?.credit_config?.currency_id ? findCurrency(settings.paymentMethods, item.price.metadata.credit_config.currency_id) : null; const validDuration = item.price.metadata?.credit_config?.valid_duration_value; const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || 'days'; const scheduleConfig = item.price.metadata?.credit_config?.schedule; const hasSchedule = scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== 'invoice'; const userDid = session?.user?.did; const { data: pendingAmount } = useRequest( async () => { if (!isCreditProduct || !userDid) return null; try { const { data } = await api.get('/api/meter-events/pending-amount', { params: { customer_id: userDid, currency_id: creditCurrency?.id, }, }); return data?.[creditCurrency?.id || '']; } catch (error) { console.warn('Failed to fetch pending amount:', error); return null; } }, { refreshDeps: [isCreditProduct, userDid, creditCurrency?.id], } ); const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal'; const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1); const quantityAvailable = Math.min( item.price?.quantity_limit_per_checkout ?? Infinity, item.price?.quantity_available ?? Infinity ); const maxQuantity = quantityAvailable ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable) : adjustableQuantity.maximum || Infinity; const getMinQuantityForPending = useMemo(() => { if (!isCreditProduct || !pendingAmount) return null; const pendingAmountBN = new BN(pendingAmount || '0'); if (!pendingAmountBN.gt(new BN(0))) return null; // Check if creditAmount is valid (should be > 0 for non-schedule mode) if (!creditAmount || creditAmount <= 0) return null; const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2); // Check if creditAmountBN is zero to avoid division by zero error if (!creditAmountBN || creditAmountBN.isZero()) return null; return Math.ceil(pendingAmountBN.mul(new BN(100)).div(creditAmountBN).toNumber() / 100); }, [isCreditProduct, pendingAmount, creditAmount, creditCurrency?.decimal]); const initialQuantity = useMemo(() => { const urlQuantity = getRecommendedQuantityFromUrl(item.price.id); if (urlQuantity && urlQuantity > 0) { if (canAdjustQuantity && getMinQuantityForPending) { return Math.max(urlQuantity, getMinQuantityForPending, minQuantity); } return urlQuantity; } if (userDid) { const preferredQuantity = getUserQuantityPreference(userDid, item.price.id); if (preferredQuantity && preferredQuantity > 0) { if (canAdjustQuantity && getMinQuantityForPending) { return Math.max(preferredQuantity, getMinQuantityForPending, minQuantity); } return preferredQuantity; } } let baseQuantity = item.quantity; if (canAdjustQuantity && getMinQuantityForPending) { baseQuantity = Math.max(baseQuantity, getMinQuantityForPending, minQuantity); } return baseQuantity; }, [item.quantity, item.price.id, userDid, getMinQuantityForPending, canAdjustQuantity, minQuantity]); const [localQuantity, setLocalQuantity] = useState(initialQuantity); const localQuantityNum = localQuantity || 0; useEffect(() => { if (initialQuantity && initialQuantity > 0) { if (initialQuantity !== localQuantity) { setLocalQuantity(initialQuantity); } if (initialQuantity !== item.quantity) { onQuantityChange(item.price_id, initialQuantity); } if (isCreditProduct && pendingAmount && getMinQuantityForPending) { setPayable(initialQuantity >= getMinQuantityForPending); } else { setPayable(true); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialQuantity, isCreditProduct, pendingAmount, getMinQuantityForPending]); const handleQuantityChange = (newQuantity: number) => { if (!newQuantity) { setLocalQuantity(undefined); setPayable(false); return; } if (newQuantity >= minQuantity && newQuantity <= maxQuantity) { if (formatQuantityInventory(item.price, newQuantity, locale)) { return; } if (isCreditProduct && pendingAmount && getMinQuantityForPending) { if (newQuantity < getMinQuantityForPending) { setPayable(false); setLocalQuantity(newQuantity); onQuantityChange(item.price_id, newQuantity); return; } } setPayable(true); setLocalQuantity(newQuantity); onQuantityChange(item.price_id, newQuantity); if (userDid && newQuantity > 0) { saveUserQuantityPreference(userDid, item.price?.id, newQuantity); } } }; const handleQuantityIncrease = () => { if (localQuantityNum < maxQuantity) { handleQuantityChange(localQuantityNum + 1); } }; const handleQuantityDecrease = () => { if (localQuantityNum > minQuantity) { handleQuantityChange(localQuantityNum - 1); } }; const handleQuantityInputChange = (event: React.ChangeEvent) => { const value = parseInt(event.target.value || '0', 10); if (!Number.isNaN(value)) { handleQuantityChange(value); } }; // Credit 信息格式化 const formatCreditInfo = () => { if (!isCreditProduct) return null; const totalCreditStr = formatNumber(creditAmount * (localQuantity || 0)); const currencySymbol = creditCurrency?.symbol || 'Credits'; const formattedTotalCredit = formatCreditForCheckout(totalCreditStr, currencySymbol, locale); const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0)); const isRecurring = item.price?.type === 'recurring'; const hasExpiry = validDuration && validDuration > 0; const buildBaseParams = () => ({ amount: formattedTotalCredit, ...(hasExpiry && { duration: validDuration, unit: t(`common.${validDurationUnit}`), }), ...(isRecurring && { period: formatRecurring(item.price?.recurring!, true, 'per', locale), }), }); const buildPendingParams = (pendingBN: BN, availableAmount?: string) => ({ amount: formatCreditForCheckout( formatBNStr(pendingBN.toString(), creditCurrency?.decimal || 2), currencySymbol, locale ), totalAmount: formattedTotalCredit, ...(availableAmount && { availableAmount: formatCreditForCheckout( formatBNStr(availableAmount, creditCurrency?.decimal || 2), currencySymbol, locale ), }), ...(hasExpiry && { duration: validDuration, unit: t(`common.${validDurationUnit}`), }), ...(isRecurring && { period: formatRecurring(item.price?.recurring!, true, 'per', locale), }), }); const getLocaleKey = (category: 'normal' | 'pending', type: string) => { const suffix = hasExpiry ? 'WithExpiry' : ''; return `payment.checkout.credit.${category}.${type}${suffix}`; }; if (!hasPendingAmount) { const type = isRecurring ? 'recurring' : 'oneTime'; return t(getLocaleKey('normal', type), buildBaseParams()); } const pendingAmountBN = new BN(pendingAmount || '0'); const creditAmountBN = fromTokenToUnit(creditAmount, creditCurrency?.decimal || 2); const minQuantityNeeded = getMinQuantityForPending || 0; const currentPurchaseCreditBN = creditAmountBN.mul(new BN(localQuantity || 0)); const actualAvailable = currentPurchaseCreditBN.sub(pendingAmountBN).toString(); if (!new BN(actualAvailable).gt(new BN(0))) { return t('payment.checkout.credit.pending.notEnough', { amount: formatCreditForCheckout( formatBNStr(pendingAmountBN.toString(), creditCurrency?.decimal || 2), currencySymbol, locale ), quantity: formatNumber(minQuantityNeeded), }); } const type = isRecurring ? 'recurringEnough' : 'oneTimeEnough'; return t(getLocaleKey('pending', type), buildPendingParams(pendingAmountBN, actualAvailable)); }; const quantityForUsd = Number.isFinite(localQuantity) ? Number(localQuantity) : item.quantity || 0; // For dynamic pricing, calculate token display from live exchange rate // Final Freeze: Only use live rate (prop) for preview, quoted_amount for locked quotes // Also supports non-dynamic pricing with exchange rate (e.g., cross-sell products) const dynamicTokenDisplay = useMemo(() => { if (isStripePayment || !currency) { return null; } // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing) const baseAmount = (pricingSource as any)?.base_amount; const unitAmount = pricingSource?.unit_amount; // For dynamic pricing products if (isDynamicPricing && baseAmount !== undefined && baseAmount !== null) { // Only use quoted_amount when price is locked (after submit) if (isPriceLocked) { const quotedAmount = (item as any)?.quoted_amount; if (quotedAmount) { const totalBN = new BN(quotedAmount); const tokenValue = fromUnitToToken(totalBN, currency.decimal); return `${formatDynamicPrice(tokenValue, true, 6)} ${currency.symbol}`; } } if (!exchangeRate) { return null; } const rate = Number(exchangeRate); if (rate <= 0 || Number.isNaN(rate)) { return null; } // base_amount is stored in dollar format (e.g., "1.00" = $1.00), NOT cents const usdAmount = Number(baseAmount); const tokenAmount = usdAmount / rate; const totalTokens = tokenAmount * (quantityForUsd || 1); return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`; } // For non-dynamic pricing products (e.g., cross-sell): convert USD to current currency using rate // This allows showing "10 PLAY3" instead of "$1.00" when paying with crypto if (!isDynamicPricing && exchangeRate && unitAmount) { const rate = Number(exchangeRate); if (rate <= 0 || Number.isNaN(rate)) { return null; } // unit_amount is in cents, convert to dollars first const usdAmount = Number(unitAmount) / 100; const tokenAmount = usdAmount / rate; const totalTokens = tokenAmount * (quantityForUsd || 1); return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`; } return null; }, [isStripePayment, isDynamicPricing, currency, pricingSource, item, exchangeRate, quantityForUsd, isPriceLocked]); // Format credit schedule info for display const formatScheduleInfo = () => { if (!hasSchedule || !scheduleConfig) return null; const totalCredit = creditAmount * (localQuantity || 0); const currencySymbol = creditCurrency?.symbol || 'Credits'; const intervalUnit = scheduleConfig.interval_unit; const intervalValue = scheduleConfig.interval_value; // Calculate amount per grant let amountPerGrant: number; if (scheduleConfig.amount_per_grant) { amountPerGrant = Number(scheduleConfig.amount_per_grant) * (localQuantity || 1); } else { // Divide total by period intervals const billingIntervalUnit = item.price.recurring?.interval || 'month'; const billingIntervalCount = item.price.recurring?.interval_count || 1; // Calculate how many schedule intervals fit in billing period const unitToHours: Record = { hour: 1, day: 24, week: 168, month: 720, // ~30 days }; const billingHours = unitToHours[billingIntervalUnit] * billingIntervalCount; const scheduleHours = unitToHours[intervalUnit] * intervalValue; const grantsPerPeriod = Math.floor(billingHours / scheduleHours); amountPerGrant = grantsPerPeriod > 0 ? totalCredit / grantsPerPeriod : totalCredit; } const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol); const intervalDisplay = intervalValue === 1 ? t(`common.${intervalUnit}`) : ` ${intervalValue} ${t(`common.${intervalUnit}s` as 'common.hours' | 'common.days' | 'common.weeks' | 'common.months')} `; const expireWithNext = scheduleConfig.expire_with_next_grant; if (expireWithNext) { return t('payment.checkout.credit.schedule.withRefresh', { amount: formattedAmount, interval: intervalDisplay, }); } return t('payment.checkout.credit.schedule.periodic', { amount: formattedAmount, interval: intervalDisplay, }); }; const primaryText = useMemo(() => { const price = item.upsell_price || item.price || {}; const isRecurring = price?.type === 'recurring' && price?.recurring; // For trial scenarios, always use the formatted trial message from pricing.primary // (e.g., "Free 7-day trial") instead of the calculated token amount if (isTrial) { return pricing.primary; } // Determine display amount based on payment type and available data let displayAmount: string; // If we have dynamicTokenDisplay (dynamic pricing OR cross-sell with exchange rate), use it if (!isStripePayment && dynamicTokenDisplay) { displayAmount = dynamicTokenDisplay; } else if (isDynamicPricing && !isStripePayment) { // Dynamic pricing but no rate available - show USD fallback const baseAmount = (pricingSource as any)?.base_amount; if (baseAmount !== undefined && baseAmount !== null) { const usdValue = Number(baseAmount); displayAmount = `≈ $${usdValue.toFixed(2)}`; } else { displayAmount = pricing.primary; } } else { // Stripe payment or no special handling needed displayAmount = pricing.primary; } if (isRecurring && price?.recurring?.usage_type !== 'metered') { return `${displayAmount} ${price.recurring ? formatRecurring(price.recurring, false, 'slash', locale) : ''}`; } return displayAmount; }, [isTrial, pricing, item, locale, dynamicTokenDisplay, isDynamicPricing, pricingSource, isStripePayment]); const usdReference = useMemo(() => { // Stripe payments don't need USD reference - base_amount is already USD if (!currency || !isDynamicPricing || isStripePayment) { return null; } const baseAmount = (pricingSource as any)?.base_amount; const hasBaseAmount = baseAmount !== undefined && baseAmount !== null; if (hasBaseAmount) { return getUsdAmountFromBaseAmount(baseAmount, quantityForUsd); } return null; }, [currency, pricingSource, quantityForUsd, isDynamicPricing, isStripePayment]); const usdReferenceDisplay = useMemo(() => formatUsdAmount(usdReference, locale), [usdReference, locale]); // Calculate upsell price display with exchange rate conversion // Shows "X PLAY3 每月" instead of "$1.00 每月" when paying with crypto // For Stripe, shows "1 USD 每月" instead of "$1.00 每月" const upsellTokenDisplay = useMemo(() => { const upsellPrice = item.price?.upsell?.upsells_to; if (!upsellPrice) { return null; } // For Stripe payment: format as "1 USD 每月" instead of "$1.00 每月" if (isStripePayment && currency) { const baseAmount = (upsellPrice as any)?.base_amount; const unitAmount = upsellPrice?.unit_amount; let usdAmount: number; if (baseAmount !== undefined && baseAmount !== null) { usdAmount = Number(baseAmount); } else if (unitAmount) { usdAmount = Number(unitAmount) / 100; } else { return null; } const recurring = upsellPrice?.recurring ? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}` : ''; return `${usdAmount} ${currency.symbol}${recurring}`; } // For crypto payment: need exchange rate if (!currency || !exchangeRate) { return null; } const rate = Number(exchangeRate); if (rate <= 0 || Number.isNaN(rate)) { return null; } // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing) const baseAmount = (upsellPrice as any)?.base_amount; const unitAmount = upsellPrice?.unit_amount; let usdAmount: number; if (baseAmount !== undefined && baseAmount !== null) { // base_amount is in dollar format (e.g., "1.00" = $1.00) usdAmount = Number(baseAmount); } else if (unitAmount) { // unit_amount is in cents usdAmount = Number(unitAmount) / 100; } else { return null; } const tokenAmount = usdAmount / rate; const recurring = upsellPrice?.recurring ? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}` : ''; return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`; }, [isStripePayment, currency, exchangeRate, item.price?.upsell?.upsells_to, locale]); // Calculate downsell price display with exchange rate conversion // Shows "X ABT 每天" instead of "$0.50 每天" when paying with crypto const downsellTokenDisplay = useMemo(() => { // Only show when upsell is active (item.upsell_price_id exists) if (!item.upsell_price_id || !item.price) { return null; } const originalPrice = item.price; // For Stripe payment: format as "0.5 USD 每天" instead of "$0.50 每天" if (isStripePayment && currency) { const baseAmount = (originalPrice as any)?.base_amount; const unitAmount = originalPrice?.unit_amount; let usdAmount: number; if (baseAmount !== undefined && baseAmount !== null) { usdAmount = Number(baseAmount); } else if (unitAmount) { usdAmount = Number(unitAmount) / 100; } else { return null; } const recurring = originalPrice?.recurring ? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}` : ''; return `${usdAmount} ${currency.symbol}${recurring}`; } // For crypto payment: need exchange rate if (!currency || !exchangeRate) { return null; } const rate = Number(exchangeRate); if (rate <= 0 || Number.isNaN(rate)) { return null; } // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing) const baseAmount = (originalPrice as any)?.base_amount; const unitAmount = originalPrice?.unit_amount; let usdAmount: number; if (baseAmount !== undefined && baseAmount !== null) { usdAmount = Number(baseAmount); } else if (unitAmount) { usdAmount = Number(unitAmount) / 100; } else { return null; } const tokenAmount = usdAmount / rate; const recurring = originalPrice?.recurring ? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}` : ''; return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`; }, [isStripePayment, currency, exchangeRate, item.price, item.upsell_price_id, locale]); // Calculate frontend-displayed discount amount for this item // For dynamic pricing: use coupon info + exchange rate to calculate // For Stripe: calculate from USD amounts // For single-item scenarios: use calculatedDiscountAmount directly const displayItemDiscountAmount = useMemo(() => { // Skip discount display when no payment is made: // 1. During trial for recurring items - trial period has no charge // 2. Metered pricing - initial amount is 0 (charged based on actual usage) if ((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') { return null; } // Skip if no discounts if (!discounts?.length) { return null; } const couponDetails = discounts[0]?.coupon_details; // For Stripe payment: calculate discount from USD amounts (in cents) if (isStripePayment && couponDetails) { // Check if this item is discountable if (!(item as any).discountable) { return null; } // Get item's USD amount in cents const isDynamic = (pricingSource as any)?.pricing_type === 'dynamic'; const baseAmount = (pricingSource as any)?.base_amount; const unitAmount = pricingSource?.unit_amount; let amountCents: number; if (isDynamic && baseAmount !== undefined && baseAmount !== null) { // base_amount is in dollars, convert to cents amountCents = Number(baseAmount) * 100; } else if (unitAmount) { // unit_amount is in cents amountCents = Number(unitAmount); } else { return null; } const quantity = localQuantity || item.quantity || 1; const itemSubtotalCents = amountCents * quantity; // For percent_off: itemAmount * percent_off / 100 if (couponDetails.percent_off && couponDetails.percent_off > 0) { const discountCents = Math.round((itemSubtotalCents * couponDetails.percent_off) / 100); return discountCents.toString(); } // For amount_off with single item: use the passed calculatedDiscountAmount if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) { return calculatedDiscountAmount; } return null; } // For dynamic pricing with live rate, calculate discount from scratch if (isDynamicPricing && exchangeRate && couponDetails) { const rateNum = Number(exchangeRate); if (rateNum <= 0) { return null; } // Check if this item is discountable if (!(item as any).discountable) { return null; } // Get item's base amount const baseAmount = (pricingSource as any)?.base_amount; if (!baseAmount) { return null; } const quantity = localQuantity || item.quantity || 1; const itemTokenAmount = (Number(baseAmount) * quantity) / rateNum; // For percent_off: itemAmount * percent_off / 100 if (couponDetails.percent_off && couponDetails.percent_off > 0) { const discountAmount = (itemTokenAmount * couponDetails.percent_off) / 100; const discountAmountUnit = fromTokenToUnit( discountAmount.toFixed(currency.decimal || 8), currency.decimal || 8 ); return discountAmountUnit.toString(); } // For amount_off with single item: use the passed calculatedDiscountAmount if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) { return calculatedDiscountAmount; } } // Fallback for single item: use passed calculatedDiscountAmount if (items.length === 1 && calculatedDiscountAmount) { return calculatedDiscountAmount; } return null; }, [ isTrial, discounts, isStripePayment, isDynamicPricing, exchangeRate, item, pricingSource, localQuantity, currency.decimal, items.length, calculatedDiscountAmount, ]); // Get discount code for display const discountCodeDisplay = useMemo(() => { if (!discounts?.length) return null; const discount = discounts[0]; return discount.promotion_code_details?.code || discount.verification_data?.code || 'DISCOUNT'; }, [discounts]); const quantityInventoryError = formatQuantityInventory(item.price, localQuantityNum, locale); const features = item.price?.product?.features || []; return ( {item.price?.type === 'recurring' && item.price?.recurring ? [pricing.quantity, t('common.billed', { rule: `${formatRecurring(item.upsell_price?.recurring || item.price?.recurring, true, 'per', locale)} ${metered}` })].filter(Boolean).join(', ') // prettier-ignore : pricing.quantity} } /> {/* For trial scenarios, don't show USD reference or secondary pricing - they're redundant */} {/* No skeleton needed - USD equivalent is based on base_amount which doesn't change */} {isDynamicPricing && !isStripePayment && !isTrial && usdReferenceDisplay && ( ≈ ${usdReferenceDisplay} )} {pricing.secondary && !isTrial && ( {pricing.secondary} )} {/* Display discount information for this item */} {/* Priority: use frontend-calculated displayItemDiscountAmount for dynamic pricing */} {/* Fallback: use backend item.discount_amounts for non-dynamic pricing */} {/* Hide when no payment: trial for recurring items, or metered pricing (initial amount is 0) */} {!((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') && (displayItemDiscountAmount || (item.discount_amounts && item.discount_amounts.length > 0)) && ( } label={ {discountCodeDisplay || 'DISCOUNT'} (- {formatAmount( displayItemDiscountAmount || item.discount_amounts?.[0]?.amount || '0', currency.decimal )}{' '} {currency.symbol}) } size="small" variant="filled" sx={{ height: 20, '& .MuiChip-icon': { color: 'warning.main', }, '& .MuiChip-label': { px: 1, }, }} /> )} {showFeatures && features.length > 0 && ( {features.map((feature) => ( {feature.name} ))} )} {quantityInventoryError ? ( ) : null} {/* 数量调整器 */} {canAdjustQuantity && !completed && ( {t('common.quantity')}: = maxQuantity} sx={{ minWidth: 32, width: 32, height: 32 }}> )} {/* Credit 信息展示 */} {isCreditProduct && ( {hasSchedule ? ( {formatScheduleInfo()} ) : ( {formatCreditInfo()} )} )} {children} {canUpsell && !item.upsell_price_id && item.price?.upsell?.upsells_to && ( onUpsell(item.price_id, item.price?.upsell?.upsells_to_id)} /> {t('payment.checkout.upsell.save', { recurring: formatRecurring( item.price?.upsell?.upsells_to?.recurring as PriceRecurring, true, 'per', locale ), })} {upsellTokenDisplay || formatPrice(item.price?.upsell?.upsells_to, currency, item.price?.product?.unit_label, 1, true, locale)} )} {canUpsell && item.upsell_price_id && ( onDownsell(item.upsell_price_id)} /> {t('payment.checkout.upsell.revert', { recurring: t(`common.${formatRecurring(item.price?.recurring as PriceRecurring)}`), })} {downsellTokenDisplay || formatPrice(item.price, currency, item.price?.product?.unit_label, 1, true, locale)} )} ); }