@blocklet/payment-react 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/es/components/blockchain/tx.d.ts +1 -1
  2. package/es/components/blockchain/tx.js +9 -11
  3. package/es/components/country-select.d.ts +1 -1
  4. package/es/components/date-range-picker.d.ts +13 -0
  5. package/es/components/date-range-picker.js +279 -0
  6. package/es/components/input.d.ts +5 -2
  7. package/es/components/input.js +6 -2
  8. package/es/components/label.d.ts +7 -0
  9. package/es/components/label.js +49 -0
  10. package/es/components/loading-button.d.ts +1 -1
  11. package/es/history/credit/grants-list.d.ts +14 -0
  12. package/es/history/credit/grants-list.js +215 -0
  13. package/es/history/credit/transactions-list.d.ts +13 -0
  14. package/es/history/credit/transactions-list.js +255 -0
  15. package/es/history/invoice/list.js +21 -1
  16. package/es/index.d.ts +5 -1
  17. package/es/index.js +10 -1
  18. package/es/libs/util.d.ts +2 -0
  19. package/es/libs/util.js +12 -0
  20. package/es/locales/en.js +20 -2
  21. package/es/locales/zh.js +20 -2
  22. package/es/payment/form/index.js +44 -6
  23. package/es/payment/index.js +18 -3
  24. package/es/payment/product-item.d.ts +8 -1
  25. package/es/payment/product-item.js +137 -5
  26. package/es/payment/summary.d.ts +3 -1
  27. package/es/payment/summary.js +9 -0
  28. package/lib/components/blockchain/tx.d.ts +1 -1
  29. package/lib/components/blockchain/tx.js +9 -8
  30. package/lib/components/country-select.d.ts +1 -1
  31. package/lib/components/date-range-picker.d.ts +13 -0
  32. package/lib/components/date-range-picker.js +329 -0
  33. package/lib/components/input.d.ts +5 -2
  34. package/lib/components/input.js +8 -4
  35. package/lib/components/label.d.ts +7 -0
  36. package/lib/components/label.js +60 -0
  37. package/lib/components/loading-button.d.ts +1 -1
  38. package/lib/history/credit/grants-list.d.ts +14 -0
  39. package/lib/history/credit/grants-list.js +277 -0
  40. package/lib/history/credit/transactions-list.d.ts +13 -0
  41. package/lib/history/credit/transactions-list.js +301 -0
  42. package/lib/history/invoice/list.js +24 -0
  43. package/lib/index.d.ts +5 -1
  44. package/lib/index.js +39 -0
  45. package/lib/libs/util.d.ts +2 -0
  46. package/lib/libs/util.js +14 -0
  47. package/lib/locales/en.js +20 -2
  48. package/lib/locales/zh.js +20 -2
  49. package/lib/payment/form/index.js +45 -6
  50. package/lib/payment/index.js +20 -2
  51. package/lib/payment/product-item.d.ts +8 -1
  52. package/lib/payment/product-item.js +144 -4
  53. package/lib/payment/summary.d.ts +3 -1
  54. package/lib/payment/summary.js +9 -0
  55. package/package.json +3 -3
  56. package/src/components/blockchain/tx.tsx +9 -15
  57. package/src/components/country-select.tsx +2 -2
  58. package/src/components/date-range-picker.tsx +310 -0
  59. package/src/components/input.tsx +14 -3
  60. package/src/components/label.tsx +58 -0
  61. package/src/components/loading-button.tsx +1 -1
  62. package/src/history/credit/grants-list.tsx +276 -0
  63. package/src/history/credit/transactions-list.tsx +317 -0
  64. package/src/history/invoice/list.tsx +18 -1
  65. package/src/index.ts +9 -0
  66. package/src/libs/util.ts +14 -0
  67. package/src/locales/en.tsx +20 -0
  68. package/src/locales/zh.tsx +19 -0
  69. package/src/payment/form/address.tsx +2 -2
  70. package/src/payment/form/index.tsx +110 -52
  71. package/src/payment/index.tsx +17 -1
  72. package/src/payment/product-item.tsx +152 -4
  73. package/src/payment/summary.tsx +13 -2
@@ -30,6 +30,10 @@ export default flat({
30
30
  remove: 'Remove',
31
31
  removed: 'Resource removed',
32
32
  confirm: 'Confirm',
33
+ clear: 'Clear',
34
+ selectTimeRange: 'Select time range',
35
+ startDate: 'Start date',
36
+ endDate: 'End date',
33
37
  upload: 'Upload',
34
38
  change: 'Change',
35
39
  cancel: 'Cancel',
@@ -103,6 +107,7 @@ export default flat({
103
107
  confirm: 'Confirm',
104
108
  cancel: 'Cancel',
105
109
  },
110
+ paymentMethod: 'Payment Method',
106
111
  },
107
112
  payment: {
108
113
  checkout: {
@@ -209,6 +214,11 @@ export default flat({
209
214
  add: 'Add to order',
210
215
  remove: 'Remove from order',
211
216
  },
217
+ credit: {
218
+ oneTimeInfo: 'You will receive {amount} {symbol} credits after payment',
219
+ recurringInfo: 'You will receive {amount} {symbol} credits {period}',
220
+ expiresIn: 'credits have a validity period of {duration} {unit}',
221
+ },
212
222
  expired: {
213
223
  title: 'Expired Link',
214
224
  description:
@@ -241,6 +251,16 @@ export default flat({
241
251
  amount: 'Amount',
242
252
  failed: 'Account changed, please pay manually.',
243
253
  balanceLink: 'View Balance',
254
+ credit: {
255
+ title: 'Confirm Credit Payment',
256
+ availableAmount: 'Available Credit: {amount}',
257
+ confirmMessage: 'You will use {amount} credits to subscribe to this service.',
258
+ meteringSubscriptionMessage:
259
+ 'This subscription service will deduct credits in real-time based on actual usage. You currently have {available} credits available. Confirm to continue?',
260
+ insufficientTitle: 'Insufficient Credit',
261
+ insufficientMessage:
262
+ 'This subscription service will deduct credits in real-time based on actual usage. You currently have insufficient credits. Please top up your credits first.',
263
+ },
244
264
  },
245
265
  },
246
266
  customer: {
@@ -33,6 +33,10 @@ export default flat({
33
33
  change: '更换',
34
34
  confirm: '确认',
35
35
  cancel: '取消',
36
+ clear: '清空',
37
+ selectTimeRange: '选择时间范围',
38
+ startDate: '开始日期',
39
+ endDate: '结束日期',
36
40
  close: '关闭',
37
41
  back: '返回',
38
42
  every: '每',
@@ -103,6 +107,7 @@ export default flat({
103
107
  confirm: '确认',
104
108
  cancel: '取消',
105
109
  },
110
+ paymentMethod: '支付方式',
106
111
  },
107
112
  payment: {
108
113
  checkout: {
@@ -215,6 +220,11 @@ export default flat({
215
220
  add: '添加到订单',
216
221
  remove: '从订单移除',
217
222
  },
223
+ credit: {
224
+ oneTimeInfo: '付款完成后您将获得 {amount} {symbol} 额度',
225
+ recurringInfo: '您将{period}获得 {amount} {symbol} 额度',
226
+ expiresIn: '额度有效期为 {duration} {unit}',
227
+ },
218
228
  emptyItems: {
219
229
  title: '没有任何购买项目',
220
230
  description: '可能这个付款链接没有正确配置',
@@ -236,6 +246,15 @@ export default flat({
236
246
  amount: '支付金额',
237
247
  failed: '账户发生变化,无法自动完成支付,请手动支付。',
238
248
  balanceLink: '查看余额',
249
+ credit: {
250
+ title: '确认额度支付',
251
+ availableAmount: '可用额度:{amount}',
252
+ confirmMessage: '您将使用 {amount} 额度来订阅此服务。',
253
+ meteringSubscriptionMessage:
254
+ '此订阅服务将根据实际使用情况实时扣除额度。您当前可用额度为 {available},确认是否继续?',
255
+ insufficientTitle: '额度不足',
256
+ insufficientMessage: '此订阅服务将根据实际使用情况实时扣除额度。您当前可用额度不足,请先充值额度。',
257
+ },
239
258
  },
240
259
  },
241
260
  customer: {
@@ -79,7 +79,7 @@ export default function AddressForm({ mode, stripe, sx = {}, fieldValidation = {
79
79
  render={({ field }) => (
80
80
  <CountrySelect
81
81
  {...field}
82
- ref={field.ref as unknown as React.RefObject<HTMLDivElement>}
82
+ ref={field.ref as unknown as React.RefObject<HTMLDivElement | null>}
83
83
  sx={{
84
84
  '&.Mui-focused .MuiOutlinedInput-notchedOutline': {
85
85
  borderColor: 'transparent',
@@ -127,7 +127,7 @@ export default function AddressForm({ mode, stripe, sx = {}, fieldValidation = {
127
127
  render={({ field }) => (
128
128
  <CountrySelect
129
129
  {...field}
130
- ref={field.ref as unknown as React.RefObject<HTMLDivElement>}
130
+ ref={field.ref as unknown as React.RefObject<HTMLDivElement | null>}
131
131
  sx={{
132
132
  '.MuiOutlinedInput-notchedOutline': {
133
133
  borderColor: 'transparent !important',
@@ -110,9 +110,11 @@ type UserInfo = {
110
110
  type FastCheckoutInfo = {
111
111
  open: boolean;
112
112
  loading: boolean;
113
- sourceType: 'balance' | 'delegation';
113
+ sourceType: 'balance' | 'delegation' | 'credit';
114
114
  amount: string;
115
115
  payer?: string;
116
+ availableCredit?: string;
117
+ balance?: string;
116
118
  };
117
119
 
118
120
  const setUserFormValues = (
@@ -206,6 +208,9 @@ export default function PaymentForm({
206
208
  customerLimited?: boolean;
207
209
  stripePaying: boolean;
208
210
  fastCheckoutInfo: FastCheckoutInfo | null;
211
+ creditInsufficientInfo: {
212
+ open: boolean;
213
+ } | null;
209
214
  }>({
210
215
  submitting: false,
211
216
  paying: false,
@@ -216,6 +221,7 @@ export default function PaymentForm({
216
221
  customerLimited: false,
217
222
  stripePaying: false,
218
223
  fastCheckoutInfo: null,
224
+ creditInsufficientInfo: null,
219
225
  });
220
226
 
221
227
  const currencies = flattenPaymentMethods(paymentMethods);
@@ -418,6 +424,10 @@ export default function PaymentForm({
418
424
  setState({ fastCheckoutInfo: null });
419
425
  };
420
426
 
427
+ const handleCreditInsufficientClose = () => {
428
+ setState({ creditInsufficientInfo: null });
429
+ };
430
+
421
431
  const openConnect = () => {
422
432
  try {
423
433
  if (!['arcblock', 'ethereum', 'base'].includes(method.type)) {
@@ -476,7 +486,30 @@ export default function PaymentForm({
476
486
  });
477
487
 
478
488
  if (['arcblock', 'ethereum', 'base'].includes(method.type)) {
479
- if (
489
+ // 优先判断 credit 支付
490
+ if (paymentCurrency?.type === 'credit') {
491
+ if (result.data.creditSufficient === true) {
492
+ // 如果是 credit 支付且有足够额度,显示 credit 确认弹窗
493
+ setState({
494
+ fastCheckoutInfo: {
495
+ open: true,
496
+ loading: false,
497
+ sourceType: 'credit',
498
+ amount: result.data.fastPayInfo?.amount || '0',
499
+ payer: result.data.fastPayInfo?.payer,
500
+ availableCredit: result.data.fastPayInfo?.amount || '0',
501
+ balance: result.data.fastPayInfo?.token?.balance || '0',
502
+ },
503
+ });
504
+ } else {
505
+ // 如果是 credit 支付但额度不足,显示额度不足弹窗
506
+ setState({
507
+ creditInsufficientInfo: {
508
+ open: true,
509
+ },
510
+ });
511
+ }
512
+ } else if (
480
513
  (result.data.balance?.sufficient || result.data.delegation?.sufficient) &&
481
514
  !isDonationMode &&
482
515
  result.data.fastPayInfo
@@ -581,73 +614,96 @@ export default function PaymentForm({
581
614
  }, [state.submitting, state.paying, state.stripePaying, quantityInventoryStatus, payable]); // eslint-disable-line react-hooks/exhaustive-deps
582
615
 
583
616
  const balanceLink = getTokenBalanceLink(method, state.fastCheckoutInfo?.payer || '');
617
+
584
618
  const FastCheckoutConfirmDialog = state.fastCheckoutInfo && (
585
619
  <ConfirmDialog
586
620
  onConfirm={handleFastCheckoutConfirm}
587
621
  onCancel={handleFastCheckoutCancel}
588
- title={t('payment.checkout.fastPay.title')}
622
+ title={
623
+ state.fastCheckoutInfo.sourceType === 'credit'
624
+ ? t('payment.checkout.fastPay.credit.title')
625
+ : t('payment.checkout.fastPay.title')
626
+ }
589
627
  message={
590
- <Stack>
591
- <Typography>{t('payment.checkout.fastPay.autoPaymentReason')}</Typography>
592
- <Divider sx={{ mt: 1.5, mb: 1.5 }} />
593
- <Stack spacing={1}>
594
- <Stack
595
- sx={{
596
- flexDirection: 'row',
597
- alignItems: 'center',
598
- justifyContent: 'space-between',
599
- }}>
600
- <Typography
628
+ state.fastCheckoutInfo.sourceType === 'credit' ? (
629
+ <Typography>
630
+ {t('payment.checkout.fastPay.credit.meteringSubscriptionMessage', {
631
+ available: `${fromUnitToToken(state.fastCheckoutInfo?.balance || '0', paymentCurrency?.decimal || 18).toString()} ${paymentCurrency?.symbol}`,
632
+ })}
633
+ </Typography>
634
+ ) : (
635
+ <Stack>
636
+ <Typography>{t('payment.checkout.fastPay.autoPaymentReason')}</Typography>
637
+ <Divider sx={{ mt: 1.5, mb: 1.5 }} />
638
+ <Stack spacing={1}>
639
+ <Stack
601
640
  sx={{
602
- color: 'text.primary',
603
- whiteSpace: 'nowrap',
641
+ flexDirection: 'row',
642
+ alignItems: 'center',
643
+ justifyContent: 'space-between',
604
644
  }}>
605
- {t('payment.checkout.fastPay.payer')}
606
- </Typography>
607
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
608
- <DID did={state.fastCheckoutInfo.payer || ''} compact responsive={false} />
609
- {balanceLink && (
610
- <Tooltip title={t('payment.checkout.fastPay.balanceLink')} placement="top">
611
- <OpenInNew
612
- sx={{
613
- color: 'text.lighter',
614
- fontSize: '0.85rem',
615
- cursor: 'pointer',
616
- '&:hover': { color: 'text.primary' },
617
- }}
618
- onClick={() => {
619
- window.open(balanceLink, '_blank');
620
- }}
621
- />
622
- </Tooltip>
623
- )}
624
- </Box>
625
- </Stack>
626
- <Stack
627
- sx={{
628
- flexDirection: 'row',
629
- alignItems: 'center',
630
- justifyContent: 'space-between',
631
- }}>
632
- <Typography
645
+ <Typography
646
+ sx={{
647
+ color: 'text.primary',
648
+ whiteSpace: 'nowrap',
649
+ }}>
650
+ {t('payment.checkout.fastPay.payer')}
651
+ </Typography>
652
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
653
+ <DID did={state.fastCheckoutInfo.payer || ''} compact responsive={false} />
654
+ {balanceLink && (
655
+ <Tooltip title={t('payment.checkout.fastPay.balanceLink')} placement="top">
656
+ <OpenInNew
657
+ sx={{
658
+ color: 'text.lighter',
659
+ fontSize: '0.85rem',
660
+ cursor: 'pointer',
661
+ '&:hover': { color: 'text.primary' },
662
+ }}
663
+ onClick={() => {
664
+ window.open(balanceLink, '_blank');
665
+ }}
666
+ />
667
+ </Tooltip>
668
+ )}
669
+ </Box>
670
+ </Stack>
671
+ <Stack
633
672
  sx={{
634
- color: 'text.primary',
673
+ flexDirection: 'row',
674
+ alignItems: 'center',
675
+ justifyContent: 'space-between',
635
676
  }}>
636
- {t('payment.checkout.fastPay.amount')}
637
- </Typography>
638
- <Typography>
639
- {fromUnitToToken(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18).toString()}{' '}
640
- {paymentCurrency?.symbol}
641
- </Typography>
677
+ <Typography
678
+ sx={{
679
+ color: 'text.primary',
680
+ }}>
681
+ {t('payment.checkout.fastPay.amount')}
682
+ </Typography>
683
+ <Typography>
684
+ {fromUnitToToken(state.fastCheckoutInfo.amount, paymentCurrency?.decimal || 18).toString()}{' '}
685
+ {paymentCurrency?.symbol}
686
+ </Typography>
687
+ </Stack>
642
688
  </Stack>
643
689
  </Stack>
644
- </Stack>
690
+ )
645
691
  }
646
692
  loading={state.fastCheckoutInfo.loading}
647
693
  color="primary"
648
694
  />
649
695
  );
650
696
 
697
+ const CreditInsufficientDialog = state.creditInsufficientInfo && (
698
+ <ConfirmDialog
699
+ onConfirm={handleCreditInsufficientClose}
700
+ onCancel={handleCreditInsufficientClose}
701
+ title={t('payment.checkout.fastPay.credit.insufficientTitle')}
702
+ message={<Typography>{t('payment.checkout.fastPay.credit.insufficientMessage')}</Typography>}
703
+ confirm={t('common.confirm')}
704
+ />
705
+ );
706
+
651
707
  if (onlyShowBtn) {
652
708
  return (
653
709
  <>
@@ -700,6 +756,7 @@ export default function PaymentForm({
700
756
  />
701
757
  )}
702
758
  {FastCheckoutConfirmDialog}
759
+ {CreditInsufficientDialog}
703
760
  </>
704
761
  );
705
762
  }
@@ -890,6 +947,7 @@ export default function PaymentForm({
890
947
  />
891
948
  )}
892
949
  {FastCheckoutConfirmDialog}
950
+ {CreditInsufficientDialog}
893
951
  </>
894
952
  );
895
953
  }
@@ -30,6 +30,7 @@ import {
30
30
  getStatementDescriptor,
31
31
  isMobileSafari,
32
32
  isValidCountry,
33
+ showStaking,
33
34
  } from '../libs/util';
34
35
  import type { CheckoutCallbacks, CheckoutContext, CheckoutFormData } from '../types';
35
36
  import PaymentError from './error';
@@ -208,6 +209,19 @@ function PaymentInner({
208
209
  }
209
210
  };
210
211
 
212
+ const onQuantityChange = async (itemId: string, quantity: number) => {
213
+ try {
214
+ const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/adjust-quantity`, {
215
+ itemId,
216
+ quantity,
217
+ });
218
+ setState({ checkoutSession: data });
219
+ } catch (err) {
220
+ console.error(err);
221
+ Toast.error(formatError(err));
222
+ }
223
+ };
224
+
211
225
  const onCancelCrossSell = async () => {
212
226
  try {
213
227
  const { data } = await api.delete(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`);
@@ -263,10 +277,11 @@ function PaymentInner({
263
277
  // @ts-ignore
264
278
  state.checkoutSession.subscription_data?.min_stake_amount || 0
265
279
  )}
266
- showStaking={method.type === 'arcblock' && !state.checkoutSession.subscription_data?.no_stake}
280
+ showStaking={showStaking(method, currency, !!state.checkoutSession.subscription_data?.no_stake)}
267
281
  currency={currency}
268
282
  onUpsell={onUpsell}
269
283
  onDownsell={onDownsell}
284
+ onQuantityChange={onQuantityChange}
270
285
  onApplyCrossSell={onApplyCrossSell}
271
286
  onCancelCrossSell={onCancelCrossSell}
272
287
  onChangeAmount={onChangeAmount}
@@ -274,6 +289,7 @@ function PaymentInner({
274
289
  crossSellBehavior={state.checkoutSession.cross_sell_behavior}
275
290
  donationSettings={paymentLink?.donation_settings}
276
291
  action={action}
292
+ completed={completed}
277
293
  />
278
294
  {mode === 'standalone' && !isMobile && (
279
295
  <CheckoutFooter className="cko-footer" sx={{ color: 'text.lighter' }} />
@@ -1,11 +1,13 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import type { PriceRecurring, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
3
- import { Box, Stack, Typography } from '@mui/material';
3
+ import { Box, Stack, Typography, IconButton, TextField, Alert } from '@mui/material';
4
+ import { Add, Remove } from '@mui/icons-material';
4
5
 
5
- import React, { useMemo } from 'react';
6
+ import React, { useMemo, useState } from 'react';
6
7
  import Status from '../components/status';
7
8
  import Switch from '../components/switch-button';
8
9
  import {
10
+ findCurrency,
9
11
  formatLineItemPricing,
10
12
  formatPrice,
11
13
  formatQuantityInventory,
@@ -14,6 +16,7 @@ import {
14
16
  } from '../libs/util';
15
17
  import ProductCard from './product-card';
16
18
  import dayjs from '../libs/dayjs';
19
+ import { usePaymentContext } from '../contexts/payment';
17
20
 
18
21
  type Props = {
19
22
  item: TLineItemExpanded;
@@ -25,6 +28,14 @@ type Props = {
25
28
  onDownsell: Function;
26
29
  mode?: 'normal' | 'cross-sell';
27
30
  children?: React.ReactNode;
31
+ // 数量调整相关
32
+ adjustableQuantity?: {
33
+ enabled: boolean;
34
+ minimum?: number;
35
+ maximum?: number;
36
+ };
37
+ onQuantityChange?: (itemId: string, quantity: number) => void;
38
+ completed?: boolean;
28
39
  };
29
40
 
30
41
  export default function ProductItem({
@@ -37,12 +48,90 @@ export default function ProductItem({
37
48
  children = null,
38
49
  onUpsell,
39
50
  onDownsell,
51
+ completed = false,
52
+ adjustableQuantity = { enabled: false },
53
+ onQuantityChange = () => {},
40
54
  }: Props) {
41
55
  const { t, locale } = useLocaleContext();
56
+ const { settings } = usePaymentContext();
42
57
  const pricing = formatLineItemPricing(item, currency, { trialEnd, trialInDays }, locale);
43
58
  const saving = formatUpsellSaving(items, currency);
44
59
  const metered = item.price?.recurring?.usage_type === 'metered' ? t('common.metered') : '';
45
60
  const canUpsell = mode === 'normal' && items.length === 1;
61
+
62
+ const isCreditProduct = item.price.product?.type === 'credit' && item.price.metadata?.credit_config?.credit_amount;
63
+ const creditAmount = isCreditProduct ? Number(item.price.metadata.credit_config.credit_amount) : 0;
64
+ const creditCurrency = isCreditProduct
65
+ ? findCurrency(settings.paymentMethods, item.price.metadata?.credit_config?.currency_id ?? '')
66
+ : null;
67
+ const validDuration = item.price.metadata?.credit_config?.valid_duration_value;
68
+ const validDurationUnit = item.price.metadata?.credit_config?.valid_duration_unit || 'days';
69
+
70
+ const [localQuantity, setLocalQuantity] = useState(item.quantity);
71
+ const canAdjustQuantity = adjustableQuantity.enabled && mode === 'normal';
72
+ const minQuantity = Math.max(adjustableQuantity.minimum || 1, 1);
73
+ const quantityAvailable = Math.min(item.price.quantity_limit_per_checkout, item.price.quantity_available);
74
+ const maxQuantity = Math.min(adjustableQuantity.maximum || 999, quantityAvailable || 999);
75
+
76
+ const handleQuantityChange = (newQuantity: number) => {
77
+ if (newQuantity >= minQuantity && newQuantity <= maxQuantity) {
78
+ setLocalQuantity(newQuantity);
79
+ if (formatQuantityInventory(item.price, newQuantity, locale)) {
80
+ return;
81
+ }
82
+ onQuantityChange(item.price_id, newQuantity);
83
+ }
84
+ };
85
+
86
+ const handleQuantityIncrease = () => {
87
+ if (localQuantity < maxQuantity) {
88
+ handleQuantityChange(localQuantity + 1);
89
+ }
90
+ };
91
+
92
+ const handleQuantityDecrease = () => {
93
+ if (localQuantity > minQuantity) {
94
+ handleQuantityChange(localQuantity - 1);
95
+ }
96
+ };
97
+
98
+ const handleQuantityInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
99
+ const value = parseInt(event.target.value, 10);
100
+ if (!Number.isNaN(value)) {
101
+ handleQuantityChange(value);
102
+ }
103
+ };
104
+
105
+ // Credit 信息格式化
106
+ const formatCreditInfo = () => {
107
+ if (!isCreditProduct) return null;
108
+
109
+ const isRecurring = item.price.type === 'recurring';
110
+ const totalCredit = creditAmount * localQuantity;
111
+
112
+ let message = '';
113
+ if (isRecurring) {
114
+ message = t('payment.checkout.credit.recurringInfo', {
115
+ amount: totalCredit,
116
+ period: formatRecurring(item.price.recurring!, true, 'per', locale),
117
+ });
118
+ } else {
119
+ message = t('payment.checkout.credit.oneTimeInfo', {
120
+ amount: totalCredit,
121
+ symbol: creditCurrency?.symbol || 'Credits',
122
+ });
123
+ }
124
+
125
+ if (validDuration && validDuration > 0) {
126
+ message += `,${t('payment.checkout.credit.expiresIn', {
127
+ duration: validDuration,
128
+ unit: t(`common.${validDurationUnit}`),
129
+ })}`;
130
+ }
131
+
132
+ return message;
133
+ };
134
+
46
135
  const primaryText = useMemo(() => {
47
136
  const price = item.upsell_price || item.price || {};
48
137
  const isRecurring = price?.type === 'recurring' && price?.recurring;
@@ -53,6 +142,8 @@ export default function ProductItem({
53
142
  return pricing.primary;
54
143
  }, [trialInDays, trialEnd, pricing, item, locale]);
55
144
 
145
+ const quantityInventoryError = formatQuantityInventory(item.price, localQuantity, locale);
146
+
56
147
  return (
57
148
  <Stack
58
149
  direction="column"
@@ -108,9 +199,9 @@ export default function ProductItem({
108
199
  )}
109
200
  </Stack>
110
201
  </Stack>
111
- {formatQuantityInventory(item.price, item.quantity, locale) ? (
202
+ {quantityInventoryError ? (
112
203
  <Status
113
- label={formatQuantityInventory(item.price, item.quantity, locale)}
204
+ label={quantityInventoryError}
114
205
  variant="outlined"
115
206
  sx={{
116
207
  mt: 1,
@@ -120,6 +211,63 @@ export default function ProductItem({
120
211
  }}
121
212
  />
122
213
  ) : null}
214
+
215
+ {/* 数量调整器 */}
216
+ {canAdjustQuantity && !completed && (
217
+ <Box sx={{ mt: 1, p: 1 }}>
218
+ <Stack
219
+ direction="row"
220
+ spacing={1}
221
+ sx={{
222
+ alignItems: 'center',
223
+ }}>
224
+ <Typography
225
+ variant="body2"
226
+ sx={{
227
+ color: 'text.secondary',
228
+ minWidth: 'fit-content',
229
+ }}>
230
+ {t('common.quantity')}:
231
+ </Typography>
232
+ <IconButton
233
+ size="small"
234
+ onClick={handleQuantityDecrease}
235
+ disabled={localQuantity <= minQuantity}
236
+ sx={{ minWidth: 32, width: 32, height: 32 }}>
237
+ <Remove fontSize="small" />
238
+ </IconButton>
239
+ <TextField
240
+ size="small"
241
+ value={localQuantity}
242
+ onChange={handleQuantityInputChange}
243
+ sx={{ width: 60 }}
244
+ type="number"
245
+ slotProps={{
246
+ htmlInput: {
247
+ min: minQuantity,
248
+ max: maxQuantity,
249
+ style: { textAlign: 'center', padding: '4px' },
250
+ },
251
+ }}
252
+ />
253
+ <IconButton
254
+ size="small"
255
+ onClick={handleQuantityIncrease}
256
+ disabled={localQuantity >= maxQuantity}
257
+ sx={{ minWidth: 32, width: 32, height: 32 }}>
258
+ <Add fontSize="small" />
259
+ </IconButton>
260
+ </Stack>
261
+ </Box>
262
+ )}
263
+
264
+ {/* Credit 信息展示 */}
265
+ {isCreditProduct && (
266
+ <Alert severity="info" sx={{ mt: 1, fontSize: '0.875rem' }} icon={false}>
267
+ {formatCreditInfo()}
268
+ </Alert>
269
+ )}
270
+
123
271
  {children}
124
272
  </Stack>
125
273
  {canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && (
@@ -62,6 +62,7 @@ type Props = {
62
62
  showStaking?: boolean;
63
63
  onUpsell?: Function;
64
64
  onDownsell?: Function;
65
+ onQuantityChange?: Function;
65
66
  onChangeAmount?: Function;
66
67
  onApplyCrossSell?: Function;
67
68
  onCancelCrossSell?: Function;
@@ -69,6 +70,7 @@ type Props = {
69
70
  crossSellBehavior?: string;
70
71
  donationSettings?: DonationSettings; // only include backend part
71
72
  action?: string;
73
+ completed?: boolean;
72
74
  };
73
75
 
74
76
  async function fetchCrossSell(id: string) {
@@ -118,7 +120,6 @@ function getStakingSetup(items: TLineItemExpanded[], currency: TPaymentCurrency,
118
120
 
119
121
  return '0';
120
122
  }
121
-
122
123
  export default function PaymentSummary({
123
124
  items,
124
125
  currency,
@@ -126,6 +127,7 @@ export default function PaymentSummary({
126
127
  billingThreshold,
127
128
  onUpsell = noop,
128
129
  onDownsell = noop,
130
+ onQuantityChange = noop,
129
131
  onApplyCrossSell = noop,
130
132
  onCancelCrossSell = noop,
131
133
  onChangeAmount = noop,
@@ -135,6 +137,7 @@ export default function PaymentSummary({
135
137
  donationSettings = undefined,
136
138
  action = '',
137
139
  trialEnd = 0,
140
+ completed = false,
138
141
  ...rest
139
142
  }: Props) {
140
143
  const { t, locale } = useLocaleContext();
@@ -167,6 +170,11 @@ export default function PaymentSummary({
167
170
  runAsync();
168
171
  };
169
172
 
173
+ const handleQuantityChange = async (itemId: string, quantity: number) => {
174
+ await onQuantityChange!(itemId, quantity);
175
+ runAsync();
176
+ };
177
+
170
178
  const handleDownsell = async (from: string) => {
171
179
  await onDownsell!(from);
172
180
  runAsync();
@@ -222,7 +230,10 @@ export default function PaymentSummary({
222
230
  trialEnd={trialEnd}
223
231
  currency={currency}
224
232
  onUpsell={handleUpsell}
225
- onDownsell={handleDownsell}>
233
+ onDownsell={handleDownsell}
234
+ adjustableQuantity={x.adjustable_quantity}
235
+ completed={completed}
236
+ onQuantityChange={handleQuantityChange}>
226
237
  {x.cross_sell && (
227
238
  <Stack
228
239
  direction="row"