payment-kit 1.18.2 → 1.18.4

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 (44) hide show
  1. package/api/src/index.ts +4 -21
  2. package/api/src/libs/logger.ts +2 -1
  3. package/api/src/routes/checkout-sessions.ts +7 -7
  4. package/api/src/routes/customers.ts +6 -5
  5. package/api/src/routes/events.ts +3 -2
  6. package/api/src/routes/invoices.ts +4 -3
  7. package/api/src/routes/payment-currencies.ts +2 -2
  8. package/api/src/routes/payment-intents.ts +6 -6
  9. package/api/src/routes/payment-links.ts +1 -1
  10. package/api/src/routes/payment-stats.ts +3 -2
  11. package/api/src/routes/payouts.ts +6 -5
  12. package/api/src/routes/pricing-table.ts +2 -2
  13. package/api/src/routes/settings.ts +251 -2
  14. package/api/src/routes/subscription-items.ts +3 -3
  15. package/api/src/routes/subscriptions.ts +15 -15
  16. package/api/src/routes/usage-records.ts +2 -2
  17. package/api/src/routes/webhook-attempts.ts +2 -1
  18. package/api/src/routes/webhook-endpoints.ts +3 -2
  19. package/api/src/store/migrations/20250211-setting.ts +10 -0
  20. package/api/src/store/migrations/20250214-setting-component.ts +22 -0
  21. package/api/src/store/models/index.ts +6 -1
  22. package/api/src/store/models/setting.ts +84 -0
  23. package/api/src/store/models/types.ts +2 -0
  24. package/blocklet.yml +13 -1
  25. package/package.json +24 -24
  26. package/src/app.tsx +6 -1
  27. package/src/components/invoice/list.tsx +1 -3
  28. package/src/components/payment-intent/list.tsx +5 -6
  29. package/src/components/payouts/list.tsx +3 -3
  30. package/src/components/refund/list.tsx +3 -6
  31. package/src/components/subscription/list.tsx +1 -2
  32. package/src/locales/en.tsx +100 -1
  33. package/src/locales/zh.tsx +96 -0
  34. package/src/pages/admin/customers/customers/index.tsx +27 -16
  35. package/src/pages/admin/developers/webhooks/index.tsx +14 -12
  36. package/src/pages/admin/products/links/index.tsx +22 -15
  37. package/src/pages/admin/products/pricing-tables/index.tsx +16 -14
  38. package/src/pages/admin/products/products/index.tsx +19 -15
  39. package/src/pages/home.tsx +1 -1
  40. package/src/pages/integrations/donations/edit-form.tsx +349 -0
  41. package/src/pages/integrations/donations/index.tsx +360 -0
  42. package/src/pages/integrations/donations/preview.tsx +229 -0
  43. package/src/pages/integrations/index.tsx +80 -0
  44. package/src/pages/integrations/overview.tsx +121 -0
@@ -0,0 +1,349 @@
1
+ import { useForm, Controller, FormProvider } from 'react-hook-form';
2
+ import {
3
+ Box,
4
+ Grid,
5
+ TextField,
6
+ Switch,
7
+ Button,
8
+ Typography,
9
+ Stack,
10
+ Paper,
11
+ Divider,
12
+ ToggleButtonGroup,
13
+ ToggleButton,
14
+ } from '@mui/material';
15
+ import { api, formatError, clearDonateCache } from '@blocklet/payment-react';
16
+ import { Toast } from '@arcblock/ux';
17
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
18
+ import type { TSetting } from '@blocklet/payment-types';
19
+ import { useState, useEffect, useRef } from 'react';
20
+ import DonationPreview from './preview';
21
+
22
+ type FormData = {
23
+ settings: {
24
+ btnText: string;
25
+ amount: {
26
+ presets: string[];
27
+ preset: string;
28
+ minimum: string;
29
+ maximum: string;
30
+ custom: boolean;
31
+ };
32
+ historyType: 'avatar' | 'table';
33
+ };
34
+ };
35
+
36
+ function NumberArrayInput({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) {
37
+ const { t } = useLocaleContext();
38
+ const [inputValue, setInputValue] = useState(value.join(', '));
39
+ const isSelfUpdate = useRef(false); // 新增标志位
40
+
41
+ useEffect(() => {
42
+ if (isSelfUpdate.current) {
43
+ isSelfUpdate.current = false;
44
+ return;
45
+ }
46
+ setInputValue(value.join(', '));
47
+ }, [value]);
48
+
49
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
50
+ const { value: val } = e.target;
51
+
52
+ if (!/^[\d,.\s]*$/.test(val)) return;
53
+
54
+ setInputValue(val);
55
+
56
+ // 添加处理空字符串的情况
57
+ const rawValues = val.split(/[,\s]+/).filter(Boolean);
58
+ const numbers = rawValues
59
+ .map((v) => v.trim())
60
+ .filter((v) => !Number.isNaN(Number(v)))
61
+ .map(Number)
62
+ .filter((v, i, arr) => arr.indexOf(v) === i);
63
+
64
+ isSelfUpdate.current = true; // 标记为内部更新
65
+ onChange(numbers.map(String));
66
+ };
67
+
68
+ return (
69
+ <TextField
70
+ fullWidth
71
+ label={t('admin.donate.presets')}
72
+ helperText={t('admin.donate.presetsHelper')}
73
+ value={inputValue}
74
+ onChange={handleChange}
75
+ placeholder="1, 5, 10"
76
+ sx={{ bgcolor: 'background.paper' }}
77
+ onBlur={() => {
78
+ // 失焦时强制同步显示格式
79
+ const formatted = (inputValue.match(/[\d.]+/g) || []).join(', ');
80
+ setInputValue(formatted);
81
+ }}
82
+ />
83
+ );
84
+ }
85
+
86
+ export default function EditDonationForm({
87
+ item,
88
+ onSuccess,
89
+ onCancel,
90
+ }: {
91
+ item: TSetting;
92
+ onSuccess: () => void;
93
+ onCancel: () => void;
94
+ }) {
95
+ const { t } = useLocaleContext();
96
+ const methods = useForm<FormData>({
97
+ defaultValues: {
98
+ settings: {
99
+ amount: {
100
+ presets: item?.settings?.amount?.presets,
101
+ preset: item?.settings?.amount?.preset,
102
+ minimum: item?.settings?.amount?.minimum,
103
+ maximum: item?.settings?.amount?.maximum,
104
+ custom: item?.settings?.amount?.custom,
105
+ },
106
+ btnText: item?.settings?.btnText,
107
+ historyType: item?.settings?.historyType,
108
+ },
109
+ },
110
+ });
111
+
112
+ const { handleSubmit, watch } = methods;
113
+ const formValues = watch();
114
+
115
+ const onSubmit = async (data: FormData) => {
116
+ if (data.settings.amount.presets?.length > 0 && data.settings.amount.preset) {
117
+ const inPresets = data.settings.amount.presets.includes(data.settings.amount.preset);
118
+ if (!inPresets) {
119
+ Toast.error(t('admin.donate.validation.invalidPreset'));
120
+ return;
121
+ }
122
+ }
123
+ try {
124
+ await api.put(`/api/settings/${item.id}`, data);
125
+ clearDonateCache(item.mount_location);
126
+ Toast.success(t('admin.donate.updateSuccess'));
127
+ onSuccess();
128
+ } catch (err) {
129
+ Toast.error(formatError(err));
130
+ }
131
+ };
132
+
133
+ return (
134
+ <Box
135
+ sx={{
136
+ display: 'flex',
137
+ flexDirection: 'column',
138
+ height: '100%',
139
+ minHeight: '100vh',
140
+ }}>
141
+ <Box
142
+ sx={{
143
+ flex: 1,
144
+ overflow: {
145
+ xs: 'visible',
146
+ md: 'auto',
147
+ },
148
+ pb: 2,
149
+ display: 'flex',
150
+ flexDirection: 'column',
151
+ }}>
152
+ <FormProvider {...methods}>
153
+ <Grid
154
+ container
155
+ spacing={3}
156
+ sx={{
157
+ flex: 1,
158
+ minHeight: 0,
159
+ }}>
160
+ <Grid
161
+ item
162
+ xs={12}
163
+ md={6}
164
+ sx={{
165
+ height: '100%',
166
+ }}>
167
+ <Paper
168
+ elevation={0}
169
+ sx={{
170
+ p: { xs: 2, md: 3 },
171
+ bgcolor: 'background.neutral',
172
+ height: '100%',
173
+ overflow: 'auto',
174
+ }}>
175
+ <Stack spacing={4}>
176
+ <Box>
177
+ <Typography variant="h6" gutterBottom color="text.primary" sx={{ mb: 3 }}>
178
+ {t('admin.donate.buttonSettings')}
179
+ </Typography>
180
+ <Stack spacing={3}>
181
+ <Controller
182
+ name="settings.btnText"
183
+ render={({ field }) => (
184
+ <TextField
185
+ {...field}
186
+ fullWidth
187
+ label={t('admin.donate.btn.text')}
188
+ helperText={t('admin.donate.btn.helper')}
189
+ sx={{ bgcolor: 'background.paper' }}
190
+ />
191
+ )}
192
+ />
193
+
194
+ <Stack gap={1}>
195
+ <Typography variant="subtitle2" gutterBottom>
196
+ {t('admin.donate.historyType.title')}
197
+ </Typography>
198
+ <Controller
199
+ name="settings.historyType"
200
+ render={({ field }) => (
201
+ <ToggleButtonGroup
202
+ value={field.value}
203
+ exclusive
204
+ onChange={(_, value) => field.onChange(value)}
205
+ size="small"
206
+ sx={{ bgcolor: 'background.paper' }}>
207
+ <ToggleButton value="table" size="small">
208
+ {t('admin.donate.historyType.list')}
209
+ </ToggleButton>
210
+ <ToggleButton value="avatar" size="small">
211
+ {t('admin.donate.historyType.avatar')}
212
+ </ToggleButton>
213
+ </ToggleButtonGroup>
214
+ )}
215
+ />
216
+ </Stack>
217
+ </Stack>
218
+ </Box>
219
+
220
+ <Divider />
221
+
222
+ <Box>
223
+ <Typography variant="h6" gutterBottom color="text.primary" sx={{ mb: 3 }}>
224
+ {t('admin.donate.amountSettings')}
225
+ </Typography>
226
+ <Stack spacing={3}>
227
+ <Controller
228
+ control={methods.control}
229
+ name="settings.amount.presets"
230
+ render={({ field }) => <NumberArrayInput value={field.value || []} onChange={field.onChange} />}
231
+ />
232
+ <Controller
233
+ name="settings.amount.preset"
234
+ render={({ field }) => (
235
+ <TextField
236
+ {...field}
237
+ fullWidth
238
+ label={t('admin.donate.preset')}
239
+ helperText={t('admin.donate.presetHelper')}
240
+ sx={{ bgcolor: 'background.paper' }}
241
+ />
242
+ )}
243
+ />
244
+
245
+ <Box
246
+ sx={{
247
+ display: 'flex',
248
+ alignItems: 'center',
249
+ gap: 2,
250
+ }}>
251
+ <Typography variant="subtitle2" gutterBottom>
252
+ {t('admin.donate.custom')}
253
+ </Typography>
254
+ <Controller
255
+ name="settings.amount.custom"
256
+ render={({ field }) => (
257
+ <Switch
258
+ checked={field.value}
259
+ onChange={(e) => field.onChange(e.target.checked)}
260
+ size="small"
261
+ />
262
+ )}
263
+ />
264
+ </Box>
265
+
266
+ {formValues.settings.amount.custom && (
267
+ <Stack spacing={2} sx={{ mt: 2 }}>
268
+ <Controller
269
+ name="settings.amount.minimum"
270
+ render={({ field }) => (
271
+ <TextField
272
+ {...field}
273
+ fullWidth
274
+ label={t('admin.donate.minimum')}
275
+ type="number"
276
+ sx={{ bgcolor: 'background.paper' }}
277
+ />
278
+ )}
279
+ />
280
+ <Controller
281
+ name="settings.amount.maximum"
282
+ render={({ field }) => (
283
+ <TextField
284
+ {...field}
285
+ fullWidth
286
+ label={t('admin.donate.maximum')}
287
+ type="number"
288
+ sx={{ bgcolor: 'background.paper' }}
289
+ />
290
+ )}
291
+ />
292
+ </Stack>
293
+ )}
294
+ </Stack>
295
+ </Box>
296
+ </Stack>
297
+ </Paper>
298
+ </Grid>
299
+
300
+ <Grid
301
+ item
302
+ md={6}
303
+ sx={{
304
+ display: { xs: 'none', md: 'block' },
305
+ height: '100%',
306
+ }}>
307
+ <Paper
308
+ elevation={0}
309
+ sx={{
310
+ p: 3,
311
+ bgcolor: 'background.neutral',
312
+ height: '100%',
313
+ overflow: 'auto',
314
+ }}>
315
+ <DonationPreview config={methods.watch()} />
316
+ </Paper>
317
+ </Grid>
318
+ </Grid>
319
+ </FormProvider>
320
+ </Box>
321
+
322
+ <Box
323
+ sx={{
324
+ mt: 2,
325
+ pt: 2,
326
+ pb: 2,
327
+ px: 2,
328
+ borderTop: 1,
329
+ borderColor: 'divider',
330
+ display: 'flex',
331
+ justifyContent: 'flex-end',
332
+ gap: 2,
333
+ bgcolor: 'background.paper',
334
+ position: 'sticky',
335
+ bottom: 0,
336
+ left: 0,
337
+ right: 0,
338
+ zIndex: 1,
339
+ }}>
340
+ <Button onClick={onCancel} variant="outlined">
341
+ {t('common.cancel')}
342
+ </Button>
343
+ <Button variant="contained" onClick={handleSubmit(onSubmit)}>
344
+ {t('common.save')}
345
+ </Button>
346
+ </Box>
347
+ </Box>
348
+ );
349
+ }
@@ -0,0 +1,360 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { Toast } from '@arcblock/ux';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+ import { api, formatError, Table, ConfirmDialog, Switch, clearDonateCache, getPrefix } from '@blocklet/payment-react';
5
+ import { Alert, Typography, Button, CircularProgress, Stack, Avatar, Box, IconButton, Tooltip } from '@mui/material';
6
+ import { ContentCopy } from '@mui/icons-material';
7
+ import { useLocalStorageState, useRequest } from 'ahooks';
8
+ import Dialog from '@arcblock/ux/lib/Dialog';
9
+ import { useState, useEffect } from 'react';
10
+ import { joinURL } from 'ufo';
11
+ import { getDurableData } from '@arcblock/ux/lib/Datatable';
12
+ import useBus from 'use-bus';
13
+ import EditDonationForm from './edit-form';
14
+
15
+ const exampleCode = `import { PaymentProvider, DonateProvider, CheckoutDonate } from '@blocklet/payment-react';
16
+
17
+ // In your component
18
+ function YourComponent() {
19
+ return (
20
+ <PaymentProvider session={session} connect={connectApi}>
21
+ <DonateProvider
22
+ mountLocation="your-unique-donate-instance"
23
+ description="Help locate this donation instance"
24
+ >
25
+ <CheckoutDonate
26
+ settings={{
27
+ target: "post-123", // required, unique identifier for the donation instance
28
+ title: "Support Author", // required, title of the donation modal
29
+ description: "If you find this article helpful, feel free to buy me a coffee", // required, description of the donation
30
+ reference: "https://your-site.com/posts/123", // required, reference link of the donation
31
+ beneficiaries: [
32
+ {
33
+ address: "tip user did", // required, address of the beneficiary
34
+ share: "100", // required, percentage share
35
+ },
36
+ ],
37
+ }}
38
+ />
39
+ </DonateProvider>
40
+ </PaymentProvider>
41
+ );
42
+ }`;
43
+
44
+ const fetchData = (params: Record<string, any> = {}): Promise<any> => {
45
+ const search = new URLSearchParams();
46
+ Object.keys(params).forEach((key) => {
47
+ let v = params[key];
48
+ if (key === 'q') {
49
+ v = Object.entries(v)
50
+ .map((x) => x.join(':'))
51
+ .join(' ');
52
+ }
53
+ search.set(key, String(v));
54
+ });
55
+ return api.get(`/api/settings/donate?${search.toString()}`).then((res) => res.data);
56
+ };
57
+
58
+ function CodeExample({ code }: { code: string }) {
59
+ const { t } = useLocaleContext();
60
+
61
+ const handleCopy = async () => {
62
+ try {
63
+ await navigator.clipboard.writeText(code);
64
+ Toast.success(t('common.copySuccess'));
65
+ } catch (err) {
66
+ Toast.error(t('common.copyFailed'));
67
+ }
68
+ };
69
+
70
+ return (
71
+ <Box sx={{ position: 'relative' }}>
72
+ <Box
73
+ component="pre"
74
+ sx={{
75
+ backgroundColor: '#F8F9FA', // 更浅的背景色
76
+ color: '#495057', // 更柔和的文字颜色
77
+ borderRadius: 1,
78
+ p: 2,
79
+ overflow: 'auto',
80
+ '&::-webkit-scrollbar': {
81
+ height: 8,
82
+ bgcolor: 'background.paper',
83
+ },
84
+ '&::-webkit-scrollbar-thumb': {
85
+ bgcolor: '#CED4DA', // 更浅的滚动条颜色
86
+ borderRadius: 4,
87
+ },
88
+ fontSize: '0.875rem',
89
+ fontFamily: 'monospace',
90
+ lineHeight: 1.5, // 增加行高
91
+ border: '1px solid #E9ECEF', // 添加柔和的边框
92
+ }}>
93
+ {code}
94
+ </Box>
95
+ <Tooltip title={t('common.copy')} placement="top">
96
+ <IconButton
97
+ onClick={handleCopy}
98
+ sx={{
99
+ position: 'absolute',
100
+ top: 8,
101
+ right: 8,
102
+ bgcolor: 'background.paper',
103
+ '&:hover': {
104
+ bgcolor: '#F8F9FA', // 更浅的悬停色
105
+ },
106
+ }}>
107
+ <ContentCopy fontSize="small" />
108
+ </IconButton>
109
+ </Tooltip>
110
+ </Box>
111
+ );
112
+ }
113
+
114
+ type SearchProps = {
115
+ pageSize: number;
116
+ page: number;
117
+ q?: {
118
+ 'like-description'?: string;
119
+ 'like-component_did'?: string;
120
+ 'like-mount_location'?: string;
121
+ };
122
+ o?: 'asc' | 'desc';
123
+ };
124
+
125
+ export default function DonationList() {
126
+ const listKey = 'donation-settings';
127
+ const { t } = useLocaleContext();
128
+ const [editingItem, setEditingItem] = useState<any>(null);
129
+ const [showExample, setShowExample] = useState(false);
130
+ const [confirmDialog, setConfirmDialog] = useState({
131
+ open: false,
132
+ id: '',
133
+ active: false,
134
+ mountLocation: '',
135
+ });
136
+
137
+ const persisted = getDurableData(listKey);
138
+ const [search, setSearch] = useLocalStorageState<SearchProps>(listKey, {
139
+ defaultValue: {
140
+ o: 'desc',
141
+ pageSize: persisted.rowsPerPage || 10,
142
+ page: persisted.page ? persisted.page + 1 : 1,
143
+ },
144
+ });
145
+
146
+ const { error, data = { list: [], count: 0 }, refresh } = useRequest(() => fetchData(search));
147
+ const listData = data.list || [];
148
+
149
+ useEffect(() => {
150
+ refresh();
151
+ }, [search, refresh]);
152
+
153
+ useBus('donation.updated', () => refresh(), []);
154
+
155
+ const handleStatusChange = async (item: any, active: boolean) => {
156
+ if (active) {
157
+ setConfirmDialog({ open: true, id: item.id, active, mountLocation: item.mount_location });
158
+ return;
159
+ }
160
+ try {
161
+ await api.put(`/api/settings/${item.id}`, { active: !active });
162
+ clearDonateCache(item.mount_location);
163
+ Toast.success(t('admin.donate.status.activeSuccess'));
164
+ refresh();
165
+ } catch (err) {
166
+ Toast.error(formatError(err));
167
+ }
168
+ };
169
+
170
+ const handleConfirmStatusChange = async () => {
171
+ try {
172
+ await api.put(`/api/settings/${confirmDialog.id}`, { active: !confirmDialog.active });
173
+ clearDonateCache(confirmDialog.mountLocation);
174
+ Toast.success(t('admin.donate.status.inactiveSuccess'));
175
+ refresh();
176
+ } catch (err) {
177
+ Toast.error(formatError(err));
178
+ } finally {
179
+ setConfirmDialog({ open: false, id: '', active: false, mountLocation: '' });
180
+ }
181
+ };
182
+
183
+ const columns = [
184
+ {
185
+ label: t('admin.donate.component'),
186
+ name: 'component_did',
187
+ options: {
188
+ sort: false,
189
+ customBodyRenderLite: (index: number) => {
190
+ const componentInfo = window.blocklet?.componentMountPoints.find(
191
+ (c) => c?.did === listData[index]?.component_did
192
+ );
193
+ if (!componentInfo) {
194
+ return data[index]?.component_did || t('common.none');
195
+ }
196
+ return (
197
+ <Stack direction="row" alignItems="center" gap={1}>
198
+ <Avatar
199
+ src={joinURL(getPrefix(), `.well-known/service/blocklet/logo-bundle/${componentInfo.did}`)}
200
+ alt={componentInfo?.title}
201
+ />
202
+ <Typography onClick={() => window.open(componentInfo.mountPoint, '_blank')} sx={{ cursor: 'pointer' }}>
203
+ {componentInfo?.title}
204
+ </Typography>
205
+ </Stack>
206
+ );
207
+ },
208
+ },
209
+ },
210
+ {
211
+ label: t('admin.donate.mountLocation'),
212
+ name: 'mount_location',
213
+ options: { sort: false },
214
+ },
215
+ {
216
+ label: t('admin.donate.description'),
217
+ name: 'description',
218
+ options: { sort: false },
219
+ },
220
+ {
221
+ label: t('admin.donate.status.title'),
222
+ name: 'active',
223
+ width: 100,
224
+ options: {
225
+ sort: false,
226
+ customBodyRenderLite: (index: number) => (
227
+ <Switch
228
+ checked={listData[index].active}
229
+ onChange={() => handleStatusChange(listData[index], listData[index].active)}
230
+ />
231
+ ),
232
+ },
233
+ },
234
+ {
235
+ label: t('common.setting'),
236
+ width: 100,
237
+ options: {
238
+ sort: false,
239
+ customBodyRenderLite: (index: number) => (
240
+ <Button
241
+ component="text"
242
+ sx={{
243
+ color: 'text.link',
244
+ minWidth: 'fit-content',
245
+ }}
246
+ onClick={() => setEditingItem(listData[index])}>
247
+ {t('common.edit')}
248
+ </Button>
249
+ ),
250
+ },
251
+ },
252
+ ];
253
+
254
+ const onTableChange = ({ page, rowsPerPage }: { page: number; rowsPerPage: number }) => {
255
+ if (search!.pageSize !== rowsPerPage) {
256
+ setSearch((x: any) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
257
+ } else if (search!.page !== page + 1) {
258
+ setSearch((x: any) => ({ ...x, page: page + 1 }));
259
+ }
260
+ };
261
+
262
+ if (error) {
263
+ return <Alert severity="error">{error.message}</Alert>;
264
+ }
265
+
266
+ if (!data) {
267
+ return <CircularProgress />;
268
+ }
269
+
270
+ return (
271
+ <>
272
+ <Stack direction="row" alignItems="center" gap={1} flexWrap="wrap" mb={-1}>
273
+ <Typography variant="h6">{t('admin.donate.intro')}</Typography>
274
+ <Button variant="text" size="small" onClick={() => setShowExample(true)} sx={{ color: 'text.link' }}>
275
+ {t('admin.donate.usage.viewExample')}
276
+ </Button>
277
+ </Stack>
278
+ <Table
279
+ hasSearch
280
+ durable={`__${listKey}__`}
281
+ durableKeys={['searchText']}
282
+ data={listData}
283
+ columns={columns}
284
+ options={{
285
+ count: data.count,
286
+ page: search!.page - 1,
287
+ rowsPerPage: search!.pageSize,
288
+ onSearchChange: (text: string) => {
289
+ if (text) {
290
+ setSearch((x) => ({
291
+ ...x!,
292
+ q: {
293
+ 'like-mount_location': text,
294
+ 'like-description': text,
295
+ 'like-component_did': text,
296
+ },
297
+ page: 1,
298
+ }));
299
+ } else {
300
+ setSearch((x) => ({
301
+ ...x!,
302
+ page: 1,
303
+ q: {},
304
+ }));
305
+ }
306
+ },
307
+ }}
308
+ onChange={onTableChange}
309
+ />
310
+
311
+ {showExample && (
312
+ <Dialog
313
+ open
314
+ onClose={() => setShowExample(false)}
315
+ title={t('admin.donate.usage.title')}
316
+ maxWidth="md"
317
+ sx={{ '.MuiDialogContent-root': { padding: 0 } }}
318
+ fullWidth>
319
+ <Box sx={{ p: 3 }}>
320
+ <Typography variant="body2" paragraph>
321
+ {t('admin.donate.usage.description')}
322
+ </Typography>
323
+ <CodeExample code={exampleCode} />
324
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
325
+ {t('admin.donate.usage.note')}
326
+ </Typography>
327
+ </Box>
328
+ </Dialog>
329
+ )}
330
+
331
+ {editingItem && (
332
+ <Dialog
333
+ open
334
+ onClose={() => setEditingItem(null)}
335
+ title={t('admin.donate.editTitle')}
336
+ maxWidth="md"
337
+ fullWidth
338
+ sx={{ '.MuiDialogContent-root': { padding: 0 } }}>
339
+ <EditDonationForm
340
+ item={editingItem}
341
+ onSuccess={() => {
342
+ refresh();
343
+ setEditingItem(null);
344
+ }}
345
+ onCancel={() => setEditingItem(null)}
346
+ />
347
+ </Dialog>
348
+ )}
349
+
350
+ {confirmDialog.open && (
351
+ <ConfirmDialog
352
+ title={t('admin.donate.status.inactiveTitle')}
353
+ message={t('admin.donate.status.inactiveTip')}
354
+ onConfirm={handleConfirmStatusChange}
355
+ onCancel={() => setConfirmDialog({ open: false, id: '', active: false, mountLocation: '' })}
356
+ />
357
+ )}
358
+ </>
359
+ );
360
+ }