@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,681 @@
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
+ const productDesc = product?.description || '';
158
+ if (productDesc && productDesc.length > 10 && productDesc !== creditName) {
159
+ return productDesc;
160
+ }
161
+ return '';
162
+ }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, creditName, product, t]);
163
+
164
+ // Validity text: "Credits are valid for X days after purchase."
165
+ const validityText = useMemo(() => {
166
+ if (!hasExpiry) return '';
167
+ return t('payment.checkout.creditTopup.validFor', {
168
+ duration: validDuration,
169
+ unit: t(`common.${validDurationUnit}`),
170
+ });
171
+ }, [hasExpiry, validDuration, validDurationUnit, t]);
172
+
173
+ const hasSubtitle = !!creditInfoText;
174
+
175
+ // ── State: user inputs desired credits, we compute packs ──
176
+ const currentQty = item?.quantity || 1;
177
+ const [localQty, setLocalQty] = useState(currentQty); // pack quantity (source of truth for API)
178
+ const [desiredCredits, setDesiredCredits] = useState(currentQty * step); // what user sees/types
179
+ const [isEditing, setIsEditing] = useState(false);
180
+ const [editValue, setEditValue] = useState('');
181
+ const inputRef = useRef<HTMLInputElement>(null);
182
+
183
+ // Sync when item quantity changes from backend (skip if locked or already completed)
184
+ useEffect(() => {
185
+ if (configLocked || session?.status === 'complete') return;
186
+ if (item?.quantity && item.quantity !== localQty) {
187
+ setLocalQty(item.quantity);
188
+ setDesiredCredits(item.quantity * step);
189
+ }
190
+ }, [item?.quantity]); // eslint-disable-line react-hooks/exhaustive-deps
191
+
192
+ // Enforce min quantity for pending — only once on initial load.
193
+ // After user confirms quantity (e.g. clicks "Connect and Pay"), don't override their choice.
194
+ const pendingEnforcedRef = useRef(false);
195
+ useEffect(() => {
196
+ if (configLocked || session?.status === 'complete') return;
197
+ if (pendingEnforcedRef.current) return;
198
+ if (minQtyForPending && minQtyForPending > localQty) {
199
+ pendingEnforcedRef.current = true;
200
+ const newQty = Math.min(Math.max(minQtyForPending, minQuantity), maxQuantity);
201
+ setLocalQty(newQty);
202
+ setDesiredCredits(newQty * step);
203
+ if (item) lineItems.updateQuantity(item.price_id, newQty);
204
+ }
205
+ }, [minQtyForPending]); // eslint-disable-line react-hooks/exhaustive-deps
206
+
207
+ // Max credits = maxQuantity packs * credits per pack
208
+ const maxCredits = maxQuantity * step;
209
+ const minCredits = minQuantity * step;
210
+
211
+ // Commit desired credits → compute packs → update API
212
+ const commitCredits = useCallback(
213
+ (credits: number) => {
214
+ if (credits <= 0) return;
215
+ // Clamp credits to valid range
216
+ const clamped = Math.max(minCredits, Math.min(maxCredits, credits));
217
+ const packs = Math.ceil(clamped / step);
218
+ const clampedPacks = Math.max(minQuantity, Math.min(maxQuantity, packs));
219
+ setLocalQty(clampedPacks);
220
+ setDesiredCredits(clamped);
221
+ if (item) lineItems.updateQuantity(item.price_id, clampedPacks);
222
+ },
223
+ [step, minQuantity, maxQuantity, minCredits, maxCredits, item, lineItems]
224
+ );
225
+
226
+ // +/- buttons: step by one pack's worth of credits
227
+ const handleStep = useCallback(
228
+ (delta: number) => {
229
+ const newCredits = desiredCredits + delta * step;
230
+ if (newCredits < step * minQuantity || Math.ceil(newCredits / step) > maxQuantity) return;
231
+ commitCredits(newCredits);
232
+ },
233
+ [desiredCredits, step, minQuantity, maxQuantity, commitCredits]
234
+ );
235
+
236
+ // Actual credits user will receive (always >= desired, rounded up to whole packs)
237
+ const actualPacks = Math.max(minQuantity, Math.min(maxQuantity, Math.ceil(desiredCredits / step)));
238
+ const actualCredits = actualPacks * step;
239
+ const actualCreditsFormatted = formatNumber(actualCredits, 6, true, true);
240
+ const desiredCreditsFormatted = formatNumber(desiredCredits, 6, true, true);
241
+ const showReceiveSection = canAdjust && step > 1;
242
+
243
+ // Pre-compute pending message to avoid deep ternary in JSX
244
+ const pendingMessage = useMemo(() => {
245
+ if (!hasPendingAmount) return '';
246
+ if (actualCredits >= minCreditsForPending) {
247
+ return t('payment.checkout.creditTopup.pendingEnough', {
248
+ pendingAmount: pendingDisplayAmount,
249
+ availableAmount: formatCreditForCheckout(
250
+ formatNumber(actualCredits - minCreditsForPending),
251
+ creditCurrencySymbol,
252
+ locale
253
+ ),
254
+ });
255
+ }
256
+ return t('payment.checkout.creditTopup.pendingWarning', {
257
+ pendingAmount: pendingDisplayAmount,
258
+ minCredits: minCreditsForPendingFormatted,
259
+ });
260
+ }, [
261
+ hasPendingAmount,
262
+ actualCredits,
263
+ minCreditsForPending,
264
+ pendingDisplayAmount,
265
+ creditCurrencySymbol,
266
+ locale,
267
+ minCreditsForPendingFormatted,
268
+ t,
269
+ ]);
270
+
271
+ // Editing handlers — user types desired credit amount
272
+ const startEditing = useCallback(() => {
273
+ if (!canAdjust) return;
274
+ setEditValue(String(desiredCredits));
275
+ setIsEditing(true);
276
+ setTimeout(() => inputRef.current?.select(), 0);
277
+ }, [canAdjust, desiredCredits]);
278
+
279
+ const commitEdit = useCallback(() => {
280
+ setIsEditing(false);
281
+ const parsed = parseInt(editValue.replace(/,/g, ''), 10);
282
+ if (!Number.isFinite(parsed) || parsed <= 0) return;
283
+ commitCredits(parsed);
284
+ }, [editValue, commitCredits]);
285
+
286
+ const handleEditKeyDown = useCallback(
287
+ (e: React.KeyboardEvent) => {
288
+ if (e.key === 'Enter') commitEdit();
289
+ else if (e.key === 'Escape') setIsEditing(false);
290
+ },
291
+ [commitEdit]
292
+ );
293
+
294
+ // ── Shared styles ──
295
+ const numberHeight = { xs: 48, md: 72 };
296
+ const numberFontSx = {
297
+ fontSize: numberHeight,
298
+ fontWeight: 800,
299
+ lineHeight: 1,
300
+ letterSpacing: '-0.03em',
301
+ };
302
+ const circleBtnSx = {
303
+ width: { xs: 40, md: 56 },
304
+ height: { xs: 40, md: 56 },
305
+ borderRadius: '50%',
306
+ bgcolor: 'background.paper',
307
+ border: '1px solid',
308
+ borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'divider'),
309
+ color: 'text.secondary',
310
+ transition: 'all 0.2s ease',
311
+ '&:hover': {
312
+ borderColor: 'primary.main',
313
+ color: 'primary.main',
314
+ bgcolor: 'background.paper',
315
+ },
316
+ '&.Mui-disabled': {
317
+ borderColor: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
318
+ color: (theme: any) => (theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'),
319
+ },
320
+ };
321
+
322
+ return (
323
+ <Box
324
+ sx={{
325
+ display: 'flex',
326
+ flexDirection: 'column',
327
+ height: '100%',
328
+ }}>
329
+ {/* Main content — vertically centered */}
330
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
331
+ {/* Badge */}
332
+ <ScenarioBadge livemode={livemode} label={tSafe(t, 'payment.checkout.typeBadge.topup', 'TOP-UP')} />
333
+
334
+ {/* Title: Get Credits */}
335
+ <Typography
336
+ sx={{
337
+ fontWeight: 800,
338
+ fontSize: { xs: 28, md: 48 },
339
+ lineHeight: 1.1,
340
+ letterSpacing: '-0.03em',
341
+ color: 'text.primary',
342
+ mb: { xs: 0.5, md: 1 },
343
+ }}>
344
+ {t('payment.checkout.creditTopup.title', { name: creditName })}
345
+ </Typography>
346
+
347
+ {/* Credit info (schedule/description) */}
348
+ {hasSubtitle && (
349
+ <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: { xs: 1.5, md: 2.5 } }}>
350
+ <Typography
351
+ sx={{ fontSize: { xs: 13, md: 15 }, fontWeight: 500, color: 'text.secondary', lineHeight: 1.5 }}>
352
+ {creditInfoText}
353
+ </Typography>
354
+ </Box>
355
+ )}
356
+
357
+ {/* Pending/overdue warning */}
358
+ {hasPendingAmount && (
359
+ <Stack
360
+ direction="row"
361
+ alignItems="flex-start"
362
+ spacing={1.5}
363
+ sx={{
364
+ mb: { xs: 1.5, md: 2.5 },
365
+ p: { xs: 1.5, md: 2 },
366
+ borderRadius: '12px',
367
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.12)' : 'rgba(255,152,0,0.08)'),
368
+ border: '1px solid',
369
+ borderColor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255,152,0,0.25)' : 'rgba(255,152,0,0.2)'),
370
+ }}>
371
+ <WarningAmberIcon sx={{ fontSize: 18, color: 'warning.main', mt: 0.25, flexShrink: 0 }} />
372
+ <Typography sx={{ fontSize: { xs: 12, md: 13 }, fontWeight: 600, lineHeight: 1.5, color: 'text.primary' }}>
373
+ {pendingMessage}
374
+ </Typography>
375
+ </Stack>
376
+ )}
377
+
378
+ {canAdjust ? (
379
+ <Box sx={{ display: 'flex', flexDirection: 'column', maxWidth: 480 }}>
380
+ {/* Question label — spaced away from subtitle, close to input */}
381
+ <Typography
382
+ sx={{
383
+ fontSize: { xs: 14, md: 17 },
384
+ fontWeight: 700,
385
+ color: 'text.secondary',
386
+ mt: { xs: 3, md: 5 },
387
+ mb: { xs: 2, md: 3 },
388
+ }}>
389
+ {t('payment.checkout.creditTopup.question', { symbol: currencySymbol })}
390
+ </Typography>
391
+
392
+ {/* ── Credit input with +/- ── */}
393
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, md: 2 } }}>
394
+ <IconButton onClick={() => handleStep(-1)} disabled={actualPacks <= minQuantity} sx={circleBtnSx}>
395
+ <Box component="span" sx={{ fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1, mt: '-1px' }}>
396
+ &#x2212;
397
+ </Box>
398
+ </IconButton>
399
+
400
+ <Box
401
+ sx={{ flex: 1, minWidth: 0, textAlign: 'center', cursor: canAdjust ? 'text' : 'default' }}
402
+ onClick={startEditing}>
403
+ <Box
404
+ sx={{
405
+ height: numberHeight,
406
+ display: 'flex',
407
+ alignItems: 'center',
408
+ justifyContent: 'center',
409
+ }}>
410
+ {isEditing ? (
411
+ <InputBase
412
+ inputRef={inputRef}
413
+ value={editValue}
414
+ onChange={(e) => setEditValue(e.target.value.replace(/\D/g, ''))}
415
+ onBlur={commitEdit}
416
+ onKeyDown={handleEditKeyDown}
417
+ autoFocus
418
+ inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
419
+ sx={{
420
+ width: '100%',
421
+ bgcolor: 'transparent',
422
+ '&.Mui-focused': { bgcolor: 'transparent' },
423
+ '& input': {
424
+ textAlign: 'center',
425
+ ...numberFontSx,
426
+ p: 0,
427
+ fontFamily: 'inherit',
428
+ bgcolor: 'transparent',
429
+ '&:focus': { bgcolor: 'transparent' },
430
+ },
431
+ }}
432
+ />
433
+ ) : (
434
+ <Typography sx={{ ...numberFontSx, color: 'text.primary', userSelect: 'none' }}>
435
+ {desiredCreditsFormatted}
436
+ </Typography>
437
+ )}
438
+ </Box>
439
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75, mt: 1 }}>
440
+ <Typography
441
+ sx={{
442
+ fontSize: { xs: 12, md: 14 },
443
+ fontWeight: 700,
444
+ color: 'text.secondary',
445
+ textTransform: 'uppercase',
446
+ letterSpacing: '0.05em',
447
+ }}>
448
+ {currencySymbol}
449
+ </Typography>
450
+ </Box>
451
+ </Box>
452
+
453
+ <IconButton onClick={() => handleStep(1)} disabled={actualPacks >= maxQuantity} sx={circleBtnSx}>
454
+ <Box component="span" sx={{ fontSize: { xs: 22, md: 28 }, fontWeight: 300, lineHeight: 1 }}>
455
+ +
456
+ </Box>
457
+ </IconButton>
458
+ </Box>
459
+
460
+ {/* Increment hint */}
461
+ {step > 1 && (
462
+ <Typography
463
+ sx={{
464
+ fontSize: 11,
465
+ fontWeight: 600,
466
+ color: 'grey.500',
467
+ textTransform: 'uppercase',
468
+ letterSpacing: '0.08em',
469
+ textAlign: 'center',
470
+ mt: { xs: 1.5, md: 2 },
471
+ }}>
472
+ {t('payment.checkout.creditTopup.increment', {
473
+ step: formatNumber(step, 6, true, true),
474
+ symbol: currencySymbol,
475
+ })}
476
+ </Typography>
477
+ )}
478
+
479
+ {/* ── "You'll receive" result card — frosted glass ── */}
480
+ {showReceiveSection && (
481
+ <Box
482
+ sx={{
483
+ display: 'flex',
484
+ flexDirection: 'column',
485
+ mt: { xs: 3, md: 4 },
486
+ borderRadius: '20px',
487
+ backdropFilter: 'blur(12px)',
488
+ bgcolor: (theme) =>
489
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)',
490
+ border: '1px solid',
491
+ borderColor: (theme) =>
492
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)',
493
+ boxShadow: (theme) =>
494
+ theme.palette.mode === 'dark'
495
+ ? '0 8px 32px rgba(59,130,246,0.06)'
496
+ : '0 8px 32px rgba(59,130,246,0.04)',
497
+ }}>
498
+ {/* Main content */}
499
+ <Box sx={{ p: { xs: 2.5, md: 3.5 } }}>
500
+ <Typography
501
+ sx={{
502
+ fontSize: { xs: 16, md: 20 },
503
+ fontWeight: 700,
504
+ color: 'primary.main',
505
+ mb: 0.5,
506
+ }}>
507
+ {t('payment.checkout.creditTopup.willReceive')}
508
+ {': '}
509
+ <Box component="span" sx={{ fontWeight: 800 }}>
510
+ {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)}
511
+ </Box>
512
+ </Typography>
513
+ {/* Pack info line */}
514
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 0.5 }}>
515
+ <Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
516
+ <Typography sx={{ fontSize: { xs: 12, md: 14 }, fontWeight: 600, color: 'text.secondary' }}>
517
+ {t('payment.checkout.creditTopup.packInfo', {
518
+ packs: actualPacks,
519
+ perPack: formatNumber(step, 6, true, true),
520
+ })}
521
+ </Typography>
522
+ </Box>
523
+ {/* Auto match hint */}
524
+ <Typography
525
+ sx={{
526
+ fontSize: { xs: 12, md: 13 },
527
+ fontWeight: 500,
528
+ color: 'grey.500',
529
+ }}>
530
+ {t('payment.checkout.creditTopup.autoMatch')}
531
+ </Typography>
532
+ </Box>
533
+ {/* Validity footer */}
534
+ {validityText && (
535
+ <Box
536
+ sx={{
537
+ display: 'flex',
538
+ alignItems: 'center',
539
+ gap: 0.75,
540
+ px: { xs: 2.5, md: 3.5 },
541
+ py: { xs: 1.5, md: 2 },
542
+ borderTop: '1px solid',
543
+ borderColor: (theme) =>
544
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)',
545
+ }}>
546
+ <AccessTimeOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
547
+ <Typography
548
+ sx={{
549
+ fontSize: { xs: 12, md: 13 },
550
+ fontWeight: 500,
551
+ color: 'grey.500',
552
+ }}>
553
+ {validityText}
554
+ </Typography>
555
+ </Box>
556
+ )}
557
+ </Box>
558
+ )}
559
+
560
+ {/* Validity hint — shown below input when no receive box */}
561
+ {!showReceiveSection && validityText && (
562
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: { xs: 2.5, md: 3.5 } }}>
563
+ <InfoOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
564
+ <Typography
565
+ sx={{
566
+ fontSize: { xs: 12, md: 13 },
567
+ fontWeight: 500,
568
+ color: 'grey.500',
569
+ }}>
570
+ {validityText}
571
+ </Typography>
572
+ </Box>
573
+ )}
574
+ </Box>
575
+ ) : (
576
+ // ── Non-adjustable: show fixed credit amount in a styled card ──
577
+ <Box
578
+ sx={{
579
+ display: 'flex',
580
+ flexDirection: 'column',
581
+ mt: { xs: 2, md: 3 },
582
+ borderRadius: '20px',
583
+ backdropFilter: 'blur(12px)',
584
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)'),
585
+ border: '1px solid',
586
+ borderColor: (theme) =>
587
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.10)',
588
+ boxShadow: (theme) =>
589
+ theme.palette.mode === 'dark' ? '0 8px 32px rgba(59,130,246,0.06)' : '0 8px 32px rgba(59,130,246,0.04)',
590
+ }}>
591
+ <Box sx={{ p: { xs: 2.5, md: 3.5 } }}>
592
+ <Typography
593
+ sx={{
594
+ fontSize: { xs: 13, md: 14 },
595
+ fontWeight: 600,
596
+ color: 'text.secondary',
597
+ mb: 1,
598
+ }}>
599
+ {t('payment.checkout.creditTopup.willReceive')}
600
+ </Typography>
601
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
602
+ <Typography
603
+ sx={{
604
+ fontSize: { xs: 32, md: 48 },
605
+ fontWeight: 800,
606
+ lineHeight: 1,
607
+ letterSpacing: '-0.03em',
608
+ color: 'primary.main',
609
+ }}>
610
+ {formatCreditForCheckout(actualCreditsFormatted, currencySymbol, locale)}
611
+ </Typography>
612
+ </Box>
613
+ </Box>
614
+ {/* Validity footer */}
615
+ {validityText && (
616
+ <Box
617
+ sx={{
618
+ display: 'flex',
619
+ alignItems: 'center',
620
+ gap: 0.75,
621
+ px: { xs: 2.5, md: 3.5 },
622
+ py: { xs: 1.5, md: 2 },
623
+ borderTop: '1px solid',
624
+ borderColor: (theme) =>
625
+ theme.palette.mode === 'dark' ? 'rgba(59,130,246,0.15)' : 'rgba(59,130,246,0.08)',
626
+ }}>
627
+ <AccessTimeOutlinedIcon sx={{ fontSize: 14, color: 'grey.500' }} />
628
+ <Typography
629
+ sx={{
630
+ fontSize: { xs: 12, md: 13 },
631
+ fontWeight: 500,
632
+ color: 'grey.500',
633
+ }}>
634
+ {validityText}
635
+ </Typography>
636
+ </Box>
637
+ )}
638
+ </Box>
639
+ )}
640
+ </Box>
641
+ {/* End main content */}
642
+
643
+ {/* Exchange rate — shown for all dynamic pricing scenarios */}
644
+ <Box sx={{ flexShrink: 0 }}>
645
+ {rate.hasDynamicPricing && rate.status === 'unavailable' && !isStripe && (
646
+ <Stack direction="row" alignItems="center" spacing={0.75} sx={{ mb: 2 }}>
647
+ <Typography sx={{ fontSize: 13, color: 'text.secondary', fontWeight: 500 }}>
648
+ {t('payment.dynamicPricing.unavailable.title')}
649
+ </Typography>
650
+ <Typography
651
+ component="span"
652
+ onClick={rate.refresh}
653
+ sx={{
654
+ fontSize: 13,
655
+ color: 'primary.main',
656
+ fontWeight: 600,
657
+ cursor: 'pointer',
658
+ '&:hover': { textDecoration: 'underline' },
659
+ }}>
660
+ {t('payment.dynamicPricing.unavailable.retry')}
661
+ </Typography>
662
+ </Stack>
663
+ )}
664
+ <ExchangeRateFooter
665
+ hasDynamicPricing={rate.hasDynamicPricing}
666
+ rate={{
667
+ value: rate.value,
668
+ display: rate.display,
669
+ provider: rate.provider,
670
+ providerDisplay: rate.providerDisplay,
671
+ fetchedAt: rate.fetchedAt,
672
+ status: rate.status,
673
+ }}
674
+ slippage={{ percent: slippage.percent, set: slippage.set }}
675
+ currencySymbol={currency?.symbol || ''}
676
+ isSubscription={['subscription', 'setup'].includes(session?.mode || 'payment')}
677
+ />
678
+ </Box>
679
+ </Box>
680
+ );
681
+ }