payment-kit 1.18.18 → 1.18.19

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 (35) 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/locales/en.tsx +1 -0
  19. package/src/locales/zh.tsx +1 -0
  20. package/src/pages/admin/billing/invoices/detail.tsx +54 -76
  21. package/src/pages/admin/billing/subscriptions/detail.tsx +34 -71
  22. package/src/pages/admin/customers/customers/detail.tsx +41 -64
  23. package/src/pages/admin/payments/intents/detail.tsx +28 -42
  24. package/src/pages/admin/payments/payouts/detail.tsx +27 -36
  25. package/src/pages/admin/payments/refunds/detail.tsx +27 -41
  26. package/src/pages/admin/products/links/detail.tsx +30 -55
  27. package/src/pages/admin/products/prices/detail.tsx +43 -50
  28. package/src/pages/admin/products/pricing-tables/detail.tsx +23 -25
  29. package/src/pages/admin/products/products/detail.tsx +52 -81
  30. package/src/pages/customer/index.tsx +183 -108
  31. package/src/pages/customer/invoice/detail.tsx +49 -50
  32. package/src/pages/customer/payout/detail.tsx +16 -22
  33. package/src/pages/customer/recharge/account.tsx +92 -34
  34. package/src/pages/customer/recharge/subscription.tsx +6 -0
  35. 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,73 @@ 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', alwaysShow: true },
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
+ // 优化后的计算函数
198
+ const getCardVisibility = (currency: any, data: any) => {
199
+ return Object.entries(CARD_CONFIG).reduce(
200
+ (acc, [type, config]) => {
201
+ const summaryKey = config.key;
202
+ const isVisible =
203
+ config.alwaysShow ||
204
+ (data?.summary?.[summaryKey]?.[currency.id] && data?.summary?.[summaryKey]?.[currency.id] !== '0');
205
+
206
+ if (isVisible) {
207
+ acc.visibleCards.push(type as CardType);
208
+ }
209
+ return acc;
210
+ },
211
+ { visibleCards: [] as CardType[] }
212
+ );
213
+ };
214
+
215
+ // 计算最大卡片数量
216
+ const calculateMaxCardCount = (currencies: any[], data: any) => {
217
+ return currencies.reduce((maxCount, currency) => {
218
+ const { visibleCards } = getCardVisibility(currency, data);
219
+ return Math.max(maxCount, visibleCards.length);
220
+ }, 0);
221
+ };
222
+
223
+ const getCardIcon = (type: CardType) => {
224
+ const iconMap = {
225
+ balance: <AccountBalanceWalletOutlined color="success" fontSize="small" />,
226
+ spent: <CreditCardOutlined color="warning" fontSize="small" />,
227
+ stake: <AccountBalanceOutlined fontSize="small" color="info" />,
228
+ refund: <AssignmentReturnOutlined fontSize="small" sx={{ color: 'var(--tags-tag-purple-icon, #7c3aed)' }} />,
229
+ due: <InfoOutlined fontSize="small" color="error" />,
230
+ };
231
+ return iconMap[type];
232
+ };
233
+
159
234
  export default function CustomerHome() {
160
235
  const { t } = useLocaleContext();
161
236
  const { events } = useSessionContext();
162
237
  const { settings } = usePaymentContext();
163
- const [currency, setCurrency] = useState(settings?.baseCurrency);
164
238
  const [subscriptionLoading, setSubscriptionLoading] = useState(false);
165
239
  const currencies = flatten(
166
240
  settings.paymentMethods?.map((method) =>
@@ -250,10 +324,6 @@ export default function CustomerHome() {
250
324
  setSubscriptionLoading(false);
251
325
  }, 300);
252
326
  };
253
- const handleCurrencyChange = (e: SelectChangeEvent) => {
254
- const newCurrency = currencies.find((c) => c.id === e.target.value) || settings?.baseCurrency;
255
- setCurrency(newCurrency);
256
- };
257
327
 
258
328
  const SubscriptionCard =
259
329
  loadingCard || !hasSubscriptions ? null : (
@@ -308,6 +378,14 @@ export default function CustomerHome() {
308
378
  </Box>
309
379
  );
310
380
 
381
+ const maxCardCount = calculateMaxCardCount(currencies, data);
382
+
383
+ const responsiveColumns = {
384
+ xs: Math.min(2, maxCardCount),
385
+ sm: Math.min(3, maxCardCount),
386
+ md: maxCardCount,
387
+ };
388
+
311
389
  const SummaryCard = loadingCard ? (
312
390
  <SummaryCardSkeleton />
313
391
  ) : (
@@ -316,104 +394,101 @@ export default function CustomerHome() {
316
394
  <>
317
395
  <Box className="section-header">
318
396
  <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
397
  </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)' }} />
408
- }
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
- />
398
+ <Stack gap={2} mt={2}>
399
+ {currencies.map((c) => {
400
+ return isSummaryEmpty(data?.summary, c.id) && c.id !== settings.baseCurrency.id ? null : (
401
+ <Stack
402
+ key={c.id}
403
+ sx={{
404
+ pb: 2,
405
+ borderBottom: '1px solid var(--stroke-border-base, #EFF1F5)',
406
+ '&:last-child': { borderBottom: 'none', pb: 0 },
407
+ }}>
408
+ <Box alignItems="center" display="flex" gap={1}>
409
+ <Avatar src={c?.logo} alt={c?.symbol} sx={{ width: 18, height: 18 }} />
410
+ <Typography
411
+ variant="h5"
412
+ component="div"
413
+ sx={{ fontSize: '16px', color: 'text.primary', fontWeight: '500' }}>
414
+ {c?.symbol}
415
+ </Typography>
416
+ <Typography sx={{ fontSize: 12 }} color="text.lighter">
417
+ {c?.methodName}
418
+ </Typography>
419
+ </Box>
420
+ <Stack
421
+ className="section-body"
422
+ flexDirection="row"
423
+ sx={{
424
+ gap: 3,
425
+ mt: 1.5,
426
+ flexWrap: 'wrap',
427
+ position: 'relative',
428
+ display: 'grid',
429
+ gridTemplateColumns: {
430
+ xs: `repeat(${responsiveColumns.xs}, 1fr)`,
431
+ sm: `repeat(${responsiveColumns.sm}, 1fr)`,
432
+ md: `repeat(${responsiveColumns.md}, 1fr)`,
433
+ },
434
+ '&::after': {
435
+ content: '""',
436
+ position: 'absolute',
437
+ right: 0,
438
+ top: 0,
439
+ bottom: 0,
440
+ width: '1px',
441
+ backgroundColor: 'background.paper',
442
+ zIndex: 1,
443
+ },
444
+ }}>
445
+ {/* 使用配置渲染卡片 */}
446
+ {Object.entries(CARD_CONFIG).map(([type, config]) => {
447
+ const summaryKey = config.key;
448
+ const isVisible =
449
+ config.alwaysShow ||
450
+ (data?.summary?.[summaryKey]?.[c.id] && data?.summary?.[summaryKey]?.[c.id] !== '0');
451
+
452
+ if (!isVisible) return null;
453
+
454
+ return (
455
+ <Box
456
+ key={`card-${c.id}-${type}`}
457
+ className="card-item"
458
+ sx={{
459
+ position: 'relative',
460
+ '&::after': {
461
+ content: '""',
462
+ position: 'absolute',
463
+ right: 0,
464
+ top: 0,
465
+ bottom: 0,
466
+ width: '1px',
467
+ backgroundColor: 'var(--stroke-border-base, #EFF1F5)',
468
+ zIndex: 1,
469
+ },
470
+
471
+ '&:has(+ .placeholder)::after': {
472
+ display: 'none',
473
+ },
474
+ '&:last-child::after': {
475
+ display: 'none',
476
+ },
477
+ }}>
478
+ <CurrencyCard
479
+ label={t(`admin.customer.summary.${type}`)}
480
+ data={data?.summary?.[summaryKey] || emptyObject}
481
+ currency={c}
482
+ type={type as CardType}
483
+ icon={getCardIcon(type as CardType)}
484
+ />
485
+ </Box>
486
+ );
487
+ })}
488
+ </Stack>
489
+ </Stack>
490
+ );
491
+ })}
417
492
  </Stack>
418
493
  </>
419
494
  )}