@blocklet/payment-react 1.25.9 → 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 +10 -9
  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,465 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
3
+ import TrendingDownIcon from '@mui/icons-material/TrendingDown';
4
+ import { Avatar, Box, Button, Collapse, Stack, Typography } from '@mui/material';
5
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
6
+ import Toast from '@arcblock/ux/lib/Toast';
7
+ import {
8
+ useCheckoutStatus,
9
+ useLineItems,
10
+ useBillingInterval,
11
+ usePricingFeature,
12
+ useExchangeRate,
13
+ useSlippage,
14
+ useSessionContext,
15
+ usePaymentMethodContext,
16
+ useProduct,
17
+ } from '@blocklet/payment-react-headless';
18
+
19
+ import { useMobile } from '../../../hooks/mobile';
20
+ import { INTERVAL_LOCALE_KEY, formatTrialText, getSessionHeaderMeta, tSafe } from '../../utils/format';
21
+ import ProductItemCard from '../../components/left/product-item-card';
22
+ import BillingToggle from '../../components/left/billing-toggle';
23
+ import CrossSellCard from '../../components/left/cross-sell-card';
24
+ import ExchangeRateFooter from '../../components/shared/exchange-rate-footer';
25
+ import TrialInfo from '../../components/left/trial-info';
26
+
27
+ export default function CompositePanel() {
28
+ const { t } = useLocaleContext();
29
+ const { session } = useSessionContext();
30
+ const { currency, isStripe, switching: currencySwitching } = usePaymentMethodContext();
31
+ const { livemode } = useCheckoutStatus();
32
+ const { product, pageInfo } = useProduct();
33
+ const lineItems = useLineItems();
34
+ const billingInterval = useBillingInterval();
35
+ const pricing = usePricingFeature();
36
+ const rate = useExchangeRate();
37
+ const slippage = useSlippage();
38
+ const { isMobile } = useMobile();
39
+
40
+ const mode = session?.mode || 'payment';
41
+ const discounts = (session as any)?.discounts || [];
42
+ const appName = (session as any)?.app_name || (session as any)?.payment_link?.app_name || '';
43
+ const appLogo = (session as any)?.app_logo || (session as any)?.payment_link?.app_logo || '';
44
+ const showItemsCollapse = isMobile || lineItems.items.length >= 4;
45
+ const [itemsExpanded, setItemsExpanded] = useState(
46
+ isMobile ? lineItems.items.length <= 1 : lineItems.items.length < 4
47
+ );
48
+
49
+ // Detect cross-sell not yet added
50
+ const crossSellNotAdded =
51
+ lineItems.crossSellItem && !lineItems.items.some((i: any) => i.price_id === lineItems.crossSellItem?.id);
52
+
53
+ // ── Find the single item with upsell → promote toggle to top ──
54
+ // Backend rejects upsell when line_items > 1 (only cross-sell items are auto-removable).
55
+ // So upsell is only possible when all other items are cross-sell.
56
+ const nonCrossSellItems = lineItems.items.filter((i: any) => !i.cross_sell);
57
+ const itemsWithUpsell = lineItems.items.filter((i: any) => (i.price as any)?.upsell?.upsells_to);
58
+ const upsellPrimaryItem = itemsWithUpsell.length === 1 ? itemsWithUpsell[0] : null;
59
+ const upsellTarget = upsellPrimaryItem ? (upsellPrimaryItem.price as any)?.upsell?.upsells_to : null;
60
+ const canUpsell = nonCrossSellItems.length <= 1;
61
+ const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ['subscription', 'setup'].includes(mode);
62
+ const isUpselled = !!(upsellPrimaryItem as any)?.upsell_price;
63
+
64
+ // Intervals for capsule toggle
65
+ const currentInterval = hasTopUpsell ? (upsellPrimaryItem!.price as any)?.recurring?.interval : null;
66
+ const upsellInterval = hasTopUpsell ? upsellTarget?.recurring?.interval : null;
67
+
68
+ // Savings %
69
+ let upsellSavings = 0;
70
+ if (hasTopUpsell && currentInterval && upsellInterval) {
71
+ const fromAmt = parseFloat(
72
+ (upsellPrimaryItem!.price as any)?.base_amount || (upsellPrimaryItem!.price as any)?.unit_amount || '0'
73
+ );
74
+ const toAmt = parseFloat(upsellTarget?.base_amount || upsellTarget?.unit_amount || '0');
75
+ const yearMap: Record<string, number> = { day: 365, week: 52, month: 12, year: 1 };
76
+ const fromY = fromAmt * (yearMap[currentInterval] || 1);
77
+ const toY = toAmt * (yearMap[upsellInterval] || 1);
78
+ if (fromY > toY && fromY > 0) upsellSavings = Math.round(((fromY - toY) / fromY) * 100);
79
+ }
80
+
81
+ // Toast on rate failure (only once per transition to 'unavailable')
82
+ const prevRateStatusRef = useRef(rate.status);
83
+ useEffect(() => {
84
+ if (prevRateStatusRef.current !== 'unavailable' && rate.status === 'unavailable' && !isStripe) {
85
+ Toast.error(t('payment.dynamicPricing.unavailable.message'));
86
+ }
87
+ prevRateStatusRef.current = rate.status;
88
+ }, [rate.status, isStripe, t]);
89
+
90
+ // Structured header meta: badge, title, subtitle
91
+ const headerMeta = getSessionHeaderMeta(t, session, product, lineItems.items);
92
+ const isMultiItem = lineItems.items.length > 1;
93
+
94
+ // Capsule button sx helper
95
+ const activeSx = {
96
+ bgcolor: 'primary.main',
97
+ color: '#fff',
98
+ boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',
99
+ };
100
+ const inactiveSx = {
101
+ color: 'text.secondary',
102
+ '&:hover': { color: 'text.primary' },
103
+ };
104
+ const capsuleBtnSx = (active: boolean) => ({
105
+ px: 3.5,
106
+ py: 1,
107
+ borderRadius: '9999px',
108
+ cursor: 'pointer',
109
+ transition: 'all 0.3s ease',
110
+ userSelect: 'none' as const,
111
+ ...(active ? activeSx : inactiveSx),
112
+ });
113
+
114
+ return (
115
+ <Box sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
116
+ {/* App branding — desktop only (mobile uses compact inline) */}
117
+ {!isMobile && (appName || appLogo) && (
118
+ <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 5 }}>
119
+ {appLogo && (
120
+ <Avatar
121
+ src={appLogo}
122
+ alt={appName}
123
+ sx={{
124
+ width: 48,
125
+ height: 48,
126
+ borderRadius: '16px',
127
+ bgcolor: 'background.paper',
128
+ boxShadow: 1,
129
+ border: '1px solid',
130
+ borderColor: 'divider',
131
+ }}
132
+ />
133
+ )}
134
+ {appName && <Typography sx={{ fontWeight: 600, fontSize: 15, color: 'text.primary' }}>{appName}</Typography>}
135
+ </Stack>
136
+ )}
137
+
138
+ {/* Top spacer — balances bottom spacer to vertically center main content (desktop only) */}
139
+ {!isMobile && <Box sx={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }} />}
140
+
141
+ {/* ── Top: header content (fixed, does not scroll) ── */}
142
+ <Box sx={{ flexShrink: 0 }}>
143
+ {/* Header section: type badge + title + subtitle */}
144
+ <Box sx={{ mb: { xs: 3, md: 4 } }}>
145
+ {/* Mobile: compact branding row */}
146
+ {isMobile && (appName || appLogo) && (
147
+ <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
148
+ {appLogo && <Avatar src={appLogo} alt={appName} sx={{ width: 24, height: 24, borderRadius: '6px' }} />}
149
+ {appName && (
150
+ <Typography sx={{ fontWeight: 600, fontSize: 13, color: 'text.secondary' }}>{appName}</Typography>
151
+ )}
152
+ </Stack>
153
+ )}
154
+
155
+ {/* Type badge + trial tag + TEST MODE */}
156
+ <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
157
+ <Typography
158
+ component="span"
159
+ sx={{
160
+ fontSize: 10,
161
+ fontWeight: 700,
162
+ letterSpacing: '0.1em',
163
+ lineHeight: 1,
164
+ textTransform: 'uppercase',
165
+ color: 'primary.main',
166
+ bgcolor: (theme) =>
167
+ theme.palette.mode === 'dark' ? `${theme.palette.primary.main}1A` : `${theme.palette.primary.main}0D`,
168
+ px: 1,
169
+ py: 0.5,
170
+ borderRadius: '4px',
171
+ }}>
172
+ {headerMeta.badgeLabel}
173
+ </Typography>
174
+ {pricing.trial.active && pricing.trial.days > 0 && (
175
+ <Typography
176
+ component="span"
177
+ sx={{
178
+ fontSize: 10,
179
+ fontWeight: 700,
180
+ letterSpacing: '0.1em',
181
+ lineHeight: 1,
182
+ textTransform: 'uppercase',
183
+ color: 'primary.main',
184
+ bgcolor: (theme) =>
185
+ theme.palette.mode === 'dark'
186
+ ? `${theme.palette.primary.main}1A`
187
+ : `${theme.palette.primary.main}0D`,
188
+ px: 1,
189
+ py: 0.5,
190
+ borderRadius: '4px',
191
+ }}>
192
+ {formatTrialText(t, pricing.trial.days, pricing.trial.afterTrialInterval || 'day')}
193
+ </Typography>
194
+ )}
195
+ {!livemode && (
196
+ <Typography
197
+ component="span"
198
+ sx={{
199
+ fontSize: 10,
200
+ fontWeight: 700,
201
+ letterSpacing: '0.1em',
202
+ lineHeight: 1,
203
+ textTransform: 'uppercase',
204
+ color: (theme) => (theme.palette.mode === 'dark' ? theme.palette.grey[500] : theme.palette.grey[400]),
205
+ bgcolor: (theme) =>
206
+ theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : theme.palette.grey[100],
207
+ px: 1,
208
+ py: 0.5,
209
+ borderRadius: '4px',
210
+ }}>
211
+ {t('common.livemode')}
212
+ </Typography>
213
+ )}
214
+ </Stack>
215
+
216
+ {/* Title: product name (single item) or "Order Summary" (multi item) */}
217
+ <Typography
218
+ sx={{
219
+ fontWeight: 800,
220
+ fontSize: { xs: 24, md: 36 },
221
+ lineHeight: 1.1,
222
+ letterSpacing: '-0.03em',
223
+ color: 'text.primary',
224
+ mb: 0.75,
225
+ }}>
226
+ {isMultiItem ? tSafe(t, 'payment.checkout.orderSummary', 'Order Summary') : headerMeta.title}
227
+ </Typography>
228
+
229
+ {/* Subtitle */}
230
+ {(isMultiItem || headerMeta.subtitle) && (
231
+ <Typography
232
+ sx={{
233
+ color: 'text.secondary',
234
+ fontSize: { xs: 14, md: 16 },
235
+ fontWeight: 500,
236
+ lineHeight: 1.5,
237
+ }}>
238
+ {isMultiItem
239
+ ? tSafe(t, 'payment.checkout.orderSummarySubtitle', 'Items included in this purchase')
240
+ : headerMeta.subtitle}
241
+ </Typography>
242
+ )}
243
+
244
+ {/* ── Top-level upsell toggle (single subscription) ── */}
245
+ {hasTopUpsell && (
246
+ <Stack direction="row" alignItems="center" spacing={2} sx={{ mt: 2.5 }}>
247
+ <Stack
248
+ direction="row"
249
+ alignItems="center"
250
+ sx={{
251
+ bgcolor: 'background.paper',
252
+ borderRadius: '9999px',
253
+ border: '1px solid',
254
+ borderColor: 'divider',
255
+ p: '4px',
256
+ display: 'inline-flex',
257
+ boxShadow: (theme) =>
258
+ theme.palette.mode === 'dark' ? '0 1px 2px 0 rgba(0,0,0,0.3)' : '0 1px 2px 0 rgba(0,0,0,0.05)',
259
+ }}>
260
+ {/* Current interval */}
261
+ <Box
262
+ onClick={async () => {
263
+ if (isUpselled) {
264
+ try {
265
+ await lineItems.downsell(
266
+ (upsellPrimaryItem as any).upsell_price?.id || upsellPrimaryItem!.price_id
267
+ );
268
+ } catch (err: any) {
269
+ Toast.error(err?.response?.data?.error || err?.message || 'Failed');
270
+ }
271
+ }
272
+ }}
273
+ sx={capsuleBtnSx(!isUpselled)}>
274
+ <Typography component="span" sx={{ fontSize: 14, fontWeight: 700, color: 'inherit', lineHeight: 1 }}>
275
+ {t(INTERVAL_LOCALE_KEY[currentInterval!] || '')}
276
+ </Typography>
277
+ </Box>
278
+ {/* Upsell interval */}
279
+ <Box
280
+ onClick={async () => {
281
+ if (!isUpselled) {
282
+ try {
283
+ await lineItems.upsell(upsellPrimaryItem!.price_id, upsellTarget.id);
284
+ } catch (err: any) {
285
+ Toast.error(err?.response?.data?.error || err?.message || 'Failed');
286
+ }
287
+ }
288
+ }}
289
+ sx={capsuleBtnSx(isUpselled)}>
290
+ <Typography component="span" sx={{ fontSize: 14, fontWeight: 700, color: 'inherit', lineHeight: 1 }}>
291
+ {t(INTERVAL_LOCALE_KEY[upsellInterval!] || '')}
292
+ </Typography>
293
+ </Box>
294
+ </Stack>
295
+
296
+ {/* SAVE badge */}
297
+ {upsellSavings > 0 && (
298
+ <Stack
299
+ direction="row"
300
+ alignItems="center"
301
+ spacing={0.75}
302
+ sx={{
303
+ px: 1.5,
304
+ py: 0.75,
305
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(18,184,134,0.1)' : '#ebfef5'),
306
+ color: '#12b886',
307
+ fontSize: 11,
308
+ fontWeight: 700,
309
+ borderRadius: '9999px',
310
+ border: '1px solid',
311
+ borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(18,184,134,0.2)' : '#d3f9e8'),
312
+ textTransform: 'uppercase',
313
+ letterSpacing: '0.05em',
314
+ }}>
315
+ <TrendingDownIcon sx={{ fontSize: 14 }} />
316
+ <Typography
317
+ component="span"
318
+ sx={{ fontSize: 'inherit', fontWeight: 'inherit', color: 'inherit', lineHeight: 1 }}>
319
+ SAVE {upsellSavings}%
320
+ </Typography>
321
+ </Stack>
322
+ )}
323
+ </Stack>
324
+ )}
325
+ </Box>
326
+
327
+ {/* Billing interval toggle (multi-interval, not upsell) */}
328
+ <BillingToggle billingInterval={billingInterval} />
329
+ </Box>
330
+ {/* end top section */}
331
+
332
+ {/* ── Middle: items area — shrinks & scrolls only when content exceeds one screen ── */}
333
+ <Box
334
+ sx={{
335
+ flexGrow: 0,
336
+ flexShrink: 1,
337
+ flexBasis: 'auto',
338
+ minHeight: { md: 0 },
339
+ overflowY: { md: 'auto' },
340
+ '&::-webkit-scrollbar': { display: 'none' },
341
+ scrollbarWidth: 'none',
342
+ }}>
343
+ {/* Items collapse header */}
344
+ {showItemsCollapse && (
345
+ <Stack
346
+ direction="row"
347
+ alignItems="center"
348
+ justifyContent="space-between"
349
+ onClick={() => setItemsExpanded(!itemsExpanded)}
350
+ sx={{ cursor: 'pointer', mb: 1.5 }}>
351
+ <Typography sx={{ fontSize: 14, fontWeight: 600 }}>
352
+ {t('payment.checkout.productListTotal', { total: lineItems.items.length })}
353
+ </Typography>
354
+ <ExpandMoreIcon sx={{ transform: itemsExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: '0.3s' }} />
355
+ </Stack>
356
+ )}
357
+
358
+ {/* Line items — individual cards with spacing */}
359
+ <Collapse in={itemsExpanded || !showItemsCollapse}>
360
+ <Stack spacing={2} sx={{ mb: 2 }}>
361
+ {lineItems.items.map((item: any) => (
362
+ <ProductItemCard
363
+ key={item.id}
364
+ item={item}
365
+ currency={currency}
366
+ discounts={discounts}
367
+ exchangeRate={rate.value}
368
+ onQuantityChange={lineItems.updateQuantity}
369
+ onUpsell={lineItems.upsell}
370
+ onDownsell={lineItems.downsell}
371
+ trialActive={pricing.trial.active}
372
+ trialDays={pricing.trial.days}
373
+ hideUpsell={hasTopUpsell || !canUpsell}
374
+ isRateLoading={currencySwitching || (rate.hasDynamicPricing && rate.status === 'loading')}
375
+ showFeatures={pageInfo?.showProductFeatures ?? false}
376
+ t={t}>
377
+ {/* Cross-sell remove button inside card */}
378
+ {item.cross_sell && (
379
+ <Button
380
+ size="small"
381
+ color="error"
382
+ variant="text"
383
+ onClick={lineItems.removeCrossSell}
384
+ sx={{ mt: 1, ml: -0.5, textTransform: 'none', fontSize: 12 }}>
385
+ {t('payment.checkout.cross_sell.remove')}
386
+ </Button>
387
+ )}
388
+ </ProductItemCard>
389
+ ))}
390
+ </Stack>
391
+
392
+ {/* Cross-sell card — collapses together with line items */}
393
+ {crossSellNotAdded && (
394
+ <Box sx={{ mb: 2 }}>
395
+ <CrossSellCard
396
+ crossSellItem={lineItems.crossSellItem!}
397
+ currency={currency}
398
+ exchangeRate={rate.value}
399
+ crossSellRequired={lineItems.crossSellRequired}
400
+ onAdd={lineItems.addCrossSell}
401
+ />
402
+ </Box>
403
+ )}
404
+ </Collapse>
405
+ </Box>
406
+ {/* end middle section */}
407
+
408
+ {/* TrialInfo — follows items directly, no gap */}
409
+ <Box sx={{ flexShrink: 0 }}>
410
+ <TrialInfo
411
+ trial={{
412
+ active: pricing.trial.active,
413
+ days: pricing.trial.days,
414
+ afterTrialPrice: pricing.trial.afterTrialPrice,
415
+ afterTrialInterval: pricing.trial.afterTrialInterval,
416
+ }}
417
+ mode={mode}
418
+ items={lineItems.items}
419
+ />
420
+ </Box>
421
+
422
+ {/* Spacer — fills remaining space so exchange rate is pushed to bottom */}
423
+ <Box sx={{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }} />
424
+
425
+ {/* Exchange rate — always at the very bottom */}
426
+ <Box sx={{ flexShrink: 0 }}>
427
+ {/* Rate unavailable — inline hint with retry */}
428
+ {rate.hasDynamicPricing && rate.status === 'unavailable' && !isStripe && (
429
+ <Stack direction="row" alignItems="center" spacing={0.75} sx={{ mb: 2 }}>
430
+ <Typography sx={{ fontSize: 13, color: 'text.secondary', fontWeight: 500 }}>
431
+ {t('payment.dynamicPricing.unavailable.title')}
432
+ </Typography>
433
+ <Typography
434
+ component="span"
435
+ onClick={rate.refresh}
436
+ sx={{
437
+ fontSize: 13,
438
+ color: 'primary.main',
439
+ fontWeight: 600,
440
+ cursor: 'pointer',
441
+ '&:hover': { textDecoration: 'underline' },
442
+ }}>
443
+ {t('payment.dynamicPricing.unavailable.retry')}
444
+ </Typography>
445
+ </Stack>
446
+ )}
447
+
448
+ <ExchangeRateFooter
449
+ hasDynamicPricing={rate.hasDynamicPricing}
450
+ rate={{
451
+ value: rate.value,
452
+ display: rate.display,
453
+ provider: rate.provider,
454
+ providerDisplay: rate.providerDisplay,
455
+ fetchedAt: rate.fetchedAt,
456
+ status: rate.status,
457
+ }}
458
+ slippage={{ percent: slippage.percent, set: slippage.set }}
459
+ currencySymbol={currency?.symbol || ''}
460
+ isSubscription={['subscription', 'setup'].includes(mode)}
461
+ />
462
+ </Box>
463
+ </Box>
464
+ );
465
+ }