@inspirer-dev/crm-dashboard 1.0.90 → 1.0.91

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.
@@ -0,0 +1,560 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ Box,
4
+ Button,
5
+ Flex,
6
+ Loader,
7
+ Modal,
8
+ Typography,
9
+ Badge,
10
+ TextInput,
11
+ Field,
12
+ Checkbox,
13
+ Textarea,
14
+ } from '@strapi/design-system';
15
+ import {
16
+ useManualPushTemplates,
17
+ useManualPushHistory,
18
+ useDispatchManualPush,
19
+ useDispatchTestManualPush,
20
+ } from '../../../hooks/api';
21
+ import type {
22
+ ManualPushTemplate,
23
+ ManualPushHistoryItem,
24
+ DispatchPayload,
25
+ } from '../../../hooks/api';
26
+
27
+ const stripHtml = (html: string): string =>
28
+ html
29
+ .replace(/<br\s*\/?>/gi, '\n')
30
+ .replace(/<\/p>/gi, '\n')
31
+ .replace(/<[^>]+>/g, '')
32
+ .replace(/&nbsp;/g, ' ')
33
+ .replace(/&amp;/g, '&')
34
+ .replace(/&lt;/g, '<')
35
+ .replace(/&gt;/g, '>')
36
+ .trim();
37
+
38
+ const truncate = (text: string, n: number): string =>
39
+ text.length > n ? `${text.slice(0, n)}…` : text;
40
+
41
+ const formatDateTime = (iso: string): string =>
42
+ new Date(iso).toLocaleString('ru-RU', {
43
+ day: '2-digit',
44
+ month: '2-digit',
45
+ year: 'numeric',
46
+ hour: '2-digit',
47
+ minute: '2-digit',
48
+ });
49
+
50
+ const parseUserIdList = (raw: string): number[] => {
51
+ if (!raw.trim()) return [];
52
+ return raw
53
+ .split(/[\s,]+/)
54
+ .map((t) => t.trim())
55
+ .filter(Boolean)
56
+ .map((t) => Number(t))
57
+ .filter((n) => Number.isInteger(n) && n > 0);
58
+ };
59
+
60
+ interface DispatchModalState {
61
+ template: ManualPushTemplate;
62
+ mode: 'test' | 'segment';
63
+ }
64
+
65
+ const ManualPushesView: React.FC = () => {
66
+ const { data: templates = [], isLoading, error, refetch } = useManualPushTemplates();
67
+ const { data: history = [], isLoading: historyLoading } = useManualPushHistory();
68
+ const [modalState, setModalState] = useState<DispatchModalState | null>(null);
69
+
70
+ const sortedTemplates = useMemo(
71
+ () =>
72
+ [...templates].sort((a, b) =>
73
+ a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0
74
+ ),
75
+ [templates]
76
+ );
77
+
78
+ return (
79
+ <Box padding={4}>
80
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={4}>
81
+ <div>
82
+ <Typography variant="beta">Ручные рассылки</Typography>
83
+ <div style={{ color: 'var(--crm-text-secondary)', fontSize: 13, marginTop: 4 }}>
84
+ Опубликованные в Strapi шаблоны Manual Push. Отправка не происходит автоматически —
85
+ используйте кнопки ниже.
86
+ </div>
87
+ </div>
88
+ <Button variant="tertiary" onClick={() => refetch()}>
89
+ Обновить
90
+ </Button>
91
+ </Flex>
92
+
93
+ {isLoading && (
94
+ <Flex justifyContent="center" padding={8}>
95
+ <Loader>Загрузка шаблонов…</Loader>
96
+ </Flex>
97
+ )}
98
+
99
+ {error && (
100
+ <Box
101
+ padding={4}
102
+ background="danger100"
103
+ hasRadius
104
+ style={{ border: '1px solid var(--crm-danger)' }}
105
+ >
106
+ <Typography textColor="danger700">
107
+ Не удалось загрузить шаблоны: {(error as Error).message}
108
+ </Typography>
109
+ </Box>
110
+ )}
111
+
112
+ {!isLoading && !error && sortedTemplates.length === 0 && (
113
+ <Box padding={6} background="neutral100" hasRadius>
114
+ <Typography variant="omega">
115
+ Шаблоны не найдены. Создайте запись в Content Manager → Manual Push и опубликуйте её.
116
+ </Typography>
117
+ </Box>
118
+ )}
119
+
120
+ <div
121
+ style={{
122
+ display: 'grid',
123
+ gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
124
+ gap: 16,
125
+ }}
126
+ >
127
+ {sortedTemplates.map((t) => (
128
+ <TemplateCard
129
+ key={t.documentId}
130
+ template={t}
131
+ onSendTest={() => setModalState({ template: t, mode: 'test' })}
132
+ onDispatch={() => setModalState({ template: t, mode: 'segment' })}
133
+ />
134
+ ))}
135
+ </div>
136
+
137
+ <Box paddingTop={8}>
138
+ <Typography variant="beta">Последние отправки</Typography>
139
+ <Box paddingTop={3}>
140
+ {historyLoading ? (
141
+ <Flex justifyContent="center" padding={4}>
142
+ <Loader>Загрузка истории…</Loader>
143
+ </Flex>
144
+ ) : history.length === 0 ? (
145
+ <Box padding={4} background="neutral100" hasRadius>
146
+ <Typography variant="omega">Пока ни одной отправки.</Typography>
147
+ </Box>
148
+ ) : (
149
+ <HistoryTable history={history} templates={templates} />
150
+ )}
151
+ </Box>
152
+ </Box>
153
+
154
+ {modalState && <DispatchModal state={modalState} onClose={() => setModalState(null)} />}
155
+ </Box>
156
+ );
157
+ };
158
+
159
+ interface TemplateCardProps {
160
+ template: ManualPushTemplate;
161
+ onSendTest: () => void;
162
+ onDispatch: () => void;
163
+ }
164
+
165
+ const TemplateCard: React.FC<TemplateCardProps> = ({ template, onSendTest, onDispatch }) => {
166
+ const preview = truncate(stripHtml(template.body || ''), 220);
167
+ const hasTestUsers = template.testUserIds.length > 0;
168
+
169
+ return (
170
+ <Box
171
+ background="neutral0"
172
+ hasRadius
173
+ padding={4}
174
+ shadow="tableShadow"
175
+ style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
176
+ >
177
+ <Flex justifyContent="space-between" alignItems="flex-start" gap={2}>
178
+ <div style={{ flex: 1, minWidth: 0 }}>
179
+ <Typography variant="delta" ellipsis>
180
+ {template.name}
181
+ </Typography>
182
+ <Flex gap={1} paddingTop={1}>
183
+ {template.locales.map((l: string) => (
184
+ <Badge key={l} backgroundColor="primary200" textColor="primary600">
185
+ {l.toUpperCase()}
186
+ </Badge>
187
+ ))}
188
+ {template.image && (
189
+ <Badge backgroundColor="secondary200" textColor="secondary700">
190
+ IMG
191
+ </Badge>
192
+ )}
193
+ {template.buttonUrl && (
194
+ <Badge backgroundColor="alternative200" textColor="alternative700">
195
+ BTN
196
+ </Badge>
197
+ )}
198
+ </Flex>
199
+ </div>
200
+ </Flex>
201
+
202
+ {template.image?.url && (
203
+ <div
204
+ style={{
205
+ background: 'var(--crm-bg-tertiary)',
206
+ borderRadius: 8,
207
+ overflow: 'hidden',
208
+ maxHeight: 120,
209
+ display: 'flex',
210
+ justifyContent: 'center',
211
+ alignItems: 'center',
212
+ }}
213
+ >
214
+ <img
215
+ src={template.image.url}
216
+ alt={template.image.alternativeText || template.name}
217
+ style={{ maxWidth: '100%', maxHeight: 120, objectFit: 'cover' }}
218
+ />
219
+ </div>
220
+ )}
221
+
222
+ <div
223
+ style={{
224
+ color: 'var(--crm-text-secondary)',
225
+ fontSize: 13,
226
+ whiteSpace: 'pre-wrap',
227
+ lineHeight: 1.4,
228
+ minHeight: 40,
229
+ }}
230
+ >
231
+ {preview || <em style={{ opacity: 0.6 }}>Тело шаблона пустое</em>}
232
+ </div>
233
+
234
+ {template.buttonLabel && template.buttonUrl && (
235
+ <div style={{ fontSize: 12, color: 'var(--crm-text-muted)' }}>
236
+ Кнопка: <strong>{template.buttonLabel}</strong> → {truncate(template.buttonUrl, 48)}
237
+ </div>
238
+ )}
239
+
240
+ <div style={{ fontSize: 12, color: 'var(--crm-text-muted)' }}>
241
+ testUserIds: {hasTestUsers ? template.testUserIds.join(', ') : '—'}
242
+ </div>
243
+
244
+ <Flex gap={2} justifyContent="flex-end" paddingTop={1}>
245
+ <Button
246
+ variant="tertiary"
247
+ disabled={!hasTestUsers}
248
+ onClick={onSendTest}
249
+ title={hasTestUsers ? '' : 'Заполните testUserIds в Strapi'}
250
+ >
251
+ Тест-отправка
252
+ </Button>
253
+ <Button variant="default" onClick={onDispatch}>
254
+ Отправить…
255
+ </Button>
256
+ </Flex>
257
+ </Box>
258
+ );
259
+ };
260
+
261
+ interface HistoryTableProps {
262
+ history: ManualPushHistoryItem[];
263
+ templates: ManualPushTemplate[];
264
+ }
265
+
266
+ const HistoryTable: React.FC<HistoryTableProps> = ({ history, templates }) => {
267
+ const nameByDocId = useMemo(() => {
268
+ const map = new Map<string, string>();
269
+ templates.forEach((t) => map.set(t.documentId, t.name));
270
+ return map;
271
+ }, [templates]);
272
+
273
+ return (
274
+ <Box background="neutral0" hasRadius shadow="tableShadow" style={{ overflow: 'hidden' }}>
275
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
276
+ <thead>
277
+ <tr style={{ background: 'var(--crm-table-header-bg)', textAlign: 'left' }}>
278
+ <th style={{ padding: '10px 12px' }}>Отправлено</th>
279
+ <th style={{ padding: '10px 12px' }}>Шаблон</th>
280
+ <th style={{ padding: '10px 12px', textAlign: 'right' }}>В очереди</th>
281
+ <th style={{ padding: '10px 12px', textAlign: 'right' }}>Отпр.</th>
282
+ <th style={{ padding: '10px 12px', textAlign: 'right' }}>Ошибки</th>
283
+ <th style={{ padding: '10px 12px', textAlign: 'right' }}>Отмен.</th>
284
+ <th style={{ padding: '10px 12px' }}>Кем</th>
285
+ </tr>
286
+ </thead>
287
+ <tbody>
288
+ {history.map((row: ManualPushHistoryItem) => (
289
+ <tr key={row.manualPushId} style={{ borderTop: '1px solid var(--crm-border-light)' }}>
290
+ <td style={{ padding: '10px 12px', whiteSpace: 'nowrap' }}>
291
+ {formatDateTime(row.dispatchedAt)}
292
+ </td>
293
+ <td style={{ padding: '10px 12px' }}>
294
+ {nameByDocId.get(row.strapiDocumentId) || row.strapiDocumentId}
295
+ </td>
296
+ <td style={{ padding: '10px 12px', textAlign: 'right' }}>{row.totalQueued}</td>
297
+ <td
298
+ style={{
299
+ padding: '10px 12px',
300
+ textAlign: 'right',
301
+ color: 'var(--crm-success)',
302
+ }}
303
+ >
304
+ {row.counts.sent}
305
+ </td>
306
+ <td
307
+ style={{
308
+ padding: '10px 12px',
309
+ textAlign: 'right',
310
+ color: 'var(--crm-danger)',
311
+ }}
312
+ >
313
+ {row.counts.failed}
314
+ </td>
315
+ <td style={{ padding: '10px 12px', textAlign: 'right' }}>{row.counts.cancelled}</td>
316
+ <td
317
+ style={{
318
+ padding: '10px 12px',
319
+ color: 'var(--crm-text-muted)',
320
+ whiteSpace: 'nowrap',
321
+ }}
322
+ >
323
+ {row.dispatchedBy || '—'}
324
+ </td>
325
+ </tr>
326
+ ))}
327
+ </tbody>
328
+ </table>
329
+ </Box>
330
+ );
331
+ };
332
+
333
+ interface DispatchModalProps {
334
+ state: DispatchModalState;
335
+ onClose: () => void;
336
+ }
337
+
338
+ const DispatchModal: React.FC<DispatchModalProps> = ({ state, onClose }) => {
339
+ const { template, mode } = state;
340
+ const isTest = mode === 'test';
341
+
342
+ const dispatchMutation = useDispatchManualPush();
343
+ const dispatchTestMutation = useDispatchTestManualPush();
344
+
345
+ const [dispatchedBy, setDispatchedBy] = useState('');
346
+ const [includeRu, setIncludeRu] = useState(false);
347
+ const [includeEn, setIncludeEn] = useState(false);
348
+ const [userIdsRaw, setUserIdsRaw] = useState('');
349
+ const [excludeOptedOut, setExcludeOptedOut] = useState(true);
350
+ const [dryRunResult, setDryRunResult] = useState<number | null>(null);
351
+ const [confirmed, setConfirmed] = useState(false);
352
+
353
+ const reset = () => {
354
+ setDispatchedBy('');
355
+ setIncludeRu(false);
356
+ setIncludeEn(false);
357
+ setUserIdsRaw('');
358
+ setExcludeOptedOut(true);
359
+ setDryRunResult(null);
360
+ setConfirmed(false);
361
+ };
362
+
363
+ const close = () => {
364
+ reset();
365
+ onClose();
366
+ };
367
+
368
+ const buildPayload = (dryRun: boolean): DispatchPayload => {
369
+ const userIds = parseUserIdList(userIdsRaw);
370
+ const languages: ('ru' | 'en')[] = [];
371
+ if (includeRu) languages.push('ru');
372
+ if (includeEn) languages.push('en');
373
+ return {
374
+ strapiDocumentId: template.documentId,
375
+ segment: {
376
+ ...(userIds.length > 0 ? { userIds } : {}),
377
+ ...(languages.length > 0 ? { languages } : {}),
378
+ excludeOptedOut,
379
+ },
380
+ dryRun,
381
+ ...(dispatchedBy ? { dispatchedBy } : {}),
382
+ };
383
+ };
384
+
385
+ const onDryRun = async () => {
386
+ setDryRunResult(null);
387
+ try {
388
+ const res = await dispatchMutation.mutateAsync(buildPayload(true));
389
+ setDryRunResult(res.totalQueued);
390
+ } catch {
391
+ // surfaced via mutation.error below
392
+ }
393
+ };
394
+
395
+ const onConfirm = async () => {
396
+ if (isTest) {
397
+ try {
398
+ await dispatchTestMutation.mutateAsync({
399
+ strapiDocumentId: template.documentId,
400
+ ...(dispatchedBy ? { dispatchedBy } : {}),
401
+ });
402
+ close();
403
+ } catch {
404
+ // surfaced via mutation.error below
405
+ }
406
+ return;
407
+ }
408
+
409
+ if (!confirmed) {
410
+ setConfirmed(true);
411
+ return;
412
+ }
413
+ try {
414
+ await dispatchMutation.mutateAsync(buildPayload(false));
415
+ close();
416
+ } catch {
417
+ // surfaced via mutation.error below
418
+ }
419
+ };
420
+
421
+ const mutation = isTest ? dispatchTestMutation : dispatchMutation;
422
+ const err = mutation.error as Error | null;
423
+
424
+ return (
425
+ <Modal.Root open onOpenChange={(open: boolean) => !open && close()}>
426
+ <Modal.Content style={{ width: 640, maxWidth: '95vw' }}>
427
+ <Modal.Header>
428
+ <Modal.Title>
429
+ {isTest ? 'Тест-отправка' : 'Отправка'}: {template.name}
430
+ </Modal.Title>
431
+ </Modal.Header>
432
+
433
+ <Modal.Body>
434
+ <Flex direction="column" gap={4} alignItems="stretch">
435
+ {isTest ? (
436
+ <Box padding={3} background="neutral100" hasRadius>
437
+ <Typography variant="omega">
438
+ Будет отправлено пользователям из <code>testUserIds</code>:
439
+ </Typography>
440
+ <div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 13 }}>
441
+ {template.testUserIds.join(', ')}
442
+ </div>
443
+ <div style={{ marginTop: 8, fontSize: 12, color: 'var(--crm-text-muted)' }}>
444
+ Anti-spam, opt-out и priority конфликты для тест-отправки пропускаются.
445
+ </div>
446
+ </Box>
447
+ ) : (
448
+ <>
449
+ <Field.Root>
450
+ <Field.Label>Языки (опц.)</Field.Label>
451
+ <Flex gap={4} paddingTop={1}>
452
+ <Checkbox
453
+ checked={includeRu}
454
+ onCheckedChange={(v: boolean | string) => setIncludeRu(Boolean(v))}
455
+ >
456
+ Русский
457
+ </Checkbox>
458
+ <Checkbox
459
+ checked={includeEn}
460
+ onCheckedChange={(v: boolean | string) => setIncludeEn(Boolean(v))}
461
+ >
462
+ English
463
+ </Checkbox>
464
+ </Flex>
465
+ <div style={{ fontSize: 12, color: 'var(--crm-text-muted)', marginTop: 4 }}>
466
+ Пусто = все языки. Фильтр по <code>languageCode</code> Telegram-аккаунта.
467
+ </div>
468
+ </Field.Root>
469
+
470
+ <Field.Root>
471
+ <Field.Label>userIds (опц.)</Field.Label>
472
+ <Textarea
473
+ value={userIdsRaw}
474
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
475
+ setUserIdsRaw(e.target.value)
476
+ }
477
+ placeholder="42, 101, 202 (или с переносами)"
478
+ />
479
+ <div style={{ fontSize: 12, color: 'var(--crm-text-muted)', marginTop: 4 }}>
480
+ Прямая адресация — если заполнено, все остальные фильтры (кроме языков)
481
+ игнорируются.
482
+ </div>
483
+ </Field.Root>
484
+
485
+ <Checkbox
486
+ checked={excludeOptedOut}
487
+ onCheckedChange={(v: boolean | string) => setExcludeOptedOut(Boolean(v))}
488
+ >
489
+ Исключить отписавшихся (opted_out)
490
+ </Checkbox>
491
+ </>
492
+ )}
493
+
494
+ <Field.Root>
495
+ <Field.Label>dispatchedBy (опц.)</Field.Label>
496
+ <TextInput
497
+ value={dispatchedBy}
498
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
499
+ setDispatchedBy(e.target.value)
500
+ }
501
+ placeholder="ваш email или ник"
502
+ />
503
+ </Field.Root>
504
+
505
+ {!isTest && dryRunResult !== null && (
506
+ <Box padding={3} background="primary100" hasRadius>
507
+ <Typography variant="omega">
508
+ Подойдёт пользователей: <strong>{dryRunResult}</strong>
509
+ </Typography>
510
+ </Box>
511
+ )}
512
+
513
+ {confirmed && !isTest && (
514
+ <Box padding={3} background="warning100" hasRadius>
515
+ <Typography textColor="warning700">
516
+ Подтвердите: будет отправлено {dryRunResult ?? '?'} сообщений. Повторное нажатие
517
+ «Отправить» запустит рассылку.
518
+ </Typography>
519
+ </Box>
520
+ )}
521
+
522
+ {err && (
523
+ <Box padding={3} background="danger100" hasRadius>
524
+ <Typography textColor="danger700">{err.message}</Typography>
525
+ </Box>
526
+ )}
527
+ </Flex>
528
+ </Modal.Body>
529
+
530
+ <Modal.Footer>
531
+ <Modal.Close>
532
+ <Button variant="tertiary" onClick={close}>
533
+ Отмена
534
+ </Button>
535
+ </Modal.Close>
536
+ {!isTest && (
537
+ <Button
538
+ variant="secondary"
539
+ onClick={onDryRun}
540
+ loading={dispatchMutation.isPending && dryRunResult === null}
541
+ disabled={dispatchMutation.isPending}
542
+ >
543
+ Оценить (dry run)
544
+ </Button>
545
+ )}
546
+ <Button
547
+ variant={confirmed && !isTest ? 'danger-light' : 'default'}
548
+ loading={mutation.isPending}
549
+ onClick={onConfirm}
550
+ disabled={mutation.isPending}
551
+ >
552
+ {isTest ? 'Отправить тест' : confirmed ? 'Подтвердить отправку' : 'Отправить'}
553
+ </Button>
554
+ </Modal.Footer>
555
+ </Modal.Content>
556
+ </Modal.Root>
557
+ );
558
+ };
559
+
560
+ export default ManualPushesView;
@@ -7,3 +7,4 @@ export { default as CohortHeatMap } from './CohortHeatMap';
7
7
  export { default as SendTimeHeatMap } from './SendTimeHeatMap';
8
8
  export { default as ABTestCard } from './ABTestCard';
9
9
  export { default as SegmentTable } from './SegmentTable';
10
+ export { default as ManualPushesView } from './ManualPushesView';
@@ -1,12 +1,13 @@
1
1
  import React, { memo, useState, useMemo, useEffect } from 'react';
2
2
  import { Main, Box } from '@strapi/design-system';
3
- import { GridFour, Clock, Cross } from '@strapi/icons';
3
+ import { GridFour, Clock, Cross, Message } from '@strapi/icons';
4
4
  import { useTheme } from 'styled-components';
5
5
  import { QueryProvider } from '../../lib/react-query';
6
6
  import { useDashboardStats } from '../../hooks/api';
7
7
  import LogsTable from './components/LogsTable';
8
8
  import AntiSpamLogsTable from './components/AntiSpamLogsTable';
9
9
  import StatsView from './components/StatsView';
10
+ import ManualPushesView from './components/ManualPushesView';
10
11
  // @ts-expect-error - Vite raw import
11
12
  import homePageStyles from './HomePage.css?inline';
12
13
  // @ts-expect-error - Vite raw import
@@ -23,7 +24,7 @@ const useIsDarkTheme = (): boolean => {
23
24
  return theme?.colors?.neutral0 === '#212134';
24
25
  };
25
26
 
26
- type TabId = 'stats' | 'logs' | 'antispam';
27
+ type TabId = 'stats' | 'logs' | 'antispam' | 'manual-pushes';
27
28
 
28
29
  interface TabConfig {
29
30
  id: TabId;
@@ -55,6 +56,13 @@ const tabs: TabConfig[] = [
55
56
  icon: <Cross width={20} height={20} />,
56
57
  color: '#d97706',
57
58
  },
59
+ {
60
+ id: 'manual-pushes',
61
+ label: 'Ручные рассылки',
62
+ description: 'Шаблоны Manual Push и ручная отправка',
63
+ icon: <Message width={20} height={20} />,
64
+ color: '#7b79ff',
65
+ },
58
66
  ];
59
67
 
60
68
  const formatNumber = (num: number): string => {
@@ -80,11 +88,12 @@ const DashboardContent: React.FC = () => {
80
88
  const { data: stats, isLoading } = useDashboardStats(defaultFilters);
81
89
 
82
90
  const badgeCounts = useMemo(() => {
83
- if (!stats) return { stats: 0, logs: 0, antispam: 0 };
91
+ if (!stats) return { stats: 0, logs: 0, antispam: 0, 'manual-pushes': 0 };
84
92
  return {
85
93
  stats: stats.totals?.sent ?? 0,
86
94
  logs: stats.funnel?.sent ?? 0,
87
95
  antispam: stats.funnel?.blocked_total ?? 0,
96
+ 'manual-pushes': 0,
88
97
  };
89
98
  }, [stats]);
90
99
 
@@ -96,6 +105,8 @@ const DashboardContent: React.FC = () => {
96
105
  return <LogsTable />;
97
106
  case 'antispam':
98
107
  return <AntiSpamLogsTable />;
108
+ case 'manual-pushes':
109
+ return <ManualPushesView />;
99
110
  default:
100
111
  return null;
101
112
  }