payment-kit 1.22.32 → 1.23.1

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 (49) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/integrations/arcblock/token.ts +599 -0
  3. package/api/src/libs/credit-grant.ts +7 -6
  4. package/api/src/libs/util.ts +34 -0
  5. package/api/src/queues/credit-consume.ts +29 -4
  6. package/api/src/queues/credit-grant.ts +245 -50
  7. package/api/src/queues/credit-reconciliation.ts +253 -0
  8. package/api/src/queues/refund.ts +263 -30
  9. package/api/src/queues/token-transfer.ts +331 -0
  10. package/api/src/routes/checkout-sessions.ts +94 -29
  11. package/api/src/routes/credit-grants.ts +35 -9
  12. package/api/src/routes/credit-tokens.ts +38 -0
  13. package/api/src/routes/credit-transactions.ts +20 -3
  14. package/api/src/routes/index.ts +2 -0
  15. package/api/src/routes/meter-events.ts +4 -0
  16. package/api/src/routes/meters.ts +32 -10
  17. package/api/src/routes/payment-currencies.ts +103 -0
  18. package/api/src/routes/payment-links.ts +3 -1
  19. package/api/src/routes/products.ts +2 -2
  20. package/api/src/routes/settings.ts +4 -3
  21. package/api/src/store/migrations/20251120-add-token-config-to-currencies.ts +20 -0
  22. package/api/src/store/migrations/20251204-add-chain-fields.ts +74 -0
  23. package/api/src/store/migrations/20251211-optimize-slow-queries.ts +33 -0
  24. package/api/src/store/models/credit-grant.ts +47 -9
  25. package/api/src/store/models/credit-transaction.ts +18 -1
  26. package/api/src/store/models/index.ts +2 -1
  27. package/api/src/store/models/payment-currency.ts +31 -4
  28. package/api/src/store/models/refund.ts +12 -2
  29. package/api/src/store/models/types.ts +48 -0
  30. package/api/src/store/sequelize.ts +1 -0
  31. package/api/third.d.ts +2 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +7 -6
  34. package/src/app.tsx +10 -0
  35. package/src/components/customer/credit-overview.tsx +19 -3
  36. package/src/components/meter/form.tsx +191 -18
  37. package/src/components/price/form.tsx +49 -37
  38. package/src/locales/en.tsx +25 -1
  39. package/src/locales/zh.tsx +27 -1
  40. package/src/pages/admin/billing/meters/create.tsx +42 -13
  41. package/src/pages/admin/billing/meters/detail.tsx +56 -5
  42. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +13 -0
  43. package/src/pages/admin/customers/customers/credit-transaction/detail.tsx +324 -0
  44. package/src/pages/admin/customers/index.tsx +5 -0
  45. package/src/pages/customer/credit-grant/detail.tsx +14 -1
  46. package/src/pages/customer/credit-transaction/detail.tsx +289 -0
  47. package/src/pages/customer/invoice/detail.tsx +1 -1
  48. package/src/pages/customer/recharge/subscription.tsx +1 -1
  49. package/src/pages/customer/subscription/detail.tsx +1 -1
@@ -5,15 +5,24 @@ import { api, formatError, usePaymentContext } from '@blocklet/payment-react';
5
5
  import { useForm, FormProvider } from 'react-hook-form';
6
6
  import Toast from '@arcblock/ux/lib/Toast';
7
7
  import { dispatch } from 'use-bus';
8
+ import { useLocalStorageState } from 'ahooks';
8
9
 
9
10
  import type { TMeter } from '@blocklet/payment-types';
10
- import MeterForm from '../../../../components/meter/form';
11
+ import MeterForm, { type TokenConfig } from '../../../../components/meter/form';
11
12
  import DrawerForm from '../../../../components/drawer-form';
12
13
 
14
+ type TMeterForm = TMeter & {
15
+ mode?: 'OffChain' | 'OnChain';
16
+ tokenFactoryAddress?: string;
17
+ };
18
+
13
19
  export default function MeterCreate() {
14
20
  const { t } = useLocaleContext();
15
21
  const { refresh } = usePaymentContext();
16
- const methods = useForm<TMeter>({
22
+ const [tokenConfig, setTokenConfig] = useLocalStorageState<TokenConfig>('payment_kit_token_config', {
23
+ defaultValue: { name: '', symbol: '', tokenFactoryAddress: '' },
24
+ });
25
+ const methods = useForm<TMeterForm>({
17
26
  defaultValues: {
18
27
  name: '',
19
28
  event_name: '',
@@ -21,22 +30,38 @@ export default function MeterCreate() {
21
30
  unit: '',
22
31
  description: '',
23
32
  metadata: {},
33
+ mode: 'OffChain',
24
34
  },
25
35
  });
26
36
 
27
- const { handleSubmit, reset, clearErrors } = methods;
28
-
29
- const onSubmit = async (data: TMeter) => {
37
+ const onSubmit = async (data: TMeterForm) => {
30
38
  try {
31
- await api.post('/api/meters', data);
39
+ const submitData: any = { ...data };
40
+ delete submitData.mode;
41
+
42
+ if (data.mode === 'OnChain') {
43
+ let tokenFactoryAddress = tokenConfig?.tokenFactoryAddress;
44
+ // Create new token if no existing token selected
45
+ if (!tokenFactoryAddress && tokenConfig?.symbol) {
46
+ const { data: factory } = await api.post('/api/credit-tokens', {
47
+ name: tokenConfig.name,
48
+ symbol: tokenConfig.symbol,
49
+ });
50
+ tokenFactoryAddress = factory.address;
51
+ }
52
+ if (tokenFactoryAddress) {
53
+ submitData.token = { tokenFactoryAddress };
54
+ }
55
+ }
56
+
57
+ await api.post('/api/meters', submitData);
32
58
  Toast.success(t('admin.meter.saved'));
33
- reset();
34
- clearErrors();
59
+ setTokenConfig({ name: '', symbol: '', tokenFactoryAddress: '' });
60
+ methods.reset();
35
61
  dispatch('meter.created');
36
62
  dispatch('drawer.submitted');
37
63
  refresh(true);
38
- } catch (err) {
39
- console.error(err);
64
+ } catch (err: any) {
40
65
  Toast.error(formatError(err));
41
66
  }
42
67
  };
@@ -45,15 +70,19 @@ export default function MeterCreate() {
45
70
  <DrawerForm
46
71
  icon={<AddOutlined />}
47
72
  text={t('admin.meter.add')}
48
- onClose={() => clearErrors()}
73
+ onClose={methods.clearErrors}
49
74
  width={640}
50
75
  addons={
51
- <Button variant="contained" size="small" onClick={handleSubmit(onSubmit)}>
76
+ <Button
77
+ variant="contained"
78
+ size="small"
79
+ disabled={methods.formState.isSubmitting}
80
+ onClick={methods.handleSubmit(onSubmit as any)}>
52
81
  {t('admin.meter.save')}
53
82
  </Button>
54
83
  }>
55
84
  <FormProvider {...methods}>
56
- <MeterForm />
85
+ <MeterForm tokenConfig={tokenConfig!} onTokenConfigChange={setTokenConfig} />
57
86
  </FormProvider>
58
87
  </DrawerForm>
59
88
  );
@@ -20,7 +20,7 @@ import SectionHeader from '../../../../components/section/header';
20
20
  import { goBackOrFallback } from '../../../../libs/util';
21
21
  import MeterEventsList from '../../../../components/meter/events-list';
22
22
  import MeterProducts from '../../../../components/meter/products';
23
- import MeterForm from '../../../../components/meter/form';
23
+ import MeterForm, { type TokenConfig } from '../../../../components/meter/form';
24
24
  import DrawerForm from '../../../../components/drawer-form';
25
25
 
26
26
  const getMeter = (id: string): Promise<TMeter & { paymentCurrency: TPaymentCurrency }> => {
@@ -58,9 +58,14 @@ export default function MeterDetail(props: { id: string }) {
58
58
  product: false,
59
59
  creditProduct: false,
60
60
  },
61
+ tokenConfig: { name: '', symbol: '', tokenFactoryAddress: '' } as TokenConfig,
61
62
  });
62
63
 
63
- const methods = useForm<TMeter>({
64
+ type TMeterForm = TMeter & {
65
+ mode?: 'OffChain' | 'OnChain';
66
+ };
67
+
68
+ const methods = useForm<TMeterForm>({
64
69
  defaultValues: {
65
70
  name: '',
66
71
  event_name: '',
@@ -68,6 +73,7 @@ export default function MeterDetail(props: { id: string }) {
68
73
  unit: '',
69
74
  description: '',
70
75
  metadata: {},
76
+ mode: 'OffChain',
71
77
  },
72
78
  });
73
79
 
@@ -108,6 +114,7 @@ export default function MeterDetail(props: { id: string }) {
108
114
  };
109
115
 
110
116
  const handleEditMeter = () => {
117
+ const hasTokenConfig = !!data.paymentCurrency?.token_config;
111
118
  // 设置表单默认值
112
119
  methods.reset({
113
120
  name: data.name,
@@ -116,14 +123,53 @@ export default function MeterDetail(props: { id: string }) {
116
123
  unit: data.unit,
117
124
  description: data.description || '',
118
125
  metadata: data.metadata || {},
126
+ mode: hasTokenConfig ? 'OnChain' : 'OffChain',
119
127
  });
120
- setState((prev) => ({ editing: { ...prev.editing, meter: true } }));
128
+ setState((prev) => ({
129
+ editing: { ...prev.editing, meter: true },
130
+ tokenConfig: { name: '', symbol: '', tokenFactoryAddress: '' },
131
+ }));
121
132
  };
122
133
 
123
- const onUpdateMeter = async (formData: TMeter) => {
134
+ const onUpdateMeter = async (formData: TMeterForm) => {
124
135
  try {
125
136
  setState((prev) => ({ loading: { ...prev.loading, meter: true } }));
137
+
138
+ const currencyId = data.paymentCurrency?.id;
139
+ const hasTokenConfig = !!data.paymentCurrency?.token_config;
140
+
141
+ // If enabling on-chain and no token_config yet
142
+ if (currencyId && !hasTokenConfig && formData.mode === 'OnChain') {
143
+ let { tokenFactoryAddress } = state.tokenConfig;
144
+
145
+ // Create new token if not using existing
146
+ if (!tokenFactoryAddress && state.tokenConfig.symbol) {
147
+ const { data: factory } = await api.post('/api/credit-tokens', {
148
+ name: state.tokenConfig.name,
149
+ symbol: state.tokenConfig.symbol,
150
+ });
151
+ tokenFactoryAddress = factory.address;
152
+ }
153
+
154
+ // Update currency with token_config
155
+ if (tokenFactoryAddress) {
156
+ await api.put(`/api/payment-currencies/${currencyId}/token-config`, {
157
+ token_factory_address: tokenFactoryAddress,
158
+ });
159
+ }
160
+ }
161
+
162
+ // If disabling on-chain and has token_config, remove it
163
+ if (currencyId && hasTokenConfig && formData.mode === 'OffChain') {
164
+ await api.delete(`/api/payment-currencies/${currencyId}/token-config`);
165
+ }
166
+
167
+ // Clean up frontend-only fields before sending
168
+ delete formData.mode;
169
+
170
+ // Update meter basic info
126
171
  await api.put(`/api/meters/${props.id}`, formData);
172
+
127
173
  Toast.success(t('admin.meter.saved'));
128
174
  setState((prev) => ({ editing: { ...prev.editing, meter: false } }));
129
175
  runAsync();
@@ -442,7 +488,12 @@ export default function MeterDetail(props: { id: string }) {
442
488
  </Button>
443
489
  }>
444
490
  <FormProvider {...methods}>
445
- <MeterForm mode="edit" />
491
+ <MeterForm
492
+ mode="edit"
493
+ paymentCurrency={data.paymentCurrency}
494
+ tokenConfig={state.tokenConfig}
495
+ onTokenConfigChange={(next) => setState({ tokenConfig: next })}
496
+ />
446
497
  </FormProvider>
447
498
  </DrawerForm>
448
499
  )}
@@ -9,6 +9,7 @@ import {
9
9
  CreditTransactionsList,
10
10
  CreditStatusChip,
11
11
  getCustomerAvatar,
12
+ TxLink,
12
13
  } from '@blocklet/payment-react';
13
14
  import type { TCreditGrantExpanded } from '@blocklet/payment-types';
14
15
  import { ArrowBackOutlined } from '@mui/icons-material';
@@ -325,6 +326,18 @@ export default function AdminCreditGrantDetail({ id }: { id: string }) {
325
326
  {data.expires_at && (
326
327
  <InfoRow label={t('common.expirationDate')} value={formatTime(data.expires_at * 1000)} />
327
328
  )}
329
+ {data.chain_detail?.mint?.hash && data.paymentMethod?.type === 'arcblock' && (
330
+ <InfoRow
331
+ label={t('common.mintTxHash')}
332
+ value={
333
+ <TxLink
334
+ details={{ arcblock: { tx_hash: data.chain_detail.mint.hash, payer: '' } }}
335
+ method={data.paymentMethod}
336
+ mode="dashboard"
337
+ />
338
+ }
339
+ />
340
+ )}
328
341
  </InfoRowGroup>
329
342
  </Box>
330
343
 
@@ -0,0 +1,324 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { api, formatBNStr, formatError, getCustomerAvatar, TxLink, SourceDataViewer } from '@blocklet/payment-react';
3
+ import type { TCreditTransactionExpanded } from '@blocklet/payment-types';
4
+ import { ArrowBackOutlined } from '@mui/icons-material';
5
+ import { Alert, Avatar, Box, Button, Chip, CircularProgress, Divider, Stack, Typography } from '@mui/material';
6
+ import { useRequest } from 'ahooks';
7
+ import { styled } from '@mui/system';
8
+ import { useCallback } from 'react';
9
+ import { useNavigate } from 'react-router-dom';
10
+
11
+ import InfoMetric from '../../../../../components/info-metric';
12
+ import InfoRow from '../../../../../components/info-row';
13
+ import InfoRowGroup from '../../../../../components/info-row-group';
14
+ import Copyable from '../../../../../components/copyable';
15
+ import SectionHeader from '../../../../../components/section/header';
16
+ import MetadataList from '../../../../../components/metadata/list';
17
+ import { goBackOrFallback } from '../../../../../libs/util';
18
+ import EventList from '../../../../../components/event/list';
19
+
20
+ const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
21
+ return api.get(`/api/credit-transactions/${id}`).then((res) => res.data);
22
+ };
23
+
24
+ export default function AdminCreditTransactionDetail({ id }: { id: string }) {
25
+ const { t } = useLocaleContext();
26
+ const navigate = useNavigate();
27
+
28
+ const { loading, error, data } = useRequest(() => fetchData(id));
29
+
30
+ const handleBack = useCallback(() => {
31
+ goBackOrFallback('/admin/customers');
32
+ }, []);
33
+
34
+ if (error) {
35
+ return <Alert severity="error">{formatError(error)}</Alert>;
36
+ }
37
+
38
+ if (loading || !data) {
39
+ return <CircularProgress />;
40
+ }
41
+
42
+ const getTransferStatusChip = (status: string | null | undefined) => {
43
+ if (!status) return null;
44
+
45
+ const statusConfig = {
46
+ pending: { label: t('common.pending'), color: 'warning' as const },
47
+ completed: { label: t('common.completed'), color: 'success' as const },
48
+ failed: { label: t('common.failed'), color: 'error' as const },
49
+ };
50
+
51
+ const config = statusConfig[status as keyof typeof statusConfig] || {
52
+ label: status,
53
+ color: 'default' as const,
54
+ };
55
+
56
+ return <Chip label={config.label} size="small" color={config.color} />;
57
+ };
58
+
59
+ return (
60
+ <Root direction="column" spacing={2.5} sx={{ mb: 4 }}>
61
+ <Box>
62
+ <Stack className="page-header" direction="row" justifyContent="space-between" alignItems="center">
63
+ <Stack
64
+ direction="row"
65
+ alignItems="center"
66
+ sx={{ fontWeight: 'normal', cursor: 'pointer' }}
67
+ onClick={handleBack}>
68
+ <ArrowBackOutlined fontSize="small" sx={{ mr: 0.5, color: 'text.secondary' }} />
69
+ <Typography variant="h6" sx={{ color: 'text.secondary', fontWeight: 'normal' }}>
70
+ {t('admin.creditTransactions.title')}
71
+ </Typography>
72
+ </Stack>
73
+ <Stack direction="row" spacing={1}>
74
+ {data.creditGrant && (
75
+ <Button
76
+ variant="outlined"
77
+ size="small"
78
+ onClick={() => navigate(`/admin/customers/${data.credit_grant_id}`)}>
79
+ {t('common.viewGrant')}
80
+ </Button>
81
+ )}
82
+ {data.subscription && (
83
+ <Button
84
+ variant="outlined"
85
+ size="small"
86
+ onClick={() => navigate(`/admin/billing/${data.subscription_id}`)}>
87
+ {t('common.viewSubscription')}
88
+ </Button>
89
+ )}
90
+ </Stack>
91
+ </Stack>
92
+
93
+ <Box
94
+ mt={4}
95
+ mb={3}
96
+ sx={{
97
+ display: 'flex',
98
+ gap: {
99
+ xs: 2,
100
+ sm: 2,
101
+ md: 5,
102
+ },
103
+ flexWrap: 'wrap',
104
+ flexDirection: {
105
+ xs: 'column',
106
+ sm: 'column',
107
+ md: 'row',
108
+ },
109
+ alignItems: {
110
+ xs: 'flex-start',
111
+ sm: 'flex-start',
112
+ md: 'center',
113
+ },
114
+ }}>
115
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
116
+ <Stack direction="column" spacing={0.5}>
117
+ <Typography variant="h2" sx={{ fontWeight: 600 }}>
118
+ {data.description || t('common.creditTransaction')}
119
+ </Typography>
120
+ <Copyable text={data.id} />
121
+ </Stack>
122
+ </Stack>
123
+
124
+ <Stack
125
+ className="section-body"
126
+ justifyContent="flex-start"
127
+ flexWrap="wrap"
128
+ sx={{
129
+ 'hr.MuiDivider-root:last-child': {
130
+ display: 'none',
131
+ },
132
+ flexDirection: {
133
+ xs: 'column',
134
+ sm: 'column',
135
+ md: 'row',
136
+ },
137
+ alignItems: 'flex-start',
138
+ gap: {
139
+ xs: 1,
140
+ sm: 1,
141
+ md: 3,
142
+ },
143
+ }}>
144
+ <InfoMetric
145
+ label={t('common.creditAmount')}
146
+ value={
147
+ <Stack direction="row" alignItems="center" spacing={0.5}>
148
+ <Typography variant="body2" sx={{ color: 'error.main' }}>
149
+ -{formatBNStr(data.credit_amount, data.creditGrant?.paymentCurrency?.decimal || 0)}{' '}
150
+ {data.creditGrant?.paymentCurrency?.symbol}
151
+ </Typography>
152
+ </Stack>
153
+ }
154
+ divider={!!data.transfer_status}
155
+ />
156
+ {data.transfer_status && (
157
+ <InfoMetric label={t('common.transferStatus')} value={getTransferStatusChip(data.transfer_status)} />
158
+ )}
159
+ </Stack>
160
+ </Box>
161
+ <Divider />
162
+ </Box>
163
+
164
+ <Stack
165
+ sx={{
166
+ flexDirection: {
167
+ xs: 'column',
168
+ lg: 'row',
169
+ },
170
+ gap: {
171
+ xs: 2.5,
172
+ md: 4,
173
+ },
174
+ '.transaction-column-1': {
175
+ minWidth: {
176
+ xs: '100%',
177
+ lg: '600px',
178
+ },
179
+ },
180
+ '.transaction-column-2': {
181
+ width: {
182
+ xs: '100%',
183
+ md: '100%',
184
+ lg: '320px',
185
+ },
186
+ maxWidth: {
187
+ xs: '100%',
188
+ md: '33%',
189
+ },
190
+ },
191
+ }}>
192
+ <Box flex={1} className="transaction-column-1" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
193
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
194
+ <SectionHeader title={t('admin.details')} />
195
+ <InfoRowGroup
196
+ sx={{
197
+ display: 'grid',
198
+ gridTemplateColumns: {
199
+ xs: 'repeat(1, 1fr)',
200
+ xl: 'repeat(2, 1fr)',
201
+ },
202
+ '@container (min-width: 1000px)': {
203
+ gridTemplateColumns: 'repeat(2, 1fr)',
204
+ },
205
+ '.info-row-wrapper': {
206
+ gap: 1,
207
+ flexDirection: {
208
+ xs: 'column',
209
+ xl: 'row',
210
+ },
211
+ alignItems: {
212
+ xs: 'flex-start',
213
+ xl: 'center',
214
+ },
215
+ '@container (min-width: 1000px)': {
216
+ flexDirection: 'row',
217
+ alignItems: 'center',
218
+ },
219
+ },
220
+ }}>
221
+ <InfoRow
222
+ label={t('common.customer')}
223
+ value={
224
+ <Stack direction="row" alignItems="center" spacing={1}>
225
+ <Avatar
226
+ src={getCustomerAvatar(
227
+ data.customer?.did,
228
+ data.customer?.updated_at ? new Date(data.customer.updated_at).toISOString() : '',
229
+ 24
230
+ )}
231
+ alt={data.customer?.name}
232
+ sx={{ width: 24, height: 24 }}
233
+ />
234
+ <Typography>{data.customer?.name}</Typography>
235
+ </Stack>
236
+ }
237
+ />
238
+ <InfoRow
239
+ label={t('common.creditGrant')}
240
+ value={
241
+ <Typography
242
+ component="span"
243
+ onClick={() => navigate(`/admin/customers/${data.credit_grant_id}`)}
244
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
245
+ {data.creditGrant?.name || data.credit_grant_id}
246
+ </Typography>
247
+ }
248
+ />
249
+ {data.meter && (
250
+ <InfoRow
251
+ label={t('common.meterEvent')}
252
+ value={
253
+ <Typography
254
+ component="span"
255
+ onClick={() => navigate(`/admin/billing/${data.meter_id}`)}
256
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
257
+ {data.meter.name || data.meter.event_name}
258
+ </Typography>
259
+ }
260
+ />
261
+ )}
262
+ {data.subscription && (
263
+ <InfoRow
264
+ label={t('admin.subscription.name')}
265
+ value={
266
+ <Typography
267
+ component="span"
268
+ onClick={() => navigate(`/admin/billing/${data.subscription_id}`)}
269
+ sx={{ color: 'text.link', cursor: 'pointer' }}>
270
+ {data.subscription.description || data.subscription_id}
271
+ </Typography>
272
+ }
273
+ />
274
+ )}
275
+ {data.transfer_hash && data.paymentMethod && (
276
+ <InfoRow
277
+ label={t('common.transferTxHash')}
278
+ value={
279
+ <TxLink
280
+ details={{ arcblock: { tx_hash: data.transfer_hash, payer: '' } }}
281
+ method={data.paymentMethod}
282
+ mode="dashboard"
283
+ />
284
+ }
285
+ />
286
+ )}
287
+ </InfoRowGroup>
288
+ </Box>
289
+
290
+ {data.meterEvent?.source_data && (
291
+ <>
292
+ <Divider />
293
+ <Box className="section">
294
+ <SectionHeader title={t('common.sourceData')} />
295
+ <Box className="section-body">
296
+ <SourceDataViewer data={data.meterEvent.source_data} />
297
+ </Box>
298
+ </Box>
299
+ </>
300
+ )}
301
+
302
+ <Divider />
303
+ <Box className="section">
304
+ <SectionHeader title={t('admin.events.title')} />
305
+ <Box className="section-body">
306
+ <EventList features={{ toolbar: false }} object_id={data.id} />
307
+ </Box>
308
+ </Box>
309
+ </Box>
310
+
311
+ <Box className="transaction-column-2" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
312
+ <Box className="section">
313
+ <SectionHeader title={t('common.metadata.label')} />
314
+ <Box className="section-body">
315
+ <MetadataList data={data.metadata} />
316
+ </Box>
317
+ </Box>
318
+ </Box>
319
+ </Stack>
320
+ </Root>
321
+ );
322
+ }
323
+
324
+ const Root = styled(Stack)``;
@@ -7,6 +7,7 @@ import { useTransitionContext } from '../../../components/progress-bar';
7
7
 
8
8
  const CustomerDetail = React.lazy(() => import('./customers/detail'));
9
9
  const CreditGrantDetail = React.lazy(() => import('./customers/credit-grant/detail'));
10
+ const CreditTransactionDetail = React.lazy(() => import('./customers/credit-transaction/detail'));
10
11
 
11
12
  const pages = {
12
13
  overview: React.lazy(() => import('./customers')),
@@ -26,6 +27,10 @@ export default function CustomerIndex() {
26
27
  return <CreditGrantDetail id={page} />;
27
28
  }
28
29
 
30
+ if (page.startsWith('cbtxn_')) {
31
+ return <CreditTransactionDetail id={page} />;
32
+ }
33
+
29
34
  const onTabChange = (newTab: string) => {
30
35
  startTransition(() => {
31
36
  navigate(`/admin/customers/${newTab}`);
@@ -7,6 +7,7 @@ import {
7
7
  CreditTransactionsList,
8
8
  CreditStatusChip,
9
9
  getCustomerAvatar,
10
+ TxLink,
10
11
  } from '@blocklet/payment-react';
11
12
  import type { TCreditGrantExpanded } from '@blocklet/payment-types';
12
13
  import { ArrowBackOutlined } from '@mui/icons-material';
@@ -40,7 +41,7 @@ export default function CustomerCreditGrantDetail() {
40
41
  }, [navigate]);
41
42
 
42
43
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
43
- return <Alert severity="error">You do not have permission to access other customer data</Alert>;
44
+ return <Alert severity="error">{t('common.accessDenied')}</Alert>;
44
45
  }
45
46
  if (error) {
46
47
  return <Alert severity="error">{error.message}</Alert>;
@@ -271,6 +272,18 @@ export default function CustomerCreditGrantDetail() {
271
272
  />
272
273
  <InfoRow label={t('admin.creditProduct.priority.label')} value={<Typography>{data.priority}</Typography>} />
273
274
  <InfoRow label={t('common.createdAt')} value={formatTime(data.created_at)} />
275
+ {data.chain_detail?.mint?.hash && data.paymentMethod?.type === 'arcblock' && (
276
+ <InfoRow
277
+ label={t('common.mintTxHash')}
278
+ value={
279
+ <TxLink
280
+ details={{ arcblock: { tx_hash: data.chain_detail.mint.hash, payer: '' } }}
281
+ method={data.paymentMethod}
282
+ mode="customer"
283
+ />
284
+ }
285
+ />
286
+ )}
274
287
  </InfoRowGroup>
275
288
  </Box>
276
289