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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
3
|
+
import { api, formatError, formatTime, useMobile, Status, usePaymentContext } from '@blocklet/payment-react';
|
|
4
|
+
import { ArrowBackOutlined } from '@mui/icons-material';
|
|
5
|
+
import { Alert, AlertTitle, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
6
|
+
import { styled } from '@mui/system';
|
|
7
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
9
|
+
import { dispatch } from 'use-bus';
|
|
10
|
+
|
|
11
|
+
import Copyable from '../../../../components/copyable';
|
|
12
|
+
import EventList from '../../../../components/event/list';
|
|
13
|
+
import InfoMetric from '../../../../components/info-metric';
|
|
14
|
+
import InfoRow from '../../../../components/info-row';
|
|
15
|
+
import InfoRowGroup from '../../../../components/info-row-group';
|
|
16
|
+
import MetadataEditor from '../../../../components/metadata/editor';
|
|
17
|
+
import MetadataList from '../../../../components/metadata/list';
|
|
18
|
+
import SectionHeader from '../../../../components/section/header';
|
|
19
|
+
import { goBackOrFallback } from '../../../../libs/util';
|
|
20
|
+
import PromotionCodeActions from './actions';
|
|
21
|
+
import VerificationConfig from './verification-config';
|
|
22
|
+
import PromotionCodeModal from './create';
|
|
23
|
+
import CurrencyRestrictions from '../../../../components/promotion/currency-restrictions';
|
|
24
|
+
import ActiveRedemptions from '../../../../components/promotion/active-redemptions';
|
|
25
|
+
|
|
26
|
+
const getPromotionCode = (id: string): Promise<any> => {
|
|
27
|
+
return api.get(`/api/promotion-codes/${id}`).then((res) => res.data);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getCoupon = (id: string): Promise<any> => {
|
|
31
|
+
return api.get(`/api/coupons/${id}`).then((res) => res.data);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default function PromotionCodeDetail(props: { id: string }) {
|
|
35
|
+
const { t } = useLocaleContext();
|
|
36
|
+
const { isMobile } = useMobile();
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const { settings } = usePaymentContext();
|
|
39
|
+
const [state, setState] = useSetState({
|
|
40
|
+
editing: {
|
|
41
|
+
metadata: false,
|
|
42
|
+
},
|
|
43
|
+
loading: {
|
|
44
|
+
metadata: false,
|
|
45
|
+
},
|
|
46
|
+
editModalOpen: false,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const { loading, error, data, runAsync } = useRequest(() => getPromotionCode(props.id));
|
|
50
|
+
const { data: couponData } = useRequest(() => (data?.coupon_id ? getCoupon(data.coupon_id) : Promise.resolve(null)), {
|
|
51
|
+
refreshDeps: [data?.coupon_id],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (error) {
|
|
55
|
+
return <Alert severity="error">{error.message}</Alert>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (loading || !data) {
|
|
59
|
+
return <CircularProgress />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get available currencies based on coupon configuration
|
|
63
|
+
const getAvailableCurrencies = () => {
|
|
64
|
+
const allCurrencies = settings.paymentMethods?.flatMap((pm) => pm.payment_currencies) || [];
|
|
65
|
+
|
|
66
|
+
if (!couponData) {
|
|
67
|
+
return allCurrencies;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (couponData.percent_off) {
|
|
71
|
+
// Percentage coupon: return all available currencies
|
|
72
|
+
return allCurrencies;
|
|
73
|
+
}
|
|
74
|
+
// Fixed amount coupon: return coupon's configured currencies
|
|
75
|
+
const currencyIds = new Set<string>();
|
|
76
|
+
|
|
77
|
+
// Add base currency
|
|
78
|
+
if (couponData.currency_id) {
|
|
79
|
+
currencyIds.add(couponData.currency_id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Add additional configured currencies
|
|
83
|
+
if (couponData.currency_options && typeof couponData.currency_options === 'object') {
|
|
84
|
+
Object.keys(couponData.currency_options).forEach((currencyId) => {
|
|
85
|
+
currencyIds.add(currencyId);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return allCurrencies.filter((currency) => currencyIds.has(currency.id));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const availableCurrencies = getAvailableCurrencies();
|
|
93
|
+
|
|
94
|
+
const onUpdateMetadata = async (updates: any) => {
|
|
95
|
+
try {
|
|
96
|
+
setState((prev) => ({ loading: { ...prev.loading, metadata: true } }));
|
|
97
|
+
// 只发送metadata字段,避免其他字段导致验证失败
|
|
98
|
+
await api.put(`/api/promotion-codes/${props.id}`, { metadata: updates.metadata });
|
|
99
|
+
Toast.success(t('common.saved'));
|
|
100
|
+
dispatch('promotion-code.updated');
|
|
101
|
+
runAsync();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(err);
|
|
104
|
+
Toast.error(formatError(err));
|
|
105
|
+
} finally {
|
|
106
|
+
setState((prev) => ({ loading: { ...prev.loading, metadata: false } }));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleEditMetadata = () => {
|
|
111
|
+
setState((prev) => ({ editing: { ...prev.editing, metadata: true } }));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleActionsChange = (action: string, promotionCodeData?: any) => {
|
|
115
|
+
if (action === 'edit' && promotionCodeData) {
|
|
116
|
+
setState({ editModalOpen: true });
|
|
117
|
+
} else {
|
|
118
|
+
runAsync();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleEditModalClose = () => {
|
|
123
|
+
setState({ editModalOpen: false });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleEditModalSubmit = () => {
|
|
127
|
+
setState({ editModalOpen: false });
|
|
128
|
+
runAsync();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const getStatus = () => {
|
|
132
|
+
const isExpired = data.expires_at && Math.floor(Date.now() / 1000) > data.expires_at;
|
|
133
|
+
const isMaxedOut = data.max_redemptions && data.times_redeemed >= data.max_redemptions;
|
|
134
|
+
if (isExpired) {
|
|
135
|
+
return { label: t('admin.promotionCode.expired'), color: 'default' as const };
|
|
136
|
+
}
|
|
137
|
+
if (isMaxedOut) {
|
|
138
|
+
return { label: t('admin.promotionCode.maxedOut'), color: 'default' as const };
|
|
139
|
+
}
|
|
140
|
+
if (!data.active) {
|
|
141
|
+
return { label: t('admin.promotionCode.inactive'), color: 'default' as const };
|
|
142
|
+
}
|
|
143
|
+
return { label: t('admin.promotionCode.active'), color: 'success' as const };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const formatRedemptions = () => {
|
|
147
|
+
const maxRedemptions = data.max_redemptions ? data.max_redemptions.toString() : t('admin.promotionCode.unlimited');
|
|
148
|
+
const timesRedeemed = data.times_redeemed || 0;
|
|
149
|
+
return `${timesRedeemed}/${maxRedemptions}`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const status = getStatus();
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
|
|
156
|
+
<Box>
|
|
157
|
+
{!data.active && (
|
|
158
|
+
<Alert severity="warning">
|
|
159
|
+
<AlertTitle>{t('admin.promotionCode.inactiveTitle')}</AlertTitle>
|
|
160
|
+
{t('admin.promotionCode.inactiveTip')}
|
|
161
|
+
</Alert>
|
|
162
|
+
)}
|
|
163
|
+
<Stack
|
|
164
|
+
className="page-header"
|
|
165
|
+
direction="row"
|
|
166
|
+
sx={{
|
|
167
|
+
justifyContent: 'space-between',
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
}}>
|
|
170
|
+
<Stack
|
|
171
|
+
direction="row"
|
|
172
|
+
onClick={() => goBackOrFallback('/admin/products/coupons')}
|
|
173
|
+
sx={{
|
|
174
|
+
alignItems: 'center',
|
|
175
|
+
fontWeight: 'normal',
|
|
176
|
+
cursor: 'pointer',
|
|
177
|
+
}}>
|
|
178
|
+
<ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
|
|
179
|
+
<Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
|
|
180
|
+
{t('admin.coupons')}
|
|
181
|
+
</Typography>
|
|
182
|
+
</Stack>
|
|
183
|
+
<Stack direction="row" spacing={2}>
|
|
184
|
+
<PromotionCodeActions data={data} onChange={handleActionsChange} variant="normal" />
|
|
185
|
+
</Stack>
|
|
186
|
+
</Stack>
|
|
187
|
+
<Box
|
|
188
|
+
sx={{
|
|
189
|
+
mt: 4,
|
|
190
|
+
mb: 3,
|
|
191
|
+
display: 'flex',
|
|
192
|
+
gap: {
|
|
193
|
+
xs: 2,
|
|
194
|
+
sm: 2,
|
|
195
|
+
md: 5,
|
|
196
|
+
},
|
|
197
|
+
flexWrap: 'wrap',
|
|
198
|
+
flexDirection: {
|
|
199
|
+
xs: 'column',
|
|
200
|
+
sm: 'column',
|
|
201
|
+
md: 'row',
|
|
202
|
+
},
|
|
203
|
+
alignItems: {
|
|
204
|
+
xs: 'flex-start',
|
|
205
|
+
sm: 'flex-start',
|
|
206
|
+
md: 'center',
|
|
207
|
+
},
|
|
208
|
+
}}>
|
|
209
|
+
<Stack
|
|
210
|
+
direction="column"
|
|
211
|
+
sx={{
|
|
212
|
+
alignItems: 'flex-start',
|
|
213
|
+
justifyContent: 'space-around',
|
|
214
|
+
}}>
|
|
215
|
+
<Typography
|
|
216
|
+
variant="h2"
|
|
217
|
+
sx={{
|
|
218
|
+
color: 'text.primary',
|
|
219
|
+
fontFamily: 'monospace',
|
|
220
|
+
letterSpacing: 1,
|
|
221
|
+
}}>
|
|
222
|
+
{data.code}
|
|
223
|
+
</Typography>
|
|
224
|
+
</Stack>
|
|
225
|
+
<Stack
|
|
226
|
+
className="section-body"
|
|
227
|
+
sx={{
|
|
228
|
+
justifyContent: 'flex-start',
|
|
229
|
+
flexWrap: 'wrap',
|
|
230
|
+
'hr.MuiDivider-root:last-child': {
|
|
231
|
+
display: 'none',
|
|
232
|
+
},
|
|
233
|
+
flexDirection: {
|
|
234
|
+
xs: 'column',
|
|
235
|
+
sm: 'column',
|
|
236
|
+
md: 'row',
|
|
237
|
+
},
|
|
238
|
+
alignItems: 'flex-start',
|
|
239
|
+
gap: {
|
|
240
|
+
xs: 1,
|
|
241
|
+
sm: 1,
|
|
242
|
+
md: 3,
|
|
243
|
+
},
|
|
244
|
+
}}>
|
|
245
|
+
<InfoMetric
|
|
246
|
+
label={t('admin.promotionCode.id')}
|
|
247
|
+
value={<Copyable text={props.id} style={{ marginLeft: 4 }} />}
|
|
248
|
+
divider
|
|
249
|
+
/>
|
|
250
|
+
<InfoMetric label={t('admin.promotionCode.redemptions')} value={formatRedemptions()} divider />
|
|
251
|
+
<InfoMetric
|
|
252
|
+
label={t('admin.promotionCode.status')}
|
|
253
|
+
value={<Status label={status.label} color={status.color} />}
|
|
254
|
+
divider
|
|
255
|
+
/>
|
|
256
|
+
<InfoMetric
|
|
257
|
+
label={t('admin.promotionCode.verificationType')}
|
|
258
|
+
value={t(`admin.promotionCode.verificationTypeMap.${data.verification_type}`)}
|
|
259
|
+
divider
|
|
260
|
+
/>
|
|
261
|
+
<InfoMetric label={t('admin.promotionCode.created')} value={formatTime(data.created_at)} divider />
|
|
262
|
+
</Stack>
|
|
263
|
+
</Box>
|
|
264
|
+
<Divider />
|
|
265
|
+
</Box>
|
|
266
|
+
<Stack
|
|
267
|
+
sx={{
|
|
268
|
+
flexDirection: {
|
|
269
|
+
xs: 'column',
|
|
270
|
+
lg: 'row',
|
|
271
|
+
},
|
|
272
|
+
gap: {
|
|
273
|
+
xs: 2.5,
|
|
274
|
+
md: 4,
|
|
275
|
+
},
|
|
276
|
+
'.promotion-code-column-1': {
|
|
277
|
+
minWidth: {
|
|
278
|
+
xs: '100%',
|
|
279
|
+
lg: '600px',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
'.promotion-code-column-2': {
|
|
283
|
+
width: {
|
|
284
|
+
xs: '100%',
|
|
285
|
+
md: '100%',
|
|
286
|
+
lg: '320px',
|
|
287
|
+
},
|
|
288
|
+
maxWidth: {
|
|
289
|
+
xs: '100%',
|
|
290
|
+
md: '33%',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
}}>
|
|
294
|
+
<Box
|
|
295
|
+
className="promotion-code-column-1"
|
|
296
|
+
sx={{
|
|
297
|
+
flex: 1,
|
|
298
|
+
gap: 2.5,
|
|
299
|
+
display: 'flex',
|
|
300
|
+
flexDirection: 'column',
|
|
301
|
+
}}>
|
|
302
|
+
<Box className="section">
|
|
303
|
+
<SectionHeader title={t('common.details')} />
|
|
304
|
+
<InfoRowGroup>
|
|
305
|
+
<InfoRow
|
|
306
|
+
label={t('common.active')}
|
|
307
|
+
value={data.active ? t('admin.promotionCode.yes') : t('admin.promotionCode.no')}
|
|
308
|
+
/>
|
|
309
|
+
{data.description && <InfoRow label={t('admin.coupon.desc')} value={data.description} />}
|
|
310
|
+
<InfoRow
|
|
311
|
+
label={t('admin.promotionCode.couponId')}
|
|
312
|
+
value={
|
|
313
|
+
<Button
|
|
314
|
+
variant="text"
|
|
315
|
+
color="inherit"
|
|
316
|
+
size="small"
|
|
317
|
+
sx={{
|
|
318
|
+
color: 'secondary.main',
|
|
319
|
+
}}
|
|
320
|
+
onClick={() => navigate(`/admin/products/coupons/${data.coupon_id}`)}>
|
|
321
|
+
{data.coupon_id}
|
|
322
|
+
</Button>
|
|
323
|
+
}
|
|
324
|
+
/>
|
|
325
|
+
<InfoRow
|
|
326
|
+
label={t('admin.promotionCode.maxRedemptions')}
|
|
327
|
+
value={data.max_redemptions || t('admin.promotionCode.unlimited')}
|
|
328
|
+
/>
|
|
329
|
+
<InfoRow label={t('admin.promotionCode.timesRedeemed')} value={data.times_redeemed || 0} />
|
|
330
|
+
<InfoRow
|
|
331
|
+
label={t('admin.promotionCode.expires')}
|
|
332
|
+
value={data.expires_at ? formatTime(data.expires_at * 1000) : t('admin.promotionCode.never')}
|
|
333
|
+
/>
|
|
334
|
+
<InfoRow label={t('admin.promotionCode.updated')} value={formatTime(data.updated_at)} />
|
|
335
|
+
</InfoRowGroup>
|
|
336
|
+
</Box>
|
|
337
|
+
|
|
338
|
+
<Divider />
|
|
339
|
+
<Box className="section">
|
|
340
|
+
<SectionHeader title={t('admin.promotionCode.verificationConfig')} />
|
|
341
|
+
<Box className="section-body">
|
|
342
|
+
<VerificationConfig data={data} />
|
|
343
|
+
</Box>
|
|
344
|
+
</Box>
|
|
345
|
+
|
|
346
|
+
{data.restrictions?.currency_options && Object.keys(data.restrictions.currency_options).length > 0 && (
|
|
347
|
+
<>
|
|
348
|
+
<Divider />
|
|
349
|
+
<Box className="section">
|
|
350
|
+
<SectionHeader title={t('admin.coupon.currencies')} />
|
|
351
|
+
<Box className="section-body">
|
|
352
|
+
<CurrencyRestrictions currencyOptions={data.restrictions.currency_options} type="promotion_code" />
|
|
353
|
+
</Box>
|
|
354
|
+
</Box>
|
|
355
|
+
</>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<Divider />
|
|
359
|
+
<Box className="section">
|
|
360
|
+
<SectionHeader title={t('admin.coupon.activeRedemptions')} />
|
|
361
|
+
<Box className="section-body">
|
|
362
|
+
<ActiveRedemptions promotionCodeId={data.id} />
|
|
363
|
+
</Box>
|
|
364
|
+
</Box>
|
|
365
|
+
|
|
366
|
+
<Divider />
|
|
367
|
+
<Box className="section">
|
|
368
|
+
<SectionHeader title={t('common.events')} />
|
|
369
|
+
<Box className="section-body">
|
|
370
|
+
<EventList features={{ toolbar: false }} object_id={data.id} />
|
|
371
|
+
</Box>
|
|
372
|
+
</Box>
|
|
373
|
+
</Box>
|
|
374
|
+
{isMobile && <Divider />}
|
|
375
|
+
<Box className="promotion-code-column-2" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
|
|
376
|
+
<Box className="section">
|
|
377
|
+
<SectionHeader title={t('common.metadata.label')}>
|
|
378
|
+
<Button
|
|
379
|
+
variant="text"
|
|
380
|
+
color="inherit"
|
|
381
|
+
size="small"
|
|
382
|
+
sx={{ color: 'text.link' }}
|
|
383
|
+
disabled={state.editing.metadata}
|
|
384
|
+
onClick={handleEditMetadata}>
|
|
385
|
+
{t('common.edit')}
|
|
386
|
+
</Button>
|
|
387
|
+
</SectionHeader>
|
|
388
|
+
<Box className="section-body">
|
|
389
|
+
<MetadataList data={data.metadata} handleEditMetadata={handleEditMetadata} />
|
|
390
|
+
{state.editing.metadata && (
|
|
391
|
+
<MetadataEditor
|
|
392
|
+
data={data}
|
|
393
|
+
loading={state.loading.metadata}
|
|
394
|
+
onSave={onUpdateMetadata}
|
|
395
|
+
onCancel={() => setState((prev) => ({ editing: { ...prev.editing, metadata: false } }))}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
</Box>
|
|
399
|
+
</Box>
|
|
400
|
+
</Box>
|
|
401
|
+
</Stack>
|
|
402
|
+
|
|
403
|
+
<PromotionCodeModal
|
|
404
|
+
open={state.editModalOpen}
|
|
405
|
+
onClose={handleEditModalClose}
|
|
406
|
+
onSubmit={handleEditModalSubmit}
|
|
407
|
+
couponId={data.coupon_id}
|
|
408
|
+
promotionCode={data}
|
|
409
|
+
isEditing
|
|
410
|
+
availableCurrencies={availableCurrencies}
|
|
411
|
+
/>
|
|
412
|
+
</Root>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const Root = styled(Stack)``;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/* eslint-disable react/no-unstable-nested-components */
|
|
2
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
|
+
import { api, formatTime, Table, Status } from '@blocklet/payment-react';
|
|
4
|
+
import { CircularProgress, Typography } from '@mui/material';
|
|
5
|
+
import { useEffect, useState } from 'react';
|
|
6
|
+
import { Link } from 'react-router-dom';
|
|
7
|
+
import useBus from 'use-bus';
|
|
8
|
+
import { useLocalStorageState, useRequest } from 'ahooks';
|
|
9
|
+
|
|
10
|
+
import PromotionCodeModal from './create';
|
|
11
|
+
import PromotionCodeActions from './actions';
|
|
12
|
+
import Copyable from '../../../../components/copyable';
|
|
13
|
+
|
|
14
|
+
const fetchPromotionCodes = (
|
|
15
|
+
couponId: string,
|
|
16
|
+
params: Record<string, any> = {}
|
|
17
|
+
): Promise<{ list: any[]; count: number }> => {
|
|
18
|
+
const search = new URLSearchParams();
|
|
19
|
+
search.set('coupon_id', couponId);
|
|
20
|
+
Object.keys(params).forEach((key) => {
|
|
21
|
+
const value = params[key];
|
|
22
|
+
// Skip empty strings and undefined values
|
|
23
|
+
if (value !== '' && value !== undefined && value !== null) {
|
|
24
|
+
// Handle object parameters like q
|
|
25
|
+
if (typeof value === 'object') {
|
|
26
|
+
// Only add if object has meaningful content
|
|
27
|
+
if (Object.keys(value).length > 0) {
|
|
28
|
+
search.set(key, JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
search.set(key, String(value));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return api.get(`/api/promotion-codes?${search.toString()}`).then((res) => res.data);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type SearchProps = {
|
|
39
|
+
active?: boolean;
|
|
40
|
+
pageSize: number;
|
|
41
|
+
page: number;
|
|
42
|
+
q?: any;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface Props {
|
|
46
|
+
couponId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function PromotionCodesList({ couponId }: Props) {
|
|
50
|
+
const { t } = useLocaleContext();
|
|
51
|
+
const [editModalOpen, setEditModalOpen] = useState(false);
|
|
52
|
+
const [selectedCode, setSelectedCode] = useState<any>(null);
|
|
53
|
+
const [search, setSearch] = useLocalStorageState<SearchProps>(`promotion-codes-${couponId}`, {
|
|
54
|
+
defaultValue: {
|
|
55
|
+
pageSize: 20,
|
|
56
|
+
page: 1,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const {
|
|
60
|
+
data = { list: [], count: 0 },
|
|
61
|
+
refresh,
|
|
62
|
+
loading,
|
|
63
|
+
} = useRequest(() => fetchPromotionCodes(couponId, search), {
|
|
64
|
+
refreshDeps: [search, couponId],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
useBus('promotion-code.created', () => refresh(), []);
|
|
68
|
+
useBus('promotion-code.updated', () => refresh(), []);
|
|
69
|
+
useBus('promotion-code.deleted', () => refresh(), []);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
refresh();
|
|
73
|
+
}, [search, couponId]);
|
|
74
|
+
|
|
75
|
+
const handleActionsChange = (action: string, code?: any) => {
|
|
76
|
+
if (action === 'edit' && code) {
|
|
77
|
+
setSelectedCode(code);
|
|
78
|
+
setEditModalOpen(true);
|
|
79
|
+
} else {
|
|
80
|
+
refresh();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleEditModalClose = () => {
|
|
85
|
+
setEditModalOpen(false);
|
|
86
|
+
setSelectedCode(null);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const promotionCodeList = data?.list || [];
|
|
90
|
+
if (loading) {
|
|
91
|
+
return <CircularProgress />;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const columns = [
|
|
95
|
+
{
|
|
96
|
+
label: t('admin.promotionCode.code'),
|
|
97
|
+
name: 'code',
|
|
98
|
+
options: {
|
|
99
|
+
filter: true,
|
|
100
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
101
|
+
const item = promotionCodeList?.[index];
|
|
102
|
+
return (
|
|
103
|
+
<Copyable text={item.code}>
|
|
104
|
+
<Link to={`/admin/products/coupons/promotion-codes/${item.id}`}>
|
|
105
|
+
<Typography sx={{ color: 'text.primary', fontWeight: 500, mr: 1 }}>{item.code}</Typography>
|
|
106
|
+
</Link>
|
|
107
|
+
</Copyable>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
label: t('admin.promotionCode.status'),
|
|
114
|
+
name: 'active',
|
|
115
|
+
options: {
|
|
116
|
+
filter: true,
|
|
117
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
118
|
+
const item = promotionCodeList?.[index];
|
|
119
|
+
const isExpired = item.expires_at && Math.floor(Date.now() / 1000) > item.expires_at;
|
|
120
|
+
const isMaxedOut = item.max_redemptions && item.times_redeemed >= item.max_redemptions;
|
|
121
|
+
|
|
122
|
+
let status = t('admin.promotionCode.active');
|
|
123
|
+
let color: 'success' | 'warning' | 'default' = 'success';
|
|
124
|
+
|
|
125
|
+
if (!item.active) {
|
|
126
|
+
status = t('admin.promotionCode.inactive');
|
|
127
|
+
color = 'default';
|
|
128
|
+
}
|
|
129
|
+
if (isExpired) {
|
|
130
|
+
status = t('admin.promotionCode.expired');
|
|
131
|
+
color = 'warning';
|
|
132
|
+
} else if (isMaxedOut) {
|
|
133
|
+
status = t('admin.promotionCode.maxedOut');
|
|
134
|
+
color = 'warning';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return <Status label={status} color={color} />;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
label: t('admin.promotionCode.redemptions'),
|
|
143
|
+
name: 'redemptions',
|
|
144
|
+
options: {
|
|
145
|
+
filter: false,
|
|
146
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
147
|
+
const item = promotionCodeList?.[index];
|
|
148
|
+
const maxRedemptions = item.max_redemptions || t('admin.coupon.unlimited');
|
|
149
|
+
const timesRedeemed = item.times_redeemed || 0;
|
|
150
|
+
return `${timesRedeemed} / ${maxRedemptions}`;
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: t('admin.promotionCode.expires'),
|
|
156
|
+
name: 'expires_at',
|
|
157
|
+
options: {
|
|
158
|
+
filter: false,
|
|
159
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
160
|
+
const item = promotionCodeList?.[index];
|
|
161
|
+
if (item.expires_at) {
|
|
162
|
+
return formatTime(item.expires_at * 1000);
|
|
163
|
+
}
|
|
164
|
+
return '—';
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
label: t('admin.promotionCode.created'),
|
|
170
|
+
name: 'created_at',
|
|
171
|
+
options: {
|
|
172
|
+
filter: false,
|
|
173
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
174
|
+
const item = promotionCodeList?.[index];
|
|
175
|
+
return formatTime(item.created_at);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
label: t('admin.promotionCode.actions'),
|
|
181
|
+
name: 'actions',
|
|
182
|
+
options: {
|
|
183
|
+
filter: false,
|
|
184
|
+
sort: false,
|
|
185
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
186
|
+
const item = promotionCodeList?.[index];
|
|
187
|
+
return <PromotionCodeActions data={item} onChange={handleActionsChange} variant="compact" />;
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const onTableChange = ({ page, rowsPerPage }: any) => {
|
|
194
|
+
if (search!.pageSize !== rowsPerPage) {
|
|
195
|
+
setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
|
|
196
|
+
} else if (search!.page !== page + 1) {
|
|
197
|
+
setSearch((x: any) => ({ ...x, page: page + 1 }));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
<Table
|
|
204
|
+
hasRowLink={false}
|
|
205
|
+
toolbar={false}
|
|
206
|
+
footer={false}
|
|
207
|
+
data={promotionCodeList}
|
|
208
|
+
columns={columns}
|
|
209
|
+
options={{
|
|
210
|
+
count: data?.count,
|
|
211
|
+
page: search!.page - 1,
|
|
212
|
+
rowsPerPage: search!.pageSize,
|
|
213
|
+
onSearchChange: (text: string) => {
|
|
214
|
+
if (text) {
|
|
215
|
+
setSearch({
|
|
216
|
+
...search!,
|
|
217
|
+
q: {
|
|
218
|
+
'like-code': text,
|
|
219
|
+
},
|
|
220
|
+
pageSize: 100,
|
|
221
|
+
page: 1,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
setSearch({
|
|
225
|
+
...search!,
|
|
226
|
+
pageSize: 100,
|
|
227
|
+
page: 1,
|
|
228
|
+
q: undefined,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
}}
|
|
233
|
+
loading={loading}
|
|
234
|
+
onChange={onTableChange}
|
|
235
|
+
/>
|
|
236
|
+
|
|
237
|
+
<PromotionCodeModal
|
|
238
|
+
open={editModalOpen}
|
|
239
|
+
onClose={handleEditModalClose}
|
|
240
|
+
onSubmit={refresh}
|
|
241
|
+
couponId={couponId}
|
|
242
|
+
promotionCode={selectedCode}
|
|
243
|
+
isEditing
|
|
244
|
+
/>
|
|
245
|
+
</>
|
|
246
|
+
);
|
|
247
|
+
}
|