@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.
- package/es/components/auto-topup/modal.d.ts +2 -0
- package/es/components/auto-topup/modal.js +48 -6
- package/es/components/auto-topup/product-card.d.ts +16 -1
- package/es/components/auto-topup/product-card.js +97 -15
- package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/es/components/dynamic-pricing-unavailable.js +58 -0
- package/es/components/loading-amount.d.ts +17 -0
- package/es/components/loading-amount.js +46 -0
- package/es/components/price-change-confirm.d.ts +18 -0
- package/es/components/price-change-confirm.js +107 -0
- package/es/components/quote-details-panel.d.ts +21 -0
- package/es/components/quote-details-panel.js +170 -0
- package/es/components/quote-lock-banner.d.ts +7 -0
- package/es/components/quote-lock-banner.js +79 -0
- package/es/components/slippage-config.d.ts +20 -0
- package/es/components/slippage-config.js +261 -0
- package/es/history/credit/transactions-list.js +11 -1
- package/es/history/invoice/list.js +125 -15
- package/es/hooks/dynamic-pricing.d.ts +102 -0
- package/es/hooks/dynamic-pricing.js +393 -0
- package/es/index.d.ts +6 -1
- package/es/index.js +9 -1
- package/es/libs/util.d.ts +42 -5
- package/es/libs/util.js +345 -57
- package/es/locales/en.js +114 -3
- package/es/locales/zh.js +114 -3
- package/es/payment/form/index.d.ts +4 -1
- package/es/payment/form/index.js +454 -22
- package/es/payment/index.d.ts +1 -1
- package/es/payment/index.js +279 -16
- package/es/payment/product-item.d.ts +26 -1
- package/es/payment/product-item.js +330 -51
- package/es/payment/summary-section/promotion-section.d.ts +32 -0
- package/es/payment/summary-section/promotion-section.js +143 -0
- package/es/payment/summary-section/total-section.d.ts +39 -0
- package/es/payment/summary-section/total-section.js +83 -0
- package/es/payment/summary.d.ts +17 -2
- package/es/payment/summary.js +300 -253
- package/es/types/index.d.ts +11 -0
- package/lib/components/auto-topup/modal.d.ts +2 -0
- package/lib/components/auto-topup/modal.js +54 -6
- package/lib/components/auto-topup/product-card.d.ts +16 -1
- package/lib/components/auto-topup/product-card.js +75 -7
- package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/lib/components/dynamic-pricing-unavailable.js +81 -0
- package/lib/components/loading-amount.d.ts +17 -0
- package/lib/components/loading-amount.js +53 -0
- package/lib/components/price-change-confirm.d.ts +18 -0
- package/lib/components/price-change-confirm.js +157 -0
- package/lib/components/quote-details-panel.d.ts +21 -0
- package/lib/components/quote-details-panel.js +226 -0
- package/lib/components/quote-lock-banner.d.ts +7 -0
- package/lib/components/quote-lock-banner.js +93 -0
- package/lib/components/slippage-config.d.ts +20 -0
- package/lib/components/slippage-config.js +316 -0
- package/lib/history/credit/transactions-list.js +11 -1
- package/lib/history/invoice/list.js +167 -27
- package/lib/hooks/dynamic-pricing.d.ts +102 -0
- package/lib/hooks/dynamic-pricing.js +390 -0
- package/lib/index.d.ts +6 -1
- package/lib/index.js +32 -0
- package/lib/libs/util.d.ts +42 -5
- package/lib/libs/util.js +367 -49
- package/lib/locales/en.js +114 -3
- package/lib/locales/zh.js +114 -3
- package/lib/payment/form/index.d.ts +4 -1
- package/lib/payment/form/index.js +476 -20
- package/lib/payment/index.d.ts +1 -1
- package/lib/payment/index.js +308 -14
- package/lib/payment/product-item.d.ts +26 -1
- package/lib/payment/product-item.js +270 -35
- package/lib/payment/summary-section/promotion-section.d.ts +32 -0
- package/lib/payment/summary-section/promotion-section.js +133 -0
- package/lib/payment/summary-section/total-section.d.ts +39 -0
- package/lib/payment/summary-section/total-section.js +117 -0
- package/lib/payment/summary.d.ts +17 -2
- package/lib/payment/summary.js +205 -127
- package/lib/types/index.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/auto-topup/modal.tsx +59 -6
- package/src/components/auto-topup/product-card.tsx +118 -11
- package/src/components/dynamic-pricing-unavailable.tsx +69 -0
- package/src/components/loading-amount.tsx +66 -0
- package/src/components/price-change-confirm.tsx +136 -0
- package/src/components/quote-details-panel.tsx +218 -0
- package/src/components/quote-lock-banner.tsx +99 -0
- package/src/components/slippage-config.tsx +336 -0
- package/src/history/credit/transactions-list.tsx +14 -1
- package/src/history/invoice/list.tsx +143 -9
- package/src/hooks/dynamic-pricing.ts +617 -0
- package/src/index.ts +9 -0
- package/src/libs/util.ts +473 -58
- package/src/locales/en.tsx +117 -0
- package/src/locales/zh.tsx +111 -0
- package/src/payment/form/index.tsx +561 -19
- package/src/payment/index.tsx +349 -10
- package/src/payment/product-item.tsx +451 -37
- package/src/payment/summary-section/promotion-section.tsx +172 -0
- package/src/payment/summary-section/total-section.tsx +141 -0
- package/src/payment/summary.tsx +334 -192
- package/src/types/index.ts +15 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/indent */
|
|
1
2
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
3
|
import type { PriceRecurring, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
|
|
3
4
|
import { Box, Stack, Typography, IconButton, TextField, Alert, Chip } from '@mui/material';
|
|
@@ -5,7 +6,8 @@ import { Add, Remove, LocalOffer } from '@mui/icons-material';
|
|
|
5
6
|
|
|
6
7
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
7
8
|
import { useRequest } from 'ahooks';
|
|
8
|
-
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
9
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
10
|
+
import LoadingAmount from '../components/loading-amount';
|
|
9
11
|
import Status from '../components/status';
|
|
10
12
|
import Switch from '../components/switch-button';
|
|
11
13
|
import {
|
|
@@ -20,11 +22,32 @@ import {
|
|
|
20
22
|
formatCreditForCheckout,
|
|
21
23
|
formatCreditAmount,
|
|
22
24
|
formatBNStr,
|
|
25
|
+
getUsdAmountFromBaseAmount,
|
|
26
|
+
formatUsdAmount,
|
|
27
|
+
formatDynamicPrice,
|
|
23
28
|
} from '../libs/util';
|
|
24
29
|
import ProductCard from './product-card';
|
|
25
30
|
import dayjs from '../libs/dayjs';
|
|
26
31
|
import { usePaymentContext } from '../contexts/payment';
|
|
27
32
|
|
|
33
|
+
interface DiscountInfo {
|
|
34
|
+
promotion_code?: string;
|
|
35
|
+
coupon?: string;
|
|
36
|
+
discount_amount?: string;
|
|
37
|
+
promotion_code_details?: {
|
|
38
|
+
code?: string;
|
|
39
|
+
};
|
|
40
|
+
coupon_details?: {
|
|
41
|
+
percent_off?: number;
|
|
42
|
+
amount_off?: string;
|
|
43
|
+
currency_id?: string;
|
|
44
|
+
currency_options?: Record<string, { amount_off?: string }>;
|
|
45
|
+
};
|
|
46
|
+
verification_data?: {
|
|
47
|
+
code?: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
type Props = {
|
|
29
52
|
item: TLineItemExpanded;
|
|
30
53
|
items: TLineItemExpanded[];
|
|
@@ -44,6 +67,13 @@ type Props = {
|
|
|
44
67
|
onQuantityChange?: (itemId: string, quantity: number) => void;
|
|
45
68
|
completed?: boolean;
|
|
46
69
|
showFeatures?: boolean;
|
|
70
|
+
exchangeRate?: string | null;
|
|
71
|
+
isStripePayment?: boolean;
|
|
72
|
+
isPriceLocked?: boolean;
|
|
73
|
+
isRateLoading?: boolean;
|
|
74
|
+
// Discount display props
|
|
75
|
+
discounts?: DiscountInfo[];
|
|
76
|
+
calculatedDiscountAmount?: string | null;
|
|
47
77
|
};
|
|
48
78
|
|
|
49
79
|
const getRecommendedQuantityFromUrl = (priceId: string): number | undefined => {
|
|
@@ -89,13 +119,22 @@ export default function ProductItem({
|
|
|
89
119
|
adjustableQuantity = { enabled: false },
|
|
90
120
|
onQuantityChange = () => {},
|
|
91
121
|
showFeatures = false,
|
|
122
|
+
exchangeRate = null,
|
|
123
|
+
isStripePayment = false,
|
|
124
|
+
isPriceLocked = false,
|
|
125
|
+
isRateLoading = false,
|
|
126
|
+
discounts = [],
|
|
127
|
+
calculatedDiscountAmount = null,
|
|
92
128
|
}: Props) {
|
|
93
129
|
const { t, locale } = useLocaleContext();
|
|
94
130
|
const { settings, setPayable, session, api } = usePaymentContext();
|
|
95
|
-
const
|
|
131
|
+
const pricingSource = item.upsell_price || item.price;
|
|
132
|
+
const isDynamicPricing = (pricingSource as any)?.pricing_type === 'dynamic';
|
|
133
|
+
const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays, exchangeRate }, locale);
|
|
96
134
|
const saving = formatUpsellSaving(items, currency);
|
|
97
135
|
const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : '';
|
|
98
136
|
const canUpsell = mode === 'normal' && items.length === 1;
|
|
137
|
+
const isTrial = trialInDays > 0 || trialEnd > dayjs().unix();
|
|
99
138
|
|
|
100
139
|
// Check if this is a credit product - be more lenient in detection
|
|
101
140
|
const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config;
|
|
@@ -137,8 +176,12 @@ export default function ProductItem({
|
|
|
137
176
|
);
|
|
138
177
|
|
|
139
178
|
const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
|
|
179
|
+
|
|
140
180
|
const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
|
|
141
|
-
const quantityAvailable = Math.min(
|
|
181
|
+
const quantityAvailable = Math.min(
|
|
182
|
+
item.price?.quantity_limit_per_checkout ?? Infinity,
|
|
183
|
+
item.price?.quantity_available ?? Infinity
|
|
184
|
+
);
|
|
142
185
|
const maxQuantity = quantityAvailable
|
|
143
186
|
? Math.min(adjustableQuantity.maximum || Infinity, quantityAvailable)
|
|
144
187
|
: adjustableQuantity.maximum || Infinity;
|
|
@@ -230,7 +273,7 @@ export default function ProductItem({
|
|
|
230
273
|
onQuantityChange(item.price_id, newQuantity);
|
|
231
274
|
|
|
232
275
|
if (userDid && newQuantity > 0) {
|
|
233
|
-
saveUserQuantityPreference(userDid, item.price
|
|
276
|
+
saveUserQuantityPreference(userDid, item.price?.id, newQuantity);
|
|
234
277
|
}
|
|
235
278
|
}
|
|
236
279
|
};
|
|
@@ -263,7 +306,7 @@ export default function ProductItem({
|
|
|
263
306
|
const formattedTotalCredit = formatCreditForCheckout(totalCreditStr, currencySymbol, locale);
|
|
264
307
|
|
|
265
308
|
const hasPendingAmount = pendingAmount && new BN(pendingAmount || '0').gt(new BN(0));
|
|
266
|
-
const isRecurring = item.price
|
|
309
|
+
const isRecurring = item.price?.type === 'recurring';
|
|
267
310
|
const hasExpiry = validDuration && validDuration > 0;
|
|
268
311
|
|
|
269
312
|
const buildBaseParams = () => ({
|
|
@@ -273,7 +316,7 @@ export default function ProductItem({
|
|
|
273
316
|
unit: t(`common.${validDurationUnit}`),
|
|
274
317
|
}),
|
|
275
318
|
...(isRecurring && {
|
|
276
|
-
period: formatRecurring(item.price
|
|
319
|
+
period: formatRecurring(item.price?.recurring!, true, 'per', locale),
|
|
277
320
|
}),
|
|
278
321
|
});
|
|
279
322
|
|
|
@@ -296,7 +339,7 @@ export default function ProductItem({
|
|
|
296
339
|
unit: t(`common.${validDurationUnit}`),
|
|
297
340
|
}),
|
|
298
341
|
...(isRecurring && {
|
|
299
|
-
period: formatRecurring(item.price
|
|
342
|
+
period: formatRecurring(item.price?.recurring!, true, 'per', locale),
|
|
300
343
|
}),
|
|
301
344
|
});
|
|
302
345
|
|
|
@@ -331,6 +374,63 @@ export default function ProductItem({
|
|
|
331
374
|
return t(getLocaleKey('pending', type), buildPendingParams(pendingAmountBN, actualAvailable));
|
|
332
375
|
};
|
|
333
376
|
|
|
377
|
+
const quantityForUsd = Number.isFinite(localQuantity) ? Number(localQuantity) : item.quantity || 0;
|
|
378
|
+
|
|
379
|
+
// For dynamic pricing, calculate token display from live exchange rate
|
|
380
|
+
// Final Freeze: Only use live rate (prop) for preview, quoted_amount for locked quotes
|
|
381
|
+
// Also supports non-dynamic pricing with exchange rate (e.g., cross-sell products)
|
|
382
|
+
const dynamicTokenDisplay = useMemo(() => {
|
|
383
|
+
if (isStripePayment || !currency) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
|
|
388
|
+
const baseAmount = (pricingSource as any)?.base_amount;
|
|
389
|
+
const unitAmount = pricingSource?.unit_amount;
|
|
390
|
+
|
|
391
|
+
// For dynamic pricing products
|
|
392
|
+
if (isDynamicPricing && baseAmount !== undefined && baseAmount !== null) {
|
|
393
|
+
// Only use quoted_amount when price is locked (after submit)
|
|
394
|
+
if (isPriceLocked) {
|
|
395
|
+
const quotedAmount = (item as any)?.quoted_amount;
|
|
396
|
+
if (quotedAmount) {
|
|
397
|
+
const totalBN = new BN(quotedAmount);
|
|
398
|
+
const tokenValue = fromUnitToToken(totalBN, currency.decimal);
|
|
399
|
+
return `${formatDynamicPrice(tokenValue, true, 6)} ${currency.symbol}`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!exchangeRate) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
const rate = Number(exchangeRate);
|
|
407
|
+
if (rate <= 0 || Number.isNaN(rate)) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
// base_amount is stored in dollar format (e.g., "1.00" = $1.00), NOT cents
|
|
411
|
+
const usdAmount = Number(baseAmount);
|
|
412
|
+
const tokenAmount = usdAmount / rate;
|
|
413
|
+
const totalTokens = tokenAmount * (quantityForUsd || 1);
|
|
414
|
+
return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// For non-dynamic pricing products (e.g., cross-sell): convert USD to current currency using rate
|
|
418
|
+
// This allows showing "10 PLAY3" instead of "$1.00" when paying with crypto
|
|
419
|
+
if (!isDynamicPricing && exchangeRate && unitAmount) {
|
|
420
|
+
const rate = Number(exchangeRate);
|
|
421
|
+
if (rate <= 0 || Number.isNaN(rate)) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
// unit_amount is in cents, convert to dollars first
|
|
425
|
+
const usdAmount = Number(unitAmount) / 100;
|
|
426
|
+
const tokenAmount = usdAmount / rate;
|
|
427
|
+
const totalTokens = tokenAmount * (quantityForUsd || 1);
|
|
428
|
+
return `${formatDynamicPrice(totalTokens, true, 6)} ${currency.symbol}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return null;
|
|
432
|
+
}, [isStripePayment, isDynamicPricing, currency, pricingSource, item, exchangeRate, quantityForUsd, isPriceLocked]);
|
|
433
|
+
|
|
334
434
|
// Format credit schedule info for display
|
|
335
435
|
const formatScheduleInfo = () => {
|
|
336
436
|
if (!hasSchedule || !scheduleConfig) return null;
|
|
@@ -386,15 +486,299 @@ export default function ProductItem({
|
|
|
386
486
|
const primaryText = useMemo(() => {
|
|
387
487
|
const price = item.upsell_price || item.price || {};
|
|
388
488
|
const isRecurring = price?.type === 'recurring' && price?.recurring;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
489
|
+
|
|
490
|
+
// For trial scenarios, always use the formatted trial message from pricing.primary
|
|
491
|
+
// (e.g., "Free 7-day trial") instead of the calculated token amount
|
|
492
|
+
if (isTrial) {
|
|
493
|
+
return pricing.primary;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Determine display amount based on payment type and available data
|
|
497
|
+
let displayAmount: string;
|
|
498
|
+
|
|
499
|
+
// If we have dynamicTokenDisplay (dynamic pricing OR cross-sell with exchange rate), use it
|
|
500
|
+
if (!isStripePayment && dynamicTokenDisplay) {
|
|
501
|
+
displayAmount = dynamicTokenDisplay;
|
|
502
|
+
} else if (isDynamicPricing && !isStripePayment) {
|
|
503
|
+
// Dynamic pricing but no rate available - show USD fallback
|
|
504
|
+
const baseAmount = (pricingSource as any)?.base_amount;
|
|
505
|
+
if (baseAmount !== undefined && baseAmount !== null) {
|
|
506
|
+
const usdValue = Number(baseAmount);
|
|
507
|
+
displayAmount = `≈ $${usdValue.toFixed(2)}`;
|
|
508
|
+
} else {
|
|
509
|
+
displayAmount = pricing.primary;
|
|
510
|
+
}
|
|
511
|
+
} else {
|
|
512
|
+
// Stripe payment or no special handling needed
|
|
513
|
+
displayAmount = pricing.primary;
|
|
392
514
|
}
|
|
393
|
-
|
|
394
|
-
|
|
515
|
+
|
|
516
|
+
if (isRecurring && price?.recurring?.usage_type !== 'metered') {
|
|
517
|
+
return `${displayAmount} ${price.recurring ? formatRecurring(price.recurring, false, 'slash', locale) : ''}`;
|
|
518
|
+
}
|
|
519
|
+
return displayAmount;
|
|
520
|
+
}, [isTrial, pricing, item, locale, dynamicTokenDisplay, isDynamicPricing, pricingSource, isStripePayment]);
|
|
521
|
+
|
|
522
|
+
const usdReference = useMemo(() => {
|
|
523
|
+
// Stripe payments don't need USD reference - base_amount is already USD
|
|
524
|
+
if (!currency || !isDynamicPricing || isStripePayment) {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
const baseAmount = (pricingSource as any)?.base_amount;
|
|
528
|
+
const hasBaseAmount = baseAmount !== undefined && baseAmount !== null;
|
|
529
|
+
if (hasBaseAmount) {
|
|
530
|
+
return getUsdAmountFromBaseAmount(baseAmount, quantityForUsd);
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}, [currency, pricingSource, quantityForUsd, isDynamicPricing, isStripePayment]);
|
|
534
|
+
const usdReferenceDisplay = useMemo(() => formatUsdAmount(usdReference, locale), [usdReference, locale]);
|
|
535
|
+
|
|
536
|
+
// Calculate upsell price display with exchange rate conversion
|
|
537
|
+
// Shows "X PLAY3 每月" instead of "$1.00 每月" when paying with crypto
|
|
538
|
+
// For Stripe, shows "1 USD 每月" instead of "$1.00 每月"
|
|
539
|
+
const upsellTokenDisplay = useMemo(() => {
|
|
540
|
+
const upsellPrice = item.price?.upsell?.upsells_to;
|
|
541
|
+
if (!upsellPrice) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// For Stripe payment: format as "1 USD 每月" instead of "$1.00 每月"
|
|
546
|
+
if (isStripePayment && currency) {
|
|
547
|
+
const baseAmount = (upsellPrice as any)?.base_amount;
|
|
548
|
+
const unitAmount = upsellPrice?.unit_amount;
|
|
549
|
+
|
|
550
|
+
let usdAmount: number;
|
|
551
|
+
if (baseAmount !== undefined && baseAmount !== null) {
|
|
552
|
+
usdAmount = Number(baseAmount);
|
|
553
|
+
} else if (unitAmount) {
|
|
554
|
+
usdAmount = Number(unitAmount) / 100;
|
|
555
|
+
} else {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const recurring = upsellPrice?.recurring
|
|
560
|
+
? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}`
|
|
561
|
+
: '';
|
|
562
|
+
return `${usdAmount} ${currency.symbol}${recurring}`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// For crypto payment: need exchange rate
|
|
566
|
+
if (!currency || !exchangeRate) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const rate = Number(exchangeRate);
|
|
571
|
+
if (rate <= 0 || Number.isNaN(rate)) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
|
|
576
|
+
const baseAmount = (upsellPrice as any)?.base_amount;
|
|
577
|
+
const unitAmount = upsellPrice?.unit_amount;
|
|
578
|
+
|
|
579
|
+
let usdAmount: number;
|
|
580
|
+
if (baseAmount !== undefined && baseAmount !== null) {
|
|
581
|
+
// base_amount is in dollar format (e.g., "1.00" = $1.00)
|
|
582
|
+
usdAmount = Number(baseAmount);
|
|
583
|
+
} else if (unitAmount) {
|
|
584
|
+
// unit_amount is in cents
|
|
585
|
+
usdAmount = Number(unitAmount) / 100;
|
|
586
|
+
} else {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const tokenAmount = usdAmount / rate;
|
|
591
|
+
const recurring = upsellPrice?.recurring
|
|
592
|
+
? ` ${formatRecurring(upsellPrice.recurring, false, 'slash', locale)}`
|
|
593
|
+
: '';
|
|
594
|
+
return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`;
|
|
595
|
+
}, [isStripePayment, currency, exchangeRate, item.price?.upsell?.upsells_to, locale]);
|
|
596
|
+
|
|
597
|
+
// Calculate downsell price display with exchange rate conversion
|
|
598
|
+
// Shows "X ABT 每天" instead of "$0.50 每天" when paying with crypto
|
|
599
|
+
const downsellTokenDisplay = useMemo(() => {
|
|
600
|
+
// Only show when upsell is active (item.upsell_price_id exists)
|
|
601
|
+
if (!item.upsell_price_id || !item.price) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const originalPrice = item.price;
|
|
606
|
+
|
|
607
|
+
// For Stripe payment: format as "0.5 USD 每天" instead of "$0.50 每天"
|
|
608
|
+
if (isStripePayment && currency) {
|
|
609
|
+
const baseAmount = (originalPrice as any)?.base_amount;
|
|
610
|
+
const unitAmount = originalPrice?.unit_amount;
|
|
611
|
+
|
|
612
|
+
let usdAmount: number;
|
|
613
|
+
if (baseAmount !== undefined && baseAmount !== null) {
|
|
614
|
+
usdAmount = Number(baseAmount);
|
|
615
|
+
} else if (unitAmount) {
|
|
616
|
+
usdAmount = Number(unitAmount) / 100;
|
|
617
|
+
} else {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const recurring = originalPrice?.recurring
|
|
622
|
+
? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}`
|
|
623
|
+
: '';
|
|
624
|
+
return `${usdAmount} ${currency.symbol}${recurring}`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// For crypto payment: need exchange rate
|
|
628
|
+
if (!currency || !exchangeRate) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const rate = Number(exchangeRate);
|
|
633
|
+
if (rate <= 0 || Number.isNaN(rate)) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Get USD amount: prefer base_amount (dynamic pricing), fallback to unit_amount (fixed pricing)
|
|
638
|
+
const baseAmount = (originalPrice as any)?.base_amount;
|
|
639
|
+
const unitAmount = originalPrice?.unit_amount;
|
|
640
|
+
|
|
641
|
+
let usdAmount: number;
|
|
642
|
+
if (baseAmount !== undefined && baseAmount !== null) {
|
|
643
|
+
usdAmount = Number(baseAmount);
|
|
644
|
+
} else if (unitAmount) {
|
|
645
|
+
usdAmount = Number(unitAmount) / 100;
|
|
646
|
+
} else {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const tokenAmount = usdAmount / rate;
|
|
651
|
+
const recurring = originalPrice?.recurring
|
|
652
|
+
? ` ${formatRecurring(originalPrice.recurring, false, 'slash', locale)}`
|
|
653
|
+
: '';
|
|
654
|
+
return `${formatDynamicPrice(tokenAmount, true, 6)} ${currency.symbol}${recurring}`;
|
|
655
|
+
}, [isStripePayment, currency, exchangeRate, item.price, item.upsell_price_id, locale]);
|
|
656
|
+
|
|
657
|
+
// Calculate frontend-displayed discount amount for this item
|
|
658
|
+
// For dynamic pricing: use coupon info + exchange rate to calculate
|
|
659
|
+
// For Stripe: calculate from USD amounts
|
|
660
|
+
// For single-item scenarios: use calculatedDiscountAmount directly
|
|
661
|
+
const displayItemDiscountAmount = useMemo(() => {
|
|
662
|
+
// Skip discount display when no payment is made:
|
|
663
|
+
// 1. During trial for recurring items - trial period has no charge
|
|
664
|
+
// 2. Metered pricing - initial amount is 0 (charged based on actual usage)
|
|
665
|
+
if ((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Skip if no discounts
|
|
670
|
+
if (!discounts?.length) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const couponDetails = discounts[0]?.coupon_details;
|
|
675
|
+
|
|
676
|
+
// For Stripe payment: calculate discount from USD amounts (in cents)
|
|
677
|
+
if (isStripePayment && couponDetails) {
|
|
678
|
+
// Check if this item is discountable
|
|
679
|
+
if (!(item as any).discountable) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Get item's USD amount in cents
|
|
684
|
+
const isDynamic = (pricingSource as any)?.pricing_type === 'dynamic';
|
|
685
|
+
const baseAmount = (pricingSource as any)?.base_amount;
|
|
686
|
+
const unitAmount = pricingSource?.unit_amount;
|
|
687
|
+
|
|
688
|
+
let amountCents: number;
|
|
689
|
+
if (isDynamic && baseAmount !== undefined && baseAmount !== null) {
|
|
690
|
+
// base_amount is in dollars, convert to cents
|
|
691
|
+
amountCents = Number(baseAmount) * 100;
|
|
692
|
+
} else if (unitAmount) {
|
|
693
|
+
// unit_amount is in cents
|
|
694
|
+
amountCents = Number(unitAmount);
|
|
695
|
+
} else {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const quantity = localQuantity || item.quantity || 1;
|
|
700
|
+
const itemSubtotalCents = amountCents * quantity;
|
|
701
|
+
|
|
702
|
+
// For percent_off: itemAmount * percent_off / 100
|
|
703
|
+
if (couponDetails.percent_off && couponDetails.percent_off > 0) {
|
|
704
|
+
const discountCents = Math.round((itemSubtotalCents * couponDetails.percent_off) / 100);
|
|
705
|
+
return discountCents.toString();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// For amount_off with single item: use the passed calculatedDiscountAmount
|
|
709
|
+
if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) {
|
|
710
|
+
return calculatedDiscountAmount;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// For dynamic pricing with live rate, calculate discount from scratch
|
|
717
|
+
if (isDynamicPricing && exchangeRate && couponDetails) {
|
|
718
|
+
const rateNum = Number(exchangeRate);
|
|
719
|
+
if (rateNum <= 0) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Check if this item is discountable
|
|
724
|
+
if (!(item as any).discountable) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Get item's base amount
|
|
729
|
+
const baseAmount = (pricingSource as any)?.base_amount;
|
|
730
|
+
if (!baseAmount) {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const quantity = localQuantity || item.quantity || 1;
|
|
735
|
+
const itemTokenAmount = (Number(baseAmount) * quantity) / rateNum;
|
|
736
|
+
|
|
737
|
+
// For percent_off: itemAmount * percent_off / 100
|
|
738
|
+
if (couponDetails.percent_off && couponDetails.percent_off > 0) {
|
|
739
|
+
const discountAmount = (itemTokenAmount * couponDetails.percent_off) / 100;
|
|
740
|
+
const discountAmountUnit = fromTokenToUnit(
|
|
741
|
+
discountAmount.toFixed(currency.decimal || 8),
|
|
742
|
+
currency.decimal || 8
|
|
743
|
+
);
|
|
744
|
+
return discountAmountUnit.toString();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// For amount_off with single item: use the passed calculatedDiscountAmount
|
|
748
|
+
if (couponDetails.amount_off && items.length === 1 && calculatedDiscountAmount) {
|
|
749
|
+
return calculatedDiscountAmount;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Fallback for single item: use passed calculatedDiscountAmount
|
|
754
|
+
if (items.length === 1 && calculatedDiscountAmount) {
|
|
755
|
+
return calculatedDiscountAmount;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return null;
|
|
759
|
+
}, [
|
|
760
|
+
isTrial,
|
|
761
|
+
discounts,
|
|
762
|
+
isStripePayment,
|
|
763
|
+
isDynamicPricing,
|
|
764
|
+
exchangeRate,
|
|
765
|
+
item,
|
|
766
|
+
pricingSource,
|
|
767
|
+
localQuantity,
|
|
768
|
+
currency.decimal,
|
|
769
|
+
items.length,
|
|
770
|
+
calculatedDiscountAmount,
|
|
771
|
+
]);
|
|
772
|
+
|
|
773
|
+
// Get discount code for display
|
|
774
|
+
const discountCodeDisplay = useMemo(() => {
|
|
775
|
+
if (!discounts?.length) return null;
|
|
776
|
+
const discount = discounts[0];
|
|
777
|
+
return discount.promotion_code_details?.code || discount.verification_data?.code || 'DISCOUNT';
|
|
778
|
+
}, [discounts]);
|
|
395
779
|
|
|
396
780
|
const quantityInventoryError = formatQuantityInventory(item.price, localQuantityNum, locale);
|
|
397
|
-
const features = item.price
|
|
781
|
+
const features = item.price?.product?.features || [];
|
|
398
782
|
|
|
399
783
|
return (
|
|
400
784
|
<Stack
|
|
@@ -422,17 +806,17 @@ export default function ProductItem({
|
|
|
422
806
|
width: '100%',
|
|
423
807
|
}}>
|
|
424
808
|
<ProductCard
|
|
425
|
-
logo={item.price
|
|
426
|
-
name={item.price
|
|
427
|
-
// description={showDescription ? item.price
|
|
809
|
+
logo={item.price?.product?.images[0]}
|
|
810
|
+
name={item.price?.product?.name}
|
|
811
|
+
// description={showDescription ? item.price?.product?.description : undefined}
|
|
428
812
|
extra={
|
|
429
813
|
<Box
|
|
430
814
|
sx={{
|
|
431
815
|
display: 'flex',
|
|
432
816
|
alignItems: 'center',
|
|
433
817
|
}}>
|
|
434
|
-
{item.price
|
|
435
|
-
? [pricing.quantity, t('common.billed', { rule: `${formatRecurring(item.upsell_price?.recurring || item.price
|
|
818
|
+
{item.price?.type === 'recurring' && item.price?.recurring
|
|
819
|
+
? [pricing.quantity, t('common.billed', { rule: `${formatRecurring(item.upsell_price?.recurring || item.price?.recurring, true, 'per', locale)} ${metered}` })].filter(Boolean).join(', ') // prettier-ignore
|
|
436
820
|
: pricing.quantity}
|
|
437
821
|
</Box>
|
|
438
822
|
}
|
|
@@ -443,29 +827,53 @@ export default function ProductItem({
|
|
|
443
827
|
alignItems: 'flex-end',
|
|
444
828
|
flex: 1,
|
|
445
829
|
}}>
|
|
446
|
-
<
|
|
447
|
-
{primaryText}
|
|
448
|
-
|
|
449
|
-
|
|
830
|
+
<LoadingAmount
|
|
831
|
+
value={primaryText}
|
|
832
|
+
loading={isRateLoading}
|
|
833
|
+
skeletonWidth={80}
|
|
834
|
+
height={20}
|
|
835
|
+
sx={{ color: 'text.primary', fontWeight: 500 }}
|
|
836
|
+
/>
|
|
837
|
+
{/* For trial scenarios, don't show USD reference or secondary pricing - they're redundant */}
|
|
838
|
+
{/* No skeleton needed - USD equivalent is based on base_amount which doesn't change */}
|
|
839
|
+
{isDynamicPricing && !isStripePayment && !isTrial && usdReferenceDisplay && (
|
|
840
|
+
<Typography sx={{ fontSize: '0.74375rem', color: 'text.lighter' }}>≈ ${usdReferenceDisplay}</Typography>
|
|
841
|
+
)}
|
|
842
|
+
{pricing.secondary && !isTrial && (
|
|
450
843
|
<Typography sx={{ fontSize: '0.74375rem', color: 'text.lighter' }}>{pricing.secondary}</Typography>
|
|
451
844
|
)}
|
|
452
845
|
</Stack>
|
|
453
846
|
</Stack>
|
|
454
847
|
|
|
455
848
|
{/* Display discount information for this item */}
|
|
456
|
-
{
|
|
457
|
-
|
|
458
|
-
|
|
849
|
+
{/* Priority: use frontend-calculated displayItemDiscountAmount for dynamic pricing */}
|
|
850
|
+
{/* Fallback: use backend item.discount_amounts for non-dynamic pricing */}
|
|
851
|
+
{/* Hide when no payment: trial for recurring items, or metered pricing (initial amount is 0) */}
|
|
852
|
+
{!((isTrial && item.price?.type === 'recurring') || item.price?.recurring?.usage_type === 'metered') &&
|
|
853
|
+
(displayItemDiscountAmount || (item.discount_amounts && item.discount_amounts.length > 0)) && (
|
|
854
|
+
<Stack
|
|
855
|
+
direction="row"
|
|
856
|
+
spacing={1}
|
|
857
|
+
sx={{
|
|
858
|
+
mt: 1,
|
|
859
|
+
alignItems: 'center',
|
|
860
|
+
opacity: isRateLoading ? 0 : 1,
|
|
861
|
+
transition: 'opacity 300ms ease-in-out',
|
|
862
|
+
}}>
|
|
459
863
|
<Chip
|
|
460
|
-
key={discountAmount.promotion_code}
|
|
461
864
|
icon={<LocalOffer sx={{ fontSize: '0.8rem !important' }} />}
|
|
462
865
|
label={
|
|
463
866
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
464
867
|
<Typography component="span" sx={{ fontSize: '0.75rem', fontWeight: 'medium' }}>
|
|
465
|
-
{
|
|
868
|
+
{discountCodeDisplay || 'DISCOUNT'}
|
|
466
869
|
</Typography>
|
|
467
870
|
<Typography component="span" sx={{ fontSize: '0.75rem' }}>
|
|
468
|
-
(-
|
|
871
|
+
(-
|
|
872
|
+
{formatAmount(
|
|
873
|
+
displayItemDiscountAmount || item.discount_amounts?.[0]?.amount || '0',
|
|
874
|
+
currency.decimal
|
|
875
|
+
)}{' '}
|
|
876
|
+
{currency.symbol})
|
|
469
877
|
</Typography>
|
|
470
878
|
</Box>
|
|
471
879
|
}
|
|
@@ -481,9 +889,8 @@ export default function ProductItem({
|
|
|
481
889
|
},
|
|
482
890
|
}}
|
|
483
891
|
/>
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
)}
|
|
892
|
+
</Stack>
|
|
893
|
+
)}
|
|
487
894
|
|
|
488
895
|
{showFeatures && features.length > 0 && (
|
|
489
896
|
<Box
|
|
@@ -613,7 +1020,7 @@ export default function ProductItem({
|
|
|
613
1020
|
|
|
614
1021
|
{children}
|
|
615
1022
|
</Stack>
|
|
616
|
-
{canUpsell && !item.upsell_price_id && item.price
|
|
1023
|
+
{canUpsell && !item.upsell_price_id && item.price?.upsell?.upsells_to && (
|
|
617
1024
|
<Stack
|
|
618
1025
|
direction="row"
|
|
619
1026
|
className="product-item-upsell"
|
|
@@ -635,10 +1042,15 @@ export default function ProductItem({
|
|
|
635
1042
|
sx={{ mr: 1 }}
|
|
636
1043
|
variant="success"
|
|
637
1044
|
checked={false}
|
|
638
|
-
onChange={() => onUpsell(item.price_id, item.price
|
|
1045
|
+
onChange={() => onUpsell(item.price_id, item.price?.upsell?.upsells_to_id)}
|
|
639
1046
|
/>
|
|
640
1047
|
{t('payment.checkout.upsell.save', {
|
|
641
|
-
recurring: formatRecurring(
|
|
1048
|
+
recurring: formatRecurring(
|
|
1049
|
+
item.price?.upsell?.upsells_to?.recurring as PriceRecurring,
|
|
1050
|
+
true,
|
|
1051
|
+
'per',
|
|
1052
|
+
locale
|
|
1053
|
+
),
|
|
642
1054
|
})}
|
|
643
1055
|
<Status
|
|
644
1056
|
label={t('payment.checkout.upsell.off', { saving })}
|
|
@@ -652,7 +1064,8 @@ export default function ProductItem({
|
|
|
652
1064
|
/>
|
|
653
1065
|
</Typography>
|
|
654
1066
|
<Typography component="span" sx={{ fontSize: 12 }}>
|
|
655
|
-
{
|
|
1067
|
+
{upsellTokenDisplay ||
|
|
1068
|
+
formatPrice(item.price?.upsell?.upsells_to, currency, item.price?.product?.unit_label, 1, true, locale)}
|
|
656
1069
|
</Typography>
|
|
657
1070
|
</Stack>
|
|
658
1071
|
)}
|
|
@@ -681,11 +1094,12 @@ export default function ProductItem({
|
|
|
681
1094
|
onChange={() => onDownsell(item.upsell_price_id)}
|
|
682
1095
|
/>
|
|
683
1096
|
{t('payment.checkout.upsell.revert', {
|
|
684
|
-
recurring: t(`common.${formatRecurring(item.price
|
|
1097
|
+
recurring: t(`common.${formatRecurring(item.price?.recurring as PriceRecurring)}`),
|
|
685
1098
|
})}
|
|
686
1099
|
</Typography>
|
|
687
1100
|
<Typography component="span" sx={{ fontSize: 12 }}>
|
|
688
|
-
{
|
|
1101
|
+
{downsellTokenDisplay ||
|
|
1102
|
+
formatPrice(item.price, currency, item.price?.product?.unit_label, 1, true, locale)}
|
|
689
1103
|
</Typography>
|
|
690
1104
|
</Stack>
|
|
691
1105
|
)}
|