@blocklet/payment-react 1.24.3 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/es/components/auto-topup/modal.d.ts +2 -0
  2. package/es/components/auto-topup/modal.js +48 -6
  3. package/es/components/auto-topup/product-card.d.ts +16 -1
  4. package/es/components/auto-topup/product-card.js +97 -15
  5. package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
  6. package/es/components/dynamic-pricing-unavailable.js +58 -0
  7. package/es/components/loading-amount.d.ts +17 -0
  8. package/es/components/loading-amount.js +46 -0
  9. package/es/components/price-change-confirm.d.ts +18 -0
  10. package/es/components/price-change-confirm.js +107 -0
  11. package/es/components/quote-details-panel.d.ts +21 -0
  12. package/es/components/quote-details-panel.js +170 -0
  13. package/es/components/quote-lock-banner.d.ts +7 -0
  14. package/es/components/quote-lock-banner.js +79 -0
  15. package/es/components/slippage-config.d.ts +20 -0
  16. package/es/components/slippage-config.js +261 -0
  17. package/es/history/credit/transactions-list.js +11 -1
  18. package/es/history/invoice/list.js +125 -15
  19. package/es/hooks/dynamic-pricing.d.ts +102 -0
  20. package/es/hooks/dynamic-pricing.js +393 -0
  21. package/es/index.d.ts +6 -1
  22. package/es/index.js +9 -1
  23. package/es/libs/util.d.ts +42 -5
  24. package/es/libs/util.js +345 -57
  25. package/es/locales/en.js +114 -3
  26. package/es/locales/zh.js +114 -3
  27. package/es/payment/form/index.d.ts +4 -1
  28. package/es/payment/form/index.js +454 -22
  29. package/es/payment/index.d.ts +1 -1
  30. package/es/payment/index.js +279 -16
  31. package/es/payment/product-item.d.ts +26 -1
  32. package/es/payment/product-item.js +330 -51
  33. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  34. package/es/payment/summary-section/promotion-section.js +143 -0
  35. package/es/payment/summary-section/total-section.d.ts +39 -0
  36. package/es/payment/summary-section/total-section.js +83 -0
  37. package/es/payment/summary.d.ts +17 -2
  38. package/es/payment/summary.js +300 -253
  39. package/es/types/index.d.ts +11 -0
  40. package/lib/components/auto-topup/modal.d.ts +2 -0
  41. package/lib/components/auto-topup/modal.js +54 -6
  42. package/lib/components/auto-topup/product-card.d.ts +16 -1
  43. package/lib/components/auto-topup/product-card.js +75 -7
  44. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  45. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  46. package/lib/components/loading-amount.d.ts +17 -0
  47. package/lib/components/loading-amount.js +53 -0
  48. package/lib/components/price-change-confirm.d.ts +18 -0
  49. package/lib/components/price-change-confirm.js +157 -0
  50. package/lib/components/quote-details-panel.d.ts +21 -0
  51. package/lib/components/quote-details-panel.js +226 -0
  52. package/lib/components/quote-lock-banner.d.ts +7 -0
  53. package/lib/components/quote-lock-banner.js +93 -0
  54. package/lib/components/slippage-config.d.ts +20 -0
  55. package/lib/components/slippage-config.js +316 -0
  56. package/lib/history/credit/transactions-list.js +11 -1
  57. package/lib/history/invoice/list.js +167 -27
  58. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  59. package/lib/hooks/dynamic-pricing.js +390 -0
  60. package/lib/index.d.ts +6 -1
  61. package/lib/index.js +32 -0
  62. package/lib/libs/util.d.ts +42 -5
  63. package/lib/libs/util.js +367 -49
  64. package/lib/locales/en.js +114 -3
  65. package/lib/locales/zh.js +114 -3
  66. package/lib/payment/form/index.d.ts +4 -1
  67. package/lib/payment/form/index.js +476 -20
  68. package/lib/payment/index.d.ts +1 -1
  69. package/lib/payment/index.js +308 -14
  70. package/lib/payment/product-item.d.ts +26 -1
  71. package/lib/payment/product-item.js +270 -35
  72. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  73. package/lib/payment/summary-section/promotion-section.js +133 -0
  74. package/lib/payment/summary-section/total-section.d.ts +39 -0
  75. package/lib/payment/summary-section/total-section.js +117 -0
  76. package/lib/payment/summary.d.ts +17 -2
  77. package/lib/payment/summary.js +205 -127
  78. package/lib/types/index.d.ts +11 -0
  79. package/package.json +3 -3
  80. package/src/components/auto-topup/modal.tsx +59 -6
  81. package/src/components/auto-topup/product-card.tsx +118 -11
  82. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  83. package/src/components/loading-amount.tsx +66 -0
  84. package/src/components/price-change-confirm.tsx +136 -0
  85. package/src/components/quote-details-panel.tsx +218 -0
  86. package/src/components/quote-lock-banner.tsx +99 -0
  87. package/src/components/slippage-config.tsx +336 -0
  88. package/src/history/credit/transactions-list.tsx +14 -1
  89. package/src/history/invoice/list.tsx +143 -9
  90. package/src/hooks/dynamic-pricing.ts +617 -0
  91. package/src/index.ts +9 -0
  92. package/src/libs/util.ts +473 -58
  93. package/src/locales/en.tsx +117 -0
  94. package/src/locales/zh.tsx +111 -0
  95. package/src/payment/form/index.tsx +561 -19
  96. package/src/payment/index.tsx +349 -10
  97. package/src/payment/product-item.tsx +451 -37
  98. package/src/payment/summary-section/promotion-section.tsx +172 -0
  99. package/src/payment/summary-section/total-section.tsx +141 -0
  100. package/src/payment/summary.tsx +334 -192
  101. package/src/types/index.ts +15 -0
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/indent */
1
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
3
  import type { PriceRecurring, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
3
4
  import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from '@mui/material';
@@ -5,7 +6,8 @@ import { Add, Remove, LocalOffer } from '@mui/icons-material';
5
6
 
6
7
  import React, { useEffect, useMemo, useState } from 'react';
7
8
  import { useRequest } from 'ahooks';
8
- import { BN, fromTokenToUnit } from '@ocap/util';
9
+ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
10
+ import LoadingAmount from '../components/loading-amount';
9
11
  import Status from '../components/status';
10
12
  import Switch from '../components/switch-button';
11
13
  import {
@@ -20,11 +22,32 @@ import {
20
22
  formatCreditForCheckout,
21
23
  formatCreditAmount,
22
24
  formatBNStr,
25
+ getUsdAmountFromBaseAmount,
26
+ formatUsdAmount,
27
+ formatDynamicPrice,
23
28
  } from '../libs/util';
24
29
  import ProductCard from './product-card';
25
30
  import dayjs from '../libs/dayjs';
26
31
  import { usePaymentContext } from '../contexts/payment';
27
32
 
33
+ interface DiscountInfo {
34
+ promotion_code?: string;
35
+ coupon?: string;
36
+ discount_amount?: string;
37
+ promotion_code_details?: {
38
+ code?: string;
39
+ };
40
+ coupon_details?: {
41
+ percent_off?: number;
42
+ amount_off?: string;
43
+ currency_id?: string;
44
+ currency_options?: Record<string, { amount_off?: string }>;
45
+ };
46
+ verification_data?: {
47
+ code?: string;
48
+ };
49
+ }
50
+
28
51
  type Props = {
29
52
  item: TLineItemExpanded;
30
53
  items: TLineItemExpanded[];
@@ -44,6 +67,13 @@ type Props = {
44
67
  onQuantityChange?: (itemId: string, quantity: number) => void;
45
68
  completed?: boolean;
46
69
  showFeatures?: boolean;
70
+ exchangeRate?: string | null;
71
+ isStripePayment?: boolean;
72
+ isPriceLocked?: boolean;
73
+ isRateLoading?: boolean;
74
+ // Discount display props
75
+ discounts?: DiscountInfo[];
76
+ calculatedDiscountAmount?: string | null;
47
77
  };
48
78
 
49
79
  const getRecommendedQuantityFromUrl = (priceId: string): number | undefined => {
@@ -89,13 +119,22 @@ export default function ProductItem({
89
119
  adjustableQuantity = { enabled: false },
90
120
  onQuantityChange = () => {},
91
121
  showFeatures = false,
122
+ exchangeRate = null,
123
+ isStripePayment = false,
124
+ isPriceLocked = false,
125
+ isRateLoading = false,
126
+ discounts = [],
127
+ calculatedDiscountAmount = null,
92
128
  }: Props) {
93
129
  const { t, locale } = useLocaleContext();
94
130
  const { settings, setPayable, session, api } = usePaymentContext();
95
- const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays }, locale);
131
+ const pricingSource = item.upsell_price || item.price;
132
+ const isDynamicPricing = (pricingSource as any)?.pricing_type === 'dynamic';
133
+ const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays, exchangeRate }, locale);
96
134
  const saving = formatUpsellSaving(items, currency);
97
135
  const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : '';
98
136
  const canUpsell = mode === 'normal' && items.length === 1;
137
+ const isTrial = trialInDays > 0 || trialEnd > dayjs().unix();
99
138
 
100
139
  // Check if this is a credit product - be more lenient in detection
101
140
  const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config;
@@ -137,8 +176,12 @@ export default function ProductItem({
137
176
  );
138
177
 
139
178
  const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
179
+
140
180
  const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
141
- const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
181
+ const quantityAvailable = Math.min(
182
+ item.price?.quantity_limit_per_checkout ?? Infinity,
183
+ item.price?.quantity_available ?? Infinity
184
+ );
142
185
  const maxQuantity = quantityAvailable
143
186
  ? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable)
144
187
  : adjustableQuantity.maximum || Infinity;
@@ -230,7 +273,7 @@ export default function ProductItem({
230
273
  onQuantityChange(item.price_id, newQuantity);
231
274
 
232
275
  if (userDid && newQuantity > 0) {
233
- saveUserQuantityPreference(userDid, item.price.id, newQuantity);
276
+ saveUserQuantityPreference(userDid, item.price?.id, newQuantity);
234
277
  }
235
278
  }
236
279
  };
@@ -263,7 +306,7 @@ export default function ProductItem({
263
306
  const formattedTotalCredit = formatCreditForCheckout(totalCreditStr, currencySymbol, locale);
264
307
 
265
308
  const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0));
266
- const isRecurring = item.price.type === 'recurring';
309
+ const isRecurring = item.price?.type === 'recurring';
267
310
  const hasExpiry = validDuration && validDuration > 0;
268
311
 
269
312
  const buildBaseParams = () => ({
@@ -273,7 +316,7 @@ export default function ProductItem({
273
316
  unit: t(`common.${validDurationUnit}`),
274
317
  }),
275
318
  ...(isRecurring && {
276
- period: formatRecurring(item.price.recurring!, true, 'per', locale),
319
+ period: formatRecurring(item.price?.recurring!, true, 'per', locale),
277
320
  }),
278
321
  });
279
322
 
@@ -296,7 +339,7 @@ export default function ProductItem({
296
339
  unit: t(`common.${validDurationUnit}`),
297
340
  }),
298
341
  ...(isRecurring && {
299
- period: formatRecurring(item.price.recurring!, true, 'per', locale),
342
+ period: formatRecurring(item.price?.recurring!, true, 'per', locale),
300
343
  }),
301
344
  });
302
345
 
@@ -331,6 +374,63 @@ export default function ProductItem({
331
374
  return t(getLocaleKey('pending', type), buildPendingParams(pendingAmountBN, actualAvailable));
332
375
  };
333
376
 
377
+ const quantityForUsd = Number.isFinite(localQuantity) ? Number(localQuantity) : item.quantity || 0;
378
+
379
+ // For dynamic pricing, calculate token display from live exchange rate
380
+ // Final Freeze: Only use live rate (prop) for preview, quoted_amount for locked quotes
381
+ // Also supports non-dynamic pricing with exchange rate (e.g., cross-sell products)
382
+ const dynamicTokenDisplay = useMemo(() => {
383
+ if (isStripePayment || !currency) {
384
+ return null;
385
+ }
386
+
387
+ // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
388
+ const baseAmount = (pricingSource as any)?.base_amount;
389
+ const unitAmount = pricingSource?.unit_amount;
390
+
391
+ // For dynamic pricing products
392
+ if (isDynamicPricing && baseAmount !== undefined && baseAmount !== null) {
393
+ // Only use quoted_amount when price is locked (after submit)
394
+ if (isPriceLocked) {
395
+ const quotedAmount = (item as any)?.quoted_amount;
396
+ if (quotedAmount) {
397
+ const totalBN = new BN(quotedAmount);
398
+ const tokenValue = fromUnitToToken(totalBN, currency.decimal);
399
+ return `${formatDynamicPrice(tokenValue, true, 6)} ${currency.symbol}`;
400
+ }
401
+ }
402
+
403
+ if (!exchangeRate) {
404
+ return null;
405
+ }
406
+ const rate = Number(exchangeRate);
407
+ if (rate <= 0 || Number.isNaN(rate)) {
408
+ return null;
409
+ }
410
+ // base_amount is stored in dollar format (e.g., "1.00" = $1.00), NOT cents
411
+ const usdAmount = Number(baseAmount);
412
+ const tokenAmount = usdAmount / rate;
413
+ const totalTokens = tokenAmount * (quantityForUsd || 1);
414
+ return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`;
415
+ }
416
+
417
+ // For non-dynamic pricing products (e.g., cross-sell): convert USD to current currency using rate
418
+ // This allows showing "10 PLAY3" instead of "$1.00" when paying with crypto
419
+ if (!isDynamicPricing && exchangeRate && unitAmount) {
420
+ const rate = Number(exchangeRate);
421
+ if (rate <= 0 || Number.isNaN(rate)) {
422
+ return null;
423
+ }
424
+ // unit_amount is in cents, convert to dollars first
425
+ const usdAmount = Number(unitAmount) / 100;
426
+ const tokenAmount = usdAmount / rate;
427
+ const totalTokens = tokenAmount * (quantityForUsd || 1);
428
+ return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`;
429
+ }
430
+
431
+ return null;
432
+ }, [isStripePayment, isDynamicPricing, currency, pricingSource, item, exchangeRate, quantityForUsd, isPriceLocked]);
433
+
334
434
  // Format credit schedule info for display
335
435
  const formatScheduleInfo = () => {
336
436
  if (!hasSchedule || !scheduleConfig) return null;
@@ -386,15 +486,299 @@ export default function ProductItem({
386
486
  const primaryText = useMemo(() => {
387
487
  const price = item.upsell_price || item.price || {};
388
488
  const isRecurring = price?.type === 'recurring' && price?.recurring;
389
- const trial = trialInDays > 0 || trialEnd > dayjs().unix();
390
- if (isRecurring && !trial && price?.recurring?.usage_type !== 'metered') {
391
- return `${pricing.primary} ${price.recurring ? formatRecurring(price.recurring, false, 'slash', locale) : ''}`;
489
+
490
+ // For trial scenarios, always use the formatted trial message from pricing.primary
491
+ // (e.g., "Free 7-day trial") instead of the calculated token amount
492
+ if (isTrial) {
493
+ return pricing.primary;
494
+ }
495
+
496
+ // Determine display amount based on payment type and available data
497
+ let displayAmount: string;
498
+
499
+ // If we have dynamicTokenDisplay (dynamic pricing OR cross-sell with exchange rate), use it
500
+ if (!isStripePayment && dynamicTokenDisplay) {
501
+ displayAmount = dynamicTokenDisplay;
502
+ } else if (isDynamicPricing && !isStripePayment) {
503
+ // Dynamic pricing but no rate available - show USD fallback
504
+ const baseAmount = (pricingSource as any)?.base_amount;
505
+ if (baseAmount !== undefined && baseAmount !== null) {
506
+ const usdValue = Number(baseAmount);
507
+ displayAmount = `≈ $${usdValue.toFixed(2)}`;
508
+ } else {
509
+ displayAmount = pricing.primary;
510
+ }
511
+ } else {
512
+ // Stripe payment or no special handling needed
513
+ displayAmount = pricing.primary;
392
514
  }
393
- return pricing.primary;
394
- }, [trialInDays, trialEnd, pricing, item, locale]);
515
+
516
+ if (isRecurring && price?.recurring?.usage_type !== 'metered') {
517
+ return `${displayAmount} ${price.recurring ? formatRecurring(price.recurring, false, 'slash', locale) : ''}`;
518
+ }
519
+ return displayAmount;
520
+ }, [isTrial, pricing, item, locale, dynamicTokenDisplay, isDynamicPricing, pricingSource, isStripePayment]);
521
+
522
+ const usdReference = useMemo(() => {
523
+ // Stripe payments don't need USD reference - base_amount is already USD
524
+ if (!currency || !isDynamicPricing || isStripePayment) {
525
+ return null;
526
+ }
527
+ const baseAmount = (pricingSource as any)?.base_amount;
528
+ const hasBaseAmount = baseAmount !== undefined && baseAmount !== null;
529
+ if (hasBaseAmount) {
530
+ return getUsdAmountFromBaseAmount(baseAmount, quantityForUsd);
531
+ }
532
+ return null;
533
+ }, [currency, pricingSource, quantityForUsd, isDynamicPricing, isStripePayment]);
534
+ const usdReferenceDisplay = useMemo(() => formatUsdAmount(usdReference, locale), [usdReference, locale]);
535
+
536
+ // Calculate upsell price display with exchange rate conversion
537
+ // Shows "X PLAY3 每月" instead of "$1.00 每月" when paying with crypto
538
+ // For Stripe, shows "1 USD 每月" instead of "$1.00 每月"
539
+ const upsellTokenDisplay = useMemo(() => {
540
+ const upsellPrice = item.price?.upsell?.upsells_to;
541
+ if (!upsellPrice) {
542
+ return null;
543
+ }
544
+
545
+ // For Stripe payment: format as "1 USD 每月" instead of "$1.00 每月"
546
+ if (isStripePayment && currency) {
547
+ const baseAmount = (upsellPrice as any)?.base_amount;
548
+ const unitAmount = upsellPrice?.unit_amount;
549
+
550
+ let usdAmount: number;
551
+ if (baseAmount !== undefined && baseAmount !== null) {
552
+ usdAmount = Number(baseAmount);
553
+ } else if (unitAmount) {
554
+ usdAmount = Number(unitAmount) / 100;
555
+ } else {
556
+ return null;
557
+ }
558
+
559
+ const recurring = upsellPrice?.recurring
560
+ ? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}`
561
+ : '';
562
+ return `${usdAmount} ${currency.symbol}${recurring}`;
563
+ }
564
+
565
+ // For crypto payment: need exchange rate
566
+ if (!currency || !exchangeRate) {
567
+ return null;
568
+ }
569
+
570
+ const rate = Number(exchangeRate);
571
+ if (rate <= 0 || Number.isNaN(rate)) {
572
+ return null;
573
+ }
574
+
575
+ // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
576
+ const baseAmount = (upsellPrice as any)?.base_amount;
577
+ const unitAmount = upsellPrice?.unit_amount;
578
+
579
+ let usdAmount: number;
580
+ if (baseAmount !== undefined && baseAmount !== null) {
581
+ // base_amount is in dollar format (e.g., "1.00" = $1.00)
582
+ usdAmount = Number(baseAmount);
583
+ } else if (unitAmount) {
584
+ // unit_amount is in cents
585
+ usdAmount = Number(unitAmount) / 100;
586
+ } else {
587
+ return null;
588
+ }
589
+
590
+ const tokenAmount = usdAmount / rate;
591
+ const recurring = upsellPrice?.recurring
592
+ ? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}`
593
+ : '';
594
+ return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`;
595
+ }, [isStripePayment, currency, exchangeRate, item.price?.upsell?.upsells_to, locale]);
596
+
597
+ // Calculate downsell price display with exchange rate conversion
598
+ // Shows "X ABT 每天" instead of "$0.50 每天" when paying with crypto
599
+ const downsellTokenDisplay = useMemo(() => {
600
+ // Only show when upsell is active (item.upsell_price_id exists)
601
+ if (!item.upsell_price_id || !item.price) {
602
+ return null;
603
+ }
604
+
605
+ const originalPrice = item.price;
606
+
607
+ // For Stripe payment: format as "0.5 USD 每天" instead of "$0.50 每天"
608
+ if (isStripePayment && currency) {
609
+ const baseAmount = (originalPrice as any)?.base_amount;
610
+ const unitAmount = originalPrice?.unit_amount;
611
+
612
+ let usdAmount: number;
613
+ if (baseAmount !== undefined && baseAmount !== null) {
614
+ usdAmount = Number(baseAmount);
615
+ } else if (unitAmount) {
616
+ usdAmount = Number(unitAmount) / 100;
617
+ } else {
618
+ return null;
619
+ }
620
+
621
+ const recurring = originalPrice?.recurring
622
+ ? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}`
623
+ : '';
624
+ return `${usdAmount} ${currency.symbol}${recurring}`;
625
+ }
626
+
627
+ // For crypto payment: need exchange rate
628
+ if (!currency || !exchangeRate) {
629
+ return null;
630
+ }
631
+
632
+ const rate = Number(exchangeRate);
633
+ if (rate <= 0 || Number.isNaN(rate)) {
634
+ return null;
635
+ }
636
+
637
+ // Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
638
+ const baseAmount = (originalPrice as any)?.base_amount;
639
+ const unitAmount = originalPrice?.unit_amount;
640
+
641
+ let usdAmount: number;
642
+ if (baseAmount !== undefined && baseAmount !== null) {
643
+ usdAmount = Number(baseAmount);
644
+ } else if (unitAmount) {
645
+ usdAmount = Number(unitAmount) / 100;
646
+ } else {
647
+ return null;
648
+ }
649
+
650
+ const tokenAmount = usdAmount / rate;
651
+ const recurring = originalPrice?.recurring
652
+ ? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}`
653
+ : '';
654
+ return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`;
655
+ }, [isStripePayment, currency, exchangeRate, item.price, item.upsell_price_id, locale]);
656
+
657
+ // Calculate frontend-displayed discount amount for this item
658
+ // For dynamic pricing: use coupon info + exchange rate to calculate
659
+ // For Stripe: calculate from USD amounts
660
+ // For single-item scenarios: use calculatedDiscountAmount directly
661
+ const displayItemDiscountAmount = useMemo(() => {
662
+ // Skip discount display when no payment is made:
663
+ // 1. During trial for recurring items - trial period has no charge
664
+ // 2. Metered pricing - initial amount is 0 (charged based on actual usage)
665
+ if ((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') {
666
+ return null;
667
+ }
668
+
669
+ // Skip if no discounts
670
+ if (!discounts?.length) {
671
+ return null;
672
+ }
673
+
674
+ const couponDetails = discounts[0]?.coupon_details;
675
+
676
+ // For Stripe payment: calculate discount from USD amounts (in cents)
677
+ if (isStripePayment && couponDetails) {
678
+ // Check if this item is discountable
679
+ if (!(item as any).discountable) {
680
+ return null;
681
+ }
682
+
683
+ // Get item's USD amount in cents
684
+ const isDynamic = (pricingSource as any)?.pricing_type === 'dynamic';
685
+ const baseAmount = (pricingSource as any)?.base_amount;
686
+ const unitAmount = pricingSource?.unit_amount;
687
+
688
+ let amountCents: number;
689
+ if (isDynamic && baseAmount !== undefined && baseAmount !== null) {
690
+ // base_amount is in dollars, convert to cents
691
+ amountCents = Number(baseAmount) * 100;
692
+ } else if (unitAmount) {
693
+ // unit_amount is in cents
694
+ amountCents = Number(unitAmount);
695
+ } else {
696
+ return null;
697
+ }
698
+
699
+ const quantity = localQuantity || item.quantity || 1;
700
+ const itemSubtotalCents = amountCents * quantity;
701
+
702
+ // For percent_off: itemAmount * percent_off / 100
703
+ if (couponDetails.percent_off && couponDetails.percent_off > 0) {
704
+ const discountCents = Math.round((itemSubtotalCents * couponDetails.percent_off) / 100);
705
+ return discountCents.toString();
706
+ }
707
+
708
+ // For amount_off with single item: use the passed calculatedDiscountAmount
709
+ if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) {
710
+ return calculatedDiscountAmount;
711
+ }
712
+
713
+ return null;
714
+ }
715
+
716
+ // For dynamic pricing with live rate, calculate discount from scratch
717
+ if (isDynamicPricing && exchangeRate && couponDetails) {
718
+ const rateNum = Number(exchangeRate);
719
+ if (rateNum <= 0) {
720
+ return null;
721
+ }
722
+
723
+ // Check if this item is discountable
724
+ if (!(item as any).discountable) {
725
+ return null;
726
+ }
727
+
728
+ // Get item's base amount
729
+ const baseAmount = (pricingSource as any)?.base_amount;
730
+ if (!baseAmount) {
731
+ return null;
732
+ }
733
+
734
+ const quantity = localQuantity || item.quantity || 1;
735
+ const itemTokenAmount = (Number(baseAmount) * quantity) / rateNum;
736
+
737
+ // For percent_off: itemAmount * percent_off / 100
738
+ if (couponDetails.percent_off && couponDetails.percent_off > 0) {
739
+ const discountAmount = (itemTokenAmount * couponDetails.percent_off) / 100;
740
+ const discountAmountUnit = fromTokenToUnit(
741
+ discountAmount.toFixed(currency.decimal || 8),
742
+ currency.decimal || 8
743
+ );
744
+ return discountAmountUnit.toString();
745
+ }
746
+
747
+ // For amount_off with single item: use the passed calculatedDiscountAmount
748
+ if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) {
749
+ return calculatedDiscountAmount;
750
+ }
751
+ }
752
+
753
+ // Fallback for single item: use passed calculatedDiscountAmount
754
+ if (items.length === 1 && calculatedDiscountAmount) {
755
+ return calculatedDiscountAmount;
756
+ }
757
+
758
+ return null;
759
+ }, [
760
+ isTrial,
761
+ discounts,
762
+ isStripePayment,
763
+ isDynamicPricing,
764
+ exchangeRate,
765
+ item,
766
+ pricingSource,
767
+ localQuantity,
768
+ currency.decimal,
769
+ items.length,
770
+ calculatedDiscountAmount,
771
+ ]);
772
+
773
+ // Get discount code for display
774
+ const discountCodeDisplay = useMemo(() => {
775
+ if (!discounts?.length) return null;
776
+ const discount = discounts[0];
777
+ return discount.promotion_code_details?.code || discount.verification_data?.code || 'DISCOUNT';
778
+ }, [discounts]);
395
779
 
396
780
  const quantityInventoryError = formatQuantityInventory(item.price, localQuantityNum, locale);
397
- const features = item.price.product?.features || [];
781
+ const features = item.price?.product?.features || [];
398
782
 
399
783
  return (
400
784
  <Stack
@@ -422,17 +806,17 @@ export default function ProductItem({
422
806
  width: '100%',
423
807
  }}>
424
808
  <ProductCard
425
- logo={item.price.product?.images[0]}
426
- name={item.price.product?.name}
427
- // description={showDescription ? item.price.product?.description : undefined}
809
+ logo={item.price?.product?.images[0]}
810
+ name={item.price?.product?.name}
811
+ // description={showDescription ? item.price?.product?.description : undefined}
428
812
  extra={
429
813
  <Box
430
814
  sx={{
431
815
  display: 'flex',
432
816
  alignItems: 'center',
433
817
  }}>
434
- {item.price.type === 'recurring' && item.price.recurring
435
- ? [pricing.quantity, t('common.billed', { rule: `${formatRecurring(item.upsell_price?.recurring || item.price.recurring, true, 'per', locale)} ${metered}` })].filter(Boolean).join(', ') // prettier-ignore
818
+ {item.price?.type === 'recurring' && item.price?.recurring
819
+ ? [pricing.quantity, t('common.billed', { rule: `${formatRecurring(item.upsell_price?.recurring || item.price?.recurring, true, 'per', locale)} ${metered}` })].filter(Boolean).join(', ') // prettier-ignore
436
820
  : pricing.quantity}
437
821
  </Box>
438
822
  }
@@ -443,29 +827,53 @@ export default function ProductItem({
443
827
  alignItems: 'flex-end',
444
828
  flex: 1,
445
829
  }}>
446
- <Typography sx={{ color: 'text.primary', fontWeight: 500, whiteSpace: 'nowrap' }} gutterBottom>
447
- {primaryText}
448
- </Typography>
449
- {pricing.secondary && (
830
+ <LoadingAmount
831
+ value={primaryText}
832
+ loading={isRateLoading}
833
+ skeletonWidth={80}
834
+ height={20}
835
+ sx={{ color: 'text.primary', fontWeight: 500 }}
836
+ />
837
+ {/* For trial scenarios, don't show USD reference or secondary pricing - they're redundant */}
838
+ {/* No skeleton needed - USD equivalent is based on base_amount which doesn't change */}
839
+ {isDynamicPricing && !isStripePayment && !isTrial && usdReferenceDisplay && (
840
+ <Typography sx={{ fontSize: '0.74375rem', color: 'text.lighter' }}>≈ ${usdReferenceDisplay}</Typography>
841
+ )}
842
+ {pricing.secondary && !isTrial && (
450
843
  <Typography sx={{ fontSize: '0.74375rem', color: 'text.lighter' }}>{pricing.secondary}</Typography>
451
844
  )}
452
845
  </Stack>
453
846
  </Stack>
454
847
 
455
848
  {/* Display discount information for this item */}
456
- {item.discount_amounts && item.discount_amounts.length > 0 && (
457
- <Stack direction="row" spacing={1} sx={{ mt: 1, alignItems: 'center' }}>
458
- {item.discount_amounts.map((discountAmount: any) => (
849
+ {/* Priority: use frontend-calculated displayItemDiscountAmount for dynamic pricing */}
850
+ {/* Fallback: use backend item.discount_amounts for non-dynamic pricing */}
851
+ {/* Hide when no payment: trial for recurring items, or metered pricing (initial amount is 0) */}
852
+ {!((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') &&
853
+ (displayItemDiscountAmount || (item.discount_amounts && item.discount_amounts.length > 0)) && (
854
+ <Stack
855
+ direction="row"
856
+ spacing={1}
857
+ sx={{
858
+ mt: 1,
859
+ alignItems: 'center',
860
+ opacity: isRateLoading ? 0 : 1,
861
+ transition: 'opacity 300ms ease-in-out',
862
+ }}>
459
863
  <Chip
460
- key={discountAmount.promotion_code}
461
864
  icon={<LocalOffer sx={{ fontSize: '0.8rem !important' }} />}
462
865
  label={
463
866
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
464
867
  <Typography component="span" sx={{ fontSize: '0.75rem', fontWeight: 'medium' }}>
465
- {discountAmount.promotion_code?.code || 'DISCOUNT'}
868
+ {discountCodeDisplay || 'DISCOUNT'}
466
869
  </Typography>
467
870
  <Typography component="span" sx={{ fontSize: '0.75rem' }}>
468
- (-{formatAmount(discountAmount.amount || '0', currency.decimal)} {currency.symbol})
871
+ (-
872
+ {formatAmount(
873
+ displayItemDiscountAmount || item.discount_amounts?.[0]?.amount || '0',
874
+ currency.decimal
875
+ )}{' '}
876
+ {currency.symbol})
469
877
  </Typography>
470
878
  </Box>
471
879
  }
@@ -481,9 +889,8 @@ export default function ProductItem({
481
889
  },
482
890
  }}
483
891
  />
484
- ))}
485
- </Stack>
486
- )}
892
+ </Stack>
893
+ )}
487
894
 
488
895
  {showFeatures && features.length > 0 && (
489
896
  <Box
@@ -613,7 +1020,7 @@ export default function ProductItem({
613
1020
 
614
1021
  {children}
615
1022
  </Stack>
616
- {canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && (
1023
+ {canUpsell && !item.upsell_price_id && item.price?.upsell?.upsells_to && (
617
1024
  <Stack
618
1025
  direction="row"
619
1026
  className="product-item-upsell"
@@ -635,10 +1042,15 @@ export default function ProductItem({
635
1042
  sx={{ mr: 1 }}
636
1043
  variant="success"
637
1044
  checked={false}
638
- onChange={() => onUpsell(item.price_id, item.price.upsell?.upsells_to_id)}
1045
+ onChange={() => onUpsell(item.price_id, item.price?.upsell?.upsells_to_id)}
639
1046
  />
640
1047
  {t('payment.checkout.upsell.save', {
641
- recurring: formatRecurring(item.price.upsell.upsells_to.recurring as PriceRecurring, true, 'per', locale),
1048
+ recurring: formatRecurring(
1049
+ item.price?.upsell?.upsells_to?.recurring as PriceRecurring,
1050
+ true,
1051
+ 'per',
1052
+ locale
1053
+ ),
642
1054
  })}
643
1055
  <Status
644
1056
  label={t('payment.checkout.upsell.off', { saving })}
@@ -652,7 +1064,8 @@ export default function ProductItem({
652
1064
  />
653
1065
  </Typography>
654
1066
  <Typography component="span" sx={{ fontSize: 12 }}>
655
- {formatPrice(item.price.upsell.upsells_to, currency, item.price.product?.unit_label, 1, true, locale)}
1067
+ {upsellTokenDisplay ||
1068
+ formatPrice(item.price?.upsell?.upsells_to, currency, item.price?.product?.unit_label, 1, true, locale)}
656
1069
  </Typography>
657
1070
  </Stack>
658
1071
  )}
@@ -681,11 +1094,12 @@ export default function ProductItem({
681
1094
  onChange={() => onDownsell(item.upsell_price_id)}
682
1095
  />
683
1096
  {t('payment.checkout.upsell.revert', {
684
- recurring: t(`common.${formatRecurring(item.price.recurring as PriceRecurring)}`),
1097
+ recurring: t(`common.${formatRecurring(item.price?.recurring as PriceRecurring)}`),
685
1098
  })}
686
1099
  </Typography>
687
1100
  <Typography component="span" sx={{ fontSize: 12 }}>
688
- {formatPrice(item.price, currency, item.price.product?.unit_label, 1, true, locale)}
1101
+ {downsellTokenDisplay ||
1102
+ formatPrice(item.price, currency, item.price?.product?.unit_label, 1, true, locale)}
689
1103
  </Typography>
690
1104
  </Stack>
691
1105
  )}