payment-kit 1.19.1 → 1.19.2

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.
@@ -52,7 +52,7 @@ export class CustomerCreditGrantLowBalanceEmailTemplate
52
52
  // 计算百分比
53
53
  const available = new BN(creditGrant.remaining_amount);
54
54
  const total = new BN(creditGrant.amount);
55
- const percentage = total.gt(0) ? available.mul(new BN(100)).div(total).toString() : '0';
55
+ const percentage = total.gt(new BN(0)) ? available.mul(new BN(100)).div(total).toString() : '0';
56
56
 
57
57
  return {
58
58
  locale,
@@ -58,7 +58,7 @@ export class CustomerCreditInsufficientEmailTemplate
58
58
  const at = formatTime(Date.now());
59
59
 
60
60
  // 检查是否完全耗尽(可用额度为0或负数)
61
- const isExhausted = new BN(this.options.availableAmount).lte(0);
61
+ const isExhausted = new BN(this.options.availableAmount).lte(new BN(0));
62
62
 
63
63
  // 如果有订阅ID,获取订阅信息
64
64
  let productName: string | undefined;
@@ -12,6 +12,7 @@ import { joinURL, withQuery, withTrailingSlash } from 'ufo';
12
12
  import axios from 'axios';
13
13
  import { ethers } from 'ethers';
14
14
  import { fromUnitToToken } from '@ocap/util';
15
+ import get from 'lodash/get';
15
16
  import dayjs from './dayjs';
16
17
  import { blocklet, wallet } from './auth';
17
18
  import type { PaymentCurrency, PaymentMethod, Subscription } from '../store/models';
@@ -268,11 +269,12 @@ export async function getUserOrAppInfo(
268
269
  }
269
270
  const { user } = await blocklet.getUser(address);
270
271
  if (user) {
272
+ const locale = get(user, 'locale', 'en');
271
273
  return {
272
274
  name: user?.fullName,
273
275
  avatar: joinURL(process.env.BLOCKLET_APP_URL!, user?.avatar),
274
276
  type: 'user',
275
- url: getCustomerProfileUrl({ userDid: address, locale: 'en' }),
277
+ url: getCustomerProfileUrl({ userDid: address, locale }),
276
278
  };
277
279
  }
278
280
  return {
@@ -227,9 +227,9 @@ async function consumeAvailableCredits(
227
227
  metadata: {
228
228
  meter_event_id: context.meterEvent.id,
229
229
  meter_event_name: context.meterEvent.event_name,
230
- required_amount: totalRequiredAmount,
230
+ required_amount: remainingToConsume.toString(),
231
231
  available_amount: totalAvailable.toString(),
232
- consumed_amount: totalConsumed.toString(),
232
+ consumed_amount: consumed.toString(),
233
233
  pending_amount: pendingAmount,
234
234
  currency_id: currencyId,
235
235
  subscription_id: context.subscription?.id,
@@ -258,6 +258,19 @@ async function consumeAvailableCredits(
258
258
  },
259
259
  });
260
260
  }
261
+ } else if (remainingBalance === '0') {
262
+ await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
263
+ metadata: {
264
+ meter_event_id: context.meterEvent.id,
265
+ meter_event_name: context.meterEvent.event_name,
266
+ required_amount: remainingToConsume.toString(),
267
+ available_amount: '0',
268
+ consumed_amount: consumed.toString(),
269
+ pending_amount: pendingAmount,
270
+ currency_id: currencyId,
271
+ subscription_id: context.subscription?.id,
272
+ },
273
+ }).catch(console.error);
261
274
  }
262
275
 
263
276
  return {
@@ -45,9 +45,6 @@ router.get('/', auth, async (req, res) => {
45
45
  const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
46
46
  const where = getWhereFromKvQuery(query.q);
47
47
 
48
- if (typeof query.livemode === 'boolean') {
49
- where.livemode = query.livemode;
50
- }
51
48
  if (query.did) {
52
49
  where.did = query.did;
53
50
  }
@@ -386,16 +383,48 @@ router.get('/payer-token', sessionMiddleware({ accessKey: true }), async (req, r
386
383
  });
387
384
 
388
385
  router.get('/:id', auth, async (req, res) => {
386
+ if (!req.params.id) {
387
+ return res.status(400).json({ error: 'Customer ID is required' });
388
+ }
389
389
  try {
390
390
  const doc = await Customer.findByPkOrDid(req.params.id as string);
391
391
  if (doc) {
392
392
  res.json(doc);
393
393
  } else {
394
- res.status(404).json(null);
394
+ if (req.body.create) {
395
+ if (!req.user) {
396
+ return res.status(403).json({ error: 'Unauthorized' });
397
+ }
398
+ const { user } = await blocklet.getUser(req.params.id);
399
+ if (!user) {
400
+ return res.status(404).json({ error: 'User not found' });
401
+ }
402
+ const customer = await Customer.create({
403
+ livemode: true,
404
+ did: user.did,
405
+ name: user.fullName,
406
+ email: user.email,
407
+ phone: user.phone,
408
+ address: Customer.formatAddressFromUser(user),
409
+ description: user.remark,
410
+ metadata: {},
411
+ balance: '0',
412
+ next_invoice_sequence: 1,
413
+ delinquent: false,
414
+ invoice_prefix: Customer.getInvoicePrefix(),
415
+ });
416
+ logger.info('customer created', {
417
+ customerId: customer.id,
418
+ did: customer.did,
419
+ });
420
+ return res.json(customer);
421
+ }
422
+ return res.status(404).json(null);
395
423
  }
424
+ return res.status(404).json(null);
396
425
  } catch (err) {
397
426
  logger.error(err);
398
- res.status(500).json({ error: `Failed to get customer: ${err.message}` });
427
+ return res.status(500).json({ error: `Failed to get customer: ${err.message}` });
399
428
  }
400
429
  });
401
430
 
@@ -148,6 +148,12 @@ router.get('/', auth, async (req, res) => {
148
148
  if (typeof query.livemode === 'string') {
149
149
  where.livemode = JSON.parse(query.livemode);
150
150
  }
151
+ where.type = 'standard';
152
+ if (query.credit) {
153
+ where.type = {
154
+ [Op.in]: ['standard', 'credit'],
155
+ };
156
+ }
151
157
  const list = await PaymentCurrency.findAll({
152
158
  where,
153
159
  order: [['created_at', 'DESC']],
@@ -43,9 +43,6 @@ router.get('/', auth, async (req, res) => {
43
43
  const { page, pageSize, status, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
44
44
  const where: WhereOptions<WebhookEndpoint> = {};
45
45
 
46
- if (typeof query.livemode === 'boolean') {
47
- where.livemode = query.livemode;
48
- }
49
46
  if (status) {
50
47
  where.status = status
51
48
  .split(',')
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.19.1
17
+ version: 1.19.2
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.19.1",
3
+ "version": "1.19.2",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -54,7 +54,7 @@
54
54
  "@blocklet/did-space-js": "^1.0.62",
55
55
  "@blocklet/js-sdk": "^1.16.44",
56
56
  "@blocklet/logger": "^1.16.44",
57
- "@blocklet/payment-react": "1.19.1",
57
+ "@blocklet/payment-react": "1.19.2",
58
58
  "@blocklet/sdk": "^1.16.44",
59
59
  "@blocklet/ui-react": "^3.0.1",
60
60
  "@blocklet/uploader": "^0.1.97",
@@ -122,7 +122,7 @@
122
122
  "devDependencies": {
123
123
  "@abtnode/types": "^1.16.44",
124
124
  "@arcblock/eslint-config-ts": "^0.3.3",
125
- "@blocklet/payment-types": "1.19.1",
125
+ "@blocklet/payment-types": "1.19.2",
126
126
  "@types/cookie-parser": "^1.4.9",
127
127
  "@types/cors": "^2.8.19",
128
128
  "@types/debug": "^4.1.12",
@@ -168,5 +168,5 @@
168
168
  "parser": "typescript"
169
169
  }
170
170
  },
171
- "gitHead": "48d5719c8ce4e89a16f8dd576ff8f72072e3909e"
171
+ "gitHead": "741c897204afc412721a942201516932bff59235"
172
172
  }
@@ -0,0 +1,87 @@
1
+ import { Box } from '@mui/material';
2
+ import { useState, ReactNode, useEffect, createContext, useContext, useMemo, useRef, useCallback } from 'react';
3
+
4
+ const ConditionalSectionContext = createContext<{
5
+ hideRender: (hide?: boolean) => void;
6
+ } | null>(null);
7
+
8
+ // 导出hook供子组件使用
9
+ export const useConditionalSection = () => {
10
+ const context = useContext(ConditionalSectionContext);
11
+ return context;
12
+ };
13
+
14
+ interface ConditionalSectionProps {
15
+ skeleton: boolean;
16
+ children: ReactNode;
17
+ skeletonComponent?: ReactNode;
18
+ }
19
+
20
+ /**
21
+ * 条件渲染组件 - 完全防闪现版本
22
+ *
23
+ * 彻底解决闪现问题的方案:
24
+ * 1. skeleton=true 时显示骨架屏组件
25
+ * 2. skeleton=false 时完全隐藏(display: none)渲染子组件,让其执行逻辑
26
+ * 3. 等待子组件执行完毕,如果没有调用hideRender则显示
27
+ * 4. 整个过程用户看不到任何闪现
28
+ *
29
+ * 使用方式:
30
+ * - 在任意深度的子组件中调用 useConditionalSection()?.hideRender()
31
+ */
32
+ export default function ConditionalSection({ skeleton, children, skeletonComponent = null }: ConditionalSectionProps) {
33
+ const [renderState, setRenderState] = useState<'hidden' | 'visible' | 'none'>('hidden');
34
+ const timerRef = useRef<NodeJS.Timeout | null>(null);
35
+
36
+ const handleHideRender = useCallback((hide: boolean = true) => {
37
+ if (timerRef.current) {
38
+ clearTimeout(timerRef.current);
39
+ timerRef.current = null;
40
+ }
41
+ setRenderState(hide ? 'none' : 'visible');
42
+ }, []);
43
+
44
+ const contextValue = useMemo(() => ({ hideRender: handleHideRender }), [handleHideRender]);
45
+
46
+ useEffect(() => {
47
+ if (!skeleton) {
48
+ timerRef.current = setTimeout(() => {
49
+ setRenderState('visible');
50
+ timerRef.current = null;
51
+ }, 3000);
52
+ }
53
+
54
+ // 清理定时器
55
+ return () => {
56
+ if (timerRef.current) {
57
+ clearTimeout(timerRef.current);
58
+ timerRef.current = null;
59
+ }
60
+ };
61
+ }, [skeleton]);
62
+
63
+ if (skeleton) {
64
+ return skeletonComponent;
65
+ }
66
+
67
+ if (renderState === 'none') {
68
+ return null;
69
+ }
70
+
71
+ return (
72
+ <ConditionalSectionContext.Provider value={contextValue}>
73
+ <Box
74
+ sx={{
75
+ position: renderState === 'hidden' ? 'absolute' : 'static',
76
+ left: renderState === 'hidden' ? '-9999px' : 'auto',
77
+ top: renderState === 'hidden' ? '-9999px' : 'auto',
78
+ visibility: renderState === 'hidden' ? 'hidden' : 'visible',
79
+ width: renderState === 'hidden' ? '0' : 'auto',
80
+ height: renderState === 'hidden' ? '0' : 'auto',
81
+ overflow: 'hidden',
82
+ }}>
83
+ {children}
84
+ </Box>
85
+ </ConditionalSectionContext.Provider>
86
+ );
87
+ }
@@ -1,9 +1,10 @@
1
1
  import { formatBNStr, CreditGrantsList, CreditTransactionsList, api } from '@blocklet/payment-react';
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
3
  import { Avatar, Box, Card, CardContent, Stack, Typography, Tabs, Tab } from '@mui/material';
4
- import { useState } from 'react';
4
+ import { useMemo, useState } from 'react';
5
5
  import type { TPaymentCurrency } from '@blocklet/payment-types';
6
6
  import { useRequest } from 'ahooks';
7
+ import { useConditionalSection } from '../conditional-section';
7
8
 
8
9
  enum CreditTab {
9
10
  OVERVIEW = 'overview',
@@ -43,9 +44,30 @@ const fetchCreditSummary = async (customerId: string) => {
43
44
  export default function CreditOverview({ customerId, settings, mode = 'portal' }: CreditOverviewProps) {
44
45
  const { t } = useLocaleContext();
45
46
  const [creditTab, setCreditTab] = useState<CreditTab>(CreditTab.OVERVIEW);
47
+ const conditionalSection = useConditionalSection();
48
+
49
+ const creditCurrencies = useMemo(() => {
50
+ return (
51
+ settings?.paymentMethods
52
+ ?.filter((method: any) => method.type === 'arcblock')
53
+ ?.flatMap((method: any) => method.payment_currencies)
54
+ ?.filter((currency: TPaymentCurrency) => {
55
+ return currency.type === 'credit';
56
+ }) || []
57
+ );
58
+ }, [settings]);
59
+
46
60
  const { data: creditSummary } = useRequest(fetchCreditSummary, {
47
61
  defaultParams: [customerId],
48
62
  refreshDeps: [creditTab === CreditTab.OVERVIEW],
63
+ onSuccess: (data) => {
64
+ if (creditTab === CreditTab.OVERVIEW) {
65
+ const filteredCurrencies = creditCurrencies.filter((currency: TPaymentCurrency) => {
66
+ return data.grants?.[currency.id];
67
+ });
68
+ conditionalSection?.hideRender(filteredCurrencies.length === 0);
69
+ }
70
+ },
49
71
  });
50
72
 
51
73
  // 渲染信用概览卡片
@@ -141,20 +163,11 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
141
163
  );
142
164
  };
143
165
 
144
- // 获取有信用额度的货币
145
- const creditCurrencies =
146
- settings?.paymentMethods
147
- ?.filter((method: any) => method.type === 'arcblock')
148
- ?.flatMap((method: any) => method.payment_currencies)
149
- ?.filter((currency: TPaymentCurrency) => {
150
- const currencyId = currency.id as string;
151
- const grantData = creditSummary?.grants?.[currencyId];
152
- return grantData;
153
- }) || [];
154
-
155
- if (creditCurrencies.length === 0) {
156
- return null;
157
- }
166
+ const filteredCreditCurrencies = useMemo(() => {
167
+ return creditCurrencies.filter((currency: TPaymentCurrency) => {
168
+ return creditSummary?.grants?.[currency.id];
169
+ });
170
+ }, [creditCurrencies, creditSummary?.grants]);
158
171
 
159
172
  return (
160
173
  <Stack sx={{ width: '100%' }}>
@@ -192,10 +205,10 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
192
205
  gridTemplateColumns: 'repeat(1, 1fr)',
193
206
  },
194
207
  }}>
195
- {creditCurrencies.map(renderCreditOverviewCard)}
208
+ {filteredCreditCurrencies.map(renderCreditOverviewCard)}
196
209
  </Box>
197
210
 
198
- {creditCurrencies.length === 0 && (
211
+ {filteredCreditCurrencies.length === 0 && (
199
212
  <Box
200
213
  sx={{
201
214
  display: 'flex',
@@ -8,8 +8,9 @@ import {
8
8
  validatePhoneNumber,
9
9
  getPhoneUtil,
10
10
  validatePostalCode,
11
+ FormLabel,
11
12
  } from '@blocklet/payment-react';
12
- import { FormLabel, Stack } from '@mui/material';
13
+ import { Stack } from '@mui/material';
13
14
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
14
15
  import isEmail from 'validator/es/lib/isEmail';
15
16
  import { useMount } from 'ahooks';
@@ -1,7 +1,7 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { FormInput } from '@blocklet/payment-react';
2
+ import { FormInput, FormLabel } from '@blocklet/payment-react';
3
3
  import { AddOutlined, Autorenew, DeleteOutlineOutlined, FormatAlignLeft } from '@mui/icons-material';
4
- import { Box, Button, Divider, IconButton, Stack, TextField, InputAdornment, Tooltip, FormLabel } from '@mui/material';
4
+ import { Box, Button, Divider, IconButton, Stack, TextField, InputAdornment, Tooltip } from '@mui/material';
5
5
  import { useEffect, useRef, useState, useCallback } from 'react';
6
6
  import { useFieldArray, useFormContext } from 'react-hook-form';
7
7
  import { isObject, debounce } from 'lodash';
@@ -1,7 +1,7 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { api, formatError, useMobile, getCustomerAvatar } from '@blocklet/payment-react';
2
+ import { api, formatError, useMobile, getCustomerAvatar, FormLabel } from '@blocklet/payment-react';
3
3
  import type { TCustomer, TPaymentCurrency } from '@blocklet/payment-types';
4
- import { Box, Stack, Autocomplete, TextField, Button, Avatar, FormLabel, Typography } from '@mui/material';
4
+ import { Box, Stack, Autocomplete, TextField, Button, Avatar, Typography } from '@mui/material';
5
5
  import { useSetState } from 'ahooks';
6
6
  import Toast from '@arcblock/ux/lib/Toast';
7
7
  import Dialog from '@arcblock/ux/lib/Dialog';
@@ -1,6 +1,6 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
- import { FormInput } from '@blocklet/payment-react';
3
- import { FormControl, Select, MenuItem, Typography, Box, FormHelperText, Stack, FormLabel } from '@mui/material';
2
+ import { FormInput, FormLabel } from '@blocklet/payment-react';
3
+ import { FormControl, Select, MenuItem, Typography, Box, FormHelperText, Stack } from '@mui/material';
4
4
  import { useFormContext, useWatch } from 'react-hook-form';
5
5
  import { InfoOutlined } from '@mui/icons-material';
6
6
 
@@ -38,7 +38,7 @@ export default function MeterProducts({ meterId, meter = undefined }: MeterProdu
38
38
  const [loading, setLoading] = useState(false);
39
39
  const [creating, setCreating] = useState(false);
40
40
  const [error, setError] = useState<string | null>(null);
41
- const [activeTab, setActiveTab] = useState<ProductType>('meter');
41
+ const [activeTab, setActiveTab] = useState<ProductType>('credit');
42
42
 
43
43
  const loadProducts = async (type: ProductType = activeTab) => {
44
44
  setLoading(true);
@@ -183,8 +183,8 @@ export default function MeterProducts({ meterId, meter = undefined }: MeterProdu
183
183
  color: 'primary.main',
184
184
  },
185
185
  }}>
186
- <Tab label={t('admin.meter.products.meterService')} value="meter" />
187
186
  <Tab label={t('admin.meter.products.creditCharge')} value="credit" />
187
+ <Tab label={t('admin.meter.products.meterService')} value="meter" />
188
188
  </Tabs>
189
189
  </Stack>
190
190
  <Stack
@@ -1,8 +1,8 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { api, findCurrency, formatError, formatPrice, usePaymentContext } from '@blocklet/payment-react';
3
+ import { api, findCurrency, formatError, formatPrice, usePaymentContext, FormLabel } from '@blocklet/payment-react';
4
4
  import type { TPrice, TProduct, TProductExpanded } from '@blocklet/payment-types';
5
- import { Box, Checkbox, FormControlLabel, FormLabel, Stack, TextField, Typography } from '@mui/material';
5
+ import { Box, Checkbox, FormControlLabel, Stack, TextField, Typography } from '@mui/material';
6
6
  import { useSetState } from 'ahooks';
7
7
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
8
8
 
@@ -10,6 +10,7 @@ import { Link } from 'react-router-dom';
10
10
  import { styled } from '@mui/system';
11
11
  import { debounce } from '../../../libs/util';
12
12
  import CustomerLink from '../../customer/link';
13
+ import { useConditionalSection } from '../../conditional-section';
13
14
 
14
15
  const fetchData = (
15
16
  params: Record<string, any> = {}
@@ -47,7 +48,6 @@ type ListProps = {
47
48
  status?: string;
48
49
  customer_id?: string;
49
50
  currency_id?: string;
50
- setHasRevenues?: (hasRevenues: boolean) => void;
51
51
  };
52
52
 
53
53
  const getListKey = (props: ListProps) => {
@@ -57,14 +57,10 @@ const getListKey = (props: ListProps) => {
57
57
  return 'payouts-mine';
58
58
  };
59
59
 
60
- export default function CustomerRevenueList({
61
- currency_id = '',
62
- status = '',
63
- customer_id = '',
64
- setHasRevenues = () => {},
65
- }: ListProps) {
60
+ export default function CustomerRevenueList({ currency_id = '', status = '', customer_id = '' }: ListProps) {
66
61
  const { t } = useLocaleContext();
67
62
  const { isMobile } = useMobile('sm');
63
+ const conditionalSection = useConditionalSection();
68
64
 
69
65
  const listKey = getListKey({ customer_id });
70
66
  const defaultPageSize = useDefaultPageSize(10);
@@ -87,12 +83,11 @@ export default function CustomerRevenueList({
87
83
  debounce(() => {
88
84
  fetchData(search).then((res: any) => {
89
85
  setData(res);
90
- if (setHasRevenues) {
91
- setHasRevenues(res.count > 0);
92
- }
86
+ const hasData = res.count > 0;
87
+ conditionalSection?.hideRender(!hasData);
93
88
  });
94
89
  }, 300)();
95
- }, [search]);
90
+ }, [search, conditionalSection]);
96
91
 
97
92
  const columns = [
98
93
  {
@@ -18,6 +18,7 @@ type Props = {
18
18
  disabled?: boolean;
19
19
  selectSX?: SxProps;
20
20
  currencyFilter?: (currency: any) => boolean;
21
+ hideMethod?: boolean;
21
22
  };
22
23
 
23
24
  export default function CurrencySelect({
@@ -29,6 +30,7 @@ export default function CurrencySelect({
29
30
  disabled = false,
30
31
  selectSX = {},
31
32
  currencyFilter = () => true,
33
+ hideMethod = false,
32
34
  }: Props) {
33
35
  const { t } = useLocaleContext();
34
36
  const { settings } = usePaymentContext();
@@ -80,7 +82,7 @@ export default function CurrencySelect({
80
82
  justifyContent: 'flex-end',
81
83
  textAlign: 'right',
82
84
  }}>
83
- {selectedCurrency?.symbol} ({selectedPaymentMethod?.name})
85
+ {selectedCurrency?.symbol} {hideMethod ? '' : `(${selectedPaymentMethod?.name})`}
84
86
  {canSelect && <ArrowDropDown sx={{ color: 'text.secondary', fontSize: 21 }} />}
85
87
  </Typography>
86
88
  );
@@ -97,7 +99,7 @@ export default function CurrencySelect({
97
99
  value={value}
98
100
  renderValue={() => (
99
101
  <Typography variant="body1" sx={{ display: 'inline-flex', fontSize: '12px', color: 'text.secondary' }}>
100
- {selectedCurrency?.symbol} ({selectedPaymentMethod?.name})
102
+ {selectedCurrency?.symbol} {hideMethod ? '' : `(${selectedPaymentMethod?.name})`}
101
103
  </Typography>
102
104
  )}
103
105
  onChange={handleSelect}
@@ -113,18 +115,20 @@ export default function CurrencySelect({
113
115
  }
114
116
 
115
117
  return [
116
- <ListSubheader
117
- key={method.id}
118
- sx={{ fontSize: '0.875rem', color: 'text.secondary', lineHeight: '2.1875rem' }}>
119
- {method.name}
120
- </ListSubheader>,
118
+ hideMethod ? null : (
119
+ <ListSubheader
120
+ key={method.id}
121
+ sx={{ fontSize: '0.875rem', color: 'text.secondary', lineHeight: '2.1875rem' }}>
122
+ {method.name}
123
+ </ListSubheader>
124
+ ),
121
125
  ...filteredCurrencies.map((currency) => (
122
126
  <MenuItem key={currency.id} sx={{ pl: 3 }} value={currency.id}>
123
127
  <Stack direction="row" sx={{ width: '100%', justifyContent: 'space-between', gap: 2 }}>
124
- <Currency logo={currency.logo} name={currency.name} />
128
+ {hideMethod ? null : <Currency logo={currency.logo} name={currency.name} />}
125
129
  <Typography
126
130
  sx={{
127
- fontWeight: 'bold',
131
+ fontWeight: hideMethod ? 'normal' : 'bold',
128
132
  }}>
129
133
  {currency.symbol}
130
134
  </Typography>
@@ -50,7 +50,6 @@ import ProductSelect from '../payment-link/product-select';
50
50
  import Collapse from '../collapse';
51
51
  import { useProductsContext } from '../../contexts/products';
52
52
  import CurrencySelect from './currency-select';
53
- import MetadataForm from '../metadata/form';
54
53
  import { getProductByPriceId } from '../../libs/util';
55
54
  import InfoCard from '../info-card';
56
55
 
@@ -225,8 +224,11 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
225
224
  if (value !== null) {
226
225
  setValue(field.name, value);
227
226
  }
228
- if (value === 'one_time' && isCreditBilling) {
227
+ if (value === 'one_time') {
229
228
  setValue(getFieldName('model'), 'standard');
229
+ setValue(getFieldName('currency_id'), settings.baseCurrency?.id);
230
+ setValue(getFieldName('recurring.meter_id'), '');
231
+ setValue(getFieldName('recurring.usage_type'), 'licensed');
230
232
  }
231
233
  }}
232
234
  exclusive
@@ -299,7 +301,7 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
299
301
  fullWidth
300
302
  size="small"
301
303
  onChange={(e) => {
302
- if (e.target.value === 'standard' && isCreditBilling) {
304
+ if (e.target.value === 'standard') {
303
305
  setValue(getFieldName('currency_id'), settings.baseCurrency?.id);
304
306
  }
305
307
  field.onChange(e.target.value);
@@ -313,13 +315,15 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
313
315
  }}>
314
316
  <MenuItem value="standard">{t('admin.price.models.standard')}</MenuItem>
315
317
  <MenuItem value="package">{t('admin.price.models.package')}</MenuItem>
318
+ <MenuItem value="credit_metered" disabled={isCreditMode}>
319
+ {t('admin.price.models.creditMetered')}
320
+ </MenuItem>
316
321
  <MenuItem value="graduated" disabled>
317
322
  {t('admin.price.models.graduated')}
318
323
  </MenuItem>
319
324
  <MenuItem value="volume" disabled>
320
325
  {t('admin.price.models.volume')}
321
326
  </MenuItem>
322
- <MenuItem value="credit_metered">{t('admin.price.models.creditMetered')}</MenuItem>
323
327
  </Select>
324
328
  </Box>
325
329
  )}
@@ -771,7 +775,7 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
771
775
  }
772
776
  expanded={isLocked}
773
777
  style={{ width: INPUT_WIDTH }}>
774
- <Box sx={{ width: INPUT_WIDTH, mb: 2 }}>
778
+ <Box sx={{ width: INPUT_WIDTH, mb: 2, pl: 2, pr: 1 }}>
775
779
  {/* Credit 数量配置 */}
776
780
  <Controller
777
781
  name={getFieldName('metadata')}
@@ -815,6 +819,7 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
815
819
  }}
816
820
  value={field.value?.credit_config?.currency_id || creditCurrencies?.[0]?.id}
817
821
  disabled={isLocked}
822
+ hideMethod
818
823
  selectSX={{ '.MuiOutlinedInput-notchedOutline': { border: 'none' } }}
819
824
  />
820
825
  </InputAdornment>
@@ -1045,16 +1050,17 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
1045
1050
  },
1046
1051
  }}>
1047
1052
  <Stack
1048
- spacing={2}
1049
1053
  sx={{
1050
1054
  alignItems: 'flex-start',
1051
1055
  width: INPUT_WIDTH,
1056
+ pl: 2,
1057
+ pr: 1,
1052
1058
  }}>
1053
1059
  <Controller
1054
1060
  name={getFieldName('quantity_available')}
1055
1061
  control={control}
1056
1062
  render={({ field }) => (
1057
- <>
1063
+ <Box sx={{ width: '100%', mb: 2 }}>
1058
1064
  <FormLabel>{t('admin.price.quantityAvailable.label')}</FormLabel>
1059
1065
  <TextField
1060
1066
  {...field}
@@ -1065,14 +1071,23 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
1065
1071
  error={!quantityPositive(field.value)}
1066
1072
  helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
1067
1073
  />
1068
- </>
1074
+ <Typography
1075
+ variant="caption"
1076
+ sx={{
1077
+ color: 'text.secondary',
1078
+ display: 'block',
1079
+ mt: 0.5,
1080
+ }}>
1081
+ {t('admin.price.quantityAvailable.description')}
1082
+ </Typography>
1083
+ </Box>
1069
1084
  )}
1070
1085
  />
1071
1086
  <Controller
1072
1087
  name={getFieldName('quantity_limit_per_checkout')}
1073
1088
  control={control}
1074
1089
  render={({ field }) => (
1075
- <>
1090
+ <Box sx={{ width: '100%', mb: 2 }}>
1076
1091
  <FormLabel>{t('admin.price.quantityLimitPerCheckout.label')}</FormLabel>
1077
1092
  <TextField
1078
1093
  {...field}
@@ -1084,7 +1099,16 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
1084
1099
  error={!quantityPositive(field.value)}
1085
1100
  helperText={!quantityPositive(field.value) && t('admin.price.quantity.tip')}
1086
1101
  />
1087
- </>
1102
+ <Typography
1103
+ variant="caption"
1104
+ sx={{
1105
+ color: 'text.secondary',
1106
+ display: 'block',
1107
+ mt: 0.5,
1108
+ }}>
1109
+ {t('admin.price.quantityLimitPerCheckout.description')}
1110
+ </Typography>
1111
+ </Box>
1088
1112
  )}
1089
1113
  />
1090
1114
  <Controller
@@ -1097,10 +1121,10 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
1097
1121
  },
1098
1122
  }}
1099
1123
  render={({ field }) => (
1100
- <>
1124
+ <Box sx={{ width: '100%', mb: 2 }}>
1101
1125
  <FormLabel>{t('admin.price.nickname.label')}</FormLabel>
1102
1126
  <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
1103
- </>
1127
+ </Box>
1104
1128
  )}
1105
1129
  />
1106
1130
  <Controller
@@ -1113,14 +1137,21 @@ export default function PriceForm({ prefix = '', simple = false, productType = u
1113
1137
  },
1114
1138
  }}
1115
1139
  render={({ field }) => (
1116
- <>
1140
+ <Box sx={{ width: '100%', mb: 2 }}>
1117
1141
  <FormLabel>{t('admin.price.lookup_key.label')}</FormLabel>
1118
1142
  <TextField {...field} size="small" sx={{ width: INPUT_WIDTH }} inputProps={{ maxLength: 64 }} />
1119
- </>
1143
+ <Typography
1144
+ variant="caption"
1145
+ sx={{
1146
+ color: 'text.secondary',
1147
+ display: 'block',
1148
+ mt: 0.5,
1149
+ }}>
1150
+ {t('admin.price.lookup_key.description')}
1151
+ </Typography>
1152
+ </Box>
1120
1153
  )}
1121
1154
  />
1122
- {/* 元数据 */}
1123
- <MetadataForm title={t('common.metadata.label')} color="inherit" name={getFieldName('metadata')} />
1124
1155
  </Stack>
1125
1156
  </Collapse>
1126
1157
  </>
@@ -1,8 +1,8 @@
1
1
  /* eslint-disable no-nested-ternary */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { FormInput } from '@blocklet/payment-react';
3
+ import { FormInput, FormLabel } from '@blocklet/payment-react';
4
4
  import type { InferFormType, TProduct } from '@blocklet/payment-types';
5
- import { Box, Stack, Typography, FormLabel, Select, MenuItem } from '@mui/material';
5
+ import { Box, Stack, Typography, Select, MenuItem } from '@mui/material';
6
6
  import { useFormContext, useWatch, Controller } from 'react-hook-form';
7
7
 
8
8
  import Collapse from '../collapse';
@@ -46,7 +46,7 @@ export default function ProductForm({ simple = false }: Props) {
46
46
  rules={{ required: true }}
47
47
  render={({ field }) => (
48
48
  <Box sx={{ width: '100%' }}>
49
- <FormLabel sx={{ color: 'text.primary' }}>{t('admin.product.type.label')}</FormLabel>
49
+ <FormLabel sx={{ color: 'text.primary', fontSize: '0.875rem' }}>{t('admin.product.type.label')}</FormLabel>
50
50
  <Select {...field} fullWidth size="small">
51
51
  <MenuItem value="good">{t('admin.product.type.good')}</MenuItem>
52
52
  <MenuItem value="service">{t('admin.product.type.service')}</MenuItem>
@@ -135,11 +135,6 @@ export default function ProductForm({ simple = false }: Props) {
135
135
  <FormInput
136
136
  name="unit_label"
137
137
  label={t('admin.product.unit_label.label')}
138
- placeholder={
139
- productType === 'credit'
140
- ? t('admin.creditProduct.unitLabel.placeholder')
141
- : t('admin.product.unit_label.placeholder')
142
- }
143
138
  rules={{
144
139
  maxLength: { value: 12, message: t('common.maxLength', { len: 12 }) },
145
140
  }}
@@ -384,7 +384,7 @@ export default flat({
384
384
  },
385
385
  unit_label: {
386
386
  label: 'Unit label',
387
- placeholder: 'Seat',
387
+ placeholder: 'Unit',
388
388
  },
389
389
  billingType: {
390
390
  label: 'Billing type',
@@ -452,6 +452,7 @@ export default flat({
452
452
  lookup_key: {
453
453
  label: 'Lookup key',
454
454
  placeholder: '',
455
+ description: 'Lookup key is used to identify the price in the API',
455
456
  },
456
457
  recurring: {
457
458
  interval: 'Billing period',
@@ -526,6 +527,7 @@ export default flat({
526
527
  format: 'Available {num} pieces',
527
528
  noLimit: 'No limit on available quantity',
528
529
  valid: 'Available quantity must be greater than or equal to sold quantity',
530
+ description: 'Enter the number of units that can be sold, 0 means unlimited',
529
531
  },
530
532
  quantitySold: {
531
533
  label: 'Sold quantity',
@@ -536,6 +538,7 @@ export default flat({
536
538
  placeholder: '0 means unlimited',
537
539
  format: 'Limit {num} pieces per checkout',
538
540
  noLimit: 'No limit on quantity per checkout',
541
+ description: 'Enter the number of units that can be purchased in a single checkout, 0 means unlimited',
539
542
  },
540
543
  inventory: 'Inventory Settings',
541
544
  },
@@ -987,7 +990,7 @@ export default flat({
987
990
  viewAllActivity: 'View All Activity',
988
991
  pendingAmount: 'Outstanding Charges',
989
992
  grantCount: 'Grant Count',
990
- noGrantsDescription: "You don't have any credit grants yet. Please contact the administrator to add them.",
993
+ noGrantsDescription: "You don't have any credit grants yet.",
991
994
  addCredit: 'Add Credit',
992
995
  },
993
996
  },
@@ -1162,7 +1165,7 @@ export default flat({
1162
1165
  totalAmount: 'Total Amount',
1163
1166
  pendingAmount: 'Pending Amount',
1164
1167
  grantCount: 'Grant Count',
1165
- noGrantsDescription: "You don't have any credit grants yet. Please contact the administrator to add them.",
1168
+ noGrantsDescription: "You don't have any credit grants yet.",
1166
1169
  status: {
1167
1170
  granted: 'Active',
1168
1171
  pending: 'Pending',
@@ -361,7 +361,7 @@ export default flat({
361
361
  },
362
362
  unit_label: {
363
363
  label: '单位标签',
364
- placeholder: '座位',
364
+ placeholder: '单位',
365
365
  },
366
366
  billingType: {
367
367
  label: '计费类型',
@@ -424,6 +424,7 @@ export default flat({
424
424
  lookup_key: {
425
425
  label: '查找键',
426
426
  placeholder: '',
427
+ description: '查找键用于在API中识别价格',
427
428
  },
428
429
  recurring: {
429
430
  interval: '计费周期',
@@ -495,6 +496,7 @@ export default flat({
495
496
  format: '可售{num}件',
496
497
  noLimit: '不限制可售数量',
497
498
  valid: '可售数量不得少于已售数量',
499
+ description: '输入可售数量,0表示不限制可售数量',
498
500
  },
499
501
  quantitySold: {
500
502
  label: '已售数量',
@@ -505,6 +507,7 @@ export default flat({
505
507
  placeholder: '0表示无限制',
506
508
  format: '单次最多购买{num}件',
507
509
  noLimit: '不限制单次购买数量',
510
+ description: '输入限制单次购买的最大数量, 0表示无限制',
508
511
  },
509
512
  inventory: '库存设置',
510
513
  },
@@ -945,7 +948,7 @@ export default flat({
945
948
  totalAmount: '总额度',
946
949
  pendingAmount: '欠费额度',
947
950
  grantCount: '额度数量',
948
- noGrantsDescription: '您还没有任何信用额度,请联系管理员添加。',
951
+ noGrantsDescription: '您还没有任何信用额度',
949
952
  status: {
950
953
  granted: '生效中',
951
954
  pending: '待生效',
@@ -1121,7 +1124,7 @@ export default flat({
1121
1124
  totalAmount: '总额度',
1122
1125
  pendingAmount: '欠费额度',
1123
1126
  grantCount: '额度数量',
1124
- noGrantsDescription: '您还没有任何信用额度,请联系管理员添加。',
1127
+ noGrantsDescription: '您还没有任何信用额度',
1125
1128
  status: {
1126
1129
  granted: '生效中',
1127
1130
  pending: '待生效',
@@ -42,10 +42,6 @@ const fetchData = async (
42
42
  ): Promise<{
43
43
  customer: TCustomerExpanded;
44
44
  summary: { [key: string]: GroupedBN };
45
- creditSummary?: {
46
- grants?: { [currencyId: string]: { totalAmount: string; remainingAmount: string } };
47
- pendingAmount?: { [currencyId: string]: string };
48
- } | null;
49
45
  }> => {
50
46
  const [customer, summary] = await Promise.all([
51
47
  api.get(`/api/customers/${id}`).then((res) => res.data),
@@ -311,15 +307,11 @@ export default function CustomerDetail(props: { id: string }) {
311
307
  }}>
312
308
  <Box className="payment-link-column-1" sx={{ flex: 1, gap: 2.5, display: 'flex', flexDirection: 'column' }}>
313
309
  {/* 信用管理区域 */}
314
- {data.creditSummary && (
315
- <>
316
- <Box className="section">
317
- <SectionHeader title={t('admin.creditGrants.title')} mb={0} />
318
- <CreditOverview customerId={props.id} settings={settings} mode="dashboard" />
319
- </Box>
320
- <Divider />
321
- </>
322
- )}
310
+ <Box className="section">
311
+ <SectionHeader title={t('admin.creditGrants.title')} mb={0} />
312
+ <CreditOverview customerId={props.id} settings={settings} mode="dashboard" />
313
+ </Box>
314
+ <Divider />
323
315
 
324
316
  <Box className="section" sx={{ containerType: 'inline-size' }}>
325
317
  <SectionHeader title={t('admin.details')} />
@@ -40,6 +40,7 @@ import { memo, useEffect, useState } from 'react';
40
40
  import { useNavigate, useSearchParams } from 'react-router-dom';
41
41
  import { joinURL } from 'ufo';
42
42
  import CreditOverview from '../../components/customer/credit-overview';
43
+ import ConditionalSection from '../../components/conditional-section';
43
44
 
44
45
  import { useTransitionContext } from '../../components/progress-bar';
45
46
  import CurrentSubscriptions from '../../components/subscription/portal/list';
@@ -214,7 +215,6 @@ export default function CustomerHome() {
214
215
  const navigate = useNavigate();
215
216
  const [subscriptionStatus, setSubscriptionStatus] = useState(false);
216
217
  const [hasSubscriptions, setHasSubscriptions] = useState(false);
217
- const [hasRevenues, setHasRevenues] = useState(false);
218
218
  const { startTransition } = useTransitionContext();
219
219
  const {
220
220
  data,
@@ -480,15 +480,15 @@ export default function CustomerHome() {
480
480
  );
481
481
 
482
482
  // 独立的Credit Card组件
483
- const CreditCard = loadingCard ? (
484
- <CardSkeleton height={400} />
485
- ) : (
486
- <Box className="base-card section section-credit">
487
- <Box className="section-header" sx={{ mb: 2 }}>
488
- <Typography variant="h3">{t('admin.creditGrants.title')}</Typography>
483
+ const CreditCard = (
484
+ <ConditionalSection skeleton={loadingCard}>
485
+ <Box className="base-card section section-credit">
486
+ <Box className="section-header" sx={{ mb: 2 }}>
487
+ <Typography variant="h3">{t('admin.creditGrants.title')}</Typography>
488
+ </Box>
489
+ <CreditOverview customerId={data?.id || ''} settings={settings} />
489
490
  </Box>
490
- <CreditOverview customerId={data?.id} settings={settings} />
491
- </Box>
491
+ </ConditionalSection>
492
492
  );
493
493
 
494
494
  const InvoiceCard = loadingCard ? (
@@ -519,13 +519,15 @@ export default function CustomerHome() {
519
519
  </Box>
520
520
  );
521
521
 
522
- const RevenueCard = loadingCard ? null : (
523
- <Box className="base-card section section-revenue" sx={{ visibility: hasRevenues ? 'visible' : 'hidden' }}>
524
- <Box className="section-header">
525
- <Typography variant="h3">{t('customer.payout.title')}</Typography>
522
+ const RevenueCard = (
523
+ <ConditionalSection skeleton={loadingCard}>
524
+ <Box className="base-card section section-revenue">
525
+ <Box className="section-header">
526
+ <Typography variant="h3">{t('customer.payout.title')}</Typography>
527
+ </Box>
528
+ <CustomerRevenueList />
526
529
  </Box>
527
- <CustomerRevenueList setHasRevenues={setHasRevenues} />
528
- </Box>
530
+ </ConditionalSection>
529
531
  );
530
532
 
531
533
  return (