@blocklet/payment-react 1.25.10 → 1.26.1

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 +611 -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 +795 -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 +642 -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 +677 -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 +205 -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,677 @@
1
+ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
2
+ import { Box, IconButton, Typography, InputBase, Stack } from '@mui/material';
3
+ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
4
+ import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined';
5
+ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
6
+ import { useRequest } from 'ahooks';
7
+ import { BN, fromTokenToUnit } from '@ocap/util';
8
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
9
+ import WarningAmberIcon from '@mui/icons-material/WarningAmber';
10
+ import {
11
+ useCheckoutStatus,
12
+ useLineItems,
13
+ useSessionContext,
14
+ useProduct,
15
+ useExchangeRate,
16
+ useSlippage,
17
+ usePaymentMethodContext,
18
+ useSubmitFeature,
19
+ } from '@blocklet/payment-react-headless';
20
+
21
+ import { usePaymentContext } from '../../../contexts/payment';
22
+ import api from '../../../libs/api';
23
+ import { formatNumber, formatBNStr, formatCreditForCheckout, formatCreditAmount } from '../../../libs/util';
24
+ import { tSafe } from '../../utils/format';
25
+ import ScenarioBadge from '../../components/shared/scenario-badge';
26
+ import ExchangeRateFooter from '../../components/shared/exchange-rate-footer';
27
+
28
+ export default function CreditTopupPanel() {
29
+ const { t, locale } = useLocaleContext();
30
+ const { session, sessionData } = useSessionContext();
31
+ const { livemode } = useCheckoutStatus();
32
+ const { product } = useProduct();
33
+ const { currency, isStripe } = usePaymentMethodContext();
34
+ const lineItems = useLineItems();
35
+ const rate = useExchangeRate();
36
+ const slippage = useSlippage();
37
+ const { locked: configLocked } = useSubmitFeature();
38
+
39
+ const item = lineItems.items[0];
40
+ const activePrice: any = item ? (item as any).upsell_price || item.price : null;
41
+
42
+ // Credit metadata
43
+ const creditConfig = activePrice?.metadata?.credit_config;
44
+ const creditAmount = creditConfig?.credit_amount ? Number(creditConfig.credit_amount) : 0;
45
+ const creditCurrencyId = creditConfig?.currency_id;
46
+
47
+ // DID session for login state and userDid; getCurrency from /api/settings (same as V1)
48
+ const { session: didSession, getCurrency } = usePaymentContext();
49
+
50
+ // Credit currency: use getCurrency from /api/settings — same data source as V1
51
+ const creditCurrency = creditCurrencyId ? getCurrency(creditCurrencyId) : null;
52
+ const userDid = didSession?.user?.did || (sessionData?.customer as any)?.did;
53
+
54
+ const creditCurrencyDecimal = creditCurrency?.decimal;
55
+ const creditCurrencySymbol = creditCurrency?.symbol || 'Credits';
56
+
57
+ const currencySymbol = 'Credits';
58
+
59
+ // Fetch meter data by meter_id from price metadata
60
+ const meterId = activePrice?.metadata?.meter_id;
61
+ const { data: meterData } = useRequest(
62
+ async () => {
63
+ if (!meterId) return null;
64
+ try {
65
+ const { data } = await api.get(`/api/meters/public/${meterId}`);
66
+ return data;
67
+ } catch {
68
+ return null;
69
+ }
70
+ },
71
+ { refreshDeps: [meterId] }
72
+ );
73
+
74
+ const creditName = meterData?.name || product?.name || 'Credits';
75
+
76
+ // Credit config details
77
+ const validDuration = creditConfig?.valid_duration_value;
78
+ const validDurationUnit = creditConfig?.valid_duration_unit || 'days';
79
+ const scheduleConfig = creditConfig?.schedule;
80
+ const hasSchedule =
81
+ scheduleConfig?.enabled && scheduleConfig?.delivery_mode && scheduleConfig.delivery_mode !== 'invoice';
82
+ const hasExpiry = validDuration && validDuration > 0;
83
+
84
+ // Step size: each +/- changes by one pack's worth of credits
85
+ const step = Math.max(creditAmount, 1);
86
+
87
+ // Adjustable quantity config
88
+ const adjustableQty = item?.adjustable_quantity;
89
+ const canAdjust = adjustableQty?.enabled !== false;
90
+ const minQuantity = Math.max(adjustableQty?.minimum || 1, 1);
91
+ const quantityAvailable = Math.min(
92
+ activePrice?.quantity_limit_per_checkout ?? Infinity,
93
+ activePrice?.quantity_available ?? Infinity
94
+ );
95
+ const maxQuantity = quantityAvailable
96
+ ? Math.min(adjustableQty?.maximum || Infinity, quantityAvailable)
97
+ : adjustableQty?.maximum || Infinity;
98
+ const { data: pendingAmount } = useRequest(
99
+ async () => {
100
+ // Wait for credit currency decimal before fetching — calculations depend on correct decimal
101
+ if (!creditConfig || !userDid || !creditCurrencyId || creditCurrencyDecimal == null) return null;
102
+ try {
103
+ const { data } = await api.get('/api/meter-events/pending-amount', {
104
+ params: { customer_id: userDid, currency_id: creditCurrencyId },
105
+ });
106
+ return data?.[creditCurrencyId];
107
+ } catch {
108
+ return null;
109
+ }
110
+ },
111
+ { refreshDeps: [creditConfig, userDid, creditCurrencyId, creditCurrencyDecimal] }
112
+ );
113
+
114
+ // Min quantity needed to cover pending
115
+ const minQtyForPending = useMemo(() => {
116
+ if (!pendingAmount || !creditAmount || creditAmount <= 0) return null;
117
+ const pendingBN = new BN(pendingAmount || '0');
118
+ if (!pendingBN.gt(new BN(0))) return null;
119
+ // creditCurrencyDecimal is guaranteed non-null here (pending fetch depends on it)
120
+ const creditBN = fromTokenToUnit(creditAmount, creditCurrencyDecimal!);
121
+ if (!creditBN || creditBN.isZero()) return null;
122
+ return Math.ceil(pendingBN.mul(new BN(100)).div(creditBN).toNumber() / 100);
123
+ }, [pendingAmount, creditAmount, creditCurrencyDecimal]);
124
+
125
+ // Credit info description (referencing V1 formatCreditInfo)
126
+ const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0));
127
+ const pendingDisplayAmount = useMemo(() => {
128
+ if (!hasPendingAmount) return '';
129
+ // creditCurrencyDecimal is guaranteed non-null here (pending fetch depends on it)
130
+ return formatCreditForCheckout(formatBNStr(pendingAmount!, creditCurrencyDecimal!), creditCurrencySymbol, locale);
131
+ }, [hasPendingAmount, pendingAmount, creditCurrencyDecimal, creditCurrencySymbol, locale]);
132
+
133
+ // Min credits needed to cover pending (for user-facing display)
134
+ const minCreditsForPending = minQtyForPending ? minQtyForPending * step : 0;
135
+ const minCreditsForPendingFormatted = minCreditsForPending
136
+ ? formatCreditForCheckout(formatNumber(minCreditsForPending), creditCurrencySymbol, locale)
137
+ : '';
138
+
139
+ // Credit info text: schedule or product description (no fallback "Purchase X")
140
+ const creditInfoText = useMemo(() => {
141
+ if (hasSchedule && scheduleConfig) {
142
+ const intervalUnit = scheduleConfig.interval_unit;
143
+ const intervalValue = scheduleConfig.interval_value;
144
+ let amountPerGrant: number;
145
+ if (scheduleConfig.amount_per_grant) {
146
+ amountPerGrant = Number(scheduleConfig.amount_per_grant);
147
+ } else {
148
+ amountPerGrant = creditAmount;
149
+ }
150
+ const formattedAmount = formatCreditAmount(formatNumber(amountPerGrant), currencySymbol);
151
+ const intervalDisplay =
152
+ intervalValue === 1 ? t(`common.${intervalUnit}`) : `${intervalValue} ${t(`common.${intervalUnit}s` as any)}`;
153
+ return scheduleConfig.expire_with_next_grant
154
+ ? t('payment.checkout.credit.schedule.withRefresh', { amount: formattedAmount, interval: intervalDisplay })
155
+ : t('payment.checkout.credit.schedule.periodic', { amount: formattedAmount, interval: intervalDisplay });
156
+ }
157
+ return '';
158
+ }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, t]);
159
+
160
+ // Validity text: "Credits are valid for X days after purchase."
161
+ const validityText = useMemo(() => {
162
+ if (!hasExpiry) return '';
163
+ return t('payment.checkout.creditTopup.validFor', {
164
+ duration: validDuration,
165
+ unit: t(`common.${validDurationUnit}`),
166
+ });
167
+ }, [hasExpiry, validDuration, validDurationUnit, t]);
168
+
169
+ const hasSubtitle = !!creditInfoText;
170
+
171
+ // ── State: user inputs desired credits, we compute packs ──
172
+ const currentQty = item?.quantity || 1;
173
+ const [localQty, setLocalQty] = useState(currentQty); // pack quantity (source of truth for API)
174
+ const [desiredCredits, setDesiredCredits] = useState(currentQty * step); // what user sees/types
175
+ const [isEditing, setIsEditing] = useState(false);
176
+ const [editValue, setEditValue] = useState('');
177
+ const inputRef = useRef<HTMLInputElement>(null);
178
+
179
+ // Sync when item quantity changes from backend (skip if locked or already completed)
180
+ useEffect(() => {
181
+ if (configLocked || session?.status === 'complete') return;
182
+ if (item?.quantity && item.quantity !== localQty) {
183
+ setLocalQty(item.quantity);
184
+ setDesiredCredits(item.quantity * step);
185
+ }
186
+ }, [item?.quantity]); // eslint-disable-line react-hooks/exhaustive-deps
187
+
188
+ // Enforce min quantity for pending — only once on initial load.
189
+ // After user confirms quantity (e.g. clicks "Connect and Pay"), don't override their choice.
190
+ const pendingEnforcedRef = useRef(false);
191
+ useEffect(() => {
192
+ if (configLocked || session?.status === 'complete') return;
193
+ if (pendingEnforcedRef.current) return;
194
+ if (minQtyForPending && minQtyForPending > localQty) {
195
+ pendingEnforcedRef.current = true;
196
+ const newQty = Math.min(Math.max(minQtyForPending, minQuantity), maxQuantity);
197
+ setLocalQty(newQty);
198
+ setDesiredCredits(newQty * step);
199
+ if (item) lineItems.updateQuantity(item.price_id, newQty);
200
+ }
201
+ }, [minQtyForPending]); // eslint-disable-line react-hooks/exhaustive-deps
202
+
203
+ // Max credits = maxQuantity packs * credits per pack
204
+ const maxCredits = maxQuantity * step;
205
+ const minCredits = minQuantity * step;
206
+
207
+ // Commit desired credits → compute packs → update API
208
+ const commitCredits = useCallback(
209
+ (credits: number) => {
210
+ if (credits <= 0) return;
211
+ // Clamp credits to valid range
212
+ const clamped = Math.max(minCredits, Math.min(maxCredits, credits));
213
+ const packs = Math.ceil(clamped / step);
214
+ const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs));
215
+ setLocalQty(clampedPacks);
216
+ setDesiredCredits(clamped);
217
+ if (item) lineItems.updateQuantity(item.price_id, clampedPacks);
218
+ },
219
+ [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems]
220
+ );
221
+
222
+ // +/- buttons: step by one pack's worth of credits
223
+ const handleStep = useCallback(
224
+ (delta: number) => {
225
+ const newCredits = desiredCredits + delta * step;
226
+ if (newCredits < step * minQuantity || Math.ceil(newCredits / step) > maxQuantity) return;
227
+ commitCredits(newCredits);
228
+ },
229
+ [desiredCredits, step, minQuantity, maxQuantity, commitCredits]
230
+ );
231
+
232
+ // Actual credits user will receive (always >= desired, rounded up to whole packs)
233
+ const actualPacks = Math.max(minQuantity, Math.min(maxQuantity, Math.ceil(desiredCredits / step)));
234
+ const actualCredits = actualPacks * step;
235
+ const actualCreditsFormatted = formatNumber(actualCredits, 6, true, true);
236
+ const desiredCreditsFormatted = formatNumber(desiredCredits, 6, true, true);
237
+ const showReceiveSection = canAdjust && step > 1;
238
+
239
+ // Pre-compute pending message to avoid deep ternary in JSX
240
+ const pendingMessage = useMemo(() => {
241
+ if (!hasPendingAmount) return '';
242
+ if (actualCredits >= minCreditsForPending) {
243
+ return t('payment.checkout.creditTopup.pendingEnough', {
244
+ pendingAmount: pendingDisplayAmount,
245
+ availableAmount: formatCreditForCheckout(
246
+ formatNumber(actualCredits - minCreditsForPending),
247
+ creditCurrencySymbol,
248
+ locale
249
+ ),
250
+ });
251
+ }
252
+ return t('payment.checkout.creditTopup.pendingWarning', {
253
+ pendingAmount: pendingDisplayAmount,
254
+ minCredits: minCreditsForPendingFormatted,
255
+ });
256
+ }, [
257
+ hasPendingAmount,
258
+ actualCredits,
259
+ minCreditsForPending,
260
+ pendingDisplayAmount,
261
+ creditCurrencySymbol,
262
+ locale,
263
+ minCreditsForPendingFormatted,
264
+ t,
265
+ ]);
266
+
267
+ // Editing handlers — user types desired credit amount
268
+ const startEditing = useCallback(() => {
269
+ if (!canAdjust) return;
270
+ setEditValue(String(desiredCredits));
271
+ setIsEditing(true);
272
+ setTimeout(() => inputRef.current?.select(), 0);
273
+ }, [canAdjust, desiredCredits]);
274
+
275
+ const commitEdit = useCallback(() => {
276
+ setIsEditing(false);
277
+ const parsed = parseInt(editValue.replace(/,/g, ''), 10);
278
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
279
+ commitCredits(parsed);
280
+ }, [editValue, commitCredits]);
281
+
282
+ const handleEditKeyDown = useCallback(
283
+ (e: React.KeyboardEvent) => {
284
+ if (e.key === 'Enter') commitEdit();
285
+ else if (e.key === 'Escape') setIsEditing(false);
286
+ },
287
+ [commitEdit]
288
+ );
289
+
290
+ // ── Shared styles ──
291
+ const numberHeight = { xs: 48, md: 72 };
292
+ const numberFontSx = {
293
+ fontSize: numberHeight,
294
+ fontWeight: 800,
295
+ lineHeight: 1,
296
+ letterSpacing: '-0.03em',
297
+ };
298
+ const circleBtnSx = {
299
+ width: { xs: 40, md: 56 },
300
+ height: { xs: 40, md: 56 },
301
+ borderRadius: '50%',
302
+ bgcolor: 'background.paper',
303
+ border: '1px solid',
304
+ borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'divider'),
305
+ color: 'text.secondary',
306
+ transition: 'all 0.2s ease',
307
+ '&:hover': {
308
+ borderColor: 'primary.main',
309
+ color: 'primary.main',
310
+ bgcolor: 'background.paper',
311
+ },
312
+ '&.Mui-disabled': {
313
+ borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
314
+ color: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'),
315
+ },
316
+ };
317
+
318
+ return (
319
+ <Box
320
+ sx={{
321
+ display: 'flex',
322
+ flexDirection: 'column',
323
+ height: '100%',
324
+ }}>
325
+ {/* Main content — vertically centered */}
326
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
327
+ {/* Badge */}
328
+ <ScenarioBadge livemode={livemode} label={tSafe(t, 'payment.checkout.typeBadge.topup', 'TOP-UP')} />
329
+
330
+ {/* Title: Get Credits */}
331
+ <Typography
332
+ sx={{
333
+ fontWeight: 800,
334
+ fontSize: { xs: 28, md: 48 },
335
+ lineHeight: 1.1,
336
+ letterSpacing: '-0.03em',
337
+ color: 'text.primary',
338
+ mb: { xs: 0.5, md: 1 },
339
+ }}>
340
+ {t('payment.checkout.creditTopup.title', { name: creditName })}
341
+ </Typography>
342
+
343
+ {/* Credit info (schedule/description) */}
344
+ {hasSubtitle && (
345
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: { xs: 1.5, md: 2.5 } }}>
346
+ <Typography
347
+ sx={{ fontSize: { xs: 13, md: 15 }, fontWeight: 500, color: 'text.secondary', lineHeight: 1.5 }}>
348
+ {creditInfoText}
349
+ </Typography>
350
+ </Box>
351
+ )}
352
+
353
+ {/* Pending/overdue warning */}
354
+ {hasPendingAmount && (
355
+ <Stack
356
+ direction="row"
357
+ alignItems="flex-start"
358
+ spacing={1.5}
359
+ sx={{
360
+ mb: { xs: 1.5, md: 2.5 },
361
+ p: { xs: 1.5, md: 2 },
362
+ borderRadius: '12px',
363
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.12)' : 'rgba(255,152,0,0.08)'),
364
+ border: '1px solid',
365
+ borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.25)' : 'rgba(255,152,0,0.2)'),
366
+ }}>
367
+ <WarningAmberIcon sx={{ fontSize: 18, color: 'warning.main', mt: 0.25, flexShrink: 0 }} />
368
+ <Typography sx={{ fontSize: { xs: 12, md: 13 }, fontWeight: 600, lineHeight: 1.5, color: 'text.primary' }}>
369
+ {pendingMessage}
370
+ </Typography>
371
+ </Stack>
372
+ )}
373
+
374
+ {canAdjust ? (
375
+ <Box sx={{ display: 'flex', flexDirection: 'column', maxWidth: 480 }}>
376
+ {/* Question label — spaced away from subtitle, close to input */}
377
+ <Typography
378
+ sx={{
379
+ fontSize: { xs: 14, md: 17 },
380
+ fontWeight: 700,
381
+ color: 'text.secondary',
382
+ mt: { xs: 3, md: 5 },
383
+ mb: { xs: 2, md: 3 },
384
+ }}>
385
+ {t('payment.checkout.creditTopup.question', { symbol: currencySymbol })}
386
+ </Typography>
387
+
388
+ {/* ── Credit input with +/- ── */}
389
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, md: 2 } }}>
390
+ <IconButton onClick={() => handleStep(-1)} disabled={actualPacks <= minQuantity} sx={circleBtnSx}>
391
+ <Box component="span" sx={{ fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1, mt: '-1px' }}>
392
+ &#x2212;
393
+ </Box>
394
+ </IconButton>
395
+
396
+ <Box
397
+ sx={{ flex: 1, minWidth: 0, textAlign: 'center', cursor: canAdjust ? 'text' : 'default' }}
398
+ onClick={startEditing}>
399
+ <Box
400
+ sx={{
401
+ height: numberHeight,
402
+ display: 'flex',
403
+ alignItems: 'center',
404
+ justifyContent: 'center',
405
+ }}>
406
+ {isEditing ? (
407
+ <InputBase
408
+ inputRef={inputRef}
409
+ value={editValue}
410
+ onChange={(e) => setEditValue(e.target.value.replace(/\D/g, ''))}
411
+ onBlur={commitEdit}
412
+ onKeyDown={handleEditKeyDown}
413
+ autoFocus
414
+ inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
415
+ sx={{
416
+ width: '100%',
417
+ bgcolor: 'transparent',
418
+ '&.Mui-focused': { bgcolor: 'transparent' },
419
+ '& input': {
420
+ textAlign: 'center',
421
+ ...numberFontSx,
422
+ p: 0,
423
+ fontFamily: 'inherit',
424
+ bgcolor: 'transparent',
425
+ '&:focus': { bgcolor: 'transparent' },
426
+ },
427
+ }}
428
+ />
429
+ ) : (
430
+ <Typography sx={{ ...numberFontSx, color: 'text.primary', userSelect: 'none' }}>
431
+ {desiredCreditsFormatted}
432
+ </Typography>
433
+ )}
434
+ </Box>
435
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75, mt: 1 }}>
436
+ <Typography
437
+ sx={{
438
+ fontSize: { xs: 12, md: 14 },
439
+ fontWeight: 700,
440
+ color: 'text.secondary',
441
+ textTransform: 'uppercase',
442
+ letterSpacing: '0.05em',
443
+ }}>
444
+ {currencySymbol}
445
+ </Typography>
446
+ </Box>
447
+ </Box>
448
+
449
+ <IconButton onClick={() => handleStep(1)} disabled={actualPacks >= maxQuantity} sx={circleBtnSx}>
450
+ <Box component="span" sx={{ fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1 }}>
451
+ +
452
+ </Box>
453
+ </IconButton>
454
+ </Box>
455
+
456
+ {/* Increment hint */}
457
+ {step > 1 && (
458
+ <Typography
459
+ sx={{
460
+ fontSize: 11,
461
+ fontWeight: 600,
462
+ color: 'grey.500',
463
+ textTransform: 'uppercase',
464
+ letterSpacing: '0.08em',
465
+ textAlign: 'center',
466
+ mt: { xs: 1.5, md: 2 },
467
+ }}>
468
+ {t('payment.checkout.creditTopup.increment', {
469
+ step: formatNumber(step, 6, true, true),
470
+ symbol: currencySymbol,
471
+ })}
472
+ </Typography>
473
+ )}
474
+
475
+ {/* ── "You'll receive" result card — frosted glass ── */}
476
+ {showReceiveSection && (
477
+ <Box
478
+ sx={{
479
+ display: 'flex',
480
+ flexDirection: 'column',
481
+ mt: { xs: 3, md: 4 },
482
+ borderRadius: '20px',
483
+ backdropFilter: 'blur(12px)',
484
+ bgcolor: (theme) =>
485
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)',
486
+ border: '1px solid',
487
+ borderColor: (theme) =>
488
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)',
489
+ boxShadow: (theme) =>
490
+ theme.palette.mode === 'dark'
491
+ ? '0 8px 32px rgba(59,130,246,0.06)'
492
+ : '0 8px 32px rgba(59,130,246,0.04)',
493
+ }}>
494
+ {/* Main content */}
495
+ <Box sx={{ p: { xs: 2.5, md: 3.5 } }}>
496
+ <Typography
497
+ sx={{
498
+ fontSize: { xs: 16, md: 20 },
499
+ fontWeight: 700,
500
+ color: 'primary.main',
501
+ mb: 0.5,
502
+ }}>
503
+ {t('payment.checkout.creditTopup.willReceive')}
504
+ {': '}
505
+ <Box component="span" sx={{ fontWeight: 800 }}>
506
+ {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)}
507
+ </Box>
508
+ </Typography>
509
+ {/* Pack info line */}
510
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
511
+ <Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
512
+ <Typography sx={{ fontSize: { xs: 12, md: 14 }, fontWeight: 600, color: 'text.secondary' }}>
513
+ {t('payment.checkout.creditTopup.packInfo', {
514
+ packs: actualPacks,
515
+ perPack: formatNumber(step, 6, true, true),
516
+ })}
517
+ </Typography>
518
+ </Box>
519
+ {/* Auto match hint */}
520
+ <Typography
521
+ sx={{
522
+ fontSize: { xs: 12, md: 13 },
523
+ fontWeight: 500,
524
+ color: 'grey.500',
525
+ }}>
526
+ {t('payment.checkout.creditTopup.autoMatch')}
527
+ </Typography>
528
+ </Box>
529
+ {/* Validity footer */}
530
+ {validityText && (
531
+ <Box
532
+ sx={{
533
+ display: 'flex',
534
+ alignItems: 'center',
535
+ gap: 0.75,
536
+ px: { xs: 2.5, md: 3.5 },
537
+ py: { xs: 1.5, md: 2 },
538
+ borderTop: '1px solid',
539
+ borderColor: (theme) =>
540
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)',
541
+ }}>
542
+ <AccessTimeOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
543
+ <Typography
544
+ sx={{
545
+ fontSize: { xs: 12, md: 13 },
546
+ fontWeight: 500,
547
+ color: 'grey.500',
548
+ }}>
549
+ {validityText}
550
+ </Typography>
551
+ </Box>
552
+ )}
553
+ </Box>
554
+ )}
555
+
556
+ {/* Validity hint — shown below input when no receive box */}
557
+ {!showReceiveSection && validityText && (
558
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: { xs: 2.5, md: 3.5 } }}>
559
+ <InfoOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
560
+ <Typography
561
+ sx={{
562
+ fontSize: { xs: 12, md: 13 },
563
+ fontWeight: 500,
564
+ color: 'grey.500',
565
+ }}>
566
+ {validityText}
567
+ </Typography>
568
+ </Box>
569
+ )}
570
+ </Box>
571
+ ) : (
572
+ // ── Non-adjustable: show fixed credit amount in a styled card ──
573
+ <Box
574
+ sx={{
575
+ display: 'flex',
576
+ flexDirection: 'column',
577
+ mt: { xs: 2, md: 3 },
578
+ borderRadius: '20px',
579
+ backdropFilter: 'blur(12px)',
580
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)'),
581
+ border: '1px solid',
582
+ borderColor: (theme) =>
583
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)',
584
+ boxShadow: (theme) =>
585
+ theme.palette.mode === 'dark' ? '0 8px 32px rgba(59,130,246,0.06)' : '0 8px 32px rgba(59,130,246,0.04)',
586
+ }}>
587
+ <Box sx={{ p: { xs: 2.5, md: 3.5 } }}>
588
+ <Typography
589
+ sx={{
590
+ fontSize: { xs: 13, md: 14 },
591
+ fontWeight: 600,
592
+ color: 'text.secondary',
593
+ mb: 1,
594
+ }}>
595
+ {t('payment.checkout.creditTopup.willReceive')}
596
+ </Typography>
597
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
598
+ <Typography
599
+ sx={{
600
+ fontSize: { xs: 32, md: 48 },
601
+ fontWeight: 800,
602
+ lineHeight: 1,
603
+ letterSpacing: '-0.03em',
604
+ color: 'primary.main',
605
+ }}>
606
+ {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)}
607
+ </Typography>
608
+ </Box>
609
+ </Box>
610
+ {/* Validity footer */}
611
+ {validityText && (
612
+ <Box
613
+ sx={{
614
+ display: 'flex',
615
+ alignItems: 'center',
616
+ gap: 0.75,
617
+ px: { xs: 2.5, md: 3.5 },
618
+ py: { xs: 1.5, md: 2 },
619
+ borderTop: '1px solid',
620
+ borderColor: (theme) =>
621
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)',
622
+ }}>
623
+ <AccessTimeOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
624
+ <Typography
625
+ sx={{
626
+ fontSize: { xs: 12, md: 13 },
627
+ fontWeight: 500,
628
+ color: 'grey.500',
629
+ }}>
630
+ {validityText}
631
+ </Typography>
632
+ </Box>
633
+ )}
634
+ </Box>
635
+ )}
636
+ </Box>
637
+ {/* End main content */}
638
+
639
+ {/* Exchange rate — shown for all dynamic pricing scenarios */}
640
+ <Box sx={{ flexShrink: 0 }}>
641
+ {rate.hasDynamicPricing && rate.status === 'unavailable' && !isStripe && (
642
+ <Stack direction="row" alignItems="center" spacing={0.75} sx={{ mb: 2 }}>
643
+ <Typography sx={{ fontSize: 13, color: 'text.secondary', fontWeight: 500 }}>
644
+ {t('payment.dynamicPricing.unavailable.title')}
645
+ </Typography>
646
+ <Typography
647
+ component="span"
648
+ onClick={rate.refresh}
649
+ sx={{
650
+ fontSize: 13,
651
+ color: 'primary.main',
652
+ fontWeight: 600,
653
+ cursor: 'pointer',
654
+ '&:hover': { textDecoration: 'underline' },
655
+ }}>
656
+ {t('payment.dynamicPricing.unavailable.retry')}
657
+ </Typography>
658
+ </Stack>
659
+ )}
660
+ <ExchangeRateFooter
661
+ hasDynamicPricing={rate.hasDynamicPricing}
662
+ rate={{
663
+ value: rate.value,
664
+ display: rate.display,
665
+ provider: rate.provider,
666
+ providerDisplay: rate.providerDisplay,
667
+ fetchedAt: rate.fetchedAt,
668
+ status: rate.status,
669
+ }}
670
+ slippage={{ percent: slippage.percent, set: slippage.set }}
671
+ currencySymbol={currency?.symbol || ''}
672
+ isSubscription={['subscription', 'setup'].includes(session?.mode || 'payment')}
673
+ />
674
+ </Box>
675
+ </Box>
676
+ );
677
+ }