@inspirer-dev/crm-dashboard 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/src/pages/HomePage/components/CampaignBuilder/index.tsx +483 -0
- package/admin/src/pages/HomePage/index.tsx +7 -7
- package/admin/src/types/crm.ts +75 -0
- package/dist/_chunks/{index--qsi_Itg.mjs → index-CQJKdWYb.mjs} +1 -1
- package/dist/_chunks/{index-bYg2IlWc.mjs → index-CSLXMNxK.mjs} +600 -1009
- package/dist/_chunks/{index-Ce3kgBOZ.js → index-CVBrqZnU.js} +1 -1
- package/dist/_chunks/{index-DcR77fsy.mjs → index-DdgfQNw_.mjs} +1 -2
- package/dist/_chunks/{index-52iMK4El.js → index-XoiSAQhK.js} +437 -966
- package/dist/_chunks/{index-BYbeeiNf.js → index-jwDYIfnW.js} +7 -8
- package/dist/_chunks/{constants-b4yEL0jh.mjs → utils-C6_ndVAZ.mjs} +181 -2
- package/dist/_chunks/{constants-BIKdjkSe.js → utils-CmonL0io.js} +181 -2
- package/dist/admin/index.js +3 -3
- package/dist/admin/index.mjs +3 -3
- package/dist/server/index.js +5 -0
- package/dist/server/index.mjs +5 -0
- package/package.json +1 -1
- package/server/src/register.ts +6 -0
- package/admin/src/pages/HomePage/components/CampaignDiagram.tsx +0 -959
- package/dist/_chunks/utils-bK_eEmpK.js +0 -183
- package/dist/_chunks/utils-uQBqt07r.mjs +0 -184
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import React, { memo, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Typography,
|
|
5
|
+
Flex,
|
|
6
|
+
Button,
|
|
7
|
+
Loader,
|
|
8
|
+
SingleSelect,
|
|
9
|
+
SingleSelectOption,
|
|
10
|
+
TextInput,
|
|
11
|
+
Field,
|
|
12
|
+
Toggle,
|
|
13
|
+
IconButton,
|
|
14
|
+
Table,
|
|
15
|
+
Thead,
|
|
16
|
+
Tbody,
|
|
17
|
+
Tr,
|
|
18
|
+
Th,
|
|
19
|
+
Td,
|
|
20
|
+
Badge,
|
|
21
|
+
} from '@strapi/design-system';
|
|
22
|
+
import { Plus, Trash, Pencil } from '@strapi/icons';
|
|
23
|
+
import type { CrmSegment, CrmTemplate } from '../../../../types/crm';
|
|
24
|
+
import getBackendUrl from '../../../../utils/getBackendUrl';
|
|
25
|
+
|
|
26
|
+
interface CampaignVariant {
|
|
27
|
+
name: string;
|
|
28
|
+
templateId: string;
|
|
29
|
+
weight: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Campaign {
|
|
33
|
+
id?: number;
|
|
34
|
+
documentId?: string;
|
|
35
|
+
name: string;
|
|
36
|
+
slug: string;
|
|
37
|
+
isActive: boolean;
|
|
38
|
+
triggerType: 'event' | 'scheduled' | 'segment_entry';
|
|
39
|
+
triggerEventName?: string;
|
|
40
|
+
segmentIds: string[];
|
|
41
|
+
variants: CampaignVariant[];
|
|
42
|
+
cooldownHours: number;
|
|
43
|
+
priority: number;
|
|
44
|
+
dailyCapPerUser: number;
|
|
45
|
+
ignoreQuietHours: boolean;
|
|
46
|
+
cancelConditions?: unknown;
|
|
47
|
+
startDate?: string;
|
|
48
|
+
endDate?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EMPTY_CAMPAIGN: Campaign = {
|
|
52
|
+
name: '',
|
|
53
|
+
slug: '',
|
|
54
|
+
isActive: false,
|
|
55
|
+
triggerType: 'event',
|
|
56
|
+
triggerEventName: '',
|
|
57
|
+
segmentIds: [],
|
|
58
|
+
variants: [{ name: 'Variant A', templateId: '', weight: 100 }],
|
|
59
|
+
cooldownHours: 24,
|
|
60
|
+
priority: 5,
|
|
61
|
+
dailyCapPerUser: 1,
|
|
62
|
+
ignoreQuietHours: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const CampaignBuilder: React.FC = () => {
|
|
66
|
+
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
|
67
|
+
const [segments, setSegments] = useState<CrmSegment[]>([]);
|
|
68
|
+
const [templates, setTemplates] = useState<CrmTemplate[]>([]);
|
|
69
|
+
const [loading, setLoading] = useState(false);
|
|
70
|
+
const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
|
|
71
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
72
|
+
|
|
73
|
+
const fetchData = useCallback(async () => {
|
|
74
|
+
setLoading(true);
|
|
75
|
+
try {
|
|
76
|
+
const backendUrl = getBackendUrl();
|
|
77
|
+
const [campaignsRes, segmentsRes, templatesRes] = await Promise.all([
|
|
78
|
+
fetch(new URL('/api/crm/campaigns', backendUrl).toString()),
|
|
79
|
+
fetch(new URL('/api/crm/segments', backendUrl).toString()),
|
|
80
|
+
fetch(new URL('/api/crm/templates', backendUrl).toString()),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
if (campaignsRes.ok) {
|
|
84
|
+
const data = await campaignsRes.json();
|
|
85
|
+
setCampaigns(Array.isArray(data) ? data : data?.data || []);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (segmentsRes.ok) {
|
|
89
|
+
const data = await segmentsRes.json();
|
|
90
|
+
setSegments(Array.isArray(data) ? data : data?.data || []);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (templatesRes.ok) {
|
|
94
|
+
const data = await templatesRes.json();
|
|
95
|
+
setTemplates(Array.isArray(data) ? data : data?.data || []);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('Failed to fetch data:', err);
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
fetchData();
|
|
106
|
+
}, [fetchData]);
|
|
107
|
+
|
|
108
|
+
const handleCreate = () => {
|
|
109
|
+
setEditingCampaign({ ...EMPTY_CAMPAIGN });
|
|
110
|
+
setIsCreating(true);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleEdit = (campaign: Campaign) => {
|
|
114
|
+
setEditingCampaign({ ...campaign });
|
|
115
|
+
setIsCreating(false);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleCancel = () => {
|
|
119
|
+
setEditingCampaign(null);
|
|
120
|
+
setIsCreating(false);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleSave = async () => {
|
|
124
|
+
if (!editingCampaign) return;
|
|
125
|
+
console.log('Saving campaign:', editingCampaign);
|
|
126
|
+
handleCancel();
|
|
127
|
+
fetchData();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleFieldChange = (field: keyof Campaign, value: unknown) => {
|
|
131
|
+
if (!editingCampaign) return;
|
|
132
|
+
setEditingCampaign({ ...editingCampaign, [field]: value });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleVariantChange = (index: number, field: keyof CampaignVariant, value: unknown) => {
|
|
136
|
+
if (!editingCampaign) return;
|
|
137
|
+
const newVariants = [...editingCampaign.variants];
|
|
138
|
+
newVariants[index] = { ...newVariants[index], [field]: value };
|
|
139
|
+
setEditingCampaign({ ...editingCampaign, variants: newVariants });
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleAddVariant = () => {
|
|
143
|
+
if (!editingCampaign) return;
|
|
144
|
+
const letter = String.fromCharCode(65 + editingCampaign.variants.length);
|
|
145
|
+
setEditingCampaign({
|
|
146
|
+
...editingCampaign,
|
|
147
|
+
variants: [...editingCampaign.variants, { name: `Variant ${letter}`, templateId: '', weight: 100 }],
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleRemoveVariant = (index: number) => {
|
|
152
|
+
if (!editingCampaign || editingCampaign.variants.length <= 1) return;
|
|
153
|
+
const newVariants = editingCampaign.variants.filter((_, i) => i !== index);
|
|
154
|
+
setEditingCampaign({ ...editingCampaign, variants: newVariants });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (loading) {
|
|
158
|
+
return (
|
|
159
|
+
<Box padding={8}>
|
|
160
|
+
<Flex justifyContent="center">
|
|
161
|
+
<Loader />
|
|
162
|
+
</Flex>
|
|
163
|
+
</Box>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (editingCampaign) {
|
|
168
|
+
return (
|
|
169
|
+
<Box padding={6}>
|
|
170
|
+
<Flex justifyContent="space-between" alignItems="center" marginBottom={5}>
|
|
171
|
+
<Typography variant="delta" fontWeight="bold">
|
|
172
|
+
{isCreating ? 'Создание кампании' : 'Редактирование кампании'}
|
|
173
|
+
</Typography>
|
|
174
|
+
<Flex gap={2}>
|
|
175
|
+
<Button onClick={handleCancel} variant="tertiary">
|
|
176
|
+
Отмена
|
|
177
|
+
</Button>
|
|
178
|
+
<Button onClick={handleSave} variant="default">
|
|
179
|
+
Сохранить
|
|
180
|
+
</Button>
|
|
181
|
+
</Flex>
|
|
182
|
+
</Flex>
|
|
183
|
+
|
|
184
|
+
<Box background="neutral0" padding={6} hasRadius shadow="filterShadow">
|
|
185
|
+
<Flex direction="column" gap={4}>
|
|
186
|
+
<Flex gap={4}>
|
|
187
|
+
<Box style={{ flex: 1 }}>
|
|
188
|
+
<Field.Root>
|
|
189
|
+
<Field.Label>Название</Field.Label>
|
|
190
|
+
<TextInput
|
|
191
|
+
value={editingCampaign.name}
|
|
192
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
193
|
+
handleFieldChange('name', e.target.value)
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
</Field.Root>
|
|
197
|
+
</Box>
|
|
198
|
+
<Box style={{ flex: 1 }}>
|
|
199
|
+
<Field.Root>
|
|
200
|
+
<Field.Label>Slug</Field.Label>
|
|
201
|
+
<TextInput
|
|
202
|
+
value={editingCampaign.slug}
|
|
203
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
204
|
+
handleFieldChange('slug', e.target.value)
|
|
205
|
+
}
|
|
206
|
+
/>
|
|
207
|
+
</Field.Root>
|
|
208
|
+
</Box>
|
|
209
|
+
</Flex>
|
|
210
|
+
|
|
211
|
+
<Flex gap={4}>
|
|
212
|
+
<Box style={{ flex: 1 }}>
|
|
213
|
+
<Field.Root>
|
|
214
|
+
<Field.Label>Тип триггера</Field.Label>
|
|
215
|
+
<SingleSelect
|
|
216
|
+
value={editingCampaign.triggerType}
|
|
217
|
+
onChange={(val: string | number) => handleFieldChange('triggerType', val)}
|
|
218
|
+
>
|
|
219
|
+
<SingleSelectOption value="event">Событие</SingleSelectOption>
|
|
220
|
+
<SingleSelectOption value="scheduled">По расписанию</SingleSelectOption>
|
|
221
|
+
<SingleSelectOption value="segment_entry">Вход в сегмент</SingleSelectOption>
|
|
222
|
+
</SingleSelect>
|
|
223
|
+
</Field.Root>
|
|
224
|
+
</Box>
|
|
225
|
+
{editingCampaign.triggerType === 'event' && (
|
|
226
|
+
<Box style={{ flex: 1 }}>
|
|
227
|
+
<Field.Root>
|
|
228
|
+
<Field.Label>Название события</Field.Label>
|
|
229
|
+
<TextInput
|
|
230
|
+
value={editingCampaign.triggerEventName || ''}
|
|
231
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
232
|
+
handleFieldChange('triggerEventName', e.target.value)
|
|
233
|
+
}
|
|
234
|
+
placeholder="e.g., user_registered"
|
|
235
|
+
/>
|
|
236
|
+
</Field.Root>
|
|
237
|
+
</Box>
|
|
238
|
+
)}
|
|
239
|
+
</Flex>
|
|
240
|
+
|
|
241
|
+
<Field.Root>
|
|
242
|
+
<Field.Label>Сегменты</Field.Label>
|
|
243
|
+
<SingleSelect
|
|
244
|
+
value={editingCampaign.segmentIds[0] || ''}
|
|
245
|
+
onChange={(val: string | number) => handleFieldChange('segmentIds', val ? [String(val)] : [])}
|
|
246
|
+
placeholder="Выберите сегмент"
|
|
247
|
+
>
|
|
248
|
+
{segments.map((s) => (
|
|
249
|
+
<SingleSelectOption key={s.documentId} value={s.documentId}>
|
|
250
|
+
{s.name}
|
|
251
|
+
</SingleSelectOption>
|
|
252
|
+
))}
|
|
253
|
+
</SingleSelect>
|
|
254
|
+
</Field.Root>
|
|
255
|
+
|
|
256
|
+
<Box>
|
|
257
|
+
<Flex justifyContent="space-between" alignItems="center" marginBottom={3}>
|
|
258
|
+
<Typography variant="omega" fontWeight="semiBold">
|
|
259
|
+
Варианты сообщений
|
|
260
|
+
</Typography>
|
|
261
|
+
<Button size="S" variant="secondary" onClick={handleAddVariant} startIcon={<Plus />}>
|
|
262
|
+
Добавить вариант
|
|
263
|
+
</Button>
|
|
264
|
+
</Flex>
|
|
265
|
+
{editingCampaign.variants.map((variant, idx) => (
|
|
266
|
+
<Box
|
|
267
|
+
key={idx}
|
|
268
|
+
padding={4}
|
|
269
|
+
marginBottom={2}
|
|
270
|
+
background="neutral100"
|
|
271
|
+
hasRadius
|
|
272
|
+
borderStyle="solid"
|
|
273
|
+
borderWidth="1px"
|
|
274
|
+
borderColor="neutral200"
|
|
275
|
+
>
|
|
276
|
+
<Flex gap={3} alignItems="flex-end">
|
|
277
|
+
<Box style={{ flex: 1 }}>
|
|
278
|
+
<Field.Root>
|
|
279
|
+
<Field.Label>Название</Field.Label>
|
|
280
|
+
<TextInput
|
|
281
|
+
value={variant.name}
|
|
282
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
283
|
+
handleVariantChange(idx, 'name', e.target.value)
|
|
284
|
+
}
|
|
285
|
+
/>
|
|
286
|
+
</Field.Root>
|
|
287
|
+
</Box>
|
|
288
|
+
<Box style={{ flex: 2 }}>
|
|
289
|
+
<Field.Root>
|
|
290
|
+
<Field.Label>Шаблон</Field.Label>
|
|
291
|
+
<SingleSelect
|
|
292
|
+
value={variant.templateId}
|
|
293
|
+
onChange={(val: string | number) => handleVariantChange(idx, 'templateId', String(val))}
|
|
294
|
+
placeholder="Выберите шаблон"
|
|
295
|
+
>
|
|
296
|
+
{templates.map((t) => (
|
|
297
|
+
<SingleSelectOption key={t.documentId} value={t.documentId}>
|
|
298
|
+
{t.name}
|
|
299
|
+
</SingleSelectOption>
|
|
300
|
+
))}
|
|
301
|
+
</SingleSelect>
|
|
302
|
+
</Field.Root>
|
|
303
|
+
</Box>
|
|
304
|
+
<Box style={{ width: 100 }}>
|
|
305
|
+
<Field.Root>
|
|
306
|
+
<Field.Label>Вес</Field.Label>
|
|
307
|
+
<TextInput
|
|
308
|
+
type="number"
|
|
309
|
+
value={String(variant.weight)}
|
|
310
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
311
|
+
handleVariantChange(idx, 'weight', parseInt(e.target.value) || 0)
|
|
312
|
+
}
|
|
313
|
+
/>
|
|
314
|
+
</Field.Root>
|
|
315
|
+
</Box>
|
|
316
|
+
{editingCampaign.variants.length > 1 && (
|
|
317
|
+
<IconButton
|
|
318
|
+
onClick={() => handleRemoveVariant(idx)}
|
|
319
|
+
label="Удалить"
|
|
320
|
+
variant="ghost"
|
|
321
|
+
>
|
|
322
|
+
<Trash />
|
|
323
|
+
</IconButton>
|
|
324
|
+
)}
|
|
325
|
+
</Flex>
|
|
326
|
+
</Box>
|
|
327
|
+
))}
|
|
328
|
+
</Box>
|
|
329
|
+
|
|
330
|
+
<Flex gap={4}>
|
|
331
|
+
<Box style={{ flex: 1 }}>
|
|
332
|
+
<Field.Root>
|
|
333
|
+
<Field.Label>Cooldown (часы)</Field.Label>
|
|
334
|
+
<TextInput
|
|
335
|
+
type="number"
|
|
336
|
+
value={String(editingCampaign.cooldownHours)}
|
|
337
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
338
|
+
handleFieldChange('cooldownHours', parseInt(e.target.value) || 0)
|
|
339
|
+
}
|
|
340
|
+
/>
|
|
341
|
+
</Field.Root>
|
|
342
|
+
</Box>
|
|
343
|
+
<Box style={{ flex: 1 }}>
|
|
344
|
+
<Field.Root>
|
|
345
|
+
<Field.Label>Приоритет</Field.Label>
|
|
346
|
+
<TextInput
|
|
347
|
+
type="number"
|
|
348
|
+
value={String(editingCampaign.priority)}
|
|
349
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
350
|
+
handleFieldChange('priority', parseInt(e.target.value) || 5)
|
|
351
|
+
}
|
|
352
|
+
/>
|
|
353
|
+
</Field.Root>
|
|
354
|
+
</Box>
|
|
355
|
+
<Box style={{ flex: 1 }}>
|
|
356
|
+
<Field.Root>
|
|
357
|
+
<Field.Label>Лимит в день</Field.Label>
|
|
358
|
+
<TextInput
|
|
359
|
+
type="number"
|
|
360
|
+
value={String(editingCampaign.dailyCapPerUser)}
|
|
361
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
362
|
+
handleFieldChange('dailyCapPerUser', parseInt(e.target.value) || 1)
|
|
363
|
+
}
|
|
364
|
+
/>
|
|
365
|
+
</Field.Root>
|
|
366
|
+
</Box>
|
|
367
|
+
</Flex>
|
|
368
|
+
|
|
369
|
+
<Flex gap={4}>
|
|
370
|
+
<Field.Root>
|
|
371
|
+
<Flex gap={2} alignItems="center">
|
|
372
|
+
<Toggle
|
|
373
|
+
checked={editingCampaign.isActive}
|
|
374
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
375
|
+
handleFieldChange('isActive', e.target.checked)
|
|
376
|
+
}
|
|
377
|
+
/>
|
|
378
|
+
<Field.Label>Активна</Field.Label>
|
|
379
|
+
</Flex>
|
|
380
|
+
</Field.Root>
|
|
381
|
+
<Field.Root>
|
|
382
|
+
<Flex gap={2} alignItems="center">
|
|
383
|
+
<Toggle
|
|
384
|
+
checked={editingCampaign.ignoreQuietHours}
|
|
385
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
386
|
+
handleFieldChange('ignoreQuietHours', e.target.checked)
|
|
387
|
+
}
|
|
388
|
+
/>
|
|
389
|
+
<Field.Label>Игнорировать тихие часы</Field.Label>
|
|
390
|
+
</Flex>
|
|
391
|
+
</Field.Root>
|
|
392
|
+
</Flex>
|
|
393
|
+
</Flex>
|
|
394
|
+
</Box>
|
|
395
|
+
</Box>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<Box padding={6}>
|
|
401
|
+
<Flex justifyContent="space-between" alignItems="center" marginBottom={5}>
|
|
402
|
+
<Box>
|
|
403
|
+
<Typography variant="delta" fontWeight="bold">
|
|
404
|
+
Кампании
|
|
405
|
+
</Typography>
|
|
406
|
+
<Typography variant="pi" textColor="neutral600" marginTop={2} style={{ display: 'block' }}>
|
|
407
|
+
Управление CRM-кампаниями
|
|
408
|
+
</Typography>
|
|
409
|
+
</Box>
|
|
410
|
+
<Flex gap={2}>
|
|
411
|
+
<Button onClick={fetchData} loading={loading} variant="tertiary">
|
|
412
|
+
Обновить
|
|
413
|
+
</Button>
|
|
414
|
+
<Button onClick={handleCreate} variant="default" startIcon={<Plus />}>
|
|
415
|
+
Создать кампанию
|
|
416
|
+
</Button>
|
|
417
|
+
</Flex>
|
|
418
|
+
</Flex>
|
|
419
|
+
|
|
420
|
+
<Box background="neutral0" hasRadius shadow="filterShadow">
|
|
421
|
+
<Table colCount={6} rowCount={campaigns.length + 1}>
|
|
422
|
+
<Thead>
|
|
423
|
+
<Tr>
|
|
424
|
+
<Th>Название</Th>
|
|
425
|
+
<Th>Триггер</Th>
|
|
426
|
+
<Th>Сегменты</Th>
|
|
427
|
+
<Th>Варианты</Th>
|
|
428
|
+
<Th>Статус</Th>
|
|
429
|
+
<Th>Действия</Th>
|
|
430
|
+
</Tr>
|
|
431
|
+
</Thead>
|
|
432
|
+
<Tbody>
|
|
433
|
+
{campaigns.length === 0 ? (
|
|
434
|
+
<Tr>
|
|
435
|
+
<Td colSpan={6}>
|
|
436
|
+
<Typography textColor="neutral600" style={{ textAlign: 'center' }}>
|
|
437
|
+
Нет кампаний
|
|
438
|
+
</Typography>
|
|
439
|
+
</Td>
|
|
440
|
+
</Tr>
|
|
441
|
+
) : (
|
|
442
|
+
campaigns.map((campaign) => (
|
|
443
|
+
<Tr key={campaign.documentId || campaign.id}>
|
|
444
|
+
<Td>
|
|
445
|
+
<Typography fontWeight="semiBold">{campaign.name}</Typography>
|
|
446
|
+
<Typography variant="pi" textColor="neutral500">
|
|
447
|
+
{campaign.slug}
|
|
448
|
+
</Typography>
|
|
449
|
+
</Td>
|
|
450
|
+
<Td>
|
|
451
|
+
<Typography>
|
|
452
|
+
{campaign.triggerType === 'event' && `Event: ${campaign.triggerEventName || '-'}`}
|
|
453
|
+
{campaign.triggerType === 'scheduled' && 'Scheduled'}
|
|
454
|
+
{campaign.triggerType === 'segment_entry' && 'Segment Entry'}
|
|
455
|
+
</Typography>
|
|
456
|
+
</Td>
|
|
457
|
+
<Td>
|
|
458
|
+
<Typography>{campaign.segmentIds?.length || 0}</Typography>
|
|
459
|
+
</Td>
|
|
460
|
+
<Td>
|
|
461
|
+
<Typography>{campaign.variants?.length || 0}</Typography>
|
|
462
|
+
</Td>
|
|
463
|
+
<Td>
|
|
464
|
+
<Badge active={campaign.isActive}>
|
|
465
|
+
{campaign.isActive ? 'Активна' : 'Неактивна'}
|
|
466
|
+
</Badge>
|
|
467
|
+
</Td>
|
|
468
|
+
<Td>
|
|
469
|
+
<IconButton onClick={() => handleEdit(campaign)} label="Редактировать">
|
|
470
|
+
<Pencil />
|
|
471
|
+
</IconButton>
|
|
472
|
+
</Td>
|
|
473
|
+
</Tr>
|
|
474
|
+
))
|
|
475
|
+
)}
|
|
476
|
+
</Tbody>
|
|
477
|
+
</Table>
|
|
478
|
+
</Box>
|
|
479
|
+
</Box>
|
|
480
|
+
);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
export default memo(CampaignBuilder);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import React, { memo } from 'react';
|
|
2
2
|
import { Main, Box, Typography, Tabs, Flex, Divider } from '@strapi/design-system';
|
|
3
|
-
import { Message, Clock, Cross,
|
|
3
|
+
import { Message, Clock, Cross, ArrowRight } from '@strapi/icons';
|
|
4
4
|
import { Layouts } from '@strapi/admin/strapi-admin';
|
|
5
5
|
import LogsTable from './components/LogsTable.tsx';
|
|
6
6
|
import AntiSpamLogsTable from './components/AntiSpamLogsTable.tsx';
|
|
7
7
|
import StatsView from './components/StatsView.tsx';
|
|
8
|
-
import
|
|
8
|
+
import CampaignBuilder from './components/CampaignBuilder/index.tsx';
|
|
9
9
|
|
|
10
10
|
const HomePage: React.FC = () => {
|
|
11
11
|
return (
|
|
@@ -57,10 +57,10 @@ const HomePage: React.FC = () => {
|
|
|
57
57
|
<Typography>Антиспам-логи</Typography>
|
|
58
58
|
</Flex>
|
|
59
59
|
</Tabs.Trigger>
|
|
60
|
-
<Tabs.Trigger value="
|
|
60
|
+
<Tabs.Trigger value="campaigns">
|
|
61
61
|
<Flex gap={2}>
|
|
62
|
-
<
|
|
63
|
-
<Typography
|
|
62
|
+
<ArrowRight width="16px" />
|
|
63
|
+
<Typography>Кампании</Typography>
|
|
64
64
|
</Flex>
|
|
65
65
|
</Tabs.Trigger>
|
|
66
66
|
</Tabs.List>
|
|
@@ -75,8 +75,8 @@ const HomePage: React.FC = () => {
|
|
|
75
75
|
<Tabs.Content value="antispam">
|
|
76
76
|
<AntiSpamLogsTable />
|
|
77
77
|
</Tabs.Content>
|
|
78
|
-
<Tabs.Content value="
|
|
79
|
-
<
|
|
78
|
+
<Tabs.Content value="campaigns">
|
|
79
|
+
<CampaignBuilder />
|
|
80
80
|
</Tabs.Content>
|
|
81
81
|
</Tabs.Root>
|
|
82
82
|
</Box>
|
package/admin/src/types/crm.ts
CHANGED
|
@@ -130,3 +130,78 @@ export interface CrmGlobalSettings {
|
|
|
130
130
|
testMode: boolean;
|
|
131
131
|
testUserIds: number[];
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
export type JourneyNodeType = 'entrance' | 'message' | 'wait' | 'branch' | 'exit';
|
|
135
|
+
export type JourneyBranchType = 'default' | 'yes' | 'no';
|
|
136
|
+
export type JourneyWaitType = 'duration' | 'until_event' | 'until_time';
|
|
137
|
+
export type JourneyConditionType = 'segment' | 'event_attribute' | 'random_split';
|
|
138
|
+
export type JourneyChannel = 'telegram' | 'email' | 'push' | 'sms';
|
|
139
|
+
|
|
140
|
+
export interface JourneyMessageVariant {
|
|
141
|
+
name: string;
|
|
142
|
+
templateId: string;
|
|
143
|
+
weight: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface JourneyMessageConfig {
|
|
147
|
+
channel: JourneyChannel;
|
|
148
|
+
variants: JourneyMessageVariant[];
|
|
149
|
+
cancelConditions?: CancelConditionsConfig;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface JourneyWaitConfig {
|
|
153
|
+
waitType: JourneyWaitType;
|
|
154
|
+
durationValue: number;
|
|
155
|
+
durationUnit: 'minutes' | 'hours' | 'days';
|
|
156
|
+
waitForEvent?: string;
|
|
157
|
+
waitUntilTime?: string;
|
|
158
|
+
timeoutDays: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface JourneyBranchConfig {
|
|
162
|
+
conditionType: JourneyConditionType;
|
|
163
|
+
segmentId?: string;
|
|
164
|
+
eventAttributeCondition?: Record<string, unknown>;
|
|
165
|
+
randomSplitPercentage: number;
|
|
166
|
+
evaluateAt: 'realtime' | 'at_entry';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface JourneyStep {
|
|
170
|
+
id: string;
|
|
171
|
+
stepKey: string;
|
|
172
|
+
name: string;
|
|
173
|
+
nodeType: JourneyNodeType;
|
|
174
|
+
position: { x: number; y: number };
|
|
175
|
+
messageConfig?: JourneyMessageConfig;
|
|
176
|
+
waitConfig?: JourneyWaitConfig;
|
|
177
|
+
branchConfig?: JourneyBranchConfig;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface JourneyTransition {
|
|
181
|
+
id: string;
|
|
182
|
+
sourceStepKey: string;
|
|
183
|
+
targetStepKey: string;
|
|
184
|
+
branchType: JourneyBranchType;
|
|
185
|
+
priority: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface CrmJourney {
|
|
189
|
+
id?: number;
|
|
190
|
+
documentId?: string;
|
|
191
|
+
name: string;
|
|
192
|
+
slug: string;
|
|
193
|
+
isActive: boolean;
|
|
194
|
+
triggerType: 'event' | 'scheduled' | 'segment_entry';
|
|
195
|
+
triggerEventName?: string;
|
|
196
|
+
entrySegmentId?: string;
|
|
197
|
+
entryOncePerUser: boolean;
|
|
198
|
+
reEntryDelayDays: number;
|
|
199
|
+
steps: JourneyStep[];
|
|
200
|
+
transitions: JourneyTransition[];
|
|
201
|
+
entranceStepId?: string;
|
|
202
|
+
priority: number;
|
|
203
|
+
dailyCapPerUser: number;
|
|
204
|
+
ignoreQuietHours: boolean;
|
|
205
|
+
startDate?: string;
|
|
206
|
+
endDate?: string;
|
|
207
|
+
}
|
|
@@ -2,7 +2,7 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { forwardRef, useState, useEffect, useMemo } from "react";
|
|
3
3
|
import { Field, Flex, Typography, Box, TextInput, Tooltip, IconButton, Button, Badge } from "@strapi/design-system";
|
|
4
4
|
import { ArrowUp, ArrowDown, Trash, Plus } from "@strapi/icons";
|
|
5
|
-
import { m as generateId } from "./utils-
|
|
5
|
+
import { m as generateId } from "./utils-C6_ndVAZ.mjs";
|
|
6
6
|
const parseButtons = (value) => {
|
|
7
7
|
if (!value) return [];
|
|
8
8
|
if (Array.isArray(value)) {
|