payment-kit 1.20.11 → 1.20.12

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 (80) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  3. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  4. package/api/src/integrations/stripe/resource.ts +253 -2
  5. package/api/src/libs/currency.ts +31 -0
  6. package/api/src/libs/discount/coupon.ts +1061 -0
  7. package/api/src/libs/discount/discount.ts +349 -0
  8. package/api/src/libs/discount/nft.ts +239 -0
  9. package/api/src/libs/discount/redemption.ts +636 -0
  10. package/api/src/libs/discount/vc.ts +73 -0
  11. package/api/src/libs/invoice.ts +44 -10
  12. package/api/src/libs/math-utils.ts +6 -0
  13. package/api/src/libs/price.ts +43 -0
  14. package/api/src/libs/session.ts +242 -57
  15. package/api/src/libs/subscription.ts +2 -6
  16. package/api/src/queues/auto-recharge.ts +1 -1
  17. package/api/src/queues/discount-status.ts +200 -0
  18. package/api/src/queues/subscription.ts +98 -5
  19. package/api/src/queues/usage-record.ts +1 -1
  20. package/api/src/routes/auto-recharge-configs.ts +5 -3
  21. package/api/src/routes/checkout-sessions.ts +755 -64
  22. package/api/src/routes/connect/change-payment.ts +6 -1
  23. package/api/src/routes/connect/change-plan.ts +6 -1
  24. package/api/src/routes/connect/setup.ts +6 -1
  25. package/api/src/routes/connect/shared.ts +80 -9
  26. package/api/src/routes/connect/subscribe.ts +12 -2
  27. package/api/src/routes/coupons.ts +518 -0
  28. package/api/src/routes/index.ts +4 -0
  29. package/api/src/routes/invoices.ts +44 -3
  30. package/api/src/routes/meter-events.ts +2 -1
  31. package/api/src/routes/payment-currencies.ts +1 -0
  32. package/api/src/routes/promotion-codes.ts +482 -0
  33. package/api/src/routes/subscriptions.ts +23 -2
  34. package/api/src/store/migrations/20250904-discount.ts +136 -0
  35. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  36. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  37. package/api/src/store/models/checkout-session.ts +12 -0
  38. package/api/src/store/models/coupon.ts +144 -4
  39. package/api/src/store/models/discount.ts +23 -10
  40. package/api/src/store/models/index.ts +13 -2
  41. package/api/src/store/models/promotion-code.ts +295 -18
  42. package/api/src/store/models/types.ts +30 -1
  43. package/api/tests/libs/session.spec.ts +48 -27
  44. package/blocklet.yml +1 -1
  45. package/package.json +20 -20
  46. package/src/app.tsx +2 -0
  47. package/src/components/customer/link.tsx +1 -1
  48. package/src/components/discount/discount-info.tsx +178 -0
  49. package/src/components/invoice/table.tsx +140 -48
  50. package/src/components/invoice-pdf/styles.ts +6 -0
  51. package/src/components/invoice-pdf/template.tsx +59 -33
  52. package/src/components/metadata/form.tsx +14 -5
  53. package/src/components/payment-link/actions.tsx +42 -0
  54. package/src/components/price/form.tsx +91 -65
  55. package/src/components/product/vendor-config.tsx +5 -3
  56. package/src/components/promotion/active-redemptions.tsx +534 -0
  57. package/src/components/promotion/currency-multi-select.tsx +350 -0
  58. package/src/components/promotion/currency-restrictions.tsx +117 -0
  59. package/src/components/promotion/product-select.tsx +292 -0
  60. package/src/components/promotion/promotion-code-form.tsx +534 -0
  61. package/src/components/subscription/portal/list.tsx +6 -1
  62. package/src/components/subscription/vendor-service-list.tsx +13 -2
  63. package/src/locales/en.tsx +227 -0
  64. package/src/locales/zh.tsx +222 -1
  65. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  66. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  67. package/src/pages/admin/products/coupons/create.tsx +612 -0
  68. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  69. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  70. package/src/pages/admin/products/coupons/index.tsx +210 -3
  71. package/src/pages/admin/products/index.tsx +22 -3
  72. package/src/pages/admin/products/products/detail.tsx +12 -2
  73. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  74. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  75. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  76. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  77. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  78. package/src/pages/admin/products/vendors/index.tsx +17 -5
  79. package/src/pages/customer/subscription/detail.tsx +5 -0
  80. package/vite.config.ts +4 -3
@@ -0,0 +1,538 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import {
4
+ api,
5
+ findCurrency,
6
+ formatError,
7
+ formatTime,
8
+ useMobile,
9
+ usePaymentContext,
10
+ Status,
11
+ ConfirmDialog,
12
+ formatAmount,
13
+ } from '@blocklet/payment-react';
14
+ import { ArrowBackOutlined, AddOutlined } from '@mui/icons-material';
15
+ import { Alert, AlertTitle, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
16
+ import { styled } from '@mui/system';
17
+ import { useRequest, useSetState } from 'ahooks';
18
+ import { useNavigate } from 'react-router-dom';
19
+ import { useState } from 'react';
20
+ import { dispatch } from 'use-bus';
21
+
22
+ import Copyable from '../../../../components/copyable';
23
+ import EventList from '../../../../components/event/list';
24
+ import InfoMetric from '../../../../components/info-metric';
25
+ import InfoRow from '../../../../components/info-row';
26
+ import InfoRowGroup from '../../../../components/info-row-group';
27
+ import MetadataEditor from '../../../../components/metadata/editor';
28
+ import MetadataList from '../../../../components/metadata/list';
29
+ import SectionHeader from '../../../../components/section/header';
30
+ import { goBackOrFallback } from '../../../../libs/util';
31
+ import PromotionCodesList from '../promotion-codes/list';
32
+ import PromotionCodeModal from '../promotion-codes/create';
33
+ import CouponRenameModal from './edit';
34
+ import ActiveRedemptions from '../../../../components/promotion/active-redemptions';
35
+ import ApplicableProductsList from './applicable-products';
36
+ import CurrencyRestrictions from '../../../../components/promotion/currency-restrictions';
37
+
38
+ const getCoupon = (id: string): Promise<any> => {
39
+ return api.get(`/api/coupons/${id}`).then((res) => res.data);
40
+ };
41
+
42
+ export default function CouponDetail(props: { id: string }) {
43
+ const { t } = useLocaleContext();
44
+ const { isMobile } = useMobile();
45
+ const navigate = useNavigate();
46
+ const { settings, setLivemode } = usePaymentContext();
47
+ const [promotionCodeModalOpen, setPromotionCodeModalOpen] = useState(false);
48
+ const [renameModalOpen, setRenameModalOpen] = useState(false);
49
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
50
+ const [state, setState] = useSetState({
51
+ editing: {
52
+ metadata: false,
53
+ },
54
+ loading: {
55
+ metadata: false,
56
+ coupon: false,
57
+ delete: false,
58
+ },
59
+ });
60
+
61
+ const { loading, error, data, runAsync } = useRequest(() => getCoupon(props.id), {
62
+ onSuccess: (res) => {
63
+ setLivemode(!!res.livemode);
64
+ },
65
+ });
66
+
67
+ if (error) {
68
+ return <Alert severity="error">{error.message}</Alert>;
69
+ }
70
+
71
+ if (loading || !data) {
72
+ return <CircularProgress />;
73
+ }
74
+
75
+ const createCouponUpdater = (key: string) => async (updates: any) => {
76
+ try {
77
+ setState((prev) => ({ loading: { ...prev.loading, [key]: true } }));
78
+ await api.put(`/api/coupons/${props.id}`, updates);
79
+ Toast.success(t('common.saved'));
80
+ dispatch('coupon.updated');
81
+ runAsync();
82
+ } catch (err) {
83
+ console.error(err);
84
+ Toast.error(formatError(err));
85
+ } finally {
86
+ setState((prev) => ({ loading: { ...prev.loading, [key]: false } }));
87
+ }
88
+ };
89
+
90
+ const onUpdateMetadata = async (updates: any) => {
91
+ try {
92
+ setState((prev) => ({ loading: { ...prev.loading, metadata: true } }));
93
+ await api.put(`/api/coupons/${props.id}`, { metadata: updates.metadata });
94
+ Toast.success(t('common.saved'));
95
+ dispatch('coupon.updated');
96
+ runAsync();
97
+ } catch (err) {
98
+ console.error(err);
99
+ Toast.error(formatError(err));
100
+ } finally {
101
+ setState((prev) => ({ loading: { ...prev.loading, metadata: false } }));
102
+ }
103
+ };
104
+
105
+ const handleUpdateCoupon = async (updates: { name: string; description?: string }) => {
106
+ await createCouponUpdater('coupon')(updates);
107
+ };
108
+
109
+ const handleDeleteCoupon = async () => {
110
+ try {
111
+ setState((prev) => ({ loading: { ...prev.loading, delete: true } }));
112
+ await api.delete(`/api/coupons/${props.id}`);
113
+ Toast.success(t('admin.coupon.deletedSuccessfully'));
114
+ dispatch('coupon.deleted');
115
+ navigate('/admin/products/coupons');
116
+ } catch (err) {
117
+ console.error(err);
118
+ Toast.error(formatError(err));
119
+ } finally {
120
+ setState((prev) => ({ loading: { ...prev.loading, delete: false } }));
121
+ setDeleteConfirmOpen(false);
122
+ }
123
+ };
124
+
125
+ const handleDeleteClick = () => {
126
+ setDeleteConfirmOpen(true);
127
+ };
128
+
129
+ const canDelete = !data?.locked && (!data?.promotion_codes || data.promotion_codes.length === 0);
130
+
131
+ const handleEditMetadata = () => {
132
+ setState((prev) => ({ editing: { ...prev.editing, metadata: true } }));
133
+ };
134
+
135
+ const formatCouponTerms = (coupon: any) => {
136
+ let couponOff = '';
137
+ if (coupon.percent_off && coupon.percent_off > 0) {
138
+ couponOff = `${coupon.percent_off}%`;
139
+ }
140
+ if (coupon.amount_off && coupon.amount_off !== '0') {
141
+ const currency = findCurrency(settings.paymentMethods, coupon.currency_id) || settings.baseCurrency;
142
+ couponOff = `${formatAmount(coupon.amount_off, currency.decimal)} ${currency.symbol}`;
143
+ }
144
+ if (couponOff) {
145
+ return t(`admin.coupon.couponTerms.${coupon.duration}`, { couponOff, months: coupon.duration_in_months });
146
+ }
147
+ return t('admin.coupon.noDiscount');
148
+ };
149
+
150
+ const formatCouponDiscount = () => {
151
+ if (data.percent_off && data.percent_off > 0) {
152
+ return t('admin.coupon.couponTermsPercentage', { percent: data.percent_off });
153
+ }
154
+ if (data.amount_off && data.amount_off !== '0') {
155
+ const currency = findCurrency(settings.paymentMethods, data.currency_id) || settings.baseCurrency;
156
+ return t('admin.coupon.couponTermsFixedAmount', {
157
+ amount: formatAmount(data.amount_off, currency.decimal),
158
+ symbol: currency.symbol,
159
+ });
160
+ }
161
+ return t('admin.coupon.noDiscount');
162
+ };
163
+
164
+ const formatDuration = () => {
165
+ if (data.duration === 'repeating' && data.duration_in_months) {
166
+ return t(`admin.coupon.couponTermsDuration.${data.duration}`, { months: data.duration_in_months });
167
+ }
168
+ return t(`admin.coupon.couponTermsDuration.${data.duration}`);
169
+ };
170
+
171
+ const formatRedemptions = () => {
172
+ const maxRedemptions = data.max_redemptions ? data.max_redemptions.toString() : t('admin.coupon.unlimited');
173
+ const timesRedeemed = data.times_redeemed || 0;
174
+ return `${timesRedeemed} / ${maxRedemptions}`;
175
+ };
176
+
177
+ // Get available currencies based on coupon configuration
178
+ const getAvailableCurrencies = () => {
179
+ const allCurrencies = settings.paymentMethods?.flatMap((pm) => pm.payment_currencies) || [];
180
+
181
+ if (data.percent_off) {
182
+ // Percentage coupon: return all available currencies
183
+ return allCurrencies;
184
+ }
185
+ // Fixed amount coupon: return coupon's configured currencies
186
+ const currencyIds = new Set<string>();
187
+
188
+ // Add base currency
189
+ if (data.currency_id) {
190
+ currencyIds.add(data.currency_id);
191
+ }
192
+
193
+ // Add additional configured currencies
194
+ if (data.currency_options && typeof data.currency_options === 'object') {
195
+ Object.keys(data.currency_options).forEach((currencyId) => {
196
+ currencyIds.add(currencyId);
197
+ });
198
+ }
199
+
200
+ return allCurrencies.filter((currency) => currencyIds.has(currency.id));
201
+ };
202
+
203
+ const availableCurrencies = getAvailableCurrencies();
204
+
205
+ return (
206
+ <Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
207
+ <Box>
208
+ {!data.valid && (
209
+ <Alert severity="warning">
210
+ <AlertTitle>{t('admin.coupon.inactive')}</AlertTitle>
211
+ {t('admin.coupon.inactiveTip')}
212
+ </Alert>
213
+ )}
214
+ <Stack
215
+ className="page-header"
216
+ direction="row"
217
+ sx={{
218
+ justifyContent: 'space-between',
219
+ alignItems: 'center',
220
+ }}>
221
+ <Stack
222
+ direction="row"
223
+ onClick={() => goBackOrFallback('/admin/products/coupons')}
224
+ sx={{
225
+ alignItems: 'center',
226
+ fontWeight: 'normal',
227
+ cursor: 'pointer',
228
+ }}>
229
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
230
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
231
+ {t('admin.coupons')}
232
+ </Typography>
233
+ </Stack>
234
+ <Stack direction="row" spacing={2}>
235
+ <Button variant="contained" color="primary" onClick={() => setRenameModalOpen(true)}>
236
+ {t('common.edit')}
237
+ </Button>
238
+ {canDelete && (
239
+ <Button
240
+ variant="outlined"
241
+ color="error"
242
+ onClick={handleDeleteClick}
243
+ disabled={state.loading.delete}
244
+ title={
245
+ !canDelete && data?.promotion_codes?.length > 0
246
+ ? t('admin.coupon.cannotDeleteWithPromotionCodes')
247
+ : undefined
248
+ }>
249
+ {state.loading.delete ? t('common.deleting') : t('common.delete')}
250
+ </Button>
251
+ )}
252
+ </Stack>
253
+ </Stack>
254
+ <Box
255
+ sx={{
256
+ mt: 4,
257
+ mb: 3,
258
+ display: 'flex',
259
+ gap: {
260
+ xs: 2,
261
+ sm: 2,
262
+ md: 5,
263
+ },
264
+ flexWrap: 'wrap',
265
+ flexDirection: {
266
+ xs: 'column',
267
+ sm: 'column',
268
+ md: 'row',
269
+ },
270
+ alignItems: {
271
+ xs: 'flex-start',
272
+ sm: 'flex-start',
273
+ md: 'center',
274
+ },
275
+ }}>
276
+ <Stack
277
+ direction="row"
278
+ sx={{
279
+ justifyContent: 'space-between',
280
+ alignItems: 'center',
281
+ }}>
282
+ <Stack
283
+ direction="column"
284
+ sx={{
285
+ alignItems: 'flex-start',
286
+ justifyContent: 'space-around',
287
+ }}>
288
+ <Typography
289
+ variant="h2"
290
+ sx={{
291
+ color: 'text.primary',
292
+ }}>
293
+ {data.name}
294
+ </Typography>
295
+ <Typography
296
+ variant="subtitle1"
297
+ sx={{
298
+ color: 'text.lighter',
299
+ }}>
300
+ {formatCouponTerms(data)}
301
+ </Typography>
302
+ </Stack>
303
+ </Stack>
304
+ <Stack
305
+ className="section-body"
306
+ sx={{
307
+ justifyContent: 'flex-start',
308
+ flexWrap: 'wrap',
309
+ 'hr.MuiDivider-root:last-child': {
310
+ display: 'none',
311
+ },
312
+ flexDirection: {
313
+ xs: 'column',
314
+ sm: 'column',
315
+ md: 'row',
316
+ },
317
+ alignItems: 'flex-start',
318
+ gap: {
319
+ xs: 1,
320
+ sm: 1,
321
+ md: 3,
322
+ },
323
+ }}>
324
+ <InfoMetric
325
+ label={t('admin.coupon.id')}
326
+ value={<Copyable text={props.id} style={{ marginLeft: 4 }} />}
327
+ divider
328
+ />
329
+ <InfoMetric label={t('admin.coupon.redemptions')} value={formatRedemptions()} divider />
330
+ <InfoMetric
331
+ label={t('common.status')}
332
+ value={
333
+ <Status
334
+ label={data.valid ? t('common.active') : t('common.inactive')}
335
+ color={data.valid ? 'success' : 'default'}
336
+ />
337
+ }
338
+ divider
339
+ />
340
+ <InfoMetric label={t('common.created')} value={formatTime(data.created_at)} divider />
341
+ </Stack>
342
+ </Box>
343
+ <Divider />
344
+ </Box>
345
+ <Stack
346
+ sx={{
347
+ flexDirection: {
348
+ xs: 'column',
349
+ lg: 'row',
350
+ },
351
+ gap: {
352
+ xs: 2.5,
353
+ md: 4,
354
+ },
355
+ '.coupon-column-1': {
356
+ minWidth: {
357
+ xs: '100%',
358
+ lg: '600px',
359
+ },
360
+ },
361
+ '.coupon-column-2': {
362
+ width: {
363
+ xs: '100%',
364
+ md: '100%',
365
+ lg: '320px',
366
+ },
367
+ maxWidth: {
368
+ xs: '100%',
369
+ md: '33%',
370
+ },
371
+ },
372
+ }}>
373
+ <Box
374
+ className="coupon-column-1"
375
+ sx={{
376
+ flex: 1,
377
+ gap: 2.5,
378
+ display: 'flex',
379
+ flexDirection: 'column',
380
+ }}>
381
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
382
+ <SectionHeader title={t('common.details')} />
383
+ <InfoRowGroup
384
+ sx={{
385
+ display: 'grid',
386
+ gridTemplateColumns: {
387
+ xs: 'repeat(1, 1fr)',
388
+ xl: 'repeat(2, 1fr)',
389
+ },
390
+ '@container (min-width: 1000px)': {
391
+ gridTemplateColumns: 'repeat(2, 1fr)',
392
+ },
393
+ '.info-row-wrapper': {
394
+ gap: 1,
395
+ flexDirection: {
396
+ xs: 'column',
397
+ xl: 'row',
398
+ },
399
+ alignItems: {
400
+ xs: 'flex-start',
401
+ xl: 'center',
402
+ },
403
+ '@container (min-width: 1000px)': {
404
+ flexDirection: 'row',
405
+ alignItems: 'center',
406
+ },
407
+ },
408
+ }}>
409
+ <InfoRow label={t('admin.coupon.name')} value={data.name} />
410
+ {data.description && <InfoRow label={t('admin.coupon.desc')} value={data.description} />}
411
+ <InfoRow label={t('admin.coupon.discount')} value={formatCouponDiscount()} />
412
+ <InfoRow label={t('admin.coupon.duration')} value={formatDuration()} />
413
+ <InfoRow
414
+ label={t('admin.coupon.maxRedemptions')}
415
+ value={data.max_redemptions || t('admin.coupon.unlimited')}
416
+ />
417
+ <InfoRow label={t('admin.coupon.timesRedeemed')} value={data.times_redeemed || 0} />
418
+ <InfoRow
419
+ label={t('admin.coupon.expires')}
420
+ value={data.redeem_by ? formatTime(data.redeem_by * 1000) : t('common.never')}
421
+ />
422
+ <InfoRow label={t('admin.coupon.updated')} value={formatTime(data.updated_at)} />
423
+ </InfoRowGroup>
424
+ </Box>
425
+ <Divider />
426
+ {data.currency_options && Object.keys(data.currency_options).length > 0 && (
427
+ <>
428
+ <Box className="section">
429
+ <SectionHeader title={t('admin.coupon.currencies')} />
430
+ <Box className="section-body">
431
+ <CurrencyRestrictions currencyOptions={data.currency_options} type="coupon" />
432
+ </Box>
433
+ </Box>
434
+ <Divider />
435
+ </>
436
+ )}
437
+ {data.applied_products && data.applied_products.length > 0 && (
438
+ <>
439
+ <Box className="section">
440
+ <SectionHeader title={t('admin.coupon.applicableProducts')} />
441
+ <Box className="section-body">
442
+ <ApplicableProductsList products={data.applied_products} />
443
+ </Box>
444
+ </Box>
445
+ <Divider />
446
+ </>
447
+ )}
448
+ <Box className="section">
449
+ <SectionHeader title={t('admin.coupon.promotionCodes')}>
450
+ <Button
451
+ variant="text"
452
+ color="inherit"
453
+ size="small"
454
+ startIcon={<AddOutlined />}
455
+ sx={{ color: 'text.link' }}
456
+ onClick={() => setPromotionCodeModalOpen(true)}>
457
+ {t('admin.coupon.addPromotionCode')}
458
+ </Button>
459
+ </SectionHeader>
460
+ <Box className="section-body">
461
+ <PromotionCodesList couponId={props.id} />
462
+ </Box>
463
+ </Box>
464
+ <Divider />
465
+ <Box className="section">
466
+ <SectionHeader title={t('admin.coupon.activeRedemptions')} />
467
+ <Box className="section-body">
468
+ <ActiveRedemptions couponId={props.id} />
469
+ </Box>
470
+ </Box>
471
+ <Divider />
472
+ <Box className="section">
473
+ <SectionHeader title={t('common.events')} />
474
+ <Box className="section-body">
475
+ <EventList features={{ toolbar: false }} object_id={data.id} />
476
+ </Box>
477
+ </Box>
478
+ </Box>
479
+ {isMobile && <Divider />}
480
+ <Box className="coupon-column-2" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
481
+ <Box className="section">
482
+ <SectionHeader title={t('common.metadata.label')}>
483
+ <Button
484
+ variant="text"
485
+ color="inherit"
486
+ size="small"
487
+ sx={{ color: 'text.link' }}
488
+ disabled={state.editing.metadata}
489
+ onClick={handleEditMetadata}>
490
+ {t('common.edit')}
491
+ </Button>
492
+ </SectionHeader>
493
+ <Box className="section-body">
494
+ <MetadataList data={data.metadata} handleEditMetadata={handleEditMetadata} />
495
+ {state.editing.metadata && (
496
+ <MetadataEditor
497
+ data={data}
498
+ loading={state.loading.metadata}
499
+ onSave={onUpdateMetadata}
500
+ onCancel={() => setState((prev) => ({ editing: { ...prev.editing, metadata: false } }))}
501
+ />
502
+ )}
503
+ </Box>
504
+ </Box>
505
+ </Box>
506
+ </Stack>
507
+
508
+ <PromotionCodeModal
509
+ open={promotionCodeModalOpen}
510
+ onClose={() => setPromotionCodeModalOpen(false)}
511
+ onSubmit={() => setPromotionCodeModalOpen(false)}
512
+ couponId={props.id}
513
+ availableCurrencies={availableCurrencies}
514
+ />
515
+
516
+ <CouponRenameModal
517
+ open={renameModalOpen}
518
+ onClose={() => setRenameModalOpen(false)}
519
+ onSave={handleUpdateCoupon}
520
+ initialName={data.name}
521
+ initialDescription={data.description || ''}
522
+ loading={state.loading.coupon}
523
+ />
524
+
525
+ {deleteConfirmOpen && (
526
+ <ConfirmDialog
527
+ onConfirm={handleDeleteCoupon}
528
+ onCancel={() => setDeleteConfirmOpen(false)}
529
+ title={t('admin.coupon.deleteConfirmTitle')}
530
+ message={t('admin.coupon.deleteConfirmMessage')}
531
+ loading={state.loading.delete}
532
+ />
533
+ )}
534
+ </Root>
535
+ );
536
+ }
537
+
538
+ const Root = styled(Stack)``;
@@ -0,0 +1,127 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { Box, Button, Stack, TextField, Typography } from '@mui/material';
3
+ import { useState, useEffect } from 'react';
4
+ import Dialog from '@arcblock/ux/lib/Dialog';
5
+
6
+ interface Props {
7
+ open: boolean;
8
+ onClose: () => void;
9
+ onSave: (updates: { name: string; description?: string }) => Promise<void>;
10
+ initialName: string;
11
+ initialDescription?: string;
12
+ loading?: boolean;
13
+ }
14
+
15
+ export default function CouponRenameModal({
16
+ open,
17
+ onClose,
18
+ onSave,
19
+ initialName,
20
+ initialDescription = '',
21
+ loading = false,
22
+ }: Props) {
23
+ const { t } = useLocaleContext();
24
+ const [name, setName] = useState(initialName);
25
+ const [description, setDescription] = useState(initialDescription);
26
+
27
+ useEffect(() => {
28
+ if (open) {
29
+ setName(initialName);
30
+ setDescription(initialDescription);
31
+ }
32
+ }, [open, initialName, initialDescription]);
33
+
34
+ const handleSave = async () => {
35
+ if (name.trim()) {
36
+ await onSave({ name: name.trim(), description: description.trim() });
37
+ onClose();
38
+ }
39
+ };
40
+
41
+ const handleCancel = () => {
42
+ setName(initialName);
43
+ setDescription(initialDescription);
44
+ onClose();
45
+ };
46
+
47
+ return (
48
+ <Dialog
49
+ open={open}
50
+ onClose={handleCancel}
51
+ maxWidth="sm"
52
+ fullWidth
53
+ title={t('admin.coupon.updateCouponDetails')}
54
+ PaperProps={{
55
+ sx: {
56
+ borderRadius: 2,
57
+ },
58
+ }}
59
+ actions={
60
+ <Stack direction="row" spacing={2}>
61
+ <Button variant="outlined" onClick={handleCancel} disabled={loading}>
62
+ {t('common.cancel')}
63
+ </Button>
64
+ <Button
65
+ variant="contained"
66
+ onClick={handleSave}
67
+ disabled={
68
+ loading || !name.trim() || (name.trim() === initialName && description.trim() === initialDescription)
69
+ }>
70
+ {t('admin.coupon.updateCouponButton')}
71
+ </Button>
72
+ </Stack>
73
+ }>
74
+ <Box>
75
+ <Typography variant="h6" sx={{ mb: 2 }}>
76
+ {t('admin.coupon.name')}
77
+ </Typography>
78
+ <TextField
79
+ autoFocus
80
+ fullWidth
81
+ value={name}
82
+ onChange={(e) => setName(e.target.value)}
83
+ variant="outlined"
84
+ sx={{ mb: 1 }}
85
+ inputProps={{
86
+ maxLength: 64,
87
+ }}
88
+ />
89
+ <Typography
90
+ variant="body2"
91
+ sx={{
92
+ color: 'text.secondary',
93
+ mb: 3,
94
+ }}>
95
+ {t('admin.coupon.nameWillAppear')}
96
+ </Typography>
97
+
98
+ <Typography variant="h6" sx={{ mb: 2 }}>
99
+ {t('admin.coupon.desc')}
100
+ </Typography>
101
+ <TextField
102
+ fullWidth
103
+ value={description}
104
+ onChange={(e) => setDescription(e.target.value)}
105
+ variant="outlined"
106
+ multiline
107
+ minRows={2}
108
+ maxRows={4}
109
+ sx={{ mb: 1 }}
110
+ placeholder={t('admin.coupon.desc')}
111
+ slotProps={{
112
+ htmlInput: {
113
+ maxLength: 250,
114
+ },
115
+ }}
116
+ />
117
+ <Typography
118
+ variant="body2"
119
+ sx={{
120
+ color: 'text.secondary',
121
+ }}>
122
+ {t('admin.coupon.descriptionInternal')}
123
+ </Typography>
124
+ </Box>
125
+ </Dialog>
126
+ );
127
+ }