@blocklet/payment-react 1.24.3 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/es/components/auto-topup/modal.d.ts +2 -0
  2. package/es/components/auto-topup/modal.js +48 -6
  3. package/es/components/auto-topup/product-card.d.ts +16 -1
  4. package/es/components/auto-topup/product-card.js +97 -15
  5. package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
  6. package/es/components/dynamic-pricing-unavailable.js +58 -0
  7. package/es/components/loading-amount.d.ts +17 -0
  8. package/es/components/loading-amount.js +46 -0
  9. package/es/components/price-change-confirm.d.ts +18 -0
  10. package/es/components/price-change-confirm.js +107 -0
  11. package/es/components/quote-details-panel.d.ts +21 -0
  12. package/es/components/quote-details-panel.js +170 -0
  13. package/es/components/quote-lock-banner.d.ts +7 -0
  14. package/es/components/quote-lock-banner.js +79 -0
  15. package/es/components/slippage-config.d.ts +20 -0
  16. package/es/components/slippage-config.js +261 -0
  17. package/es/history/credit/transactions-list.js +11 -1
  18. package/es/history/invoice/list.js +125 -15
  19. package/es/hooks/dynamic-pricing.d.ts +102 -0
  20. package/es/hooks/dynamic-pricing.js +393 -0
  21. package/es/index.d.ts +6 -1
  22. package/es/index.js +9 -1
  23. package/es/libs/util.d.ts +42 -5
  24. package/es/libs/util.js +345 -57
  25. package/es/locales/en.js +114 -3
  26. package/es/locales/zh.js +114 -3
  27. package/es/payment/form/index.d.ts +4 -1
  28. package/es/payment/form/index.js +454 -22
  29. package/es/payment/index.d.ts +1 -1
  30. package/es/payment/index.js +279 -16
  31. package/es/payment/product-item.d.ts +26 -1
  32. package/es/payment/product-item.js +330 -51
  33. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  34. package/es/payment/summary-section/promotion-section.js +143 -0
  35. package/es/payment/summary-section/total-section.d.ts +39 -0
  36. package/es/payment/summary-section/total-section.js +83 -0
  37. package/es/payment/summary.d.ts +17 -2
  38. package/es/payment/summary.js +300 -253
  39. package/es/types/index.d.ts +11 -0
  40. package/lib/components/auto-topup/modal.d.ts +2 -0
  41. package/lib/components/auto-topup/modal.js +54 -6
  42. package/lib/components/auto-topup/product-card.d.ts +16 -1
  43. package/lib/components/auto-topup/product-card.js +75 -7
  44. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  45. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  46. package/lib/components/loading-amount.d.ts +17 -0
  47. package/lib/components/loading-amount.js +53 -0
  48. package/lib/components/price-change-confirm.d.ts +18 -0
  49. package/lib/components/price-change-confirm.js +157 -0
  50. package/lib/components/quote-details-panel.d.ts +21 -0
  51. package/lib/components/quote-details-panel.js +226 -0
  52. package/lib/components/quote-lock-banner.d.ts +7 -0
  53. package/lib/components/quote-lock-banner.js +93 -0
  54. package/lib/components/slippage-config.d.ts +20 -0
  55. package/lib/components/slippage-config.js +316 -0
  56. package/lib/history/credit/transactions-list.js +11 -1
  57. package/lib/history/invoice/list.js +167 -27
  58. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  59. package/lib/hooks/dynamic-pricing.js +390 -0
  60. package/lib/index.d.ts +6 -1
  61. package/lib/index.js +32 -0
  62. package/lib/libs/util.d.ts +42 -5
  63. package/lib/libs/util.js +367 -49
  64. package/lib/locales/en.js +114 -3
  65. package/lib/locales/zh.js +114 -3
  66. package/lib/payment/form/index.d.ts +4 -1
  67. package/lib/payment/form/index.js +476 -20
  68. package/lib/payment/index.d.ts +1 -1
  69. package/lib/payment/index.js +308 -14
  70. package/lib/payment/product-item.d.ts +26 -1
  71. package/lib/payment/product-item.js +270 -35
  72. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  73. package/lib/payment/summary-section/promotion-section.js +133 -0
  74. package/lib/payment/summary-section/total-section.d.ts +39 -0
  75. package/lib/payment/summary-section/total-section.js +117 -0
  76. package/lib/payment/summary.d.ts +17 -2
  77. package/lib/payment/summary.js +205 -127
  78. package/lib/types/index.d.ts +11 -0
  79. package/package.json +3 -3
  80. package/src/components/auto-topup/modal.tsx +59 -6
  81. package/src/components/auto-topup/product-card.tsx +118 -11
  82. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  83. package/src/components/loading-amount.tsx +66 -0
  84. package/src/components/price-change-confirm.tsx +136 -0
  85. package/src/components/quote-details-panel.tsx +218 -0
  86. package/src/components/quote-lock-banner.tsx +99 -0
  87. package/src/components/slippage-config.tsx +336 -0
  88. package/src/history/credit/transactions-list.tsx +14 -1
  89. package/src/history/invoice/list.tsx +143 -9
  90. package/src/hooks/dynamic-pricing.ts +617 -0
  91. package/src/index.ts +9 -0
  92. package/src/libs/util.ts +473 -58
  93. package/src/locales/en.tsx +117 -0
  94. package/src/locales/zh.tsx +111 -0
  95. package/src/payment/form/index.tsx +561 -19
  96. package/src/payment/index.tsx +349 -10
  97. package/src/payment/product-item.tsx +451 -37
  98. package/src/payment/summary-section/promotion-section.tsx +172 -0
  99. package/src/payment/summary-section/total-section.tsx +141 -0
  100. package/src/payment/summary.tsx +334 -192
  101. package/src/types/index.ts +15 -0
@@ -1,14 +1,16 @@
1
1
  /* eslint-disable @typescript-eslint/indent */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import Toast from '@arcblock/ux/lib/Toast';
3
4
  import type {
4
5
  DonationSettings,
6
+ TCheckoutSession,
5
7
  TLineItemExpanded,
6
8
  TPaymentCurrency,
7
- TCheckoutSession,
9
+ TPaymentIntent,
8
10
  TPaymentMethodExpanded,
9
11
  } from '@blocklet/payment-types';
10
- import { HelpOutline, Close, LocalOffer } from '@mui/icons-material';
11
- import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton, Button } from '@mui/material';
12
+ import { HelpOutline } from '@mui/icons-material';
13
+ import { Box, Divider, Fade, Grow, Stack, Tooltip, Typography, Collapse, IconButton } from '@mui/material';
12
14
  import type { IconButtonProps } from '@mui/material';
13
15
  import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
14
16
  import { useRequest, useSetState } from 'ahooks';
@@ -16,42 +18,28 @@ import noop from 'lodash/noop';
16
18
  import useBus from 'use-bus';
17
19
  import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
18
20
  import { styled } from '@mui/material/styles';
21
+ import { useEffect, useMemo, useRef } from 'react';
19
22
  import Status from '../components/status';
20
23
  import api from '../libs/api';
21
24
  import {
22
25
  formatAmount,
23
26
  formatCheckoutHeadlines,
24
27
  getPriceUintAmountByCurrency,
25
- formatCouponTerms,
26
- formatNumber,
28
+ formatDynamicPrice,
27
29
  findCurrency,
30
+ formatError,
28
31
  } from '../libs/util';
29
- import PaymentAmount from './amount';
30
32
  import ProductDonation from './product-donation';
31
33
  import ProductItem from './product-item';
32
34
  import Livemode from '../components/livemode';
33
35
  import { usePaymentContext } from '../contexts/payment';
34
36
  import { useMobile } from '../hooks/mobile';
37
+ import { useDynamicPricing, type LiveRateInfo, type LiveQuoteSnapshot } from '../hooks/dynamic-pricing';
35
38
  import LoadingButton from '../components/loading-button';
36
- import PromotionCode from '../components/promotion-code';
37
-
38
- // const shake = keyframes`
39
- // 0% {
40
- // transform: rotate(0deg);
41
- // }
42
- // 25% {
43
- // transform: rotate(2deg);
44
- // }
45
- // 50% {
46
- // transform: rotate(0eg);
47
- // }
48
- // 75% {
49
- // transform: rotate(-2deg);
50
- // }
51
- // 100% {
52
- // transform: rotate(0deg);
53
- // }
54
- // `;
39
+ import DynamicPricingUnavailable from '../components/dynamic-pricing-unavailable';
40
+ import PromotionSection from './summary-section/promotion-section';
41
+ import TotalSection from './summary-section/total-section';
42
+ import type { SlippageConfigValue } from '../components/slippage-config';
55
43
 
56
44
  interface ExpandMoreProps extends IconButtonProps {
57
45
  expand: boolean;
@@ -83,13 +71,25 @@ type Props = {
83
71
  onCancelCrossSell?: Function;
84
72
  checkoutSessionId?: string;
85
73
  crossSellBehavior?: string;
86
- donationSettings?: DonationSettings; // only include backend part
74
+ donationSettings?: DonationSettings;
87
75
  action?: string;
88
76
  completed?: boolean;
89
77
  checkoutSession?: TCheckoutSession;
78
+ paymentIntent?: TPaymentIntent | null;
90
79
  onPromotionUpdate?: () => void;
91
80
  paymentMethods?: TPaymentMethodExpanded[];
92
81
  showFeatures?: boolean;
82
+ rateUnavailable?: boolean;
83
+ rateError?: string;
84
+ isRateLoading?: boolean; // Loading state for skeleton display during currency switch
85
+ onQuoteExpired?: (forceRefresh?: boolean) => void;
86
+ onRefreshRate?: () => Promise<void>;
87
+ onSlippageChange?: (slippageConfig: SlippageConfigValue) => void;
88
+ slippageConfig?: SlippageConfigValue;
89
+ liveRate?: LiveRateInfo;
90
+ liveQuoteSnapshot?: LiveQuoteSnapshot;
91
+ isStripePayment?: boolean;
92
+ isSubscription?: boolean;
93
93
  };
94
94
 
95
95
  async function fetchCrossSell(id: string, skipError = true) {
@@ -98,7 +98,6 @@ async function fetchCrossSell(id: string, skipError = true) {
98
98
  if (!data.error) {
99
99
  return data;
100
100
  }
101
-
102
101
  return null;
103
102
  } catch (err) {
104
103
  return null;
@@ -139,6 +138,7 @@ function getStakingSetup(items: TLineItemExpanded[], currency: TPaymentCurrency,
139
138
 
140
139
  return '0';
141
140
  }
141
+
142
142
  export default function PaymentSummary({
143
143
  items,
144
144
  currency,
@@ -158,9 +158,22 @@ export default function PaymentSummary({
158
158
  trialEnd = 0,
159
159
  completed = false,
160
160
  checkoutSession = undefined,
161
+ paymentIntent = undefined,
161
162
  paymentMethods = [],
162
163
  onPromotionUpdate = noop,
163
164
  showFeatures = false,
165
+ rateUnavailable = false,
166
+ isRateLoading = false,
167
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
168
+ rateError: _rateError = undefined, // Technical errors are logged but not displayed to users
169
+ onQuoteExpired = undefined,
170
+ onRefreshRate = undefined,
171
+ onSlippageChange = undefined,
172
+ slippageConfig: slippageConfigProp = undefined,
173
+ liveRate = undefined,
174
+ liveQuoteSnapshot = undefined,
175
+ isStripePayment = false,
176
+ isSubscription: isSubscriptionProp = undefined,
164
177
  ...rest
165
178
  }: Props) {
166
179
  const { t, locale } = useLocaleContext();
@@ -171,10 +184,12 @@ export default function PaymentSummary({
171
184
  checkoutSessionId ? fetchCrossSell(checkoutSessionId, skipError) : Promise.resolve(null)
172
185
  );
173
186
 
187
+ // Discounts and promotion codes
174
188
  const sessionDiscounts = (checkoutSession as any)?.discounts || [];
175
189
  const allowPromotionCodes = !!checkoutSession?.allow_promotion_codes;
176
190
  const hasDiscounts = sessionDiscounts?.length > 0;
177
191
 
192
+ // Resolve discount currency
178
193
  const discountCurrency =
179
194
  paymentMethods && checkoutSession
180
195
  ? (findCurrency(
@@ -182,26 +197,183 @@ export default function PaymentSummary({
182
197
  hasDiscounts ? checkoutSession?.currency_id || currency.id : (currency.id as string)
183
198
  ) as TPaymentCurrency) || settings.settings?.baseCurrency
184
199
  : currency;
200
+ const slippageConfig = slippageConfigProp ?? (checkoutSession as any)?.metadata?.slippage;
201
+
202
+ // Use the dynamic pricing hook for all rate-related calculations
203
+ const {
204
+ hasDynamicPricing,
205
+ isPriceLocked,
206
+ quoteMeta,
207
+ rateInfo,
208
+ quoteLockedAt,
209
+ currentSlippagePercent,
210
+ rateDisplay,
211
+ calculatedTokenAmount,
212
+ calculatedDiscountAmount,
213
+ calculateUsdDisplay,
214
+ buildQuoteDetailRows,
215
+ } = useDynamicPricing({
216
+ items,
217
+ currency: discountCurrency,
218
+ liveRate,
219
+ liveQuoteSnapshot,
220
+ checkoutSession,
221
+ paymentIntent,
222
+ locale,
223
+ isStripePayment,
224
+ isSubscription: isSubscriptionProp,
225
+ slippageConfig,
226
+ trialInDays,
227
+ trialEnd,
228
+ discounts: sessionDiscounts,
229
+ });
185
230
 
186
- const headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale);
231
+ // Headlines and amount calculations
232
+ const headlines = formatCheckoutHeadlines(items, discountCurrency, { trialEnd, trialInDays }, locale, {
233
+ exchangeRate: rateInfo.exchangeRate,
234
+ });
187
235
  const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : '0';
188
236
 
189
- const getAppliedPromotionCodes = () => {
190
- if (!sessionDiscounts?.length) return [];
237
+ // For Stripe payments, always use standard formatting regardless of dynamic pricing
238
+ // This prevents calculation errors when switching from crypto to fiat payments
239
+ const effectiveHasDynamicPricing = hasDynamicPricing && !isStripePayment;
191
240
 
192
- return sessionDiscounts.map((discount: any) => ({
193
- id: discount.promotion_code || discount.coupon,
194
- code: discount.verification_data?.code || 'APPLIED',
195
- discount_amount: discount.discount_amount,
196
- }));
197
- };
241
+ // Check if this is a trial scenario: actualAmount is '0' AND it's a subscription
242
+ // Only subscriptions (recurring items) have trials - one_time products should still be charged
243
+ const hasRecurringItems = items.some((x) => (x.upsell_price || x.price)?.type === 'recurring');
244
+ const isTrialScenario = headlines.actualAmount === '0' && hasRecurringItems;
245
+
246
+ const headlineAmountDisplay = useMemo(() => {
247
+ // For Stripe payments, use standard formatting regardless of dynamic pricing
248
+ if (!effectiveHasDynamicPricing) {
249
+ return headlines.amount;
250
+ }
251
+ // For trial scenarios, use the formatted trial amount (e.g., "Free 7-day trial")
252
+ if (isTrialScenario || !headlines.amount.includes(discountCurrency.symbol)) {
253
+ return headlines.amount;
254
+ }
255
+ if (calculatedTokenAmount) {
256
+ const displayAmount = fromUnitToToken(calculatedTokenAmount, discountCurrency?.decimal);
257
+ const formatted = formatDynamicPrice(displayAmount, true, 6);
258
+ return `${formatted} ${discountCurrency.symbol}`;
259
+ }
260
+ const formatted = formatDynamicPrice(headlines.actualAmount, true, 6);
261
+ return `${formatted} ${discountCurrency.symbol}`;
262
+ }, [
263
+ headlines.amount,
264
+ headlines.actualAmount,
265
+ discountCurrency.symbol,
266
+ discountCurrency?.decimal,
267
+ effectiveHasDynamicPricing,
268
+ calculatedTokenAmount,
269
+ isTrialScenario,
270
+ ]);
271
+
272
+ // Amount calculations - wrap discountAmount in useMemo to avoid dependency warning
273
+ const discountAmount = useMemo(
274
+ () => new BN(checkoutSession?.total_details?.amount_discount || '0'),
275
+ [checkoutSession?.total_details?.amount_discount]
276
+ );
277
+ const subtotalAmountUnit = new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal))
278
+ .add(new BN(staking))
279
+ .toString();
280
+ const subtotalAmount = fromUnitToToken(subtotalAmountUnit, discountCurrency?.decimal);
281
+ const totalAmountUnit = new BN(subtotalAmountUnit).sub(discountAmount).toString();
282
+ const totalAmountValue = fromUnitToToken(totalAmountUnit, discountCurrency?.decimal);
283
+
284
+ // Format displays - for dynamic pricing, include staking in the calculated total
285
+ // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0
286
+ const subtotalDisplay = useMemo(() => {
287
+ if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
288
+ const dynamicSubtotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).toString();
289
+ const displayAmount = fromUnitToToken(dynamicSubtotalUnit, discountCurrency?.decimal);
290
+ return formatDynamicPrice(displayAmount, true, 6);
291
+ }
292
+ return formatDynamicPrice(subtotalAmount, effectiveHasDynamicPricing, 6);
293
+ }, [
294
+ effectiveHasDynamicPricing,
295
+ calculatedTokenAmount,
296
+ staking,
297
+ discountCurrency?.decimal,
298
+ subtotalAmount,
299
+ isTrialScenario,
300
+ ]);
301
+
302
+ const totalAmountDisplay = useMemo(() => {
303
+ // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0
304
+ if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
305
+ // Use calculatedDiscountAmount (frontend) instead of discountAmount (backend)
306
+ // Backend discountAmount is stale when exchange rate changes
307
+ const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount;
308
+ const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString();
309
+ const displayAmount = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal);
310
+ const numericValue = Number(displayAmount);
311
+ if (Number.isFinite(numericValue) && numericValue >= 0) {
312
+ return formatDynamicPrice(displayAmount, true, 6);
313
+ }
314
+ }
315
+
316
+ // For Stripe payments: use calculatedDiscountAmount when backend discountAmount is 0 or stale
317
+ // This ensures correct total when discount is applied but backend hasn't recalculated
318
+ if (isStripePayment && calculatedDiscountAmount && !isTrialScenario) {
319
+ const effectiveDiscount = new BN(calculatedDiscountAmount);
320
+ const adjustedTotalUnit = new BN(subtotalAmountUnit).sub(effectiveDiscount).toString();
321
+ const displayAmount = fromUnitToToken(adjustedTotalUnit, discountCurrency?.decimal);
322
+ const numericValue = Number(displayAmount);
323
+ if (Number.isFinite(numericValue) && numericValue >= 0) {
324
+ return formatDynamicPrice(displayAmount, false, 6);
325
+ }
326
+ }
327
+
328
+ return formatDynamicPrice(totalAmountValue, effectiveHasDynamicPricing, 6);
329
+ }, [
330
+ effectiveHasDynamicPricing,
331
+ calculatedTokenAmount,
332
+ staking,
333
+ discountAmount,
334
+ calculatedDiscountAmount,
335
+ discountCurrency?.decimal,
336
+ totalAmountValue,
337
+ isTrialScenario,
338
+ isStripePayment,
339
+ subtotalAmountUnit,
340
+ ]);
341
+ const totalAmountText = totalAmountDisplay === '—' ? '—' : `${totalAmountDisplay} ${discountCurrency.symbol}`;
342
+
343
+ // Fix: Use dynamically calculated total for USD display, not static totalAmountValue
344
+ // For Stripe payments, no USD equivalent display is needed since it's already USD
345
+ // For trial scenarios, don't add calculatedTokenAmount since payment amount is 0
346
+ const totalUsdDisplay = useMemo(() => {
347
+ if (effectiveHasDynamicPricing && calculatedTokenAmount && !isTrialScenario) {
348
+ // Use calculatedDiscountAmount (frontend) instead of discountAmount (backend)
349
+ const effectiveDiscount = calculatedDiscountAmount ? new BN(calculatedDiscountAmount) : discountAmount;
350
+ const dynamicTotalUnit = new BN(calculatedTokenAmount).add(new BN(staking)).sub(effectiveDiscount).toString();
351
+ const dynamicTotalToken = fromUnitToToken(dynamicTotalUnit, discountCurrency?.decimal);
352
+ return calculateUsdDisplay(dynamicTotalToken);
353
+ }
354
+ return calculateUsdDisplay(totalAmountValue);
355
+ }, [
356
+ effectiveHasDynamicPricing,
357
+ calculatedTokenAmount,
358
+ staking,
359
+ discountAmount,
360
+ calculatedDiscountAmount,
361
+ discountCurrency?.decimal,
362
+ totalAmountValue,
363
+ calculateUsdDisplay,
364
+ isTrialScenario,
365
+ ]);
198
366
 
367
+ const quoteDetailRows = buildQuoteDetailRows(t);
368
+ const isSubscription =
369
+ isSubscriptionProp ?? (checkoutSession?.mode === 'subscription' || checkoutSession?.mode === 'setup');
370
+
371
+ // Promotion handlers
199
372
  const handlePromotionUpdate = () => {
200
373
  onPromotionUpdate?.();
201
374
  };
202
375
 
203
376
  const handleRemovePromotion = async (sessionId: string) => {
204
- // Prevent removing promotion during payment process
205
377
  if (paymentState.paying || paymentState.stripePaying) {
206
378
  return;
207
379
  }
@@ -213,18 +385,54 @@ export default function PaymentSummary({
213
385
  }
214
386
  };
215
387
 
216
- const discountAmount = new BN(checkoutSession?.total_details?.amount_discount || '0');
388
+ // Quote expiry handling (backward compatibility - Final Freeze deprecates this)
389
+ const expiredHandledRef = useRef(false);
217
390
 
218
- const subtotalAmount = fromUnitToToken(
219
- new BN(fromTokenToUnit(headlines.actualAmount, discountCurrency?.decimal)).add(new BN(staking)).toString(),
220
- discountCurrency?.decimal
221
- );
391
+ useEffect(() => {
392
+ if (completed || expiredHandledRef.current) {
393
+ return;
394
+ }
395
+ // In Final Freeze, there's no quote during preview, so no expiry check needed
396
+ if (!liveQuoteSnapshot?.expires_at && !quoteMeta?.expiresAt) {
397
+ return;
398
+ }
399
+ const currentTime = Math.floor(Date.now() / 1000);
400
+ const effectiveExpiresAt = liveQuoteSnapshot?.expires_at ?? quoteMeta?.expiresAt;
401
+ const quoteRemaining = effectiveExpiresAt ? Math.max(0, effectiveExpiresAt - currentTime) : 0;
402
+ const lockRemaining = quoteLockedAt ? Math.max(0, quoteLockedAt + 180 - currentTime) : 0;
403
+ const hasExpiry = !!effectiveExpiresAt;
222
404
 
223
- const totalAmount = fromUnitToToken(
224
- new BN(fromTokenToUnit(subtotalAmount, discountCurrency?.decimal)).sub(discountAmount).toString(),
225
- discountCurrency?.decimal
226
- );
405
+ const lockActive = lockRemaining > 0;
406
+ if (hasExpiry && !lockActive && quoteRemaining <= 0) {
407
+ expiredHandledRef.current = true;
408
+ onQuoteExpired?.();
409
+ }
410
+ }, [liveQuoteSnapshot?.expires_at, quoteMeta?.expiresAt, quoteLockedAt, completed, onQuoteExpired]);
411
+
412
+ // Slippage handler
413
+ const handleSlippageChange = async (newSlippageConfig: SlippageConfigValue) => {
414
+ if (!onSlippageChange) {
415
+ return;
416
+ }
417
+ if (!checkoutSessionId) {
418
+ onSlippageChange(newSlippageConfig);
419
+ return;
420
+ }
421
+ try {
422
+ await api.put(`/api/checkout-sessions/${checkoutSessionId}/slippage`, {
423
+ slippage_config: newSlippageConfig,
424
+ });
425
+ onSlippageChange(newSlippageConfig);
426
+ if (onQuoteExpired) {
427
+ await onQuoteExpired(true);
428
+ }
429
+ } catch (err: any) {
430
+ console.error('Failed to update slippage', err);
431
+ Toast.error(err.response?.data?.error || formatError(err));
432
+ }
433
+ };
227
434
 
435
+ // Cross-sell event handling
228
436
  useBus(
229
437
  'error.REQUIRE_CROSS_SELL',
230
438
  () => {
@@ -236,6 +444,7 @@ export default function PaymentSummary({
236
444
  []
237
445
  );
238
446
 
447
+ // Product action handlers
239
448
  const handleUpsell = async (from: string, to: string) => {
240
449
  await onUpsell!(from, to);
241
450
  runAsync(false);
@@ -276,6 +485,8 @@ export default function PaymentSummary({
276
485
  };
277
486
 
278
487
  const hasSubTotal = +staking > 0 || allowPromotionCodes;
488
+
489
+ // Product list component
279
490
  const ProductCardList = (
280
491
  <Stack
281
492
  className="cko-product-list"
@@ -285,7 +496,7 @@ export default function PaymentSummary({
285
496
  }}>
286
497
  <Stack spacing={{ xs: 1, sm: 2 }}>
287
498
  {items.map((x: TLineItemExpanded) =>
288
- x.price.custom_unit_amount && onChangeAmount && donationSettings ? (
499
+ x.price?.custom_unit_amount && onChangeAmount && donationSettings ? (
289
500
  <ProductDonation
290
501
  key={`${x.price_id}-${discountCurrency.id}`}
291
502
  item={x}
@@ -301,12 +512,20 @@ export default function PaymentSummary({
301
512
  trialInDays={trialInDays}
302
513
  trialEnd={trialEnd}
303
514
  currency={discountCurrency}
515
+ exchangeRate={rateInfo.exchangeRate}
516
+ isStripePayment={isStripePayment}
517
+ isPriceLocked={isPriceLocked}
518
+ isRateLoading={isRateLoading}
304
519
  onUpsell={handleUpsell}
305
520
  onDownsell={handleDownsell}
306
521
  adjustableQuantity={x.adjustable_quantity}
307
522
  completed={completed}
308
523
  showFeatures={showFeatures}
309
- onQuantityChange={handleQuantityChange}>
524
+ onQuantityChange={handleQuantityChange}
525
+ discounts={sessionDiscounts}
526
+ calculatedDiscountAmount={
527
+ calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null)
528
+ }>
310
529
  {x.cross_sell && (
311
530
  <Stack
312
531
  direction="row"
@@ -334,16 +553,17 @@ export default function PaymentSummary({
334
553
  </Stack>
335
554
  {data && items.some((x) => x.price_id === data.id) === false && (
336
555
  <Grow in>
337
- <Stack
338
- sx={{
339
- mt: 1,
340
- }}>
556
+ <Stack sx={{ mt: 1 }}>
341
557
  <ProductItem
342
558
  item={{ quantity: 1, price: data, price_id: data.id, cross_sell: true } as TLineItemExpanded}
343
559
  items={items}
344
560
  trialInDays={trialInDays}
345
561
  currency={discountCurrency}
346
562
  trialEnd={trialEnd}
563
+ exchangeRate={rateInfo.exchangeRate}
564
+ isStripePayment={isStripePayment}
565
+ isPriceLocked={isPriceLocked}
566
+ isRateLoading={isRateLoading}
347
567
  onUpsell={noop}
348
568
  onDownsell={noop}>
349
569
  <Stack
@@ -375,9 +595,15 @@ export default function PaymentSummary({
375
595
  )}
376
596
  </Stack>
377
597
  );
598
+
599
+ if (!discountCurrency || !items?.length) {
600
+ return null;
601
+ }
602
+
378
603
  return (
379
604
  <Fade in>
380
605
  <Stack className="cko-product" direction="column" {...rest}>
606
+ {/* Header */}
381
607
  <Box
382
608
  sx={{
383
609
  display: 'flex',
@@ -388,10 +614,7 @@ export default function PaymentSummary({
388
614
  title={t('payment.checkout.orderSummary')}
389
615
  sx={{
390
616
  color: 'text.primary',
391
- fontSize: {
392
- xs: '18px',
393
- md: '24px',
394
- },
617
+ fontSize: { xs: '18px', md: '24px' },
395
618
  fontWeight: '700',
396
619
  lineHeight: '32px',
397
620
  }}>
@@ -399,6 +622,13 @@ export default function PaymentSummary({
399
622
  </Typography>
400
623
  {!settings.livemode && <Livemode />}
401
624
  </Box>
625
+
626
+ {/* Dynamic pricing unavailable warning - hide for Stripe payments since no exchange rate needed */}
627
+ {effectiveHasDynamicPricing && rateUnavailable && (
628
+ <DynamicPricingUnavailable sx={{ mb: 2 }} onRetry={onRefreshRate} />
629
+ )}
630
+
631
+ {/* Product list (collapsible on mobile) */}
402
632
  {isMobile && !donationSettings ? (
403
633
  <>
404
634
  <Stack
@@ -421,42 +651,23 @@ export default function PaymentSummary({
421
651
  ) : (
422
652
  ProductCardList
423
653
  )}
654
+
424
655
  <Divider sx={{ mt: 2.5, mb: 2.5 }} />
656
+
657
+ {/* Staking section */}
425
658
  {+staking > 0 && (
426
659
  <Stack spacing={1}>
427
- <Stack
428
- direction="row"
429
- spacing={1}
430
- sx={{
431
- justifyContent: 'space-between',
432
- alignItems: 'center',
433
- }}>
434
- <Stack
435
- direction="row"
436
- spacing={0.5}
437
- sx={{
438
- alignItems: 'center',
439
- }}>
660
+ <Stack direction="row" spacing={1} sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
661
+ <Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
440
662
  <Typography sx={{ color: 'text.secondary' }}>{t('payment.checkout.paymentRequired')}</Typography>
441
663
  <Tooltip title={t('payment.checkout.stakingConfirm')} placement="top" sx={{ maxWidth: '150px' }}>
442
664
  <HelpOutline fontSize="small" sx={{ color: 'text.lighter' }} />
443
665
  </Tooltip>
444
666
  </Stack>
445
- <Typography>{headlines.amount}</Typography>
667
+ <Typography>{headlineAmountDisplay}</Typography>
446
668
  </Stack>
447
- <Stack
448
- direction="row"
449
- spacing={1}
450
- sx={{
451
- justifyContent: 'space-between',
452
- alignItems: 'center',
453
- }}>
454
- <Stack
455
- direction="row"
456
- spacing={0.5}
457
- sx={{
458
- alignItems: 'center',
459
- }}>
669
+ <Stack direction="row" spacing={1} sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
670
+ <Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
460
671
  <Typography sx={{ color: 'text.secondary' }}>{t('payment.checkout.staking.title')}</Typography>
461
672
  <Tooltip title={t('payment.checkout.staking.tooltip')} placement="top" sx={{ maxWidth: '150px' }}>
462
673
  <HelpOutline fontSize="small" sx={{ color: 'text.lighter' }} />
@@ -468,6 +679,8 @@ export default function PaymentSummary({
468
679
  </Stack>
469
680
  </Stack>
470
681
  )}
682
+
683
+ {/* Subtotal row */}
471
684
  {(allowPromotionCodes || hasDiscounts) && (
472
685
  <Stack
473
686
  direction="row"
@@ -484,120 +697,49 @@ export default function PaymentSummary({
484
697
  }}>
485
698
  <Typography className="base-label">{t('common.subtotal')}</Typography>
486
699
  <Typography>
487
- {formatNumber(subtotalAmount)} {discountCurrency.symbol}
700
+ {subtotalDisplay} {discountCurrency.symbol}
488
701
  </Typography>
489
702
  </Stack>
490
703
  )}
491
- {/* Promotion Code Section - only show add button if no discounts applied */}
492
- {allowPromotionCodes && !hasDiscounts && (
493
- <Box sx={{ mt: 1 }}>
494
- <PromotionCode
495
- checkoutSessionId={checkoutSession.id}
496
- initialAppliedCodes={getAppliedPromotionCodes()}
497
- disabled={completed}
498
- onUpdate={handlePromotionUpdate}
499
- currencyId={currency.id}
500
- />
501
- </Box>
502
- )}
503
704
 
504
- {/* Promotion Code Details */}
505
- {hasDiscounts && (
506
- <Box>
507
- {sessionDiscounts.map((discount: any) => {
508
- const promotionCodeInfo = discount.promotion_code_details;
509
- const couponInfo = discount.coupon_details;
510
- const discountDescription = couponInfo ? formatCouponTerms(couponInfo, discountCurrency, locale) : '';
511
- const notSupported = discountDescription === t('payment.checkout.coupon.noDiscount');
512
-
513
- return (
514
- <Stack key={discount.promotion_code || discount.coupon || `discount-${discount.discount_amount}`}>
515
- <Stack
516
- direction="row"
517
- spacing={1}
518
- sx={{
519
- justifyContent: 'space-between',
520
- alignItems: 'center',
521
- }}>
522
- <Stack
523
- direction="row"
524
- spacing={1}
525
- sx={{
526
- alignItems: 'center',
527
- backgroundColor: 'grey.100',
528
- width: 'fit-content',
529
- px: 1,
530
- py: 1,
531
- borderRadius: 1,
532
- }}>
533
- <Typography
534
- sx={{
535
- fontWeight: 'medium',
536
- display: 'flex',
537
- alignItems: 'center',
538
- gap: 0.5,
539
- }}>
540
- <LocalOffer sx={{ color: 'warning.main', fontSize: 'small' }} />
541
- {promotionCodeInfo?.code || discount.verification_data?.code || t('payment.checkout.discount')}
542
- </Typography>
543
- {!completed && (
544
- <Button
545
- size="small"
546
- disabled={paymentState.paying || paymentState.stripePaying}
547
- onClick={() => handleRemovePromotion(checkoutSessionId)}
548
- sx={{
549
- minWidth: 'auto',
550
- width: 16,
551
- height: 16,
552
- color: 'text.secondary',
553
- '&.Mui-disabled': {
554
- color: 'text.disabled',
555
- },
556
- }}>
557
- <Close sx={{ fontSize: 14 }} />
558
- </Button>
559
- )}
560
- </Stack>
561
- <Typography sx={{ color: 'text.secondary' }}>
562
- -{formatAmount(discount.discount_amount || '0', discountCurrency.decimal)}{' '}
563
- {discountCurrency.symbol}
564
- </Typography>
565
- </Stack>
566
- {/* Show discount description */}
567
- {discountDescription && (
568
- <Typography
569
- sx={{
570
- fontSize: 'small',
571
- color: notSupported ? 'error.main' : 'text.secondary',
572
- mt: 0.5,
573
- }}>
574
- {discountDescription}
575
- </Typography>
576
- )}
577
- </Stack>
578
- );
579
- })}
580
- </Box>
581
- )}
705
+ {/* Promotion section */}
706
+ {/* For Stripe payments, use total_details.amount_discount (backend calculated) */}
707
+ {/* For dynamic pricing, use calculatedDiscountAmount (frontend calculated based on rate) */}
708
+ <PromotionSection
709
+ checkoutSessionId={checkoutSession?.id || checkoutSessionId}
710
+ currency={discountCurrency}
711
+ currencyId={currency.id}
712
+ discounts={sessionDiscounts}
713
+ allowPromotionCodes={allowPromotionCodes}
714
+ completed={completed}
715
+ disabled={paymentState.paying || paymentState.stripePaying}
716
+ onPromotionUpdate={handlePromotionUpdate}
717
+ onRemovePromotion={handleRemovePromotion}
718
+ calculatedDiscountAmount={calculatedDiscountAmount || (isStripePayment ? discountAmount.toString() : null)}
719
+ isRateLoading={isRateLoading}
720
+ />
721
+
582
722
  {hasSubTotal && <Divider sx={{ my: 1 }} />}
583
- <Stack
584
- sx={{
585
- display: 'flex',
586
- justifyContent: 'space-between',
587
- flexDirection: 'row',
588
- alignItems: 'center',
589
- width: '100%',
590
- }}>
591
- <Box className="base-label">{t('common.total')} </Box>
592
- <PaymentAmount amount={`${totalAmount} ${discountCurrency.symbol}`} sx={{ fontSize: '16px' }} />
593
- </Stack>
594
- {headlines.then && headlines.showThen && (
595
- <Typography
596
- component="div"
597
- sx={{ fontSize: '0.7875rem', color: 'text.lighter', textAlign: 'right', margin: '-2px 0 8px' }}>
598
- {headlines.then}
599
- </Typography>
600
- )}
723
+
724
+ {/* Total section */}
725
+ <TotalSection
726
+ totalAmountText={totalAmountText}
727
+ totalUsdDisplay={totalUsdDisplay}
728
+ currency={discountCurrency}
729
+ hasDynamicPricing={hasDynamicPricing}
730
+ rateDisplay={rateDisplay}
731
+ rateInfo={rateInfo}
732
+ quoteDetailRows={quoteDetailRows}
733
+ currentSlippagePercent={currentSlippagePercent}
734
+ slippageConfig={slippageConfig}
735
+ isPriceLocked={isPriceLocked}
736
+ isSubscription={isSubscription}
737
+ completed={completed}
738
+ onSlippageChange={onSlippageChange ? handleSlippageChange : undefined}
739
+ isStripePayment={isStripePayment}
740
+ isRateLoading={isRateLoading}
741
+ thenInfo={headlines.thenValue && headlines.showThen ? headlines.thenValue : undefined}
742
+ />
601
743
  </Stack>
602
744
  </Fade>
603
745
  );