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
@@ -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
+ }