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.
- package/api/src/crons/index.ts +8 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +44 -10
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
- package/api/src/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/fulfillment.ts +1 -1
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
- package/api/src/queues/vendors/return-processor.ts +184 -0
- package/api/src/queues/vendors/return-scanner.ts +119 -0
- package/api/src/queues/vendors/status-check.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/routes/vendor.ts +89 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
- package/api/src/store/models/checkout-session.ts +17 -2
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/product-vendor.ts +6 -0
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +227 -0
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
|
@@ -1,5 +1,212 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|