@blocklet/payment-react 1.20.13 → 1.20.14

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.
@@ -6,13 +6,13 @@
6
6
  /* eslint-disable react/no-unstable-nested-components */
7
7
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
8
8
  import type { Paginated, TCreditTransactionExpanded } from '@blocklet/payment-types';
9
- import { Box, Typography, Stack, Link, Grid } from '@mui/material';
9
+ import { Box, Typography, Grid, Stack, Link, Button, Popover } from '@mui/material';
10
10
  import { useRequest } from 'ahooks';
11
11
  // eslint-disable-next-line import/no-extraneous-dependencies
12
12
  import { useNavigate } from 'react-router-dom';
13
13
  import React, { useCallback, useEffect, useRef, useState } from 'react';
14
- import { joinURL } from 'ufo';
15
14
  import { styled } from '@mui/system';
15
+ import { joinURL } from 'ufo';
16
16
  import DateRangePicker, { type DateRangeValue } from '../../components/date-range-picker';
17
17
  // eslint-disable-next-line import/no-extraneous-dependencies
18
18
 
@@ -21,6 +21,7 @@ import { usePaymentContext } from '../../contexts/payment';
21
21
  import api from '../../libs/api';
22
22
  import Table from '../../components/table';
23
23
  import { createLink, handleNavigation } from '../../libs/navigation';
24
+ import SourceDataViewer from '../../components/source-data-viewer';
24
25
 
25
26
  type Result = Paginated<TCreditTransactionExpanded>;
26
27
 
@@ -43,6 +44,7 @@ type Props = {
43
44
  onTableDataChange?: Function;
44
45
  showAdminColumns?: boolean;
45
46
  showTimeFilter?: boolean;
47
+ includeGrants?: boolean; // Enable unified cash flow view
46
48
  source?: string;
47
49
  mode?: 'dashboard' | 'portal';
48
50
  };
@@ -59,6 +61,18 @@ const getGrantDetailLink = (grantId: string, inDashboard: boolean) => {
59
61
  };
60
62
  };
61
63
 
64
+ const getInvoiceDetailLink = (invoiceId: string, inDashboard: boolean) => {
65
+ let path = `/customer/invoice/${invoiceId}`;
66
+ if (inDashboard) {
67
+ path = `/admin/billing/${invoiceId}`;
68
+ }
69
+
70
+ return {
71
+ link: createLink(path),
72
+ connect: false,
73
+ };
74
+ };
75
+
62
76
  const TransactionsTable = React.memo((props: Props) => {
63
77
  const {
64
78
  pageSize,
@@ -68,6 +82,7 @@ const TransactionsTable = React.memo((props: Props) => {
68
82
  onTableDataChange,
69
83
  showAdminColumns = false,
70
84
  showTimeFilter = false,
85
+ includeGrants = false,
71
86
  source,
72
87
  mode = 'portal',
73
88
  } = props;
@@ -95,6 +110,12 @@ const TransactionsTable = React.memo((props: Props) => {
95
110
  end: undefined,
96
111
  });
97
112
 
113
+ // Source Data Popover state
114
+ const [sourceDataPopover, setSourceDataPopover] = useState<{
115
+ anchorEl: HTMLElement | null;
116
+ data: any;
117
+ }>({ anchorEl: null, data: null });
118
+
98
119
  const handleDateRangeChange = useCallback((newValue: DateRangeValue) => {
99
120
  setFilters(newValue);
100
121
  setSearch((prev) => ({
@@ -113,9 +134,10 @@ const TransactionsTable = React.memo((props: Props) => {
113
134
  subscription_id,
114
135
  credit_grant_id,
115
136
  source,
137
+ include_grants: includeGrants,
116
138
  }),
117
139
  {
118
- refreshDeps: [search, effectiveCustomerId, subscription_id, credit_grant_id, source],
140
+ refreshDeps: [search, effectiveCustomerId, subscription_id, credit_grant_id, source, includeGrants],
119
141
  }
120
142
  );
121
143
 
@@ -143,35 +165,55 @@ const TransactionsTable = React.memo((props: Props) => {
143
165
  align: 'right',
144
166
  options: {
145
167
  customBodyRenderLite: (_: string, index: number) => {
146
- const transaction = data?.list[index] as TCreditTransactionExpanded;
147
- const unit = transaction.meter?.unit || transaction.paymentCurrency.symbol;
168
+ const item = data?.list[index] as any;
169
+ const isGrant = item.activity_type === 'grant';
170
+ const amount = isGrant ? item.amount : item.credit_amount;
171
+ const currency = item.paymentCurrency || item.currency;
172
+ const unit = !isGrant && item.meter?.unit ? item.meter.unit : currency?.symbol;
173
+ const displayAmount = formatBNStr(amount, currency?.decimal || 0);
174
+
175
+ if (!includeGrants) {
176
+ return (
177
+ <Typography>
178
+ {displayAmount} {unit}
179
+ </Typography>
180
+ );
181
+ }
182
+
148
183
  return (
149
- <Typography>
150
- {formatBNStr(transaction.credit_amount, transaction.paymentCurrency.decimal)} {unit}
184
+ <Typography
185
+ sx={{
186
+ color: isGrant ? 'success.main' : 'error.main',
187
+ }}>
188
+ {isGrant ? '+' : '-'} {displayAmount} {unit}
151
189
  </Typography>
152
190
  );
153
191
  },
154
192
  },
155
193
  },
156
- !credit_grant_id && {
194
+ {
157
195
  label: t('common.creditGrant'),
158
196
  name: 'credit_grant',
159
197
  options: {
160
198
  customBodyRenderLite: (_: string, index: number) => {
161
- const transaction = data?.list[index] as TCreditTransactionExpanded;
199
+ const item = data?.list[index] as any;
200
+ const isGrant = item.activity_type === 'grant';
201
+
202
+ const grantName = isGrant ? item.name : item.creditGrant.name;
203
+ const grantId = isGrant ? item.id : item.credit_grant_id;
162
204
  return (
163
205
  <Stack
164
206
  direction="row"
165
207
  spacing={1}
166
208
  onClick={(e) => {
167
- const link = getGrantDetailLink(transaction.credit_grant_id, isAdmin && mode === 'dashboard');
209
+ const link = getGrantDetailLink(grantId, isAdmin && mode === 'dashboard');
168
210
  handleNavigation(e, link.link, navigate);
169
211
  }}
170
212
  sx={{
171
213
  alignItems: 'center',
172
214
  }}>
173
- <Typography variant="body2" sx={{ color: 'text.link', cursor: 'pointer' }}>
174
- {transaction.creditGrant.name || `Grant ${transaction.credit_grant_id.slice(-6)}`}
215
+ <Typography variant="body2" sx={{ cursor: 'pointer' }}>
216
+ {grantName || `Grant ${grantId.slice(-6)}`}
175
217
  </Typography>
176
218
  </Stack>
177
219
  );
@@ -180,12 +222,19 @@ const TransactionsTable = React.memo((props: Props) => {
180
222
  },
181
223
  {
182
224
  label: t('common.description'),
183
- name: 'subscription',
225
+ name: 'description',
184
226
  options: {
185
227
  customBodyRenderLite: (_: string, index: number) => {
186
- const transaction = data?.list[index] as TCreditTransactionExpanded;
228
+ const item = data?.list[index] as any;
229
+ const isGrant = item.activity_type === 'grant';
230
+ const description = isGrant
231
+ ? item.name || item.description || 'Credit Granted'
232
+ : item.subscription?.description || item.description || `${item.meter_event_name} usage`;
233
+
187
234
  return (
188
- <Typography variant="body2">{transaction.subscription?.description || transaction.description}</Typography>
235
+ <Typography variant="body2" sx={{ fontWeight: 400 }}>
236
+ {description}
237
+ </Typography>
189
238
  );
190
239
  },
191
240
  },
@@ -218,15 +267,60 @@ const TransactionsTable = React.memo((props: Props) => {
218
267
  name: 'created_at',
219
268
  options: {
220
269
  customBodyRenderLite: (_: string, index: number) => {
221
- const transaction = data?.list[index] as TCreditTransactionExpanded;
270
+ const item = data?.list[index] as any;
222
271
  return (
223
- <Typography variant="body2">
224
- {formatToDate(transaction.created_at, locale, 'YYYY-MM-DD HH:mm:ss')}
272
+ <Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
273
+ {formatToDate(item.created_at, locale, 'YYYY-MM-DD HH:mm')}
225
274
  </Typography>
226
275
  );
227
276
  },
228
277
  },
229
278
  },
279
+ {
280
+ label: t('common.actions'),
281
+ name: 'actions',
282
+ options: {
283
+ customBodyRenderLite: (_: string, index: number) => {
284
+ const item = data?.list[index] as any;
285
+ const isGrant = item.activity_type === 'grant';
286
+ const invoiceId = isGrant ? item.metadata?.invoice_id : null;
287
+ const sourceData = !isGrant && item.meterEvent?.source_data;
288
+
289
+ return (
290
+ <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
291
+ {isGrant && invoiceId && (
292
+ <Button
293
+ variant="text"
294
+ size="small"
295
+ color="primary"
296
+ onClick={(e) => {
297
+ e.preventDefault();
298
+ const link = getInvoiceDetailLink(invoiceId, isAdmin && mode === 'dashboard');
299
+ handleNavigation(e, link.link, navigate);
300
+ }}>
301
+ {t('common.viewInvoice')}
302
+ </Button>
303
+ )}
304
+ {sourceData && (
305
+ <Button
306
+ variant="text"
307
+ size="small"
308
+ color="primary"
309
+ onClick={(e) => {
310
+ e.preventDefault();
311
+ setSourceDataPopover({
312
+ anchorEl: e.currentTarget,
313
+ data: sourceData,
314
+ });
315
+ }}>
316
+ {t('common.viewSourceData')}
317
+ </Button>
318
+ )}
319
+ </Box>
320
+ );
321
+ },
322
+ },
323
+ },
230
324
  ].filter(Boolean);
231
325
 
232
326
  const onTableChange = ({ page, rowsPerPage }: any) => {
@@ -279,6 +373,35 @@ const TransactionsTable = React.memo((props: Props) => {
279
373
  mobileTDFlexDirection="row"
280
374
  emptyNodeText={t('admin.creditTransactions.noTransactions')}
281
375
  />
376
+ <Popover
377
+ open={Boolean(sourceDataPopover.anchorEl)}
378
+ anchorEl={sourceDataPopover.anchorEl}
379
+ onClose={() => setSourceDataPopover({ anchorEl: null, data: null })}
380
+ anchorOrigin={{
381
+ vertical: 'bottom',
382
+ horizontal: 'left',
383
+ }}
384
+ transformOrigin={{
385
+ vertical: 'top',
386
+ horizontal: 'left',
387
+ }}
388
+ slotProps={{
389
+ paper: {
390
+ sx: {
391
+ minWidth: {
392
+ xs: 0,
393
+ md: 320,
394
+ },
395
+ maxHeight: 450,
396
+ p: {
397
+ xs: 1,
398
+ md: 3,
399
+ },
400
+ },
401
+ },
402
+ }}>
403
+ {sourceDataPopover.data && <SourceDataViewer data={sourceDataPopover.data} showGroups />}
404
+ </Popover>
282
405
  </TableRoot>
283
406
  );
284
407
  });
@@ -308,6 +431,7 @@ export default function CreditTransactionsList(rawProps: Props) {
308
431
  onTableDataChange: () => {},
309
432
  showAdminColumns: false,
310
433
  showTimeFilter: false,
434
+ includeGrants: false,
311
435
  mode: 'portal',
312
436
  },
313
437
  rawProps
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import AutoTopupModal from './components/auto-topup/modal';
40
40
  import AutoTopup from './components/auto-topup';
41
41
  import Collapse from './components/collapse';
42
42
  import PromotionCode from './components/promotion-code';
43
+ import SourceDataViewer from './components/source-data-viewer';
43
44
 
44
45
  export { PaymentThemeProvider } from './theme';
45
46
 
@@ -104,4 +105,5 @@ export {
104
105
  AutoTopup,
105
106
  Collapse,
106
107
  PromotionCode,
108
+ SourceDataViewer,
107
109
  };
@@ -110,6 +110,7 @@ export default flat({
110
110
  cancel: 'Cancel',
111
111
  },
112
112
  paymentMethod: 'Payment Method',
113
+ viewInvoice: 'View Invoice',
113
114
  },
114
115
  payment: {
115
116
  checkout: {
@@ -110,6 +110,7 @@ export default flat({
110
110
  cancel: '取消',
111
111
  },
112
112
  paymentMethod: '支付方式',
113
+ viewInvoice: '查看账单',
113
114
  },
114
115
  payment: {
115
116
  checkout: {
@@ -173,10 +173,7 @@ function PaymentInner({
173
173
  settings.baseCurrency;
174
174
  const method = paymentMethods.find((x: any) => x.id === currency.payment_method_id) as TPaymentMethod;
175
175
 
176
- useEffect(() => {
177
- if (onChange) {
178
- onChange(methods.getValues());
179
- }
176
+ const recalculatePromotion = () => {
180
177
  if ((state.checkoutSession as any)?.discounts?.length) {
181
178
  api
182
179
  .post(`/api/checkout-sessions/${state.checkoutSession.id}/recalculate-promotion`, {
@@ -186,11 +183,22 @@ function PaymentInner({
186
183
  onPromotionUpdate();
187
184
  });
188
185
  }
186
+ };
187
+
188
+ useEffect(() => {
189
+ if (onChange) {
190
+ onChange(methods.getValues());
191
+ }
192
+ recalculatePromotion();
189
193
  }, [currencyId]); // eslint-disable-line
190
194
 
191
195
  const onUpsell = async (from: string, to: string) => {
192
196
  try {
193
197
  const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/upsell`, { from, to });
198
+ if (data.discounts?.length) {
199
+ recalculatePromotion();
200
+ return;
201
+ }
194
202
  setState({ checkoutSession: data });
195
203
  } catch (err) {
196
204
  console.error(err);
@@ -201,6 +209,10 @@ function PaymentInner({
201
209
  const onDownsell = async (from: string) => {
202
210
  try {
203
211
  const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/downsell`, { from });
212
+ if (data.discounts?.length) {
213
+ recalculatePromotion();
214
+ return;
215
+ }
204
216
  setState({ checkoutSession: data });
205
217
  } catch (err) {
206
218
  console.error(err);
@@ -211,6 +223,10 @@ function PaymentInner({
211
223
  const onApplyCrossSell = async (to: string) => {
212
224
  try {
213
225
  const { data } = await api.put(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`, { to });
226
+ if (data.discounts?.length) {
227
+ recalculatePromotion();
228
+ return;
229
+ }
214
230
  setState({ checkoutSession: data });
215
231
  } catch (err) {
216
232
  console.error(err);
@@ -224,6 +240,10 @@ function PaymentInner({
224
240
  itemId,
225
241
  quantity,
226
242
  });
243
+ if (data.discounts?.length) {
244
+ recalculatePromotion();
245
+ return;
246
+ }
227
247
  setState({ checkoutSession: data });
228
248
  } catch (err) {
229
249
  console.error(err);
@@ -234,6 +254,10 @@ function PaymentInner({
234
254
  const onCancelCrossSell = async () => {
235
255
  try {
236
256
  const { data } = await api.delete(`/api/checkout-sessions/${state.checkoutSession.id}/cross-sell`);
257
+ if (data.discounts?.length) {
258
+ recalculatePromotion();
259
+ return;
260
+ }
237
261
  setState({ checkoutSession: data });
238
262
  } catch (err) {
239
263
  console.error(err);
@@ -247,6 +271,10 @@ function PaymentInner({
247
271
  priceId,
248
272
  amount: fromTokenToUnit(amount, currency.decimal).toString(),
249
273
  });
274
+ if (data.discounts?.length) {
275
+ recalculatePromotion();
276
+ return;
277
+ }
250
278
  setState({ checkoutSession: data });
251
279
  } catch (err) {
252
280
  console.error(err);
@@ -92,9 +92,9 @@ type Props = {
92
92
  showFeatures?: boolean;
93
93
  };
94
94
 
95
- async function fetchCrossSell(id: string) {
95
+ async function fetchCrossSell(id: string, skipError = true) {
96
96
  try {
97
- const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell`);
97
+ const { data } = await api.get(`/api/checkout-sessions/${id}/cross-sell?skipError=${skipError}`);
98
98
  if (!data.error) {
99
99
  return data;
100
100
  }
@@ -167,8 +167,8 @@ export default function PaymentSummary({
167
167
  const { isMobile } = useMobile();
168
168
  const { paymentState, ...settings } = usePaymentContext();
169
169
  const [state, setState] = useSetState({ loading: false, shake: false, expanded: items?.length < 3 });
170
- const { data, runAsync } = useRequest(() =>
171
- checkoutSessionId ? fetchCrossSell(checkoutSessionId) : Promise.resolve(null)
170
+ const { data, runAsync } = useRequest((skipError) =>
171
+ checkoutSessionId ? fetchCrossSell(checkoutSessionId, skipError) : Promise.resolve(null)
172
172
  );
173
173
 
174
174
  const sessionDiscounts = (checkoutSession as any)?.discounts || [];
@@ -238,17 +238,17 @@ export default function PaymentSummary({
238
238
 
239
239
  const handleUpsell = async (from: string, to: string) => {
240
240
  await onUpsell!(from, to);
241
- runAsync();
241
+ runAsync(false);
242
242
  };
243
243
 
244
244
  const handleQuantityChange = async (itemId: string, quantity: number) => {
245
245
  await onQuantityChange!(itemId, quantity);
246
- runAsync();
246
+ runAsync(false);
247
247
  };
248
248
 
249
249
  const handleDownsell = async (from: string) => {
250
250
  await onDownsell!(from);
251
- runAsync();
251
+ runAsync(false);
252
252
  };
253
253
 
254
254
  const handleApplyCrossSell = async () => {