payment-kit 1.20.11 → 1.20.13

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 (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -1,5 +1,212 @@
1
- import { Alert } from '@mui/material';
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { getDurableData } from '@arcblock/ux/lib/Datatable';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { Status, api, formatTime, usePaymentContext, Table, findCurrency, formatAmount } from '@blocklet/payment-react';
5
+ import { useEffect } from 'react';
6
+ import { Link } from 'react-router-dom';
7
+ import useBus from 'use-bus';
2
8
 
3
- export default function Coupons() {
4
- return <Alert severity="info">This feature is planned, but not implemented yet.</Alert>;
9
+ import { useLocalStorageState, useRequest } from 'ahooks';
10
+ import FilterToolbar from '../../../../components/filter-toolbar';
11
+
12
+ const fetchData = (params: Record<string, any> = {}): Promise<{ list: any[]; count: number }> => {
13
+ const search = new URLSearchParams();
14
+ Object.keys(params).forEach((key) => {
15
+ let v = params[key];
16
+ if (key === 'q') {
17
+ v = Object.entries(v)
18
+ .map((x) => x.join(':'))
19
+ .join(' ');
20
+ }
21
+ if (key === 'status' && ['active', 'inactive'].includes(v)) {
22
+ search.set('valid', v === 'active' ? 'true' : 'false');
23
+ return;
24
+ }
25
+ search.set(key, String(v));
26
+ });
27
+ return api.get(`/api/coupons?${search.toString()}`).then((res) => res.data);
28
+ };
29
+
30
+ type SearchProps = {
31
+ valid: string;
32
+ pageSize: number;
33
+ page: number;
34
+ q?: any;
35
+ o?: string;
36
+ };
37
+
38
+ export default function CouponsList() {
39
+ const listKey = 'coupons';
40
+ const persisted = getDurableData(listKey);
41
+
42
+ const { t } = useLocaleContext();
43
+ const { settings } = usePaymentContext();
44
+ const [search, setSearch] = useLocalStorageState<SearchProps>(listKey, {
45
+ defaultValue: {
46
+ valid: '',
47
+ pageSize: persisted.rowsPerPage || 20,
48
+ page: persisted.page ? persisted.page + 1 : 1,
49
+ },
50
+ });
51
+
52
+ const {
53
+ data = {
54
+ list: [],
55
+ count: 0,
56
+ },
57
+ refresh,
58
+ } = useRequest(() => fetchData(search), {
59
+ refreshDeps: [search],
60
+ });
61
+
62
+ const couponList = data?.list || [];
63
+ useBus('coupon.created', () => refresh(), []);
64
+ useBus('coupon.updated', () => refresh(), []);
65
+ useBus('coupon.deleted', () => refresh(), []);
66
+
67
+ useEffect(() => {
68
+ refresh();
69
+ }, [search]);
70
+
71
+ const formatCouponTerms = (coupon: any) => {
72
+ let couponOff = '';
73
+ if (coupon.percent_off && coupon.percent_off > 0) {
74
+ couponOff = `${coupon.percent_off}%`;
75
+ }
76
+ if (coupon.amount_off && coupon.amount_off !== '0') {
77
+ const currency = findCurrency(settings.paymentMethods, coupon.currency_id) || settings.baseCurrency;
78
+ couponOff = `${formatAmount(coupon.amount_off, currency.decimal)} ${currency.symbol}`;
79
+ }
80
+ if (couponOff) {
81
+ return t(`admin.coupon.couponTerms.${coupon.duration}`, { couponOff, months: coupon.duration_in_months });
82
+ }
83
+ return t('admin.coupon.noDiscount');
84
+ };
85
+
86
+ const columns = [
87
+ {
88
+ label: t('admin.coupon.listTitle'),
89
+ name: 'name',
90
+ options: {
91
+ filter: true,
92
+ customBodyRenderLite: (_: string, index: number) => {
93
+ const item = couponList[index];
94
+ return <Link to={`/admin/products/coupons/${item.id}`}>{item.name}</Link>;
95
+ },
96
+ },
97
+ },
98
+ {
99
+ label: t('admin.coupon.terms'),
100
+ name: 'terms',
101
+ options: {
102
+ filter: false,
103
+ customBodyRenderLite: (_: string, index: number) => {
104
+ const item = couponList[index];
105
+ return <Link to={`/admin/products/coupons/${item.id}`}>{formatCouponTerms(item)}</Link>;
106
+ },
107
+ },
108
+ },
109
+ {
110
+ label: t('admin.coupon.redemptions'),
111
+ name: 'redemptions',
112
+ options: {
113
+ filter: false,
114
+ customBodyRenderLite: (_: string, index: number) => {
115
+ const item = couponList[index];
116
+ const maxRedemptions = item.max_redemptions || t('admin.coupon.unlimited');
117
+ const timesRedeemed = item.times_redeemed || 0;
118
+ return (
119
+ <Link to={`/admin/products/coupons/${item.id}`}>
120
+ {timesRedeemed} / {maxRedemptions}
121
+ </Link>
122
+ );
123
+ },
124
+ },
125
+ },
126
+ {
127
+ label: t('admin.coupon.expires'),
128
+ name: 'expires',
129
+ options: {
130
+ filter: false,
131
+ customBodyRenderLite: (_: string, index: number) => {
132
+ const item = couponList[index];
133
+ if (item.redeem_by) {
134
+ return <Link to={`/admin/products/coupons/${item.id}`}>{formatTime(item.redeem_by * 1000)}</Link>;
135
+ }
136
+ return <Link to={`/admin/products/coupons/${item.id}`}>—</Link>;
137
+ },
138
+ },
139
+ },
140
+ {
141
+ label: t('common.status'),
142
+ name: 'valid',
143
+ options: {
144
+ filter: true,
145
+ customBodyRenderLite: (_: string, index: number) => {
146
+ const item = couponList[index];
147
+ return (
148
+ <Link to={`/admin/products/coupons/${item.id}`}>
149
+ <Status
150
+ label={item?.valid ? t('common.active') : t('common.inactive')}
151
+ color={item?.valid ? 'success' : 'default'}
152
+ />
153
+ </Link>
154
+ );
155
+ },
156
+ },
157
+ },
158
+ ];
159
+
160
+ const onTableChange = ({ page, rowsPerPage }: any) => {
161
+ if (search!.pageSize !== rowsPerPage) {
162
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
163
+ } else if (search!.page !== page + 1) {
164
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
165
+ }
166
+ };
167
+
168
+ return (
169
+ <Table
170
+ hasRowLink
171
+ durable={`__${listKey}__`}
172
+ durableKeys={['page', 'rowsPerPage', 'searchText']}
173
+ title={<FilterToolbar setSearch={setSearch} search={search} status={['active', 'inactive']} />}
174
+ data={couponList}
175
+ columns={columns}
176
+ options={{
177
+ count: data.count,
178
+ page: search!.page - 1,
179
+ rowsPerPage: search!.pageSize,
180
+ onColumnSortChange(_: any, order: any) {
181
+ setSearch({
182
+ ...search!,
183
+ q: search!.q || {},
184
+ o: order,
185
+ });
186
+ },
187
+ onSearchChange: (text: string) => {
188
+ if (text) {
189
+ setSearch({
190
+ ...search!,
191
+ q: {
192
+ 'like-name': text,
193
+ 'like-id': text,
194
+ },
195
+ pageSize: 100,
196
+ page: 1,
197
+ });
198
+ } else {
199
+ setSearch({
200
+ ...search!,
201
+ pageSize: 100,
202
+ page: 1,
203
+ q: {},
204
+ });
205
+ }
206
+ },
207
+ }}
208
+ loading={!couponList}
209
+ onChange={onTableChange}
210
+ />
211
+ );
5
212
  }
@@ -5,6 +5,7 @@ import React, { isValidElement, useState } from 'react';
5
5
  import { useNavigate, useParams } from 'react-router-dom';
6
6
 
7
7
  import { useTransitionContext } from '../../../components/progress-bar';
8
+ import CouponCreate from './coupons/create';
8
9
 
9
10
  const ProductCreate = React.lazy(() => import('./products/create'));
10
11
  const ProductDetail = React.lazy(() => import('./products/detail'));
@@ -13,24 +14,28 @@ const PaymentLinkCreate = React.lazy(() => import('./links/create'));
13
14
  const PaymentLinkDetail = React.lazy(() => import('./links/detail'));
14
15
  const PricingTableCreate = React.lazy(() => import('./pricing-tables/create'));
15
16
  const PricingTableDetail = React.lazy(() => import('./pricing-tables/detail'));
17
+ const CouponDetail = React.lazy(() => import('./coupons/detail'));
18
+ const PromotionCodeDetail = React.lazy(() => import('./promotion-codes/detail'));
16
19
  const VendorCreate = React.lazy(() => import('./vendors/create'));
17
20
 
18
21
  const pages = {
19
22
  products: React.lazy(() => import('./products')),
23
+ coupons: React.lazy(() => import('./coupons')),
20
24
  links: React.lazy(() => import('./links')),
21
25
  'pricing-tables': React.lazy(() => import('./pricing-tables')),
22
26
  passports: React.lazy(() => import('./passports')),
23
27
  vendors: React.lazy(() => import('./vendors')),
24
- // coupons: React.lazy(() => import('./coupons')),
25
28
  };
26
29
 
27
30
  export default function Products() {
28
31
  const navigate = useNavigate();
29
32
  const { t } = useLocaleContext();
30
- const { page = 'products' } = useParams();
33
+ const { page = 'products', id, subpage } = useParams();
31
34
  const [createProduct, setCreateProduct] = useState(false);
35
+ const [createCoupon, setCreateCoupon] = useState(false);
32
36
  const { startTransition } = useTransitionContext();
33
37
 
38
+ // Handle old-style routing where ID is passed as page parameter
34
39
  if (page.startsWith('prod_')) {
35
40
  return <ProductDetail id={page} />;
36
41
  }
@@ -47,6 +52,18 @@ export default function Products() {
47
52
  return <PaymentLinkDetail id={page} />;
48
53
  }
49
54
 
55
+ if (page === 'coupons' && subpage === 'promotion-codes' && id) {
56
+ return <PromotionCodeDetail id={id} />;
57
+ }
58
+
59
+ if (page === 'coupons' && id && !subpage) {
60
+ return <CouponDetail id={id} />;
61
+ }
62
+
63
+ if (page === 'promotion-codes' && id) {
64
+ return <PromotionCodeDetail id={id} />;
65
+ }
66
+
50
67
  // @ts-ignore
51
68
  const TabComponent = pages[page] || pages.products;
52
69
 
@@ -56,11 +73,11 @@ export default function Products() {
56
73
  }
57
74
  const tabs = [
58
75
  { label: t('admin.products'), value: 'products' },
76
+ { label: t('admin.coupons'), value: 'coupons' },
59
77
  { label: t('admin.paymentLinks'), value: 'links' },
60
78
  { label: t('admin.pricingTables'), value: 'pricing-tables' },
61
79
  { label: t('admin.passports'), value: 'passports' },
62
80
  { label: t('admin.vendors'), value: 'vendors' },
63
- // { label: t('admin.coupons'), value: 'coupons' },
64
81
  ];
65
82
 
66
83
  let extra = null;
@@ -70,6 +87,8 @@ export default function Products() {
70
87
  extra = <PaymentLinkCreate />;
71
88
  } else if (page === 'pricing-tables') {
72
89
  extra = <PricingTableCreate />;
90
+ } else if (page === 'coupons') {
91
+ extra = <CouponCreate open={createCoupon} onClose={() => setCreateCoupon(false)} />;
73
92
  } else if (page === 'vendors') {
74
93
  extra = <VendorCreate open={createProduct} onClose={() => setCreateProduct(false)} />;
75
94
  }
@@ -430,7 +430,12 @@ export default function ProductDetail(props: { id: string }) {
430
430
  borderRadius: 1,
431
431
  backgroundColor: 'background.paper',
432
432
  }}>
433
- <Stack direction="row" justifyContent="space-between" alignItems="center">
433
+ <Stack
434
+ direction="row"
435
+ sx={{
436
+ justifyContent: 'space-between',
437
+ alignItems: 'center',
438
+ }}>
434
439
  <Box>
435
440
  <Typography variant="body1" sx={{ color: 'text.primary', fontWeight: 500 }}>
436
441
  {vendor.name || vendor.vendor_key || vendor.vendor_id}
@@ -441,7 +446,12 @@ export default function ProductDetail(props: { id: string }) {
441
446
  </Typography>
442
447
  )}
443
448
  </Box>
444
- <Stack direction="row" spacing={3} alignItems="center">
449
+ <Stack
450
+ direction="row"
451
+ spacing={3}
452
+ sx={{
453
+ alignItems: 'center',
454
+ }}>
445
455
  <Typography variant="body2" sx={{ color: 'text.secondary' }}>
446
456
  {vendor.commission_type === 'percentage'
447
457
  ? t('admin.vendor.percentage')
@@ -0,0 +1,103 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import { ConfirmDialog, api, formatError } from '@blocklet/payment-react';
4
+ import { useSetState } from 'ahooks';
5
+ import { dispatch } from 'use-bus';
6
+ import Actions from '../../../../components/actions';
7
+
8
+ type Props = {
9
+ data: any;
10
+ onChange: Function;
11
+ variant?: string;
12
+ };
13
+
14
+ export default function PromotionCodeActions({ data, onChange, variant = 'compact' }: Props) {
15
+ const { t } = useLocaleContext();
16
+
17
+ const canEdit = !data.locked && data.active;
18
+ const canDelete = !data.locked;
19
+ const canArchive = data.active;
20
+
21
+ const [state, setState] = useSetState({
22
+ action: '',
23
+ loading: false,
24
+ });
25
+
26
+ const onArchivePromotionCode = async () => {
27
+ try {
28
+ setState({ loading: true });
29
+ await api.put(`/api/promotion-codes/${data.id}/archive`).then((res) => res.data);
30
+ Toast.success(t('common.saved'));
31
+ dispatch('promotion-code.updated');
32
+ onChange(state.action);
33
+ } catch (err) {
34
+ console.error(err);
35
+ Toast.error(formatError(err));
36
+ } finally {
37
+ setState({ loading: false, action: '' });
38
+ }
39
+ };
40
+
41
+ const onDeletePromotionCode = async () => {
42
+ try {
43
+ setState({ loading: true });
44
+ await api.delete(`/api/promotion-codes/${data.id}`).then((res) => res.data);
45
+ Toast.success(t('common.removed'));
46
+ dispatch('promotion-code.deleted');
47
+ onChange(state.action);
48
+ } catch (err) {
49
+ console.error(err);
50
+ Toast.error(formatError(err));
51
+ } finally {
52
+ setState({ loading: false, action: '' });
53
+ }
54
+ };
55
+
56
+ const onEditPromotionCode = () => {
57
+ setState({ action: 'edit' });
58
+ onChange('edit', data);
59
+ };
60
+
61
+ const actions = [
62
+ {
63
+ label: t('admin.promotionCode.edit'),
64
+ handler: onEditPromotionCode,
65
+ color: canEdit ? 'text.primary' : 'text.disabled',
66
+ disabled: !canEdit,
67
+ },
68
+ {
69
+ label: t('admin.promotionCode.archive'),
70
+ handler: () => setState({ action: 'archive' }),
71
+ color: canArchive ? 'text.primary' : 'text.disabled',
72
+ disabled: !canArchive,
73
+ },
74
+ {
75
+ label: t('admin.promotionCode.delete'),
76
+ handler: () => setState({ action: 'delete' }),
77
+ color: canDelete ? 'error.main' : 'text.disabled',
78
+ disabled: !canDelete,
79
+ },
80
+ ];
81
+
82
+ return (
83
+ <>
84
+ <Actions variant={variant} actions={actions} />
85
+ {state.action === 'archive' && (
86
+ <ConfirmDialog
87
+ onConfirm={onArchivePromotionCode}
88
+ onCancel={() => setState({ action: '' })}
89
+ title={t('admin.promotionCode.archive')}
90
+ message={t('admin.promotionCode.archiveTip')}
91
+ />
92
+ )}
93
+ {state.action === 'delete' && (
94
+ <ConfirmDialog
95
+ onConfirm={onDeletePromotionCode}
96
+ onCancel={() => setState({ action: '' })}
97
+ title={t('admin.promotionCode.delete')}
98
+ message={t('admin.promotionCode.deleteTip')}
99
+ />
100
+ )}
101
+ </>
102
+ );
103
+ }
@@ -0,0 +1,235 @@
1
+ import Dialog from '@arcblock/ux/lib/Dialog';
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import Toast from '@arcblock/ux/lib/Toast';
4
+ import { api, formatError, findCurrency, usePaymentContext } from '@blocklet/payment-react';
5
+ import { Button, Box, Typography, CircularProgress, Stack } from '@mui/material';
6
+ import { FormProvider, useForm } from 'react-hook-form';
7
+ import { dispatch } from 'use-bus';
8
+ import { useEffect } from 'react';
9
+ import { fromUnitToToken } from '@ocap/util';
10
+
11
+ import PromotionCodeForm, {
12
+ PromotionCodeData,
13
+ DEFAULT_PROMOTION_CODE,
14
+ } from '../../../../components/promotion/promotion-code-form';
15
+
16
+ type PromotionCodeFormData = PromotionCodeData;
17
+
18
+ interface Props {
19
+ open: boolean;
20
+ onClose: () => void;
21
+ onSubmit?: () => void;
22
+ couponId: string;
23
+ promotionCode?: any; // For editing
24
+ isEditing?: boolean;
25
+ couponType?: 'percentage' | 'fixed_amount';
26
+ availableCurrencies?: Array<{ id: string; symbol: string; name: string }>;
27
+ }
28
+
29
+ export default function PromotionCodeModal({
30
+ open,
31
+ onClose,
32
+ onSubmit: onSubmitCallback = () => {},
33
+ couponId,
34
+ promotionCode = null,
35
+ isEditing = false,
36
+ availableCurrencies = [],
37
+ }: Omit<Props, 'couponType'>) {
38
+ const { t } = useLocaleContext();
39
+ const { settings } = usePaymentContext();
40
+
41
+ const methods = useForm<PromotionCodeFormData>({
42
+ mode: 'onChange',
43
+ defaultValues: DEFAULT_PROMOTION_CODE,
44
+ });
45
+
46
+ const {
47
+ handleSubmit,
48
+ reset,
49
+ formState: { isSubmitting },
50
+ } = methods;
51
+
52
+ // Reset form when modal opens or promotion code changes
53
+ useEffect(() => {
54
+ if (open) {
55
+ if (isEditing && promotionCode) {
56
+ // Keep currency_options in object format for direct use
57
+ const convertedCurrencyOptions: Record<string, { minimum_amount: number }> = {};
58
+ const minimumAmountCurrency = promotionCode.restrictions?.minimum_amount_currency
59
+ ? findCurrency(settings.paymentMethods, promotionCode.restrictions.minimum_amount_currency)
60
+ : null;
61
+ const minimumAmount = promotionCode.restrictions?.minimum_amount
62
+ ? parseFloat(fromUnitToToken(promotionCode.restrictions.minimum_amount, minimumAmountCurrency?.decimal || 0))
63
+ : undefined;
64
+ let requireMinimumAmount = false;
65
+
66
+ if (promotionCode.restrictions?.currency_options) {
67
+ const currencyEntries = Object.entries(promotionCode.restrictions.currency_options);
68
+ requireMinimumAmount = currencyEntries.length > 0;
69
+ currencyEntries.forEach(([currencyId, optionData]) => {
70
+ const currency = findCurrency(settings.paymentMethods, currencyId);
71
+ let amount = 0;
72
+ if (optionData && typeof optionData === 'object' && 'minimum_amount' in optionData) {
73
+ const optionMinimumAmount = (optionData as any).minimum_amount;
74
+ if (currency && optionMinimumAmount) {
75
+ amount = parseFloat(fromUnitToToken(optionMinimumAmount || '0', currency.decimal));
76
+ } else {
77
+ amount = optionMinimumAmount || 0;
78
+ }
79
+ }
80
+
81
+ convertedCurrencyOptions[currencyId] = {
82
+ minimum_amount: amount,
83
+ };
84
+ });
85
+ }
86
+
87
+ // Populate form with existing data for editing
88
+ reset({
89
+ code: promotionCode.code || '',
90
+ active: promotionCode.active ?? true,
91
+ max_redemptions: promotionCode.max_redemptions || undefined,
92
+ description: promotionCode.description || '',
93
+ expires_at: promotionCode.expires_at
94
+ ? new Date(promotionCode.expires_at * 1000).toISOString().slice(0, 16)
95
+ : '',
96
+ verification_type: promotionCode.verification_type || 'code',
97
+ limit_number_redemptions: !!promotionCode.max_redemptions,
98
+ add_expiration_date: !!promotionCode.expires_at,
99
+ restrictions: {
100
+ first_time_transaction: promotionCode.restrictions?.first_time_transaction || false,
101
+ require_minimum_amount: requireMinimumAmount,
102
+ minimum_amount: minimumAmount,
103
+ minimum_amount_currency: minimumAmountCurrency?.id,
104
+ currency_options: convertedCurrencyOptions,
105
+ },
106
+ customer_dids: promotionCode.customer_dids || [],
107
+ nft_addresses: promotionCode.nft_config?.addresses || [],
108
+ nft_tags: promotionCode.nft_config?.tags || [],
109
+ trusted_issuers: promotionCode.nft_config?.trusted_issuers || [],
110
+ trusted_parents: promotionCode.nft_config?.trusted_parents || [],
111
+ min_balance: promotionCode.nft_config?.min_balance || 1,
112
+ vc_roles: promotionCode.vc_config?.roles || [],
113
+ vc_trusted_issuers: promotionCode.vc_config?.trusted_issuers || [],
114
+ });
115
+ } else {
116
+ // Reset to default values for creating
117
+ reset(DEFAULT_PROMOTION_CODE);
118
+ }
119
+ }
120
+ }, [open, isEditing, promotionCode, reset, settings.paymentMethods]);
121
+
122
+ const onSubmit = async (data: PromotionCodeFormData) => {
123
+ try {
124
+ const payload: any = {
125
+ active: data.active,
126
+ verification_type: data.verification_type,
127
+ restrictions: data.restrictions,
128
+ description: data.description,
129
+ metadata: {},
130
+ };
131
+
132
+ if (!isEditing) {
133
+ payload.coupon_id = couponId;
134
+ }
135
+
136
+ // Only include custom code if provided, otherwise let backend generate
137
+ if (data.code && data.code.trim()) {
138
+ payload.code = data.code;
139
+ }
140
+
141
+ if (data.limit_number_redemptions && data.max_redemptions) {
142
+ payload.max_redemptions = data.max_redemptions;
143
+ }
144
+
145
+ if (data.expires_at) {
146
+ payload.expires_at = Math.floor(new Date(data.expires_at).getTime() / 1000);
147
+ }
148
+
149
+ // Add verification configurations based on type
150
+ if (data.verification_type === 'nft') {
151
+ payload.nft_config = {
152
+ addresses: data.nft_addresses?.filter((addr) => addr.trim()) || [],
153
+ tags: data.nft_tags?.filter((tag) => tag.trim()) || [],
154
+ trusted_issuers: data.trusted_issuers?.filter((issuer) => issuer.trim()) || [],
155
+ trusted_parents: data.trusted_parents?.filter((parent) => parent.trim()) || [],
156
+ min_balance: data.min_balance || 1,
157
+ };
158
+ } else if (data.verification_type === 'vc') {
159
+ payload.vc_config = {
160
+ roles: data.vc_roles?.filter((role) => role.trim()) || [],
161
+ trusted_issuers: data.vc_trusted_issuers?.filter((issuer) => issuer.trim()) || [],
162
+ };
163
+ } else if (data.verification_type === 'user_restricted') {
164
+ payload.customer_dids = data.customer_dids?.filter((did) => did.trim()) || [];
165
+ }
166
+
167
+ if (isEditing && promotionCode) {
168
+ await api.put(`/api/promotion-codes/${promotionCode.id}`, payload);
169
+ Toast.success(t('common.saved'));
170
+ dispatch('promotion-code.updated');
171
+ } else {
172
+ await api.post('/api/promotion-codes', payload);
173
+ Toast.success(t('admin.coupon.saved'));
174
+ dispatch('promotion-code.created');
175
+ }
176
+
177
+ if (onSubmitCallback) {
178
+ onSubmitCallback();
179
+ }
180
+ onClose();
181
+ } catch (err) {
182
+ console.error(err);
183
+ Toast.error(formatError(err));
184
+ }
185
+ };
186
+
187
+ const handleCancel = () => {
188
+ reset();
189
+ onClose();
190
+ };
191
+
192
+ const title = isEditing ? t('admin.promotionCode.edit') : t('admin.coupon.createPromotionCode');
193
+
194
+ return (
195
+ <Dialog
196
+ open={open}
197
+ disableEscapeKeyDown
198
+ fullWidth
199
+ maxWidth="sm"
200
+ className="base-dialog"
201
+ onClose={handleCancel}
202
+ showCloseButton={false}
203
+ title={title}
204
+ actions={
205
+ <Stack direction="row">
206
+ <Button size="small" sx={{ mr: 2 }} onClick={handleCancel} disabled={isSubmitting}>
207
+ {t('common.cancel')}
208
+ </Button>
209
+ <Button
210
+ variant="contained"
211
+ color="primary"
212
+ size="small"
213
+ disabled={isSubmitting}
214
+ onClick={handleSubmit(onSubmit)}>
215
+ {isSubmitting && <CircularProgress size={16} sx={{ mr: 1 }} />}
216
+ {isEditing ? t('common.save') : t('admin.coupon.createPromotionCode')}
217
+ </Button>
218
+ </Stack>
219
+ }>
220
+ <FormProvider {...methods}>
221
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
222
+ <Typography
223
+ variant="body2"
224
+ sx={{
225
+ color: 'text.secondary',
226
+ }}>
227
+ {t('admin.coupon.promotionCodeDescription')}
228
+ </Typography>
229
+
230
+ <PromotionCodeForm showCodeInput showExpandedOptions availableCurrencies={availableCurrencies} />
231
+ </Box>
232
+ </FormProvider>
233
+ </Dialog>
234
+ );
235
+ }