@inspirer-dev/crm-dashboard 1.0.17 → 1.0.18

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,974 @@
1
+ import React, { memo, useState, useCallback, useMemo, useRef, useEffect } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ Flex,
6
+ Button,
7
+ Loader,
8
+ SingleSelect,
9
+ SingleSelectOption,
10
+ TextInput,
11
+ Field,
12
+ IconButton,
13
+ } from '@strapi/design-system';
14
+ import { Plus, Trash, Drag, ArrowRight } from '@strapi/icons';
15
+ import ReactFlow, {
16
+ Background,
17
+ Controls,
18
+ Node,
19
+ Edge,
20
+ Position,
21
+ Handle,
22
+ Connection,
23
+ addEdge,
24
+ useNodesState,
25
+ useEdgesState,
26
+ type NodeProps,
27
+ type EdgeProps,
28
+ BaseEdge,
29
+ getBezierPath,
30
+ EdgeLabelRenderer,
31
+ } from 'reactflow';
32
+ import 'reactflow/dist/style.css';
33
+ import type { CrmSegment, CrmTemplate, CancelConditionsConfig } from '../../../../types/crm';
34
+ import getBackendUrl from '../../../../utils/getBackendUrl';
35
+
36
+ const NODE_COLORS = {
37
+ entrance: { bg: '#10B981', border: '#059669', text: '#fff' },
38
+ message: { bg: '#3B82F6', border: '#2563EB', text: '#fff' },
39
+ wait: { bg: '#F59E0B', border: '#D97706', text: '#fff' },
40
+ branch: { bg: '#8B5CF6', border: '#7C3AED', text: '#fff' },
41
+ exit: { bg: '#6B7280', border: '#4B5563', text: '#fff' },
42
+ };
43
+
44
+ const COLUMN_GAP = 250;
45
+ const ROW_HEIGHT = 120;
46
+
47
+ type NodeType = 'entrance' | 'message' | 'wait' | 'branch' | 'exit';
48
+ type BranchType = 'default' | 'yes' | 'no';
49
+ type WaitType = 'duration' | 'until_event' | 'until_time';
50
+ type ConditionType = 'segment' | 'event_attribute' | 'random_split';
51
+ type Channel = 'telegram' | 'email' | 'push' | 'sms';
52
+
53
+ interface JourneyMessageConfig {
54
+ channel: Channel;
55
+ variants: { name: string; templateId: string; weight: number }[];
56
+ cancelConditions?: CancelConditionsConfig;
57
+ }
58
+
59
+ interface JourneyWaitConfig {
60
+ waitType: WaitType;
61
+ durationValue: number;
62
+ durationUnit: 'minutes' | 'hours' | 'days';
63
+ waitForEvent?: string;
64
+ waitUntilTime?: string;
65
+ timeoutDays: number;
66
+ }
67
+
68
+ interface JourneyBranchConfig {
69
+ conditionType: ConditionType;
70
+ segmentId?: string;
71
+ eventAttributeCondition?: Record<string, unknown>;
72
+ randomSplitPercentage: number;
73
+ evaluateAt: 'realtime' | 'at_entry';
74
+ }
75
+
76
+ interface JourneyStep {
77
+ id: string;
78
+ stepKey: string;
79
+ name: string;
80
+ nodeType: NodeType;
81
+ position: { x: number; y: number };
82
+ messageConfig?: JourneyMessageConfig;
83
+ waitConfig?: JourneyWaitConfig;
84
+ branchConfig?: JourneyBranchConfig;
85
+ }
86
+
87
+ interface JourneyTransition {
88
+ id: string;
89
+ sourceStepKey: string;
90
+ targetStepKey: string;
91
+ branchType: BranchType;
92
+ priority: number;
93
+ }
94
+
95
+ interface Journey {
96
+ id?: number;
97
+ documentId?: string;
98
+ name: string;
99
+ slug: string;
100
+ isActive: boolean;
101
+ triggerType: 'event' | 'scheduled' | 'segment_entry';
102
+ triggerEventName?: string;
103
+ entrySegmentId?: string;
104
+ entryOncePerUser: boolean;
105
+ reEntryDelayDays: number;
106
+ steps: JourneyStep[];
107
+ transitions: JourneyTransition[];
108
+ entranceStepId?: string;
109
+ priority: number;
110
+ dailyCapPerUser: number;
111
+ ignoreQuietHours: boolean;
112
+ startDate?: string;
113
+ endDate?: string;
114
+ }
115
+
116
+ interface StepNodeData {
117
+ step: JourneyStep;
118
+ onSelect: (step: JourneyStep) => void;
119
+ onDelete: (stepKey: string) => void;
120
+ isSelected: boolean;
121
+ }
122
+
123
+ interface BranchEdgeData {
124
+ branchType: BranchType;
125
+ }
126
+
127
+ const formatWaitDuration = (config?: JourneyWaitConfig): string => {
128
+ if (!config) return '';
129
+ if (config.waitType === 'duration') {
130
+ return `${config.durationValue} ${config.durationUnit}`;
131
+ }
132
+ if (config.waitType === 'until_event') {
133
+ return `Until: ${config.waitForEvent || 'event'}`;
134
+ }
135
+ return `Until: ${config.waitUntilTime || 'time'}`;
136
+ };
137
+
138
+ const EntranceNode: React.FC<NodeProps<StepNodeData>> = ({ data }) => {
139
+ const colors = NODE_COLORS.entrance;
140
+ return (
141
+ <div
142
+ onClick={() => data.onSelect(data.step)}
143
+ style={{
144
+ background: colors.bg,
145
+ border: `2px solid ${data.isSelected ? '#fff' : colors.border}`,
146
+ borderRadius: 8,
147
+ padding: '16px 24px',
148
+ color: colors.text,
149
+ minWidth: 140,
150
+ cursor: 'pointer',
151
+ boxShadow: data.isSelected ? '0 0 0 2px #fff, 0 0 0 4px #3B82F6' : 'none',
152
+ }}
153
+ >
154
+ <div style={{ fontWeight: 600, fontSize: 14 }}>ENTRANCE</div>
155
+ <div style={{ fontSize: 11, opacity: 0.8, marginTop: 4 }}>{data.step.name}</div>
156
+ <Handle
157
+ type="source"
158
+ position={Position.Right}
159
+ style={{ background: colors.border, width: 10, height: 10 }}
160
+ />
161
+ </div>
162
+ );
163
+ };
164
+
165
+ const MessageNode: React.FC<NodeProps<StepNodeData>> = ({ data }) => {
166
+ const colors = NODE_COLORS.message;
167
+ const config = data.step.messageConfig;
168
+ return (
169
+ <div
170
+ onClick={() => data.onSelect(data.step)}
171
+ style={{
172
+ background: colors.bg,
173
+ border: `2px solid ${data.isSelected ? '#fff' : colors.border}`,
174
+ borderRadius: 8,
175
+ padding: '12px 16px',
176
+ color: colors.text,
177
+ minWidth: 140,
178
+ cursor: 'pointer',
179
+ boxShadow: data.isSelected ? '0 0 0 2px #fff, 0 0 0 4px #3B82F6' : 'none',
180
+ }}
181
+ >
182
+ <Handle
183
+ type="target"
184
+ position={Position.Left}
185
+ style={{ background: colors.border, width: 10, height: 10 }}
186
+ />
187
+ <div style={{ fontSize: 10, opacity: 0.7, textTransform: 'uppercase' }}>
188
+ {config?.channel || 'telegram'}
189
+ </div>
190
+ <div style={{ fontWeight: 600, marginTop: 2 }}>{data.step.name}</div>
191
+ {config?.variants && config.variants.length > 0 && (
192
+ <div style={{ fontSize: 10, opacity: 0.8, marginTop: 4 }}>
193
+ {config.variants.length} variant{config.variants.length !== 1 ? 's' : ''}
194
+ </div>
195
+ )}
196
+ <Handle
197
+ type="source"
198
+ position={Position.Right}
199
+ style={{ background: colors.border, width: 10, height: 10 }}
200
+ />
201
+ </div>
202
+ );
203
+ };
204
+
205
+ const WaitNode: React.FC<NodeProps<StepNodeData>> = ({ data }) => {
206
+ const colors = NODE_COLORS.wait;
207
+ return (
208
+ <div
209
+ onClick={() => data.onSelect(data.step)}
210
+ style={{
211
+ background: colors.bg,
212
+ border: `2px solid ${data.isSelected ? '#fff' : colors.border}`,
213
+ borderRadius: 8,
214
+ padding: '12px 16px',
215
+ color: colors.text,
216
+ minWidth: 120,
217
+ cursor: 'pointer',
218
+ boxShadow: data.isSelected ? '0 0 0 2px #fff, 0 0 0 4px #3B82F6' : 'none',
219
+ }}
220
+ >
221
+ <Handle
222
+ type="target"
223
+ position={Position.Left}
224
+ style={{ background: colors.border, width: 10, height: 10 }}
225
+ />
226
+ <div style={{ fontWeight: 600 }}>WAIT</div>
227
+ <div style={{ fontSize: 11, marginTop: 4 }}>{formatWaitDuration(data.step.waitConfig)}</div>
228
+ <Handle
229
+ type="source"
230
+ position={Position.Right}
231
+ style={{ background: colors.border, width: 10, height: 10 }}
232
+ />
233
+ </div>
234
+ );
235
+ };
236
+
237
+ const BranchNode: React.FC<NodeProps<StepNodeData>> = ({ data }) => {
238
+ const colors = NODE_COLORS.branch;
239
+ const config = data.step.branchConfig;
240
+ return (
241
+ <div
242
+ onClick={() => data.onSelect(data.step)}
243
+ style={{
244
+ background: colors.bg,
245
+ border: `2px solid ${data.isSelected ? '#fff' : colors.border}`,
246
+ borderRadius: 8,
247
+ padding: '12px 16px',
248
+ color: colors.text,
249
+ minWidth: 140,
250
+ cursor: 'pointer',
251
+ boxShadow: data.isSelected ? '0 0 0 2px #fff, 0 0 0 4px #3B82F6' : 'none',
252
+ }}
253
+ >
254
+ <Handle
255
+ type="target"
256
+ position={Position.Left}
257
+ style={{ background: colors.border, width: 10, height: 10 }}
258
+ />
259
+ <div style={{ fontWeight: 600 }}>BRANCH</div>
260
+ <div style={{ fontSize: 10, opacity: 0.8, marginTop: 4 }}>
261
+ {config?.conditionType === 'segment' && 'Segment Check'}
262
+ {config?.conditionType === 'event_attribute' && 'Event Attribute'}
263
+ {config?.conditionType === 'random_split' && `${config.randomSplitPercentage}% Split`}
264
+ </div>
265
+ <Handle
266
+ type="source"
267
+ position={Position.Right}
268
+ id="yes"
269
+ style={{ background: '#10B981', width: 10, height: 10, top: '30%' }}
270
+ />
271
+ <Handle
272
+ type="source"
273
+ position={Position.Right}
274
+ id="no"
275
+ style={{ background: '#EF4444', width: 10, height: 10, top: '70%' }}
276
+ />
277
+ <div
278
+ style={{
279
+ position: 'absolute',
280
+ right: -30,
281
+ top: '25%',
282
+ fontSize: 9,
283
+ color: '#10B981',
284
+ fontWeight: 600,
285
+ }}
286
+ >
287
+ YES
288
+ </div>
289
+ <div
290
+ style={{
291
+ position: 'absolute',
292
+ right: -24,
293
+ top: '65%',
294
+ fontSize: 9,
295
+ color: '#EF4444',
296
+ fontWeight: 600,
297
+ }}
298
+ >
299
+ NO
300
+ </div>
301
+ </div>
302
+ );
303
+ };
304
+
305
+ const ExitNode: React.FC<NodeProps<StepNodeData>> = ({ data }) => {
306
+ const colors = NODE_COLORS.exit;
307
+ return (
308
+ <div
309
+ onClick={() => data.onSelect(data.step)}
310
+ style={{
311
+ background: colors.bg,
312
+ border: `2px solid ${data.isSelected ? '#fff' : colors.border}`,
313
+ borderRadius: 8,
314
+ padding: '16px 24px',
315
+ color: colors.text,
316
+ minWidth: 100,
317
+ cursor: 'pointer',
318
+ boxShadow: data.isSelected ? '0 0 0 2px #fff, 0 0 0 4px #3B82F6' : 'none',
319
+ }}
320
+ >
321
+ <Handle
322
+ type="target"
323
+ position={Position.Left}
324
+ style={{ background: colors.border, width: 10, height: 10 }}
325
+ />
326
+ <div style={{ fontWeight: 600, textAlign: 'center' }}>EXIT</div>
327
+ </div>
328
+ );
329
+ };
330
+
331
+ const BranchEdge: React.FC<EdgeProps<BranchEdgeData>> = ({
332
+ id,
333
+ sourceX,
334
+ sourceY,
335
+ targetX,
336
+ targetY,
337
+ sourcePosition,
338
+ targetPosition,
339
+ style,
340
+ markerEnd,
341
+ data,
342
+ }) => {
343
+ const [edgePath, labelX, labelY] = getBezierPath({
344
+ sourceX,
345
+ sourceY,
346
+ targetX,
347
+ targetY,
348
+ sourcePosition,
349
+ targetPosition,
350
+ });
351
+
352
+ const label = data?.branchType === 'yes' ? 'YES' : data?.branchType === 'no' ? 'NO' : '';
353
+ const color = data?.branchType === 'yes' ? '#10B981' : data?.branchType === 'no' ? '#EF4444' : '#6B7280';
354
+
355
+ return (
356
+ <>
357
+ <BaseEdge id={id} path={edgePath} style={{ ...style, stroke: color, strokeWidth: 2 }} markerEnd={markerEnd} />
358
+ {label && (
359
+ <EdgeLabelRenderer>
360
+ <div
361
+ style={{
362
+ position: 'absolute',
363
+ transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
364
+ fontSize: 10,
365
+ fontWeight: 600,
366
+ color: '#fff',
367
+ background: color,
368
+ borderRadius: 4,
369
+ padding: '2px 6px',
370
+ pointerEvents: 'none',
371
+ }}
372
+ >
373
+ {label}
374
+ </div>
375
+ </EdgeLabelRenderer>
376
+ )}
377
+ </>
378
+ );
379
+ };
380
+
381
+ const nodeTypes = {
382
+ entrance: EntranceNode,
383
+ message: MessageNode,
384
+ wait: WaitNode,
385
+ branch: BranchNode,
386
+ exit: ExitNode,
387
+ };
388
+
389
+ const edgeTypes = {
390
+ branchEdge: BranchEdge,
391
+ };
392
+
393
+ const createDefaultStep = (nodeType: NodeType, position: { x: number; y: number }): JourneyStep => {
394
+ const stepKey = `${nodeType}_${Date.now()}`;
395
+ const step: JourneyStep = {
396
+ id: stepKey,
397
+ stepKey,
398
+ name: nodeType.charAt(0).toUpperCase() + nodeType.slice(1),
399
+ nodeType,
400
+ position,
401
+ };
402
+
403
+ switch (nodeType) {
404
+ case 'message':
405
+ step.messageConfig = {
406
+ channel: 'telegram',
407
+ variants: [{ name: 'Variant A', templateId: '', weight: 100 }],
408
+ };
409
+ break;
410
+ case 'wait':
411
+ step.waitConfig = {
412
+ waitType: 'duration',
413
+ durationValue: 30,
414
+ durationUnit: 'minutes',
415
+ timeoutDays: 7,
416
+ };
417
+ break;
418
+ case 'branch':
419
+ step.branchConfig = {
420
+ conditionType: 'segment',
421
+ randomSplitPercentage: 50,
422
+ evaluateAt: 'realtime',
423
+ };
424
+ break;
425
+ }
426
+
427
+ return step;
428
+ };
429
+
430
+ interface NodeConfigPanelProps {
431
+ step: JourneyStep | null;
432
+ segments: CrmSegment[];
433
+ templates: CrmTemplate[];
434
+ onUpdate: (step: JourneyStep) => void;
435
+ onClose: () => void;
436
+ }
437
+
438
+ const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
439
+ step,
440
+ segments,
441
+ templates,
442
+ onUpdate,
443
+ onClose,
444
+ }) => {
445
+ if (!step) return null;
446
+
447
+ const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
448
+ onUpdate({ ...step, name: e.target.value });
449
+ };
450
+
451
+ const handleMessageConfigChange = (field: string, value: unknown) => {
452
+ onUpdate({
453
+ ...step,
454
+ messageConfig: {
455
+ ...step.messageConfig!,
456
+ [field]: value,
457
+ },
458
+ });
459
+ };
460
+
461
+ const handleWaitConfigChange = (field: string, value: unknown) => {
462
+ onUpdate({
463
+ ...step,
464
+ waitConfig: {
465
+ ...step.waitConfig!,
466
+ [field]: value,
467
+ },
468
+ });
469
+ };
470
+
471
+ const handleBranchConfigChange = (field: string, value: unknown) => {
472
+ onUpdate({
473
+ ...step,
474
+ branchConfig: {
475
+ ...step.branchConfig!,
476
+ [field]: value,
477
+ },
478
+ });
479
+ };
480
+
481
+ return (
482
+ <Box
483
+ background="neutral0"
484
+ padding={4}
485
+ hasRadius
486
+ shadow="filterShadow"
487
+ borderStyle="solid"
488
+ borderWidth="1px"
489
+ borderColor="neutral200"
490
+ style={{ width: 320, maxHeight: '80vh', overflowY: 'auto' }}
491
+ >
492
+ <Flex justifyContent="space-between" alignItems="center" marginBottom={4}>
493
+ <Typography variant="delta" fontWeight="bold">
494
+ {step.nodeType.toUpperCase()} Config
495
+ </Typography>
496
+ <IconButton onClick={onClose} label="Close">
497
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
498
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
499
+ </svg>
500
+ </IconButton>
501
+ </Flex>
502
+
503
+ <Field.Root>
504
+ <Field.Label>Step Name</Field.Label>
505
+ <TextInput value={step.name} onChange={handleNameChange} />
506
+ </Field.Root>
507
+
508
+ {step.nodeType === 'message' && step.messageConfig && (
509
+ <Box marginTop={4}>
510
+ <Field.Root>
511
+ <Field.Label>Channel</Field.Label>
512
+ <SingleSelect
513
+ value={step.messageConfig.channel}
514
+ onChange={(val: string | number) => handleMessageConfigChange('channel', val)}
515
+ >
516
+ <SingleSelectOption value="telegram">Telegram</SingleSelectOption>
517
+ <SingleSelectOption value="email">Email</SingleSelectOption>
518
+ <SingleSelectOption value="push">Push</SingleSelectOption>
519
+ <SingleSelectOption value="sms">SMS</SingleSelectOption>
520
+ </SingleSelect>
521
+ </Field.Root>
522
+
523
+ <Box marginTop={3}>
524
+ <Typography variant="pi" fontWeight="semiBold">
525
+ Variants
526
+ </Typography>
527
+ {step.messageConfig.variants.map((variant, idx) => (
528
+ <Box
529
+ key={idx}
530
+ marginTop={2}
531
+ padding={3}
532
+ background="neutral100"
533
+ hasRadius
534
+ >
535
+ <Field.Root>
536
+ <Field.Label>Variant Name</Field.Label>
537
+ <TextInput
538
+ value={variant.name}
539
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
540
+ const newVariants = [...step.messageConfig!.variants];
541
+ newVariants[idx] = { ...variant, name: e.target.value };
542
+ handleMessageConfigChange('variants', newVariants);
543
+ }}
544
+ />
545
+ </Field.Root>
546
+ <Field.Root>
547
+ <Field.Label>Template</Field.Label>
548
+ <SingleSelect
549
+ value={variant.templateId}
550
+ onChange={(val: string | number) => {
551
+ const newVariants = [...step.messageConfig!.variants];
552
+ newVariants[idx] = { ...variant, templateId: String(val) };
553
+ handleMessageConfigChange('variants', newVariants);
554
+ }}
555
+ placeholder="Select template"
556
+ >
557
+ {templates.map((t) => (
558
+ <SingleSelectOption key={t.documentId} value={t.documentId}>
559
+ {t.name}
560
+ </SingleSelectOption>
561
+ ))}
562
+ </SingleSelect>
563
+ </Field.Root>
564
+ <Field.Root>
565
+ <Field.Label>Weight</Field.Label>
566
+ <TextInput
567
+ type="number"
568
+ value={String(variant.weight)}
569
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
570
+ const newVariants = [...step.messageConfig!.variants];
571
+ newVariants[idx] = { ...variant, weight: parseInt(e.target.value) || 0 };
572
+ handleMessageConfigChange('variants', newVariants);
573
+ }}
574
+ />
575
+ </Field.Root>
576
+ </Box>
577
+ ))}
578
+ <Button
579
+ variant="secondary"
580
+ size="S"
581
+ marginTop={2}
582
+ onClick={() => {
583
+ const newVariants = [
584
+ ...step.messageConfig!.variants,
585
+ { name: `Variant ${String.fromCharCode(65 + step.messageConfig!.variants.length)}`, templateId: '', weight: 100 },
586
+ ];
587
+ handleMessageConfigChange('variants', newVariants);
588
+ }}
589
+ >
590
+ Add Variant
591
+ </Button>
592
+ </Box>
593
+ </Box>
594
+ )}
595
+
596
+ {step.nodeType === 'wait' && step.waitConfig && (
597
+ <Box marginTop={4}>
598
+ <Field.Root>
599
+ <Field.Label>Wait Type</Field.Label>
600
+ <SingleSelect
601
+ value={step.waitConfig.waitType}
602
+ onChange={(val: string | number) => handleWaitConfigChange('waitType', val)}
603
+ >
604
+ <SingleSelectOption value="duration">Duration</SingleSelectOption>
605
+ <SingleSelectOption value="until_event">Until Event</SingleSelectOption>
606
+ <SingleSelectOption value="until_time">Until Time</SingleSelectOption>
607
+ </SingleSelect>
608
+ </Field.Root>
609
+
610
+ {step.waitConfig.waitType === 'duration' && (
611
+ <Flex gap={2} marginTop={3}>
612
+ <Box style={{ flex: 1 }}>
613
+ <Field.Root>
614
+ <Field.Label>Duration</Field.Label>
615
+ <TextInput
616
+ type="number"
617
+ value={String(step.waitConfig.durationValue)}
618
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
619
+ handleWaitConfigChange('durationValue', parseInt(e.target.value) || 0)
620
+ }
621
+ />
622
+ </Field.Root>
623
+ </Box>
624
+ <Box style={{ flex: 1 }}>
625
+ <Field.Root>
626
+ <Field.Label>Unit</Field.Label>
627
+ <SingleSelect
628
+ value={step.waitConfig.durationUnit}
629
+ onChange={(val: string | number) => handleWaitConfigChange('durationUnit', val)}
630
+ >
631
+ <SingleSelectOption value="minutes">Minutes</SingleSelectOption>
632
+ <SingleSelectOption value="hours">Hours</SingleSelectOption>
633
+ <SingleSelectOption value="days">Days</SingleSelectOption>
634
+ </SingleSelect>
635
+ </Field.Root>
636
+ </Box>
637
+ </Flex>
638
+ )}
639
+
640
+ {step.waitConfig.waitType === 'until_event' && (
641
+ <Field.Root>
642
+ <Field.Label>Event Name</Field.Label>
643
+ <TextInput
644
+ value={step.waitConfig.waitForEvent || ''}
645
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
646
+ handleWaitConfigChange('waitForEvent', e.target.value)
647
+ }
648
+ placeholder="e.g., deposit_completed"
649
+ />
650
+ </Field.Root>
651
+ )}
652
+ </Box>
653
+ )}
654
+
655
+ {step.nodeType === 'branch' && step.branchConfig && (
656
+ <Box marginTop={4}>
657
+ <Field.Root>
658
+ <Field.Label>Condition Type</Field.Label>
659
+ <SingleSelect
660
+ value={step.branchConfig.conditionType}
661
+ onChange={(val: string | number) => handleBranchConfigChange('conditionType', val)}
662
+ >
663
+ <SingleSelectOption value="segment">Segment Check</SingleSelectOption>
664
+ <SingleSelectOption value="event_attribute">Event Attribute</SingleSelectOption>
665
+ <SingleSelectOption value="random_split">Random Split</SingleSelectOption>
666
+ </SingleSelect>
667
+ </Field.Root>
668
+
669
+ {step.branchConfig.conditionType === 'segment' && (
670
+ <Field.Root>
671
+ <Field.Label>Segment</Field.Label>
672
+ <SingleSelect
673
+ value={step.branchConfig.segmentId || ''}
674
+ onChange={(val: string | number) => handleBranchConfigChange('segmentId', val)}
675
+ placeholder="Select segment"
676
+ >
677
+ {segments.map((s) => (
678
+ <SingleSelectOption key={s.documentId} value={s.documentId}>
679
+ {s.name}
680
+ </SingleSelectOption>
681
+ ))}
682
+ </SingleSelect>
683
+ </Field.Root>
684
+ )}
685
+
686
+ {step.branchConfig.conditionType === 'random_split' && (
687
+ <Field.Root>
688
+ <Field.Label>YES Percentage</Field.Label>
689
+ <TextInput
690
+ type="number"
691
+ value={String(step.branchConfig.randomSplitPercentage)}
692
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
693
+ handleBranchConfigChange('randomSplitPercentage', parseInt(e.target.value) || 50)
694
+ }
695
+ />
696
+ </Field.Root>
697
+ )}
698
+
699
+ <Field.Root>
700
+ <Field.Label>Evaluate At</Field.Label>
701
+ <SingleSelect
702
+ value={step.branchConfig.evaluateAt}
703
+ onChange={(val: string | number) => handleBranchConfigChange('evaluateAt', val)}
704
+ >
705
+ <SingleSelectOption value="realtime">Realtime</SingleSelectOption>
706
+ <SingleSelectOption value="at_entry">At Entry</SingleSelectOption>
707
+ </SingleSelect>
708
+ </Field.Root>
709
+ </Box>
710
+ )}
711
+ </Box>
712
+ );
713
+ };
714
+
715
+ const CampaignBuilder: React.FC = () => {
716
+ const [campaign, setCampaign] = useState<Journey>({
717
+ name: 'New Campaign',
718
+ slug: 'new-campaign',
719
+ isActive: false,
720
+ triggerType: 'event',
721
+ entryOncePerUser: true,
722
+ reEntryDelayDays: 0,
723
+ steps: [
724
+ createDefaultStep('entrance', { x: 50, y: 150 }),
725
+ createDefaultStep('exit', { x: 750, y: 150 }),
726
+ ],
727
+ transitions: [],
728
+ priority: 5,
729
+ dailyCapPerUser: 1,
730
+ ignoreQuietHours: false,
731
+ });
732
+
733
+ const [selectedStep, setSelectedStep] = useState<JourneyStep | null>(null);
734
+ const [segments, setSegments] = useState<CrmSegment[]>([]);
735
+ const [templates, setTemplates] = useState<CrmTemplate[]>([]);
736
+ const [loading, setLoading] = useState(false);
737
+ const containerRef = useRef<HTMLDivElement>(null);
738
+
739
+ const fetchData = useCallback(async () => {
740
+ setLoading(true);
741
+ try {
742
+ const backendUrl = getBackendUrl();
743
+ const [segmentsRes, templatesRes] = await Promise.all([
744
+ fetch(new URL('/api/crm/segments', backendUrl).toString()),
745
+ fetch(new URL('/api/crm/templates', backendUrl).toString()),
746
+ ]);
747
+
748
+ if (segmentsRes.ok) {
749
+ const data = await segmentsRes.json();
750
+ setSegments(Array.isArray(data) ? data : data?.data || []);
751
+ }
752
+
753
+ if (templatesRes.ok) {
754
+ const data = await templatesRes.json();
755
+ setTemplates(Array.isArray(data) ? data : data?.data || []);
756
+ }
757
+ } catch (err) {
758
+ console.error('Failed to fetch data:', err);
759
+ } finally {
760
+ setLoading(false);
761
+ }
762
+ }, []);
763
+
764
+ useEffect(() => {
765
+ fetchData();
766
+ }, [fetchData]);
767
+
768
+ const handleSelectStep = useCallback((step: JourneyStep) => {
769
+ setSelectedStep(step);
770
+ }, []);
771
+
772
+ const handleDeleteStep = useCallback((stepKey: string) => {
773
+ setCampaign((prev) => ({
774
+ ...prev,
775
+ steps: prev.steps.filter((s) => s.stepKey !== stepKey),
776
+ transitions: prev.transitions.filter(
777
+ (t) => t.sourceStepKey !== stepKey && t.targetStepKey !== stepKey
778
+ ),
779
+ }));
780
+ setSelectedStep(null);
781
+ }, []);
782
+
783
+ const handleUpdateStep = useCallback((updatedStep: JourneyStep) => {
784
+ setCampaign((prev) => ({
785
+ ...prev,
786
+ steps: prev.steps.map((s) => (s.stepKey === updatedStep.stepKey ? updatedStep : s)),
787
+ }));
788
+ setSelectedStep(updatedStep);
789
+ }, []);
790
+
791
+ const handleAddNode = useCallback((nodeType: NodeType) => {
792
+ const maxX = Math.max(...campaign.steps.map((s) => s.position.x), 0);
793
+ const newStep = createDefaultStep(nodeType, { x: maxX + 200, y: 150 });
794
+ setCampaign((prev) => ({
795
+ ...prev,
796
+ steps: [...prev.steps, newStep],
797
+ }));
798
+ }, [campaign.steps]);
799
+
800
+ const onConnect = useCallback((connection: Connection) => {
801
+ if (!connection.source || !connection.target) return;
802
+
803
+ const sourceStep = campaign.steps.find((s) => s.stepKey === connection.source);
804
+ let branchType: BranchType = 'default';
805
+
806
+ if (sourceStep?.nodeType === 'branch') {
807
+ branchType = connection.sourceHandle === 'yes' ? 'yes' : 'no';
808
+ }
809
+
810
+ const newTransition: JourneyTransition = {
811
+ id: `${connection.source}-${connection.target}-${branchType}`,
812
+ sourceStepKey: connection.source,
813
+ targetStepKey: connection.target,
814
+ branchType,
815
+ priority: 0,
816
+ };
817
+
818
+ setCampaign((prev) => ({
819
+ ...prev,
820
+ transitions: [...prev.transitions, newTransition],
821
+ }));
822
+ }, [campaign.steps]);
823
+
824
+ const nodes: Node[] = useMemo(() => {
825
+ return campaign.steps.map((step) => ({
826
+ id: step.stepKey,
827
+ type: step.nodeType,
828
+ position: step.position,
829
+ data: {
830
+ step,
831
+ onSelect: handleSelectStep,
832
+ onDelete: handleDeleteStep,
833
+ isSelected: selectedStep?.stepKey === step.stepKey,
834
+ },
835
+ draggable: true,
836
+ }));
837
+ }, [campaign.steps, selectedStep, handleSelectStep, handleDeleteStep]);
838
+
839
+ const edges: Edge[] = useMemo(() => {
840
+ return campaign.transitions.map((t) => ({
841
+ id: t.id,
842
+ source: t.sourceStepKey,
843
+ target: t.targetStepKey,
844
+ sourceHandle: t.branchType !== 'default' ? t.branchType : undefined,
845
+ type: t.branchType !== 'default' ? 'branchEdge' : 'default',
846
+ data: { branchType: t.branchType },
847
+ style: { strokeWidth: 2 },
848
+ animated: true,
849
+ }));
850
+ }, [campaign.transitions]);
851
+
852
+ const onNodesChange = useCallback((changes: any) => {
853
+ setCampaign((prev) => {
854
+ const newSteps = [...prev.steps];
855
+ for (const change of changes) {
856
+ if (change.type === 'position' && change.position) {
857
+ const idx = newSteps.findIndex((s) => s.stepKey === change.id);
858
+ if (idx !== -1) {
859
+ newSteps[idx] = { ...newSteps[idx], position: change.position };
860
+ }
861
+ }
862
+ }
863
+ return { ...prev, steps: newSteps };
864
+ });
865
+ }, []);
866
+
867
+ const onEdgesChange = useCallback((changes: any) => {
868
+ setCampaign((prev) => {
869
+ let newTransitions = [...prev.transitions];
870
+ for (const change of changes) {
871
+ if (change.type === 'remove') {
872
+ newTransitions = newTransitions.filter((t) => t.id !== change.id);
873
+ }
874
+ }
875
+ return { ...prev, transitions: newTransitions };
876
+ });
877
+ }, []);
878
+
879
+ const handleSaveCampaign = useCallback(async () => {
880
+ console.log('Saving campaign:', campaign);
881
+ }, [campaign]);
882
+
883
+ return (
884
+ <Box padding={6}>
885
+ <Flex justifyContent="space-between" alignItems="center" marginBottom={5}>
886
+ <Box>
887
+ <Typography variant="delta" fontWeight="bold">
888
+ Конструктор кампаний
889
+ </Typography>
890
+ <Typography variant="pi" textColor="neutral600" marginTop={2} style={{ display: 'block' }}>
891
+ Создание кампаний с ветвлением
892
+ </Typography>
893
+ </Box>
894
+ <Flex gap={2}>
895
+ <Button onClick={fetchData} loading={loading} variant="tertiary">
896
+ Обновить
897
+ </Button>
898
+ <Button onClick={handleSaveCampaign} variant="default">
899
+ Сохранить
900
+ </Button>
901
+ </Flex>
902
+ </Flex>
903
+
904
+ <Box
905
+ background="neutral0"
906
+ padding={3}
907
+ hasRadius
908
+ marginBottom={4}
909
+ borderStyle="solid"
910
+ borderWidth="1px"
911
+ borderColor="neutral200"
912
+ >
913
+ <Flex gap={2} alignItems="center">
914
+ <Typography variant="pi" fontWeight="semiBold" marginRight={2}>
915
+ Add Node:
916
+ </Typography>
917
+ <Button size="S" variant="secondary" onClick={() => handleAddNode('message')}>
918
+ Message
919
+ </Button>
920
+ <Button size="S" variant="secondary" onClick={() => handleAddNode('wait')}>
921
+ Wait
922
+ </Button>
923
+ <Button size="S" variant="secondary" onClick={() => handleAddNode('branch')}>
924
+ Branch
925
+ </Button>
926
+ <Button size="S" variant="secondary" onClick={() => handleAddNode('exit')}>
927
+ Exit
928
+ </Button>
929
+ </Flex>
930
+ </Box>
931
+
932
+ <Flex gap={4}>
933
+ <Box
934
+ ref={containerRef}
935
+ background="neutral100"
936
+ hasRadius
937
+ shadow="filterShadow"
938
+ borderStyle="solid"
939
+ borderWidth="1px"
940
+ borderColor="neutral150"
941
+ style={{ flex: 1, height: 600, position: 'relative' }}
942
+ >
943
+ <ReactFlow
944
+ nodes={nodes}
945
+ edges={edges}
946
+ nodeTypes={nodeTypes}
947
+ edgeTypes={edgeTypes}
948
+ onNodesChange={onNodesChange}
949
+ onEdgesChange={onEdgesChange}
950
+ onConnect={onConnect}
951
+ fitView
952
+ fitViewOptions={{ padding: 0.2 }}
953
+ defaultEdgeOptions={{ type: 'smoothstep' }}
954
+ >
955
+ <Background gap={20} />
956
+ <Controls />
957
+ </ReactFlow>
958
+ </Box>
959
+
960
+ {selectedStep && (
961
+ <NodeConfigPanel
962
+ step={selectedStep}
963
+ segments={segments}
964
+ templates={templates}
965
+ onUpdate={handleUpdateStep}
966
+ onClose={() => setSelectedStep(null)}
967
+ />
968
+ )}
969
+ </Flex>
970
+ </Box>
971
+ );
972
+ };
973
+
974
+ export default memo(CampaignBuilder);