@inspirer-dev/crm-dashboard 1.0.72 → 1.0.73

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 (36) hide show
  1. package/admin/src/components/StepFlowBuilder/constants.ts +0 -25
  2. package/admin/src/components/StepFlowBuilder/flow-canvas/FlowCanvas.tsx +36 -51
  3. package/admin/src/components/StepFlowBuilder/hooks/useFlowHistory.ts +3 -0
  4. package/admin/src/components/StepFlowBuilder/hooks/useFlowTheme.ts +11 -13
  5. package/admin/src/components/StepFlowBuilder/index.tsx +49 -56
  6. package/admin/src/components/StepFlowBuilder/nodes/BranchNode.tsx +6 -82
  7. package/admin/src/components/StepFlowBuilder/nodes/EntryNode.tsx +4 -46
  8. package/admin/src/components/StepFlowBuilder/nodes/EventTriggerNode.tsx +6 -83
  9. package/admin/src/components/StepFlowBuilder/nodes/ExitNode.tsx +6 -82
  10. package/admin/src/components/StepFlowBuilder/nodes/MessageNode.tsx +6 -82
  11. package/admin/src/components/StepFlowBuilder/nodes/WaitNode.tsx +6 -82
  12. package/admin/src/components/StepFlowBuilder/nodes/shared.tsx +105 -0
  13. package/admin/src/components/StepFlowBuilder/panels/BranchConfig.tsx +9 -13
  14. package/admin/src/components/StepFlowBuilder/panels/EntryConfig.tsx +19 -33
  15. package/admin/src/components/StepFlowBuilder/panels/EventTriggerConfig.tsx +28 -17
  16. package/admin/src/components/StepFlowBuilder/panels/MessageConfig.tsx +40 -68
  17. package/admin/src/components/StepFlowBuilder/panels/NodeEditPanel.tsx +9 -27
  18. package/admin/src/components/StepFlowBuilder/panels/ValidationPanel.tsx +16 -42
  19. package/admin/src/components/StepFlowBuilder/panels/WaitConfig.tsx +3 -5
  20. package/admin/src/components/StepFlowBuilder/panels/shared-icons.tsx +31 -0
  21. package/admin/src/components/StepFlowBuilder/toolbar/FlowToolbar.tsx +7 -19
  22. package/admin/src/components/StepFlowBuilder/types.ts +35 -12
  23. package/admin/src/components/StepFlowBuilder/utils.ts +14 -3
  24. package/admin/src/components/StepFlowBuilder/validation.ts +20 -37
  25. package/admin/tsconfig.tsbuildinfo +1 -1
  26. package/dist/_chunks/{index-DPbRPHfu.mjs → index-Bo1xlxu3.mjs} +376 -690
  27. package/dist/_chunks/{index-DXq4OgEg.js → index-DgSik-4_.js} +374 -688
  28. package/dist/admin/index.js +1 -1
  29. package/dist/admin/index.mjs +1 -1
  30. package/package.json +1 -1
  31. package/admin/src/components/StepFlowBuilder/edges/_backup_20260206/LabeledEdge.tsx +0 -78
  32. package/admin/src/components/StepFlowBuilder/nodes/_backup_20260206/BranchNode.tsx +0 -205
  33. package/admin/src/components/StepFlowBuilder/nodes/_backup_20260206/EntryNode.tsx +0 -133
  34. package/admin/src/components/StepFlowBuilder/nodes/_backup_20260206/ExitNode.tsx +0 -165
  35. package/admin/src/components/StepFlowBuilder/nodes/_backup_20260206/MessageNode.tsx +0 -215
  36. package/admin/src/components/StepFlowBuilder/nodes/_backup_20260206/WaitNode.tsx +0 -195
@@ -110,16 +110,12 @@ export const CHANNEL_OPTIONS: { value: ChannelType; label: string }[] = [
110
110
  { value: 'sms', label: 'SMS' },
111
111
  ];
112
112
 
113
- export const CHANNELS = CHANNEL_OPTIONS;
114
-
115
113
  export const DURATION_UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
116
114
  { value: 'minutes', label: 'Minutes' },
117
115
  { value: 'hours', label: 'Hours' },
118
116
  { value: 'days', label: 'Days' },
119
117
  ];
120
118
 
121
- export const DURATION_UNITS = DURATION_UNIT_OPTIONS;
122
-
123
119
  export const DEFAULT_NODE_DATA: Record<StepType, { name: string; config: object }> = {
124
120
  entry: {
125
121
  name: 'Entry',
@@ -166,24 +162,3 @@ export const PROXIMITY_THRESHOLD = 350;
166
162
 
167
163
  export const AUTO_DISCONNECT_ENABLED = false;
168
164
 
169
- export const DEFAULT_STEP_CONFIG: Record<StepType, object> = {
170
- entry: {},
171
- message: {
172
- channel: 'telegram' as ChannelType,
173
- variants: [],
174
- },
175
- wait: {
176
- duration: 1,
177
- durationUnit: 'hours' as DurationUnit,
178
- },
179
- branch: {
180
- branchSegmentId: undefined,
181
- },
182
- exit: {},
183
- event_trigger: {
184
- eventTrigger: {
185
- events: [],
186
- logic: 'or',
187
- },
188
- },
189
- };
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import ReactFlow, {
3
3
  Background,
4
4
  MiniMap,
@@ -52,7 +52,6 @@ interface FlowCanvasProps {
52
52
  onStepsChange: (steps: FlowStep[]) => void;
53
53
  onValidationChange?: (validation: ValidationResult) => void;
54
54
  disabled?: boolean;
55
- error?: string;
56
55
  campaignContext?: CampaignContext;
57
56
  }
58
57
 
@@ -61,29 +60,43 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
61
60
  onStepsChange,
62
61
  onValidationChange,
63
62
  disabled,
64
- error,
65
63
  campaignContext,
66
64
  }) => {
67
65
  const [nodes, setNodes, onNodesChange] = useNodesState<FlowNodeData>([]);
68
66
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
69
67
  const [selectedNode, setSelectedNode] = useState<FlowNode | null>(null);
70
68
  const [, setHistoryTick] = useState(0);
69
+ const [validation, setValidation] = useState<ValidationResult>(() =>
70
+ validateFlow([] as FlowNode[], [] as FlowEdge[])
71
+ );
71
72
  const isInitialMount = useRef(true);
72
73
  const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
74
+ const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
73
75
  const lastStepsRef = useRef<string>('');
76
+ const lastValidationJsonRef = useRef<string>('');
74
77
  const isRestoringRef = useRef(false);
75
78
  const history = useFlowHistory();
76
79
  const { fitView, getViewport, setViewport, setCenter } = useReactFlow();
77
80
  const store = useStoreApi();
78
81
  const updateNodeInternals = useUpdateNodeInternals();
79
82
 
80
- const validation = useMemo(() => {
81
- return validateFlow(nodes as FlowNode[], edges as FlowEdge[]);
82
- }, [nodes, edges]);
83
-
84
83
  useEffect(() => {
85
- onValidationChange?.(validation);
86
- }, [validation, onValidationChange]);
84
+ if (validationTimeoutRef.current) {
85
+ clearTimeout(validationTimeoutRef.current);
86
+ }
87
+ validationTimeoutRef.current = setTimeout(() => {
88
+ const result = validateFlow(nodes as FlowNode[], edges as FlowEdge[]);
89
+ const json = JSON.stringify({ e: result.errors.map((e) => e.id), w: result.warnings.map((w) => w.id) });
90
+ if (json !== lastValidationJsonRef.current) {
91
+ lastValidationJsonRef.current = json;
92
+ setValidation(result);
93
+ onValidationChange?.(result);
94
+ }
95
+ }, 200);
96
+ return () => {
97
+ if (validationTimeoutRef.current) clearTimeout(validationTimeoutRef.current);
98
+ };
99
+ }, [nodes, edges, onValidationChange]);
87
100
 
88
101
  const fitViewWithOffset = useCallback(
89
102
  ({ topOffset = 40, rightOffset = 150, targetZoom = 0.5 } = {}) => {
@@ -220,8 +233,8 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
220
233
  [store]
221
234
  );
222
235
 
223
- const handleNodeDrag = useCallback(
224
- (_: React.MouseEvent, node: Node<FlowNodeData>) => {
236
+ const applyProximityEdges = useCallback(
237
+ (node: Node<FlowNodeData>, isPreview: boolean) => {
225
238
  const closeEdge = getClosestEdge(node);
226
239
 
227
240
  setEdges((es) => {
@@ -252,7 +265,7 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
252
265
  if (!sourceHandleAlreadyUsed && !targetAlreadyHasInput) {
253
266
  nextEdges.push({
254
267
  ...closeEdge,
255
- className: 'temp',
268
+ ...(isPreview ? { className: 'temp' } : {}),
256
269
  type: closeEdge.sourceHandle ? 'labeled' : 'default',
257
270
  });
258
271
  }
@@ -264,47 +277,18 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
264
277
  [getClosestEdge, getNodeDistance, setEdges]
265
278
  );
266
279
 
267
- const handleNodeDragStop = useCallback(
280
+ const handleNodeDrag = useCallback(
268
281
  (_: React.MouseEvent, node: Node<FlowNodeData>) => {
269
- const closeEdge = getClosestEdge(node);
270
-
271
- setEdges((es) => {
272
- const nextEdges = es.filter((e) => {
273
- if (e.className === 'temp') return false;
274
- if (AUTO_DISCONNECT_ENABLED) {
275
- const isConnectedToDragged = e.source === node.id || e.target === node.id;
276
- if (isConnectedToDragged) {
277
- const otherNodeId = e.source === node.id ? e.target : e.source;
278
- const distance = getNodeDistance(node.id, otherNodeId);
279
- if (distance > PROXIMITY_THRESHOLD) {
280
- return false;
281
- }
282
- }
283
- }
284
- return true;
285
- });
286
-
287
- if (closeEdge) {
288
- const sourceHandleAlreadyUsed = nextEdges.find(
289
- (ne) =>
290
- ne.source === closeEdge.source &&
291
- ne.sourceHandle === closeEdge.sourceHandle
292
- );
293
- const targetAlreadyHasInput = nextEdges.find(
294
- (ne) => ne.target === closeEdge.target
295
- );
296
- if (!sourceHandleAlreadyUsed && !targetAlreadyHasInput) {
297
- nextEdges.push({
298
- ...closeEdge,
299
- type: closeEdge.sourceHandle ? 'labeled' : 'default',
300
- });
301
- }
302
- }
282
+ applyProximityEdges(node, true);
283
+ },
284
+ [applyProximityEdges]
285
+ );
303
286
 
304
- return nextEdges;
305
- });
287
+ const handleNodeDragStop = useCallback(
288
+ (_: React.MouseEvent, node: Node<FlowNodeData>) => {
289
+ applyProximityEdges(node, false);
306
290
  },
307
- [getClosestEdge, getNodeDistance, setEdges]
291
+ [applyProximityEdges]
308
292
  );
309
293
 
310
294
  useEffect(() => {
@@ -564,7 +548,7 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
564
548
  style={{
565
549
  height: '100%',
566
550
  width: '100%',
567
- border: error ? '1px solid #ee5e52' : 'none',
551
+ border: 'none',
568
552
  borderRadius: 0,
569
553
  overflow: 'hidden',
570
554
  position: 'relative',
@@ -609,6 +593,7 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
609
593
  wait: '#f59e0b',
610
594
  branch: '#8b5cf6',
611
595
  exit: '#6b7280',
596
+ event_trigger: '#f43f5e',
612
597
  };
613
598
  return colors[(node.data as FlowNodeData)?.stepType] || '#4a4a6a';
614
599
  }}
@@ -24,6 +24,9 @@ export const useFlowHistory = () => {
24
24
  const redo = useCallback((currentStepsJson: string): string | null => {
25
25
  if (redoStack.current.length === 0) return null;
26
26
  undoStack.current.push(currentStepsJson);
27
+ if (undoStack.current.length > MAX_HISTORY) {
28
+ undoStack.current.shift();
29
+ }
27
30
  return redoStack.current.pop()!;
28
31
  }, []);
29
32
 
@@ -7,19 +7,17 @@ export interface FlowThemeColors {
7
7
  text: string;
8
8
  textMuted: string;
9
9
  textSubtle: string;
10
- isDark: boolean;
11
10
  }
12
11
 
13
- export const useFlowTheme = (): FlowThemeColors => {
14
- return {
15
- background: '#1a1a2e',
16
- surface: '#252540',
17
- surfaceHover: '#2f2f4a',
18
- border: 'rgba(255, 255, 255, 0.1)',
19
- borderLight: 'rgba(255, 255, 255, 0.06)',
20
- text: '#ffffff',
21
- textMuted: 'rgba(255, 255, 255, 0.6)',
22
- textSubtle: 'rgba(255, 255, 255, 0.4)',
23
- isDark: true,
24
- };
12
+ const THEME: FlowThemeColors = {
13
+ background: '#1a1a2e',
14
+ surface: '#252540',
15
+ surfaceHover: '#2f2f4a',
16
+ border: 'rgba(255, 255, 255, 0.1)',
17
+ borderLight: 'rgba(255, 255, 255, 0.06)',
18
+ text: '#ffffff',
19
+ textMuted: 'rgba(255, 255, 255, 0.6)',
20
+ textSubtle: 'rgba(255, 255, 255, 0.4)',
25
21
  };
22
+
23
+ export const useFlowTheme = (): FlowThemeColors => THEME;
@@ -11,12 +11,20 @@ import {
11
11
 
12
12
  import type { StepFlowBuilderProps, FlowStep, CampaignContext, EntrySegment } from './types';
13
13
  import type { ValidationResult } from './validation';
14
- import { parseValue } from './utils';
14
+ import { parseValue, pluralize } from './utils';
15
15
  import { FlowCanvas } from './flow-canvas';
16
16
 
17
17
  type SegmentItem = { id?: number; documentId?: string; name?: string };
18
18
  type ConnectDisconnect = { connect?: SegmentItem[]; disconnect?: SegmentItem[] };
19
19
 
20
+ const useContentManagerContextSafe = () => {
21
+ try {
22
+ return useContentManagerContext();
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+
20
28
  interface StepFlowModalProps {
21
29
  isOpen: boolean;
22
30
  onClose: () => void;
@@ -27,6 +35,15 @@ interface StepFlowModalProps {
27
35
  onValidationChange: (validation: ValidationResult) => void;
28
36
  }
29
37
 
38
+ const SPIN_STYLE_ID = 'crm-spin-keyframes';
39
+ const ensureSpinKeyframes = () => {
40
+ if (document.getElementById(SPIN_STYLE_ID)) return;
41
+ const style = document.createElement('style');
42
+ style.id = SPIN_STYLE_ID;
43
+ style.textContent = `@keyframes crm-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}.crm-spin{animation:crm-spin 1s linear infinite}`;
44
+ document.head.appendChild(style);
45
+ };
46
+
30
47
  const StepFlowModal: React.FC<StepFlowModalProps> = ({
31
48
  isOpen,
32
49
  onClose,
@@ -36,11 +53,13 @@ const StepFlowModal: React.FC<StepFlowModalProps> = ({
36
53
  disabled,
37
54
  onValidationChange,
38
55
  }) => {
56
+ useEffect(() => { ensureSpinKeyframes(); }, []);
39
57
  const [steps, setSteps] = useState<FlowStep[]>(initialSteps);
40
58
  const [validation, setValidation] = useState<ValidationResult | null>(null);
41
59
  const [saveLabel, setSaveLabel] = useState<'save' | 'saved' | 'saving'>('save');
42
60
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
43
61
  const savedStepsRef = useRef<string>(JSON.stringify(initialSteps));
62
+ const saveTimersRef = useRef<NodeJS.Timeout[]>([]);
44
63
 
45
64
  useEffect(() => {
46
65
  setSteps(initialSteps);
@@ -61,15 +80,26 @@ const StepFlowModal: React.FC<StepFlowModalProps> = ({
61
80
  [onValidationChange]
62
81
  );
63
82
 
83
+ useEffect(() => {
84
+ return () => {
85
+ saveTimersRef.current.forEach(clearTimeout);
86
+ };
87
+ }, []);
88
+
64
89
  const handleSave = useCallback(() => {
90
+ saveTimersRef.current.forEach(clearTimeout);
91
+ saveTimersRef.current = [];
92
+
65
93
  setSaveLabel('saving');
66
- setTimeout(() => {
94
+ const t1 = setTimeout(() => {
67
95
  onSave(steps);
68
96
  savedStepsRef.current = JSON.stringify(steps);
69
97
  setHasUnsavedChanges(false);
70
98
  setSaveLabel('saved');
71
- setTimeout(() => setSaveLabel('save'), 1500);
99
+ const t2 = setTimeout(() => setSaveLabel('save'), 1500);
100
+ saveTimersRef.current.push(t2);
72
101
  }, 150);
102
+ saveTimersRef.current.push(t1);
73
103
  }, [steps, onSave]);
74
104
 
75
105
  if (!isOpen) return null;
@@ -78,23 +108,9 @@ const StepFlowModal: React.FC<StepFlowModalProps> = ({
78
108
  const errorCount = validation?.errors.length || 0;
79
109
  const warningCount = validation?.warnings.length || 0;
80
110
 
81
- const getStepLabel = (count: number) => {
82
- if (count === 1) return 'шаг';
83
- if (count >= 2 && count <= 4) return 'шага';
84
- return 'шагов';
85
- };
86
-
87
- const getErrorLabel = (count: number) => {
88
- if (count === 1) return 'ошибка';
89
- if (count >= 2 && count <= 4) return 'ошибки';
90
- return 'ошибок';
91
- };
92
-
93
- const getWarningLabel = (count: number) => {
94
- if (count === 1) return 'предупреждение';
95
- if (count >= 2 && count <= 4) return 'предупреждения';
96
- return 'предупреждений';
97
- };
111
+ const getStepLabel = (count: number) => pluralize(count, 'шаг', 'шага', 'шагов');
112
+ const getErrorLabel = (count: number) => pluralize(count, 'ошибка', 'ошибки', 'ошибок');
113
+ const getWarningLabel = (count: number) => pluralize(count, 'предупреждение', 'предупреждения', 'предупреждений');
98
114
 
99
115
  return (
100
116
  <Modal.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -275,7 +291,7 @@ const StepFlowModal: React.FC<StepFlowModalProps> = ({
275
291
  height="14"
276
292
  viewBox="0 0 24 24"
277
293
  fill="none"
278
- style={{ animation: 'spin 1s linear infinite' }}
294
+ className="crm-spin"
279
295
  >
280
296
  <circle
281
297
  cx="12"
@@ -306,14 +322,6 @@ const StepFlowModal: React.FC<StepFlowModalProps> = ({
306
322
  position: 'relative',
307
323
  }}
308
324
  >
309
- <style>
310
- {`
311
- @keyframes spin {
312
- from { transform: rotate(0deg); }
313
- to { transform: rotate(360deg); }
314
- }
315
- `}
316
- </style>
317
325
  <ReactFlowProvider>
318
326
  <div style={{ position: 'absolute', inset: 0 }}>
319
327
  <FlowCanvas
@@ -342,16 +350,9 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
342
350
 
343
351
  const form = useForm('StepFlowBuilder', (state) => state);
344
352
  const { get } = useFetchClient();
345
-
346
- let documentId: string | undefined;
347
- let model: string | undefined;
348
- try {
349
- const ctx = useContentManagerContext();
350
- documentId = ctx?.id;
351
- model = ctx?.model;
352
- } catch (err) {
353
- // No ContentManagerContext (test page?)
354
- }
353
+ const ctx = useContentManagerContextSafe();
354
+ const documentId = ctx?.id;
355
+ const model = ctx?.model;
355
356
 
356
357
  useEffect(() => {
357
358
  if (!documentId || !model || hasFetched) {
@@ -402,7 +403,9 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
402
403
 
403
404
  if (connect.length > 0) {
404
405
  const seg = connect[0];
405
- return { id: seg.id!, name: seg.name || `Segment #${seg.id}` };
406
+ if (seg.id != null) {
407
+ return { id: seg.id, name: seg.name || `Segment #${seg.id}` };
408
+ }
406
409
  }
407
410
 
408
411
  if (disconnect.length > 0 && initialSegment) {
@@ -438,7 +441,7 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
438
441
  useEffect(() => {
439
442
  const parsed = parseValue(value);
440
443
  setSteps(parsed);
441
- }, []);
444
+ }, [value]);
442
445
 
443
446
  const handleValidationChange = useCallback((newValidation: ValidationResult) => {
444
447
  setValidation(newValidation);
@@ -473,7 +476,7 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
473
476
  const validationError = useMemo(() => {
474
477
  if (!validation || validation.isValid) return null;
475
478
  const errorCount = validation.errors.length;
476
- const errorWord = errorCount === 1 ? 'ошибка' : errorCount < 5 ? 'ошибки' : 'ошибок';
479
+ const errorWord = pluralize(errorCount, 'ошибка', 'ошибки', 'ошибок');
477
480
  return `Поток содержит ${errorCount} ${errorWord} валидации. Исправьте все ошибки перед публикацией.`;
478
481
  }, [validation]);
479
482
 
@@ -487,11 +490,7 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
487
490
  {validation && !validation.isValid && (
488
491
  <Badge backgroundColor="danger100" textColor="danger600">
489
492
  {validation.errors.length}{' '}
490
- {validation.errors.length === 1
491
- ? 'ошибка'
492
- : validation.errors.length < 5
493
- ? 'ошибки'
494
- : 'ошибок'}
493
+ {pluralize(validation.errors.length, 'ошибка', 'ошибки', 'ошибок')}
495
494
  </Badge>
496
495
  )}
497
496
  {validation && validation.isValid && validation.warnings.length > 0 && (
@@ -502,7 +501,7 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
502
501
  )}
503
502
  </Flex>
504
503
  <Badge backgroundColor="neutral100" textColor="neutral600">
505
- {stepCount} {stepCount === 1 ? 'шаг' : stepCount < 5 ? 'шага' : 'шагов'}
504
+ {stepCount} {pluralize(stepCount, 'шаг', 'шага', 'шагов')}
506
505
  </Badge>
507
506
  </Flex>
508
507
 
@@ -552,12 +551,6 @@ const StepFlowBuilderInner = forwardRef<HTMLDivElement, StepFlowBuilderProps>(
552
551
  }
553
552
  );
554
553
 
555
- StepFlowBuilderInner.displayName = 'StepFlowBuilderInner';
556
-
557
- const StepFlowBuilder = forwardRef<HTMLDivElement, StepFlowBuilderProps>((props, ref) => {
558
- return <StepFlowBuilderInner {...props} ref={ref} />;
559
- });
560
-
561
- StepFlowBuilder.displayName = 'StepFlowBuilder';
554
+ StepFlowBuilderInner.displayName = 'StepFlowBuilder';
562
555
 
563
- export default StepFlowBuilder;
556
+ export default StepFlowBuilderInner;
@@ -1,44 +1,16 @@
1
1
  import React, { memo, useState } from 'react';
2
- import { Handle, Position, useReactFlow, type NodeProps } from 'reactflow';
2
+ import { Handle, Position, type NodeProps } from 'reactflow';
3
3
  import type { FlowNodeData } from '../types';
4
4
  import { NODE_COLORS, NODE_DIMENSIONS, HANDLE_SIZE } from '../constants';
5
5
  import { useNodeValidation } from '../context/ValidationContext';
6
+ import { getBorderStyle, getBoxShadow, ValidationBadge, DeleteButton } from './shared';
6
7
 
7
8
  const BranchNode: React.FC<NodeProps<FlowNodeData>> = ({ id, data, selected }) => {
8
9
  const [isHovered, setIsHovered] = useState(false);
9
- const { deleteElements } = useReactFlow();
10
10
  const colors = NODE_COLORS.branch;
11
11
  const segmentName = data.config.branchSegment?.name || 'No segment';
12
12
  const { hasError, hasWarning } = useNodeValidation(id);
13
13
 
14
- const handleDelete = (e: React.MouseEvent) => {
15
- e.stopPropagation();
16
- deleteElements({ nodes: [{ id }] });
17
- };
18
-
19
- const getBorderStyle = () => {
20
- if (hasError) return '2px solid #ef4444';
21
- if (hasWarning) return '2px solid #f59e0b';
22
- if (selected) return `2px solid ${colors.border}`;
23
- return '2px solid transparent';
24
- };
25
-
26
- const getBoxShadow = () => {
27
- if (hasError) {
28
- return selected
29
- ? '0 0 0 3px rgba(239, 68, 68, 0.25), 0 10px 25px -5px rgba(0, 0, 0, 0.1)'
30
- : '0 0 0 2px rgba(239, 68, 68, 0.15), 0 4px 12px -2px rgba(0, 0, 0, 0.08)';
31
- }
32
- if (hasWarning) {
33
- return selected
34
- ? '0 0 0 3px rgba(245, 158, 11, 0.25), 0 10px 25px -5px rgba(0, 0, 0, 0.1)'
35
- : '0 0 0 2px rgba(245, 158, 11, 0.15), 0 4px 12px -2px rgba(0, 0, 0, 0.08)';
36
- }
37
- return selected
38
- ? `0 0 0 3px ${colors.glow}, 0 10px 25px -5px rgba(0, 0, 0, 0.1)`
39
- : '0 4px 12px -2px rgba(0, 0, 0, 0.08)';
40
- };
41
-
42
14
  return (
43
15
  <div
44
16
  style={{
@@ -47,8 +19,8 @@ const BranchNode: React.FC<NodeProps<FlowNodeData>> = ({ id, data, selected }) =
47
19
  boxSizing: 'border-box',
48
20
  background: colors.background,
49
21
  borderRadius: 12,
50
- border: getBorderStyle(),
51
- boxShadow: getBoxShadow(),
22
+ border: getBorderStyle(hasError, hasWarning, !!selected, colors),
23
+ boxShadow: getBoxShadow(hasError, hasWarning, !!selected, colors),
52
24
  display: 'flex',
53
25
  alignItems: 'center',
54
26
  padding: '0 16px',
@@ -72,56 +44,8 @@ const BranchNode: React.FC<NodeProps<FlowNodeData>> = ({ id, data, selected }) =
72
44
  boxShadow: '0 0 0 3px white, 0 2px 4px rgba(0,0,0,0.1)',
73
45
  }}
74
46
  />
75
- {(hasError || hasWarning) && !isHovered && (
76
- <div
77
- style={{
78
- position: 'absolute',
79
- top: -6,
80
- right: -6,
81
- width: 18,
82
- height: 18,
83
- borderRadius: '50%',
84
- background: hasError ? '#ef4444' : '#f59e0b',
85
- display: 'flex',
86
- alignItems: 'center',
87
- justifyContent: 'center',
88
- boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
89
- }}
90
- >
91
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none">
92
- <path d="M12 8v4M12 16h.01" stroke="white" strokeWidth="3" strokeLinecap="round" />
93
- </svg>
94
- </div>
95
- )}
96
- {isHovered && (
97
- <button
98
- type="button"
99
- onClick={handleDelete}
100
- style={{
101
- position: 'absolute',
102
- top: -8,
103
- right: -8,
104
- width: 22,
105
- height: 22,
106
- borderRadius: '50%',
107
- background: '#ef4444',
108
- border: '2px solid white',
109
- display: 'flex',
110
- alignItems: 'center',
111
- justifyContent: 'center',
112
- cursor: 'pointer',
113
- boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
114
- transition: 'transform 0.15s ease',
115
- zIndex: 10,
116
- }}
117
- onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)'; }}
118
- onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
119
- >
120
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none">
121
- <path d="M18 6L6 18M6 6l12 12" stroke="white" strokeWidth="3" strokeLinecap="round" />
122
- </svg>
123
- </button>
124
- )}
47
+ {!isHovered && <ValidationBadge hasError={hasError} hasWarning={hasWarning} />}
48
+ {isHovered && <DeleteButton nodeId={id} />}
125
49
  <div
126
50
  style={{
127
51
  width: 36,
@@ -3,34 +3,12 @@ import { Handle, Position, type NodeProps } from 'reactflow';
3
3
  import type { FlowNodeData } from '../types';
4
4
  import { NODE_COLORS, NODE_DIMENSIONS, HANDLE_SIZE, ENTRY_NODE_ID } from '../constants';
5
5
  import { useNodeValidation } from '../context/ValidationContext';
6
+ import { getBorderStyle, getBoxShadow, ValidationBadge } from './shared';
6
7
 
7
8
  const EntryNode: React.FC<NodeProps<FlowNodeData>> = ({ selected }) => {
8
9
  const colors = NODE_COLORS.entry;
9
10
  const { hasError, hasWarning } = useNodeValidation(ENTRY_NODE_ID);
10
11
 
11
- const getBorderStyle = () => {
12
- if (hasError) return '2px solid #ef4444';
13
- if (hasWarning) return '2px solid #f59e0b';
14
- if (selected) return `2px solid ${colors.border}`;
15
- return '2px solid transparent';
16
- };
17
-
18
- const getBoxShadow = () => {
19
- if (hasError) {
20
- return selected
21
- ? '0 0 0 3px rgba(239, 68, 68, 0.25), 0 10px 25px -5px rgba(0, 0, 0, 0.1)'
22
- : '0 0 0 2px rgba(239, 68, 68, 0.15), 0 4px 12px -2px rgba(0, 0, 0, 0.08)';
23
- }
24
- if (hasWarning) {
25
- return selected
26
- ? '0 0 0 3px rgba(245, 158, 11, 0.25), 0 10px 25px -5px rgba(0, 0, 0, 0.1)'
27
- : '0 0 0 2px rgba(245, 158, 11, 0.15), 0 4px 12px -2px rgba(0, 0, 0, 0.08)';
28
- }
29
- return selected
30
- ? `0 0 0 3px ${colors.glow}, 0 10px 25px -5px rgba(0, 0, 0, 0.1)`
31
- : '0 4px 12px -2px rgba(0, 0, 0, 0.08)';
32
- };
33
-
34
12
  return (
35
13
  <div
36
14
  style={{
@@ -39,8 +17,8 @@ const EntryNode: React.FC<NodeProps<FlowNodeData>> = ({ selected }) => {
39
17
  boxSizing: 'border-box',
40
18
  background: colors.background,
41
19
  borderRadius: 12,
42
- border: getBorderStyle(),
43
- boxShadow: getBoxShadow(),
20
+ border: getBorderStyle(hasError, hasWarning, !!selected, colors),
21
+ boxShadow: getBoxShadow(hasError, hasWarning, !!selected, colors),
44
22
  display: 'flex',
45
23
  alignItems: 'center',
46
24
  justifyContent: 'center',
@@ -50,27 +28,7 @@ const EntryNode: React.FC<NodeProps<FlowNodeData>> = ({ selected }) => {
50
28
  position: 'relative',
51
29
  }}
52
30
  >
53
- {(hasError || hasWarning) && (
54
- <div
55
- style={{
56
- position: 'absolute',
57
- top: -6,
58
- right: -6,
59
- width: 18,
60
- height: 18,
61
- borderRadius: '50%',
62
- background: hasError ? '#ef4444' : '#f59e0b',
63
- display: 'flex',
64
- alignItems: 'center',
65
- justifyContent: 'center',
66
- boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
67
- }}
68
- >
69
- <svg width="10" height="10" viewBox="0 0 24 24" fill="none">
70
- <path d="M12 8v4M12 16h.01" stroke="white" strokeWidth="3" strokeLinecap="round" />
71
- </svg>
72
- </div>
73
- )}
31
+ <ValidationBadge hasError={hasError} hasWarning={hasWarning} />
74
32
  <div
75
33
  style={{
76
34
  width: 36,