payment-kit 1.18.18 → 1.18.20

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 (36) hide show
  1. package/api/src/libs/subscription.ts +116 -0
  2. package/api/src/routes/checkout-sessions.ts +28 -1
  3. package/api/src/routes/customers.ts +5 -1
  4. package/api/src/store/migrations/20250318-donate-invoice.ts +45 -0
  5. package/api/tests/libs/subscription.spec.ts +311 -0
  6. package/blocklet.yml +1 -1
  7. package/package.json +8 -8
  8. package/src/components/currency.tsx +11 -4
  9. package/src/components/customer/link.tsx +54 -14
  10. package/src/components/customer/overdraft-protection.tsx +36 -2
  11. package/src/components/info-card.tsx +55 -7
  12. package/src/components/info-row-group.tsx +122 -0
  13. package/src/components/info-row.tsx +14 -1
  14. package/src/components/payouts/portal/list.tsx +7 -2
  15. package/src/components/subscription/items/index.tsx +1 -1
  16. package/src/components/subscription/metrics.tsx +14 -6
  17. package/src/contexts/info-row.tsx +4 -0
  18. package/src/libs/dayjs.ts +4 -3
  19. package/src/locales/en.tsx +1 -0
  20. package/src/locales/zh.tsx +1 -0
  21. package/src/pages/admin/billing/invoices/detail.tsx +54 -76
  22. package/src/pages/admin/billing/subscriptions/detail.tsx +34 -71
  23. package/src/pages/admin/customers/customers/detail.tsx +41 -64
  24. package/src/pages/admin/payments/intents/detail.tsx +28 -42
  25. package/src/pages/admin/payments/payouts/detail.tsx +27 -36
  26. package/src/pages/admin/payments/refunds/detail.tsx +27 -41
  27. package/src/pages/admin/products/links/detail.tsx +30 -55
  28. package/src/pages/admin/products/prices/detail.tsx +43 -50
  29. package/src/pages/admin/products/pricing-tables/detail.tsx +23 -25
  30. package/src/pages/admin/products/products/detail.tsx +52 -81
  31. package/src/pages/customer/index.tsx +189 -107
  32. package/src/pages/customer/invoice/detail.tsx +49 -50
  33. package/src/pages/customer/payout/detail.tsx +16 -22
  34. package/src/pages/customer/recharge/account.tsx +119 -34
  35. package/src/pages/customer/recharge/subscription.tsx +11 -1
  36. package/src/pages/customer/subscription/detail.tsx +176 -94
@@ -11,7 +11,6 @@ import {
11
11
  Button,
12
12
  CircularProgress,
13
13
  Divider,
14
- Grid,
15
14
  Stack,
16
15
  Tooltip,
17
16
  Typography,
@@ -25,6 +24,7 @@ import Copyable from '../../../../components/copyable';
25
24
  import EventList from '../../../../components/event/list';
26
25
  import InfoMetric from '../../../../components/info-metric';
27
26
  import InfoRow from '../../../../components/info-row';
27
+ import InfoRowGroup from '../../../../components/info-row-group';
28
28
  import MetadataEditor from '../../../../components/metadata/editor';
29
29
  import MetadataList from '../../../../components/metadata/list';
30
30
  import ProductActions from '../../../../components/product/actions';
@@ -39,9 +39,6 @@ const getProduct = (id: string): Promise<TProductExpanded> => {
39
39
  return api.get(`/api/products/${id}`).then((res) => res.data);
40
40
  };
41
41
 
42
- const InfoDirection = 'column';
43
- const InfoAlignItems = 'flex-start';
44
-
45
42
  export default function ProductDetail(props: { id: string }) {
46
43
  const { t, locale } = useLocaleContext();
47
44
  const { isMobile } = useMobile();
@@ -212,6 +209,7 @@ export default function ProductDetail(props: { id: string }) {
212
209
  },
213
210
  }}>
214
211
  <InfoMetric label={t('common.id')} value={<Copyable text={props.id} style={{ marginLeft: 4 }} />} divider />
212
+ <InfoMetric label={t('common.createdAt')} value={formatTime(data.created_at)} divider />
215
213
  </Stack>
216
214
  </Box>
217
215
  <Divider />
@@ -246,7 +244,7 @@ export default function ProductDetail(props: { id: string }) {
246
244
  },
247
245
  }}>
248
246
  <Box flex={1} className="payment-link-column-1" sx={{ gap: 2.5, display: 'flex', flexDirection: 'column' }}>
249
- <Box className="section">
247
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
250
248
  <SectionHeader title={t('admin.details')}>
251
249
  <Button
252
250
  variant="text"
@@ -257,83 +255,56 @@ export default function ProductDetail(props: { id: string }) {
257
255
  {t('common.edit')}
258
256
  </Button>
259
257
  </SectionHeader>
260
- <Grid className="section-body" container>
261
- <Grid
262
- item
263
- xs={12}
264
- sx={{
265
- display: 'grid',
266
- gridTemplateColumns: {
267
- xs: 'repeat(1, 1fr)',
268
- sm: 'repeat(1, 1fr)',
269
- md: 'repeat(2, 1fr)',
270
- lg: 'repeat(3, 1fr)',
258
+ <InfoRowGroup
259
+ sx={{
260
+ display: 'grid',
261
+ gridTemplateColumns: {
262
+ xs: 'repeat(1, 1fr)',
263
+ xl: 'repeat(2, 1fr)',
264
+ },
265
+ '@container (min-width: 1000px)': {
266
+ gridTemplateColumns: 'repeat(2, 1fr)',
267
+ },
268
+ '.info-row-wrapper': {
269
+ gap: 1,
270
+ flexDirection: {
271
+ xs: 'column',
272
+ xl: 'row',
271
273
  },
272
- gap: {
273
- xs: 0,
274
- md: 2,
274
+ alignItems: {
275
+ xs: 'flex-start',
276
+ xl: 'center',
275
277
  },
276
- }}>
277
- <InfoRow
278
- label={t('admin.product.name.label')}
279
- value={data.name}
280
- direction={InfoDirection}
281
- alignItems={InfoAlignItems}
282
- />
283
- <InfoRow
284
- label={t('admin.product.description.label')}
285
- value={data.description}
286
- direction={InfoDirection}
287
- alignItems={InfoAlignItems}
288
- />
289
- <InfoRow
290
- label={t('admin.product.statement_descriptor.label')}
291
- value={data.statement_descriptor}
292
- direction={InfoDirection}
293
- alignItems={InfoAlignItems}
294
- />
295
- <InfoRow
296
- label={t('admin.product.unit_label.label')}
297
- value={data.unit_label}
298
- direction={InfoDirection}
299
- alignItems={InfoAlignItems}
300
- />
301
- <InfoRow
302
- label={t('admin.product.features.label')}
303
- value={data.features.map((x) => x.name).join(',')}
304
- direction={InfoDirection}
305
- alignItems={InfoAlignItems}
306
- />
307
- <InfoRow
308
- label={t('common.createdAt')}
309
- value={formatTime(data.created_at)}
310
- direction={InfoDirection}
311
- alignItems={InfoAlignItems}
312
- />
313
- <InfoRow
314
- label={t('common.updatedAt')}
315
- value={formatTime(data.updated_at)}
316
- direction={InfoDirection}
317
- alignItems={InfoAlignItems}
318
- />
319
- <InfoRow
320
- label={t('admin.product.image.label')}
321
- value={
322
- data.images.length ? (
323
- <Avatar
324
- src={data.images[0]}
325
- alt={data.name}
326
- variant="square"
327
- sx={{ width: '160px', height: '160px' }}
328
- />
329
- ) : (
330
- t('empty.image')
331
- )
332
- }
333
- direction={InfoDirection}
334
- alignItems={InfoAlignItems}
335
- />
336
- </Grid>
278
+ '@container (min-width: 1000px)': {
279
+ flexDirection: 'row',
280
+ alignItems: 'center',
281
+ },
282
+ },
283
+ }}>
284
+ <InfoRow label={t('admin.product.name.label')} value={data.name} />
285
+
286
+ <InfoRow label={t('admin.product.description.label')} value={data.description} />
287
+ <InfoRow label={t('admin.product.statement_descriptor.label')} value={data.statement_descriptor} />
288
+ <InfoRow label={t('admin.product.unit_label.label')} value={data.unit_label} />
289
+ <InfoRow label={t('admin.product.features.label')} value={data.features.map((x) => x.name).join(',')} />
290
+ <InfoRow label={t('common.updatedAt')} value={formatTime(data.updated_at)} />
291
+ <InfoRow
292
+ label={t('admin.product.image.label')}
293
+ value={
294
+ data.images.length ? (
295
+ <Avatar
296
+ src={data.images[0]}
297
+ alt={data.name}
298
+ variant="square"
299
+ sx={{ width: '160px', height: '160px' }}
300
+ />
301
+ ) : (
302
+ <Typography variant="body2" color="text.lighter">
303
+ {t('empty.image')}
304
+ </Typography>
305
+ )
306
+ }
307
+ />
337
308
  {state.editing.product && (
338
309
  <EditProduct
339
310
  product={data}
@@ -342,7 +313,7 @@ export default function ProductDetail(props: { id: string }) {
342
313
  onCancel={() => setState((prev) => ({ editing: { ...prev.editing, product: false } }))}
343
314
  />
344
315
  )}
345
- </Grid>
316
+ </InfoRowGroup>
346
317
  </Box>
347
318
  <Divider />
348
319
  <Box className="section">
@@ -78,11 +78,7 @@ const CurrencyCard = memo(
78
78
  const method = settings?.paymentMethods?.find((m) => m.id === currency.payment_method_id);
79
79
  const { t } = useLocaleContext();
80
80
  const safeData = data || {};
81
- const value = formatBNStr(safeData[currency?.id], currency?.decimal, 6, false);
82
- const hideTypes = ['stake', 'refund', 'due'];
83
- if (hideTypes.includes(type) && (!safeData[currency?.id] || safeData[currency?.id] === '0')) {
84
- return null;
85
- }
81
+ const value = formatBNStr(safeData[currency?.id], currency?.decimal, 6, true);
86
82
 
87
83
  const handleCardClick = () => {
88
84
  if (type === 'balance' && currency?.id) {
@@ -95,7 +91,11 @@ const CurrencyCard = memo(
95
91
  sx={{
96
92
  transition: 'all 0.2s ease-in-out',
97
93
  position: 'relative',
98
- borderRight: '1px solid var(--stroke-border-base, #EFF1F5)',
94
+ height: '100%',
95
+ display: 'flex',
96
+ flexDirection: 'column',
97
+ justifyContent: 'space-between',
98
+ containerType: 'inline-size',
99
99
  }}>
100
100
  <Typography
101
101
  variant="body1"
@@ -126,8 +126,20 @@ const CurrencyCard = memo(
126
126
  '&:hover': {
127
127
  color: 'text.link',
128
128
  },
129
+ '.recharge-title': {
130
+ '@container (max-width: 230px)': {
131
+ display: 'none',
132
+ },
133
+ '@container (min-width: 231px)': {
134
+ display: 'inline-block',
135
+ },
136
+ },
129
137
  }}>
130
138
  <AddOutlined fontSize="small" />
139
+
140
+ <Typography variant="body2" className="recharge-title">
141
+ {t('customer.recharge.title')}
142
+ </Typography>
131
143
  </Box>
132
144
  </Tooltip>
133
145
  )}
@@ -156,11 +168,80 @@ function SummaryCardSkeleton() {
156
168
  );
157
169
  }
158
170
 
171
+ function isSummaryEmpty(summary: any, id: string) {
172
+ const summaryKeys = ['balance', 'paid', 'stake', 'refund', 'due'];
173
+ return summaryKeys.every((key) => !summary?.[key]?.[id] || summary?.[key]?.[id] === '0');
174
+ }
175
+
176
+ // 定义卡片类型
177
+ type CardType = 'balance' | 'spent' | 'stake' | 'refund' | 'due';
178
+
179
+ // 定义一个统一的卡片配置
180
+ const CARD_CONFIG: Record<CardType, { key: keyof typeof summaryKeyMap; alwaysShow?: boolean }> = {
181
+ balance: { key: 'token' },
182
+ spent: { key: 'paid', alwaysShow: true },
183
+ stake: { key: 'stake' },
184
+ refund: { key: 'refund' },
185
+ due: { key: 'due' },
186
+ } as const;
187
+
188
+ // 映射 summary 中的键名
189
+ const summaryKeyMap = {
190
+ token: 'token',
191
+ paid: 'paid',
192
+ stake: 'stake',
193
+ refund: 'refund',
194
+ due: 'due',
195
+ } as const;
196
+
197
+ const isCardVisible = (type: string, config: any, data: any, currency: any, method: any) => {
198
+ const summaryKey = config.key;
199
+ const hasSummaryValue =
200
+ data?.summary?.[summaryKey]?.[currency.id] && data?.summary?.[summaryKey]?.[currency.id] !== '0';
201
+
202
+ if (type === 'balance') {
203
+ return method?.type === 'arcblock' && (config.alwaysShow || hasSummaryValue);
204
+ }
205
+
206
+ return config.alwaysShow || hasSummaryValue;
207
+ };
208
+
209
+ const getCardVisibility = (currency: any, data: any, method: any) => {
210
+ return Object.entries(CARD_CONFIG).reduce(
211
+ (acc, [type, config]) => {
212
+ if (isCardVisible(type, config, data, currency, method)) {
213
+ acc.visibleCards.push(type as CardType);
214
+ }
215
+ return acc;
216
+ },
217
+ { visibleCards: [] as CardType[] }
218
+ );
219
+ };
220
+
221
+ // 计算最大卡片数量
222
+ const calculateMaxCardCount = (currencies: any[], data: any, settings: any) => {
223
+ return currencies.reduce((maxCount, currency) => {
224
+ const method = settings?.paymentMethods?.find((m: any) => m.id === currency.payment_method_id);
225
+ const { visibleCards } = getCardVisibility(currency, data, method);
226
+ return Math.max(maxCount, visibleCards.length);
227
+ }, 0);
228
+ };
229
+
230
+ const getCardIcon = (type: CardType) => {
231
+ const iconMap = {
232
+ balance: <AccountBalanceWalletOutlined color="success" fontSize="small" />,
233
+ spent: <CreditCardOutlined color="warning" fontSize="small" />,
234
+ stake: <AccountBalanceOutlined fontSize="small" color="info" />,
235
+ refund: <AssignmentReturnOutlined fontSize="small" sx={{ color: 'var(--tags-tag-purple-icon, #7c3aed)' }} />,
236
+ due: <InfoOutlined fontSize="small" color="error" />,
237
+ };
238
+ return iconMap[type];
239
+ };
240
+
159
241
  export default function CustomerHome() {
160
242
  const { t } = useLocaleContext();
161
243
  const { events } = useSessionContext();
162
244
  const { settings } = usePaymentContext();
163
- const [currency, setCurrency] = useState(settings?.baseCurrency);
164
245
  const [subscriptionLoading, setSubscriptionLoading] = useState(false);
165
246
  const currencies = flatten(
166
247
  settings.paymentMethods?.map((method) =>
@@ -250,10 +331,6 @@ export default function CustomerHome() {
250
331
  setSubscriptionLoading(false);
251
332
  }, 300);
252
333
  };
253
- const handleCurrencyChange = (e: SelectChangeEvent) => {
254
- const newCurrency = currencies.find((c) => c.id === e.target.value) || settings?.baseCurrency;
255
- setCurrency(newCurrency);
256
- };
257
334
 
258
335
  const SubscriptionCard =
259
336
  loadingCard || !hasSubscriptions ? null : (
@@ -308,6 +385,14 @@ export default function CustomerHome() {
308
385
  </Box>
309
386
  );
310
387
 
388
+ const maxCardCount = calculateMaxCardCount(currencies, data, settings);
389
+
390
+ const responsiveColumns = {
391
+ xs: Math.min(2, maxCardCount),
392
+ sm: Math.min(3, maxCardCount),
393
+ md: maxCardCount,
394
+ };
395
+
311
396
  const SummaryCard = loadingCard ? (
312
397
  <SummaryCardSkeleton />
313
398
  ) : (
@@ -316,104 +401,101 @@ export default function CustomerHome() {
316
401
  <>
317
402
  <Box className="section-header">
318
403
  <Typography variant="h3">{t('admin.customer.summary.stats')}</Typography>
319
- <FormControl
320
- sx={{
321
- '.MuiInputBase-root': {
322
- background: 'none',
323
- border: 'none',
324
- },
325
- '.MuiOutlinedInput-notchedOutline': {
326
- border: 'none',
327
- },
328
- }}>
329
- <Select
330
- value={currency?.id}
331
- onChange={handleCurrencyChange}
332
- displayEmpty
333
- IconComponent={ExpandMore}
334
- inputProps={{ 'aria-label': 'Without label' }}>
335
- {currencies.map((c) => (
336
- <MenuItem key={c.id} value={c.id}>
337
- <Box alignItems="center" display="flex" gap={1}>
338
- <Avatar src={c?.logo} alt={c?.symbol} sx={{ width: 18, height: 18 }} />
339
- <Typography
340
- variant="h5"
341
- component="div"
342
- sx={{ fontSize: '16px', color: 'text.primary', fontWeight: '500' }}>
343
- {c?.symbol}
344
- </Typography>
345
- <Typography sx={{ fontSize: 12 }} color="text.lighter">
346
- {c?.methodName}
347
- </Typography>
348
- </Box>
349
- </MenuItem>
350
- ))}
351
- </Select>
352
- </FormControl>
353
404
  </Box>
354
- <Stack
355
- className="section-body"
356
- flexDirection="row"
357
- sx={{
358
- gap: 3,
359
- mt: 1.5,
360
- flexWrap: 'wrap',
361
- position: 'relative',
362
- '>div': {
363
- flex: '1',
364
- '@media (max-width: 1100px)': {
365
- flex: '0 0 calc(50% - 12px)',
366
- maxWidth: 'calc(50% - 12px)',
367
- },
368
- },
369
- '&::after': {
370
- content: '""',
371
- position: 'absolute',
372
- right: 0,
373
- top: 0,
374
- bottom: 0,
375
- width: '1px',
376
- backgroundColor: 'background.paper',
377
- zIndex: 1,
378
- },
379
- }}>
380
- <CurrencyCard
381
- label={t('admin.customer.summary.balance')}
382
- data={data?.summary?.token || emptyObject}
383
- currency={currency}
384
- type="balance"
385
- icon={<AccountBalanceWalletOutlined color="success" fontSize="small" />}
386
- />
387
- <CurrencyCard
388
- label={t('admin.customer.summary.spent')}
389
- data={data?.summary?.paid || emptyObject}
390
- currency={currency}
391
- type="spent"
392
- icon={<CreditCardOutlined color="warning" fontSize="small" />}
393
- />
394
- <CurrencyCard
395
- label={t('admin.customer.summary.stake')}
396
- data={data?.summary?.stake || emptyObject}
397
- currency={currency}
398
- type="stake"
399
- icon={<AccountBalanceOutlined fontSize="small" color="info" />}
400
- />
401
- <CurrencyCard
402
- label={t('admin.customer.summary.refund')}
403
- data={data?.summary?.refund || emptyObject}
404
- currency={currency}
405
- type="refund"
406
- icon={
407
- <AssignmentReturnOutlined fontSize="small" sx={{ color: 'var(--tags-tag-purple-icon, #7c3aed)' }} />
405
+ <Stack gap={2} mt={2}>
406
+ {currencies.map((c) => {
407
+ const method = settings?.paymentMethods?.find((m) => m.id === c.payment_method_id);
408
+ if (method?.type !== 'arcblock') {
409
+ return null;
408
410
  }
409
- />
410
- <CurrencyCard
411
- label={t('admin.customer.summary.due')}
412
- data={data?.summary?.due || emptyObject}
413
- currency={currency}
414
- type="due"
415
- icon={<InfoOutlined fontSize="small" color="error" />}
416
- />
411
+ return isSummaryEmpty(data?.summary, c.id) && c.id !== settings.baseCurrency.id ? null : (
412
+ <Stack
413
+ key={c.id}
414
+ sx={{
415
+ pb: 2,
416
+ borderBottom: '1px solid var(--stroke-border-base, #EFF1F5)',
417
+ '&:last-child': { borderBottom: 'none', pb: 0 },
418
+ }}>
419
+ <Box alignItems="center" display="flex" gap={1}>
420
+ <Avatar src={c?.logo} alt={c?.symbol} sx={{ width: 18, height: 18 }} />
421
+ <Typography
422
+ variant="h5"
423
+ component="div"
424
+ sx={{ fontSize: '16px', color: 'text.primary', fontWeight: '500' }}>
425
+ {c?.symbol}
426
+ </Typography>
427
+ <Typography sx={{ fontSize: 12 }} color="text.lighter">
428
+ {c?.methodName}
429
+ </Typography>
430
+ </Box>
431
+ <Stack
432
+ className="section-body"
433
+ flexDirection="row"
434
+ sx={{
435
+ gap: 3,
436
+ mt: 1.5,
437
+ flexWrap: 'wrap',
438
+ position: 'relative',
439
+ display: 'grid',
440
+ gridTemplateColumns: {
441
+ xs: `repeat(${responsiveColumns.xs}, 1fr)`,
442
+ sm: `repeat(${responsiveColumns.sm}, 1fr)`,
443
+ md: `repeat(${responsiveColumns.md}, 1fr)`,
444
+ },
445
+ '&::after': {
446
+ content: '""',
447
+ position: 'absolute',
448
+ right: 0,
449
+ top: 0,
450
+ bottom: 0,
451
+ width: '1px',
452
+ backgroundColor: 'background.paper',
453
+ zIndex: 1,
454
+ },
455
+ }}>
456
+ {/* 使用配置渲染卡片 */}
457
+ {Object.entries(CARD_CONFIG).map(([type, config]) => {
458
+ if (!isCardVisible(type, config, data, c, method)) {
459
+ return null;
460
+ }
461
+
462
+ return (
463
+ <Box
464
+ key={`card-${c.id}-${type}`}
465
+ className="card-item"
466
+ sx={{
467
+ position: 'relative',
468
+ '&::after': {
469
+ content: '""',
470
+ position: 'absolute',
471
+ right: 0,
472
+ top: 0,
473
+ bottom: 0,
474
+ width: '1px',
475
+ backgroundColor: 'var(--stroke-border-base, #EFF1F5)',
476
+ zIndex: 1,
477
+ },
478
+ '&:has(+ .placeholder)::after': {
479
+ display: 'none',
480
+ },
481
+ '&:last-child::after': {
482
+ display: 'none',
483
+ },
484
+ }}>
485
+ <CurrencyCard
486
+ label={t(`admin.customer.summary.${type}`)}
487
+ data={data?.summary?.[config.key] || emptyObject}
488
+ currency={c}
489
+ type={type as CardType}
490
+ icon={getCardIcon(type as CardType)}
491
+ />
492
+ </Box>
493
+ );
494
+ })}
495
+ </Stack>
496
+ </Stack>
497
+ );
498
+ })}
417
499
  </Stack>
418
500
  </>
419
501
  )}