@blocklet/payment-react 1.25.10 → 1.26.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 (160) hide show
  1. package/es/checkout-v2/checkout-v2.d.ts +2 -0
  2. package/es/checkout-v2/checkout-v2.js +121 -0
  3. package/es/checkout-v2/components/dialogs/checkout-dialogs.d.ts +1 -0
  4. package/es/checkout-v2/components/dialogs/checkout-dialogs.js +106 -0
  5. package/es/checkout-v2/components/left/billing-toggle.d.ts +6 -0
  6. package/es/checkout-v2/components/left/billing-toggle.js +118 -0
  7. package/es/checkout-v2/components/left/cross-sell-card.d.ts +10 -0
  8. package/es/checkout-v2/components/left/cross-sell-card.js +167 -0
  9. package/es/checkout-v2/components/left/product-item-card.d.ts +26 -0
  10. package/es/checkout-v2/components/left/product-item-card.js +571 -0
  11. package/es/checkout-v2/components/left/promotion-input.d.ts +19 -0
  12. package/es/checkout-v2/components/left/promotion-input.js +178 -0
  13. package/es/checkout-v2/components/left/staking-breakdown.d.ts +9 -0
  14. package/es/checkout-v2/components/left/staking-breakdown.js +48 -0
  15. package/es/checkout-v2/components/left/trial-info.d.ts +13 -0
  16. package/es/checkout-v2/components/left/trial-info.js +48 -0
  17. package/es/checkout-v2/components/right/currency-grid.d.ts +8 -0
  18. package/es/checkout-v2/components/right/currency-grid.js +48 -0
  19. package/es/checkout-v2/components/right/customer-info-card.d.ts +17 -0
  20. package/es/checkout-v2/components/right/customer-info-card.js +156 -0
  21. package/es/checkout-v2/components/right/status-feedback.d.ts +7 -0
  22. package/es/checkout-v2/components/right/status-feedback.js +17 -0
  23. package/es/checkout-v2/components/right/submit-button.d.ts +10 -0
  24. package/es/checkout-v2/components/right/submit-button.js +29 -0
  25. package/es/checkout-v2/components/right/subscription-disclaimer.d.ts +11 -0
  26. package/es/checkout-v2/components/right/subscription-disclaimer.js +8 -0
  27. package/es/checkout-v2/components/shared/exchange-rate-footer.d.ts +23 -0
  28. package/es/checkout-v2/components/shared/exchange-rate-footer.js +182 -0
  29. package/es/checkout-v2/components/shared/scenario-badge.d.ts +6 -0
  30. package/es/checkout-v2/components/shared/scenario-badge.js +47 -0
  31. package/es/checkout-v2/components/shared/total-display.d.ts +7 -0
  32. package/es/checkout-v2/components/shared/total-display.js +84 -0
  33. package/es/checkout-v2/index.d.ts +2 -0
  34. package/es/checkout-v2/index.js +1 -0
  35. package/es/checkout-v2/layouts/checkout-layout.d.ts +7 -0
  36. package/es/checkout-v2/layouts/checkout-layout.js +226 -0
  37. package/es/checkout-v2/panels/left/composite-panel.d.ts +1 -0
  38. package/es/checkout-v2/panels/left/composite-panel.js +423 -0
  39. package/es/checkout-v2/panels/left/credit-topup-panel.d.ts +1 -0
  40. package/es/checkout-v2/panels/left/credit-topup-panel.js +615 -0
  41. package/es/checkout-v2/panels/left/scenario-router.d.ts +1 -0
  42. package/es/checkout-v2/panels/left/scenario-router.js +19 -0
  43. package/es/checkout-v2/panels/right/payment-panel.d.ts +1 -0
  44. package/es/checkout-v2/panels/right/payment-panel.js +644 -0
  45. package/es/checkout-v2/types.d.ts +15 -0
  46. package/es/checkout-v2/types.js +0 -0
  47. package/es/checkout-v2/utils/format.d.ts +59 -0
  48. package/es/checkout-v2/utils/format.js +125 -0
  49. package/es/checkout-v2/utils/scenario-detector.d.ts +3 -0
  50. package/es/checkout-v2/utils/scenario-detector.js +17 -0
  51. package/es/checkout-v2/views/error-view.d.ts +7 -0
  52. package/es/checkout-v2/views/error-view.js +269 -0
  53. package/es/checkout-v2/views/loading-view.d.ts +5 -0
  54. package/es/checkout-v2/views/loading-view.js +158 -0
  55. package/es/checkout-v2/views/success-view.d.ts +29 -0
  56. package/es/checkout-v2/views/success-view.js +614 -0
  57. package/es/components/phone-field.d.ts +14 -0
  58. package/es/components/phone-field.js +96 -0
  59. package/es/index.d.ts +3 -1
  60. package/es/index.js +3 -1
  61. package/es/locales/en.js +45 -6
  62. package/es/locales/zh.js +45 -6
  63. package/es/payment/form/index.js +10 -1
  64. package/lib/checkout-v2/checkout-v2.d.ts +2 -0
  65. package/lib/checkout-v2/checkout-v2.js +151 -0
  66. package/lib/checkout-v2/components/dialogs/checkout-dialogs.d.ts +1 -0
  67. package/lib/checkout-v2/components/dialogs/checkout-dialogs.js +131 -0
  68. package/lib/checkout-v2/components/left/billing-toggle.d.ts +6 -0
  69. package/lib/checkout-v2/components/left/billing-toggle.js +126 -0
  70. package/lib/checkout-v2/components/left/cross-sell-card.d.ts +10 -0
  71. package/lib/checkout-v2/components/left/cross-sell-card.js +257 -0
  72. package/lib/checkout-v2/components/left/product-item-card.d.ts +26 -0
  73. package/lib/checkout-v2/components/left/product-item-card.js +738 -0
  74. package/lib/checkout-v2/components/left/promotion-input.d.ts +19 -0
  75. package/lib/checkout-v2/components/left/promotion-input.js +220 -0
  76. package/lib/checkout-v2/components/left/staking-breakdown.d.ts +9 -0
  77. package/lib/checkout-v2/components/left/staking-breakdown.js +96 -0
  78. package/lib/checkout-v2/components/left/trial-info.d.ts +13 -0
  79. package/lib/checkout-v2/components/left/trial-info.js +82 -0
  80. package/lib/checkout-v2/components/right/currency-grid.d.ts +8 -0
  81. package/lib/checkout-v2/components/right/currency-grid.js +96 -0
  82. package/lib/checkout-v2/components/right/customer-info-card.d.ts +17 -0
  83. package/lib/checkout-v2/components/right/customer-info-card.js +246 -0
  84. package/lib/checkout-v2/components/right/status-feedback.d.ts +7 -0
  85. package/lib/checkout-v2/components/right/status-feedback.js +30 -0
  86. package/lib/checkout-v2/components/right/submit-button.d.ts +10 -0
  87. package/lib/checkout-v2/components/right/submit-button.js +35 -0
  88. package/lib/checkout-v2/components/right/subscription-disclaimer.d.ts +11 -0
  89. package/lib/checkout-v2/components/right/subscription-disclaimer.js +33 -0
  90. package/lib/checkout-v2/components/shared/exchange-rate-footer.d.ts +23 -0
  91. package/lib/checkout-v2/components/shared/exchange-rate-footer.js +282 -0
  92. package/lib/checkout-v2/components/shared/scenario-badge.d.ts +6 -0
  93. package/lib/checkout-v2/components/shared/scenario-badge.js +57 -0
  94. package/lib/checkout-v2/components/shared/total-display.d.ts +7 -0
  95. package/lib/checkout-v2/components/shared/total-display.js +154 -0
  96. package/lib/checkout-v2/index.d.ts +2 -0
  97. package/lib/checkout-v2/index.js +13 -0
  98. package/lib/checkout-v2/layouts/checkout-layout.d.ts +7 -0
  99. package/lib/checkout-v2/layouts/checkout-layout.js +308 -0
  100. package/lib/checkout-v2/panels/left/composite-panel.d.ts +1 -0
  101. package/lib/checkout-v2/panels/left/composite-panel.js +515 -0
  102. package/lib/checkout-v2/panels/left/credit-topup-panel.d.ts +1 -0
  103. package/lib/checkout-v2/panels/left/credit-topup-panel.js +799 -0
  104. package/lib/checkout-v2/panels/left/scenario-router.d.ts +1 -0
  105. package/lib/checkout-v2/panels/left/scenario-router.js +29 -0
  106. package/lib/checkout-v2/panels/right/payment-panel.d.ts +1 -0
  107. package/lib/checkout-v2/panels/right/payment-panel.js +906 -0
  108. package/lib/checkout-v2/types.d.ts +15 -0
  109. package/lib/checkout-v2/types.js +1 -0
  110. package/lib/checkout-v2/utils/format.d.ts +59 -0
  111. package/lib/checkout-v2/utils/format.js +158 -0
  112. package/lib/checkout-v2/utils/scenario-detector.d.ts +3 -0
  113. package/lib/checkout-v2/utils/scenario-detector.js +23 -0
  114. package/lib/checkout-v2/views/error-view.d.ts +7 -0
  115. package/lib/checkout-v2/views/error-view.js +321 -0
  116. package/lib/checkout-v2/views/loading-view.d.ts +5 -0
  117. package/lib/checkout-v2/views/loading-view.js +168 -0
  118. package/lib/checkout-v2/views/success-view.d.ts +29 -0
  119. package/lib/checkout-v2/views/success-view.js +735 -0
  120. package/lib/components/phone-field.d.ts +14 -0
  121. package/lib/components/phone-field.js +130 -0
  122. package/lib/index.d.ts +3 -1
  123. package/lib/index.js +8 -0
  124. package/lib/locales/en.js +45 -6
  125. package/lib/locales/zh.js +45 -6
  126. package/lib/payment/form/index.js +10 -1
  127. package/package.json +4 -3
  128. package/src/checkout-v2/checkout-v2.tsx +155 -0
  129. package/src/checkout-v2/components/dialogs/checkout-dialogs.tsx +134 -0
  130. package/src/checkout-v2/components/left/billing-toggle.tsx +122 -0
  131. package/src/checkout-v2/components/left/cross-sell-card.tsx +170 -0
  132. package/src/checkout-v2/components/left/product-item-card.tsx +634 -0
  133. package/src/checkout-v2/components/left/promotion-input.tsx +207 -0
  134. package/src/checkout-v2/components/left/staking-breakdown.tsx +57 -0
  135. package/src/checkout-v2/components/left/trial-info.tsx +63 -0
  136. package/src/checkout-v2/components/right/currency-grid.tsx +59 -0
  137. package/src/checkout-v2/components/right/customer-info-card.tsx +214 -0
  138. package/src/checkout-v2/components/right/status-feedback.tsx +35 -0
  139. package/src/checkout-v2/components/right/submit-button.tsx +37 -0
  140. package/src/checkout-v2/components/right/subscription-disclaimer.tsx +27 -0
  141. package/src/checkout-v2/components/shared/exchange-rate-footer.tsx +221 -0
  142. package/src/checkout-v2/components/shared/scenario-badge.tsx +51 -0
  143. package/src/checkout-v2/components/shared/total-display.tsx +112 -0
  144. package/src/checkout-v2/index.ts +2 -0
  145. package/src/checkout-v2/layouts/checkout-layout.tsx +232 -0
  146. package/src/checkout-v2/panels/left/composite-panel.tsx +465 -0
  147. package/src/checkout-v2/panels/left/credit-topup-panel.tsx +681 -0
  148. package/src/checkout-v2/panels/left/scenario-router.tsx +22 -0
  149. package/src/checkout-v2/panels/right/payment-panel.tsx +703 -0
  150. package/src/checkout-v2/types.ts +18 -0
  151. package/src/checkout-v2/utils/format.ts +204 -0
  152. package/src/checkout-v2/utils/scenario-detector.ts +30 -0
  153. package/src/checkout-v2/views/error-view.tsx +293 -0
  154. package/src/checkout-v2/views/loading-view.tsx +162 -0
  155. package/src/checkout-v2/views/success-view.tsx +770 -0
  156. package/src/components/phone-field.tsx +119 -0
  157. package/src/index.ts +3 -0
  158. package/src/locales/en.tsx +45 -4
  159. package/src/locales/zh.tsx +43 -4
  160. package/src/payment/form/index.tsx +16 -1
@@ -0,0 +1,634 @@
1
+ import { useState, useEffect } from 'react';
2
+ import AddIcon from '@mui/icons-material/Add';
3
+ import CheckIcon from '@mui/icons-material/Check';
4
+ import LocalOfferIcon from '@mui/icons-material/LocalOffer';
5
+ import RemoveIcon from '@mui/icons-material/Remove';
6
+ import ShoppingCartCheckoutIcon from '@mui/icons-material/ShoppingCartCheckout';
7
+ import {
8
+ Avatar,
9
+ Box,
10
+ Chip,
11
+ Collapse,
12
+ IconButton,
13
+ Skeleton,
14
+ Stack,
15
+ Switch,
16
+ TextField,
17
+ Typography,
18
+ useMediaQuery,
19
+ useTheme,
20
+ } from '@mui/material';
21
+ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
22
+ import type { TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
23
+ import { getPriceUnitAmountByCurrency } from '@blocklet/payment-react-headless';
24
+ import Toast from '@arcblock/ux/lib/Toast';
25
+ import { INTERVAL_LOCALE_KEY, formatDynamicUnitPrice, formatTokenAmount, formatTrialText } from '../../utils/format';
26
+
27
+ interface ProductItemCardProps {
28
+ item: TLineItemExpanded & { adjustable_quantity?: { enabled: boolean; minimum?: number; maximum?: number } };
29
+ currency: TPaymentCurrency | null;
30
+ discounts: any[];
31
+ exchangeRate: string | null;
32
+ onQuantityChange: (itemId: string, qty: number) => Promise<void>;
33
+ onUpsell: (fromId: string, toId: string) => Promise<void>;
34
+ onDownsell: (priceId: string) => Promise<void>;
35
+ trialActive: boolean;
36
+ trialDays: number;
37
+ t: (key: string, params?: any) => string;
38
+ recommended?: boolean;
39
+ hideUpsell?: boolean;
40
+ isRateLoading?: boolean;
41
+ showFeatures?: boolean;
42
+ children?: React.ReactNode;
43
+ }
44
+
45
+ export default function ProductItemCard({
46
+ item,
47
+ currency,
48
+ discounts,
49
+ exchangeRate,
50
+ onQuantityChange,
51
+ onUpsell,
52
+ onDownsell,
53
+ trialActive,
54
+ trialDays,
55
+ t,
56
+ recommended = false,
57
+ hideUpsell = false,
58
+ isRateLoading = false,
59
+ showFeatures = true,
60
+ children = undefined,
61
+ }: ProductItemCardProps) {
62
+ const activePrice: any = (item as any).upsell_price || item.price;
63
+ const product = activePrice?.product;
64
+ const name = product?.name || 'Item';
65
+ const logo = product?.images?.[0] || '';
66
+ const features: Array<{ name: string; icon?: string }> = product?.features || [];
67
+ const recurring = activePrice?.recurring;
68
+
69
+ const quantity = item.quantity || 1;
70
+ const isMetered = recurring?.usage_type === 'metered';
71
+ const metered = isMetered ? ` ${t('common.metered')}` : '';
72
+
73
+ const perUnitFormatted = formatDynamicUnitPrice(activePrice, currency, exchangeRate);
74
+
75
+ // Item type badge: just "SUBSCRIPTION" or "ONE-TIME" (interval is shown with price)
76
+ const isSubscription = !!recurring;
77
+ const typeBadgeText = isSubscription
78
+ ? t('payment.checkout.typeBadge.subscription')
79
+ : t('payment.checkout.typeBadge.oneTime');
80
+
81
+ // Billing interval suffix for price display: "/ month"
82
+ const priceIntervalSuffix = recurring?.interval ? ` / ${t(`common.${recurring.interval}`)}` : '';
83
+
84
+ // Subtitle: only quantity breakdown (interval is now in the type badge)
85
+ const subtitleText = (() => {
86
+ if (quantity > 1 && perUnitFormatted && currency) {
87
+ return `${quantity} × ${perUnitFormatted} ${currency.symbol}`;
88
+ }
89
+ if (isMetered) return metered.trim();
90
+ return '';
91
+ })();
92
+
93
+ // Item total
94
+ const itemTotal = (() => {
95
+ // custom_amount is backend-quoted in a specific currency — only use when quote_currency_id is present and matches
96
+ // (when quote_currency_id is absent, custom_amount denomination is unknown — skip to base_amount path)
97
+ const quoteCurrencyId = (item as any).quote_currency_id as string | undefined;
98
+ if ((item as any).custom_amount && quoteCurrencyId && quoteCurrencyId === currency?.id) {
99
+ return formatTokenAmount((item as any).custom_amount, currency);
100
+ }
101
+ if (activePrice?.pricing_type === 'dynamic' && exchangeRate && activePrice.base_amount) {
102
+ const rate = Number(exchangeRate);
103
+ if (rate > 0 && Number.isFinite(rate)) {
104
+ const baseUsd = Number(activePrice.base_amount);
105
+ if (baseUsd > 0 && Number.isFinite(baseUsd)) {
106
+ const tokenAmount = (baseUsd * quantity) / rate;
107
+ const abs = Math.abs(tokenAmount);
108
+ const precision = abs > 0 && abs < 0.01 ? 6 : 2;
109
+ return (
110
+ tokenAmount
111
+ .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
112
+ .replace(/\.?0+$/, '') || '0'
113
+ );
114
+ }
115
+ }
116
+ }
117
+ // Fiat/Stripe fallback: use base_amount when no exchange rate
118
+ // (unit_amount may be in crypto denomination, not fiat cents)
119
+ if (!exchangeRate && activePrice?.base_amount != null) {
120
+ const baseUsd = Number(activePrice.base_amount);
121
+ if (baseUsd >= 0 && Number.isFinite(baseUsd)) {
122
+ const total = baseUsd * quantity;
123
+ return total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
124
+ }
125
+ }
126
+ // Fallback: look up unit_amount from currency_options for the selected currency
127
+ if (activePrice && currency) {
128
+ const unitAmount = getPriceUnitAmountByCurrency(activePrice, currency);
129
+ if (unitAmount && unitAmount !== '0') {
130
+ const totalUnits = BigInt(unitAmount) * BigInt(quantity);
131
+ return formatTokenAmount(totalUnits.toString(), currency);
132
+ }
133
+ }
134
+ return '0';
135
+ })();
136
+
137
+ // Per-item discount
138
+ const discount = discounts?.[0];
139
+ const discountCode =
140
+ discount?.promotion_code_details?.code || discount?.verification_data?.code || discount?.promotion_code || '';
141
+ const perItemDiscount = (() => {
142
+ if (!discountCode || !discount) return null;
143
+ const couponDetails = discount?.coupon_details;
144
+ if (couponDetails?.percent_off > 0) {
145
+ const numericTotal = parseFloat(String(itemTotal).replace(/,/g, ''));
146
+ if (!Number.isNaN(numericTotal) && numericTotal > 0) {
147
+ const discAmount = (numericTotal * couponDetails.percent_off) / 100;
148
+ const abs = Math.abs(discAmount);
149
+ const precision = abs > 0 && abs < 0.01 ? 6 : 2;
150
+ return `${discAmount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/\.?0+$/, '') || '0'} ${currency?.symbol || ''}`;
151
+ }
152
+ }
153
+ if ((item as any).discount_amounts?.length > 0 && currency) {
154
+ return `${formatTokenAmount((item as any).discount_amounts[0].amount, currency)} ${currency.symbol}`;
155
+ }
156
+ return null;
157
+ })();
158
+
159
+ // Quantity controls
160
+ const adjustable = item.adjustable_quantity?.enabled;
161
+ const min = item.adjustable_quantity?.minimum || 1;
162
+ const max = item.adjustable_quantity?.maximum;
163
+
164
+ const [qtyInput, setQtyInput] = useState(String(quantity));
165
+ const [isEditing, setIsEditing] = useState(false);
166
+ const theme = useTheme();
167
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
168
+ const [featuresOpen, setFeaturesOpen] = useState(!isMobile);
169
+ useEffect(() => {
170
+ if (!isEditing) setQtyInput(String(quantity));
171
+ }, [quantity, isEditing]);
172
+
173
+ // Upsell info
174
+ const canUpsell = !!(item.price as any)?.upsell?.upsells_to;
175
+ const isUpselled = !!(item as any).upsell_price;
176
+ const upsellTo = (item.price as any)?.upsell?.upsells_to;
177
+
178
+ let savingsPercent = 0;
179
+ if (canUpsell && upsellTo) {
180
+ const fromAmount = parseFloat((item.price as any)?.base_amount || (item.price as any)?.unit_amount || '0');
181
+ const toAmount = parseFloat(upsellTo?.base_amount || upsellTo?.unit_amount || '0');
182
+ const fromInterval = (item.price as any)?.recurring?.interval;
183
+ const toInterval = upsellTo?.recurring?.interval;
184
+ if (fromAmount > 0 && toAmount > 0 && fromInterval && toInterval) {
185
+ const monthsMap: Record<string, number> = { day: 365, week: 52, month: 12, year: 1 };
186
+ const fromYearly = fromAmount * (monthsMap[fromInterval] || 1);
187
+ const toYearly = toAmount * (monthsMap[toInterval] || 1);
188
+ if (fromYearly > toYearly) {
189
+ savingsPercent = Math.round(((fromYearly - toYearly) / fromYearly) * 100);
190
+ }
191
+ }
192
+ }
193
+
194
+ // Upsell price display
195
+ const upsellInterval = upsellTo?.recurring?.interval;
196
+ const upsellPrice = (() => {
197
+ if (!upsellTo) return '';
198
+ const slashText = upsellInterval ? t('common.slash', { interval: t(`common.${upsellInterval}`) }) : '';
199
+ if (upsellTo.pricing_type === 'dynamic' && upsellTo.base_amount && exchangeRate) {
200
+ const rate = Number(exchangeRate);
201
+ if (rate > 0 && Number.isFinite(rate)) {
202
+ const baseUsd = parseFloat(upsellTo.base_amount);
203
+ if (baseUsd > 0 && Number.isFinite(baseUsd)) {
204
+ const tokenAmount = baseUsd / rate;
205
+ const abs = Math.abs(tokenAmount);
206
+ const precision = abs > 0 && abs < 0.01 ? 6 : 2;
207
+ const formatted =
208
+ tokenAmount
209
+ .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
210
+ .replace(/\.?0+$/, '') || '0';
211
+ return `${formatted} ${currency?.symbol || ''} ${slashText}`;
212
+ }
213
+ }
214
+ }
215
+ if (upsellTo.pricing_type === 'dynamic' && upsellTo.base_amount && upsellTo.base_currency === 'USD') {
216
+ const baseUsd = parseFloat(upsellTo.base_amount);
217
+ const formattedUsd = baseUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
218
+ return `$${formattedUsd} ${slashText}`;
219
+ }
220
+ const upsellUnitFormatted = formatDynamicUnitPrice(upsellTo, currency, exchangeRate);
221
+ return upsellUnitFormatted ? `${upsellUnitFormatted} ${currency?.symbol || ''} ${slashText}` : '';
222
+ })();
223
+
224
+ // Downsell price display
225
+ const originalInterval = (item.price as any)?.recurring?.interval;
226
+ const downsellPrice = (() => {
227
+ if (!item.price || !isUpselled) return '';
228
+ const originalPrice: any = item.price;
229
+ const originalSlash = originalInterval ? t('common.slash', { interval: t(`common.${originalInterval}`) }) : '';
230
+ if (originalPrice.pricing_type === 'dynamic' && originalPrice.base_amount && exchangeRate) {
231
+ const rate = Number(exchangeRate);
232
+ if (rate > 0 && Number.isFinite(rate)) {
233
+ const baseUsd = parseFloat(originalPrice.base_amount);
234
+ if (baseUsd > 0 && Number.isFinite(baseUsd)) {
235
+ const tokenAmount = baseUsd / rate;
236
+ const abs = Math.abs(tokenAmount);
237
+ const precision = abs > 0 && abs < 0.01 ? 6 : 2;
238
+ const formatted =
239
+ tokenAmount
240
+ .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
241
+ .replace(/\.?0+$/, '') || '0';
242
+ return `${formatted} ${currency?.symbol || ''} ${originalSlash}`;
243
+ }
244
+ }
245
+ }
246
+ if (
247
+ originalPrice.pricing_type === 'dynamic' &&
248
+ originalPrice.base_amount &&
249
+ originalPrice.base_currency === 'USD'
250
+ ) {
251
+ const baseUsd = parseFloat(originalPrice.base_amount);
252
+ const formattedUsd = baseUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
253
+ return `$${formattedUsd} ${originalSlash}`;
254
+ }
255
+ const unitFormatted = formatDynamicUnitPrice(originalPrice, currency, exchangeRate);
256
+ return unitFormatted ? `${unitFormatted} ${currency?.symbol || ''} ${originalSlash}` : '';
257
+ })();
258
+
259
+ // Upsell toggle label
260
+ const upsellToggleLabel = (() => {
261
+ if (isUpselled) {
262
+ const recurringLabel = originalInterval ? t(INTERVAL_LOCALE_KEY[originalInterval] || '') : '';
263
+ return t('payment.checkout.upsell.revert', { recurring: recurringLabel });
264
+ }
265
+ const recurringLabel = upsellInterval ? t(INTERVAL_LOCALE_KEY[upsellInterval] || '') : '';
266
+ return t('payment.checkout.upsell.save', { recurring: recurringLabel });
267
+ })();
268
+
269
+ return (
270
+ <Box sx={{ position: 'relative' }}>
271
+ {/* Recommended badge — top-right */}
272
+ {recommended && (
273
+ <Chip
274
+ label="RECOMMENDED"
275
+ size="small"
276
+ sx={{
277
+ position: 'absolute',
278
+ top: 0,
279
+ right: 40,
280
+ transform: 'translateY(-50%)',
281
+ zIndex: 1,
282
+ height: 22,
283
+ fontSize: 9,
284
+ fontWeight: 900,
285
+ letterSpacing: '0.12em',
286
+ bgcolor: 'primary.main',
287
+ color: '#fff',
288
+ boxShadow: '0 4px 12px rgba(45,124,243,0.2)',
289
+ '& .MuiChip-label': { px: 1.5 },
290
+ }}
291
+ />
292
+ )}
293
+
294
+ <Box
295
+ sx={{
296
+ p: { xs: 2, md: 3 },
297
+ bgcolor: 'background.paper',
298
+ borderRadius: { xs: '16px', md: '24px' },
299
+ border: '1px solid',
300
+ borderColor: 'divider',
301
+ boxShadow: (th) =>
302
+ th.palette.mode === 'dark' ? '0 12px 40px -8px rgba(0,0,0,0.3)' : '0 12px 40px -8px rgba(0,0,0,0.06)',
303
+ transition: 'all 0.3s ease',
304
+ ...(canUpsell && !hideUpsell ? { borderRadius: { xs: '16px 16px 0 0', md: '24px 24px 0 0' } } : {}),
305
+ '&:hover': {
306
+ borderColor: (th) => (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(45,124,243,0.15)'),
307
+ },
308
+ }}>
309
+ <Stack direction="row" spacing={{ xs: 1.5, md: 2.5 }} sx={{ alignItems: 'center', width: '100%' }}>
310
+ {/* Product avatar */}
311
+ {logo ? (
312
+ <Avatar
313
+ src={logo}
314
+ alt={name}
315
+ variant="rounded"
316
+ sx={{
317
+ width: { xs: 44, md: 64 },
318
+ height: { xs: 44, md: 64 },
319
+ borderRadius: { xs: '12px', md: '16px' },
320
+ flexShrink: 0,
321
+ }}
322
+ />
323
+ ) : (
324
+ <Avatar
325
+ variant="rounded"
326
+ sx={{
327
+ width: { xs: 44, md: 64 },
328
+ height: { xs: 44, md: 64 },
329
+ borderRadius: { xs: '12px', md: '16px' },
330
+ bgcolor: (th) => (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : '#eff6ff'),
331
+ flexShrink: 0,
332
+ }}>
333
+ <ShoppingCartCheckoutIcon sx={{ fontSize: { xs: 22, md: 28 }, color: 'primary.main', opacity: 0.45 }} />
334
+ </Avatar>
335
+ )}
336
+
337
+ <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
338
+ <Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 0.25 }}>
339
+ <Box sx={{ minWidth: 0 }}>
340
+ <Typography
341
+ sx={{ color: 'text.primary', fontWeight: 800, fontSize: { xs: 15, md: 18 }, lineHeight: 1.3 }}>
342
+ {name}
343
+ </Typography>
344
+ {/* Item type badge: subscription/one-time + interval */}
345
+ <Typography
346
+ component="span"
347
+ sx={{
348
+ display: 'inline-block',
349
+ mt: 0.5,
350
+ fontSize: 10,
351
+ fontWeight: 700,
352
+ letterSpacing: '0.08em',
353
+ lineHeight: 1,
354
+ textTransform: 'uppercase',
355
+ color: 'primary.main',
356
+ bgcolor: (th) =>
357
+ th.palette.mode === 'dark' ? `${th.palette.primary.main}1A` : `${th.palette.primary.main}0D`,
358
+ px: 0.75,
359
+ py: 0.4,
360
+ borderRadius: '3px',
361
+ }}>
362
+ {typeBadgeText}
363
+ </Typography>
364
+ {subtitleText && (
365
+ <Typography sx={{ color: 'text.disabled', fontSize: 13, fontWeight: 500, mt: 0.25 }}>
366
+ {subtitleText}
367
+ </Typography>
368
+ )}
369
+ </Box>
370
+ <Stack alignItems="flex-end" sx={{ flexShrink: 0, ml: 1.5 }}>
371
+ {/* eslint-disable-next-line no-nested-ternary */}
372
+ {trialActive && isSubscription ? (
373
+ <Typography
374
+ sx={{ fontWeight: 800, color: 'text.primary', whiteSpace: 'nowrap', fontSize: { xs: 15, md: 18 } }}>
375
+ {formatTrialText(t, trialDays, recurring?.interval || 'day')}
376
+ </Typography>
377
+ ) : isRateLoading ? (
378
+ <Skeleton variant="text" width={100} height={28} />
379
+ ) : (
380
+ <>
381
+ <Typography
382
+ sx={{
383
+ fontWeight: 800,
384
+ color: 'text.primary',
385
+ whiteSpace: 'nowrap',
386
+ fontSize: { xs: 15, md: 18 },
387
+ transition: 'opacity 0.3s ease',
388
+ }}>
389
+ {itemTotal} {currency?.symbol}
390
+ {priceIntervalSuffix}
391
+ </Typography>
392
+ {exchangeRate && activePrice?.base_amount && (
393
+ <Typography sx={{ fontSize: 12, color: 'text.disabled', fontWeight: 600, lineHeight: 1 }}>
394
+ ≈ ${(Number(activePrice.base_amount) * quantity).toFixed(2)}
395
+ </Typography>
396
+ )}
397
+ </>
398
+ )}
399
+ </Stack>
400
+ </Stack>
401
+ </Box>
402
+ </Stack>
403
+
404
+ {/* Product features — collapsible, default expanded */}
405
+ {showFeatures && features.length > 0 && (
406
+ <Box
407
+ sx={{
408
+ mt: { xs: 2, md: 2.5 },
409
+ pt: { xs: 2, md: 2.5 },
410
+ borderTop: '1px solid',
411
+ borderColor: (th) => (th.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'grey.100'),
412
+ }}>
413
+ <Stack
414
+ direction="row"
415
+ alignItems="center"
416
+ justifyContent="space-between"
417
+ onClick={() => setFeaturesOpen((v) => !v)}
418
+ sx={{ cursor: 'pointer', userSelect: 'none', mb: featuresOpen ? 1.25 : 0 }}>
419
+ <Typography sx={{ fontSize: 14, fontWeight: 700, color: 'text.primary' }}>
420
+ {t('payment.checkout.planFeatures')}
421
+ </Typography>
422
+ <KeyboardArrowUpIcon
423
+ sx={{
424
+ fontSize: 20,
425
+ color: 'text.secondary',
426
+ transition: 'transform 0.2s ease',
427
+ transform: featuresOpen ? 'rotate(0deg)' : 'rotate(180deg)',
428
+ }}
429
+ />
430
+ </Stack>
431
+ <Collapse in={featuresOpen}>
432
+ <Stack spacing={1.25}>
433
+ {features.map((feature) => (
434
+ <Stack key={feature.name} direction="row" spacing={1.5} alignItems="center">
435
+ <Box
436
+ sx={{
437
+ width: 20,
438
+ height: 20,
439
+ borderRadius: '50%',
440
+ bgcolor: (th) =>
441
+ th.palette.mode === 'dark' ? 'rgba(16,185,129,0.15)' : 'rgba(16,185,129,0.1)',
442
+ display: 'flex',
443
+ alignItems: 'center',
444
+ justifyContent: 'center',
445
+ flexShrink: 0,
446
+ }}>
447
+ <CheckIcon sx={{ fontSize: 14, color: 'success.main' }} />
448
+ </Box>
449
+ <Typography sx={{ fontSize: 14, fontWeight: 500, color: 'text.secondary' }}>
450
+ {feature.name}
451
+ </Typography>
452
+ </Stack>
453
+ ))}
454
+ </Stack>
455
+ </Collapse>
456
+ </Box>
457
+ )}
458
+
459
+ {/* Discount chip */}
460
+ {discountCode && perItemDiscount && (
461
+ <Box sx={{ mt: 1.5 }}>
462
+ <Chip
463
+ icon={<LocalOfferIcon sx={{ color: 'warning.main', fontSize: 'small' }} />}
464
+ label={`${discountCode} (-${perItemDiscount})`}
465
+ size="small"
466
+ sx={{ height: 22, borderRadius: '6px', '& .MuiChip-label': { fontSize: 12 } }}
467
+ />
468
+ </Box>
469
+ )}
470
+
471
+ {/* Quantity controls */}
472
+ {adjustable && (
473
+ <Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 2 }}>
474
+ <Typography sx={{ color: 'text.secondary', minWidth: 'fit-content', fontSize: 13, fontWeight: 500 }}>
475
+ {t('common.quantity')}
476
+ </Typography>
477
+ <IconButton
478
+ size="small"
479
+ onClick={() => onQuantityChange(item.price_id, quantity - 1)}
480
+ disabled={quantity <= min}
481
+ sx={{
482
+ width: 36,
483
+ height: 36,
484
+ border: '1px solid',
485
+ borderColor: 'divider',
486
+ borderRadius: '50%',
487
+ '&:hover': { bgcolor: 'action.hover' },
488
+ }}>
489
+ <RemoveIcon sx={{ fontSize: 20 }} />
490
+ </IconButton>
491
+ <TextField
492
+ value={qtyInput}
493
+ size="small"
494
+ type="number"
495
+ slotProps={{
496
+ htmlInput: {
497
+ style: {
498
+ textAlign: 'center',
499
+ padding: '6px 4px',
500
+ fontWeight: 700,
501
+ fontSize: 16,
502
+ MozAppearance: 'textfield',
503
+ } as React.CSSProperties,
504
+ min,
505
+ max: max || undefined,
506
+ },
507
+ }}
508
+ sx={{
509
+ minWidth: 64,
510
+ '& input': { textAlign: 'center' },
511
+ '& .MuiOutlinedInput-root': { borderRadius: '12px' },
512
+ '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': {
513
+ WebkitAppearance: 'none',
514
+ margin: 0,
515
+ },
516
+ '& input[type=number]': {
517
+ MozAppearance: 'textfield',
518
+ },
519
+ }}
520
+ onFocus={() => setIsEditing(true)}
521
+ onChange={(e) => setQtyInput(e.target.value)}
522
+ onKeyDown={(e) => {
523
+ if (e.key === 'Enter') {
524
+ e.preventDefault();
525
+ e.stopPropagation();
526
+ (e.target as HTMLInputElement).blur();
527
+ }
528
+ }}
529
+ onBlur={() => {
530
+ setIsEditing(false);
531
+ const v = parseInt(qtyInput, 10);
532
+ if (!Number.isNaN(v) && v >= min && (!max || v <= max) && v !== quantity) {
533
+ onQuantityChange(item.price_id, v);
534
+ } else {
535
+ setQtyInput(String(quantity));
536
+ }
537
+ }}
538
+ />
539
+ <IconButton
540
+ size="small"
541
+ onClick={() => onQuantityChange(item.price_id, quantity + 1)}
542
+ disabled={max ? quantity >= max : false}
543
+ sx={{
544
+ width: 36,
545
+ height: 36,
546
+ border: '1px solid',
547
+ borderColor: 'divider',
548
+ borderRadius: '50%',
549
+ '&:hover': { bgcolor: 'action.hover' },
550
+ }}>
551
+ <AddIcon sx={{ fontSize: 20 }} />
552
+ </IconButton>
553
+ </Stack>
554
+ )}
555
+
556
+ {children}
557
+ </Box>
558
+
559
+ {/* Upsell toggle strip — seamless with card (hidden when promoted to top-level) */}
560
+ {canUpsell && !hideUpsell && (
561
+ <Box
562
+ sx={{
563
+ px: { xs: 2, md: 3 },
564
+ py: 1.5,
565
+ bgcolor: 'background.paper',
566
+ border: '1px solid',
567
+ borderTop: 0,
568
+ borderColor: 'divider',
569
+ borderRadius: { xs: '0 0 16px 16px', md: '0 0 24px 24px' },
570
+ boxShadow: (th) =>
571
+ th.palette.mode === 'dark' ? '0 12px 40px -8px rgba(0,0,0,0.3)' : '0 12px 40px -8px rgba(0,0,0,0.06)',
572
+ }}>
573
+ {/* Subtle divider */}
574
+ <Box sx={{ borderTop: '1px solid', borderColor: 'divider', mb: 1.5 }} />
575
+ <Stack direction="row" alignItems="center" justifyContent="space-between">
576
+ <Stack direction="row" alignItems="center" spacing={1}>
577
+ <Switch
578
+ checked={isUpselled}
579
+ onChange={async () => {
580
+ try {
581
+ if (isUpselled) {
582
+ await onDownsell((item as any).upsell_price?.id || (item.price as any).id);
583
+ } else {
584
+ await onUpsell(item.price_id, upsellTo.id);
585
+ }
586
+ } catch (err: any) {
587
+ Toast.error(err?.response?.data?.error || err?.message || 'Failed');
588
+ }
589
+ }}
590
+ size="small"
591
+ sx={{
592
+ '& .MuiSwitch-switchBase.Mui-checked': { color: '#12b886' },
593
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#12b886' },
594
+ }}
595
+ />
596
+ <Typography
597
+ sx={{ fontSize: 13, color: 'text.secondary', cursor: 'pointer', fontWeight: 600 }}
598
+ onClick={async () => {
599
+ try {
600
+ if (isUpselled) await onDownsell((item as any).upsell_price?.id || (item.price as any).id);
601
+ else await onUpsell(item.price_id, upsellTo.id);
602
+ } catch (err: any) {
603
+ Toast.error(err?.response?.data?.error || err?.message || 'Failed');
604
+ }
605
+ }}>
606
+ {upsellToggleLabel}
607
+ </Typography>
608
+ {!isUpselled && savingsPercent > 0 && (
609
+ <Chip
610
+ label={t('payment.checkout.upsell.off', { saving: savingsPercent })}
611
+ size="small"
612
+ sx={{
613
+ height: 22,
614
+ fontSize: 11,
615
+ fontWeight: 700,
616
+ bgcolor: (th) => (th.palette.mode === 'dark' ? 'rgba(18,184,134,0.1)' : '#ebfef5'),
617
+ color: '#12b886',
618
+ border: '1px solid',
619
+ borderColor: (th) => (th.palette.mode === 'dark' ? 'rgba(18,184,134,0.2)' : '#d3f9e8'),
620
+ borderRadius: '9999px',
621
+ '& .MuiChip-label': { px: 1 },
622
+ }}
623
+ />
624
+ )}
625
+ </Stack>
626
+ <Typography sx={{ fontSize: 13, color: 'text.primary', whiteSpace: 'nowrap', fontWeight: 700 }}>
627
+ {isUpselled ? downsellPrice : upsellPrice}
628
+ </Typography>
629
+ </Stack>
630
+ </Box>
631
+ )}
632
+ </Box>
633
+ );
634
+ }