@inspirer-dev/crm-dashboard 1.0.23 → 1.0.24

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.
@@ -51,12 +51,16 @@ export const CHANNEL_OPTIONS: { value: ChannelType; label: string }[] = [
51
51
  { value: 'sms', label: 'SMS' },
52
52
  ];
53
53
 
54
+ export const CHANNELS = CHANNEL_OPTIONS;
55
+
54
56
  export const DURATION_UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [
55
57
  { value: 'minutes', label: 'Minutes' },
56
58
  { value: 'hours', label: 'Hours' },
57
59
  { value: 'days', label: 'Days' },
58
60
  ];
59
61
 
62
+ export const DURATION_UNITS = DURATION_UNIT_OPTIONS;
63
+
60
64
  export const DEFAULT_NODE_DATA: Record<StepType, { name: string; config: object }> = {
61
65
  entry: {
62
66
  name: 'Entry',
@@ -89,3 +93,19 @@ export const DEFAULT_NODE_DATA: Record<StepType, { name: string; config: object
89
93
  };
90
94
 
91
95
  export const ENTRY_NODE_ID = 'entry';
96
+
97
+ export const DEFAULT_STEP_CONFIG: Record<StepType, object> = {
98
+ entry: {},
99
+ message: {
100
+ channel: 'telegram' as ChannelType,
101
+ variants: [],
102
+ },
103
+ wait: {
104
+ duration: 1,
105
+ durationUnit: 'hours' as DurationUnit,
106
+ },
107
+ branch: {
108
+ branchSegmentId: undefined,
109
+ },
110
+ exit: {},
111
+ };
@@ -0,0 +1,285 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import ReactFlow, {
3
+ Background,
4
+ Controls,
5
+ MiniMap,
6
+ useNodesState,
7
+ useEdgesState,
8
+ addEdge,
9
+ type Connection,
10
+ type OnNodesChange,
11
+ type OnEdgesChange,
12
+ type OnConnect,
13
+ } from 'reactflow';
14
+ import 'reactflow/dist/style.css';
15
+ import { Box } from '@strapi/design-system';
16
+
17
+ import type { FlowNode, FlowEdge, FlowNodeData, FlowStep, StepType } from '../types';
18
+ import { nodeTypes } from '../nodes';
19
+ import { edgeTypes } from '../edges';
20
+ import { NodeEditPanel } from '../panels';
21
+ import { FlowToolbar } from '../toolbar';
22
+ import {
23
+ stepsToFlow,
24
+ flowToSteps,
25
+ applyAutoLayout,
26
+ createNode,
27
+ getNextNodePosition,
28
+ isValidConnection,
29
+ } from '../utils';
30
+ import { ENTRY_NODE_ID } from '../constants';
31
+
32
+ interface FlowCanvasProps {
33
+ steps: FlowStep[];
34
+ onStepsChange: (steps: FlowStep[]) => void;
35
+ disabled?: boolean;
36
+ error?: string;
37
+ }
38
+
39
+ const FlowCanvas: React.FC<FlowCanvasProps> = ({ steps, onStepsChange, disabled, error }) => {
40
+ const [nodes, setNodes, onNodesChange] = useNodesState<FlowNodeData>([]);
41
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
42
+ const [selectedNode, setSelectedNode] = useState<FlowNode | null>(null);
43
+ const isInitialMount = useRef(true);
44
+ const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
45
+ const lastStepsRef = useRef<string>('');
46
+
47
+ useEffect(() => {
48
+ const stepsJson = JSON.stringify(steps);
49
+ if (stepsJson === lastStepsRef.current) return;
50
+ lastStepsRef.current = stepsJson;
51
+
52
+ const { nodes: n, edges: e } = stepsToFlow(steps);
53
+
54
+ if (n.length === 0) {
55
+ const entryNode = createNode('entry', { x: 250, y: 0 });
56
+ setNodes([entryNode]);
57
+ setEdges([]);
58
+ } else {
59
+ const layoutedNodes = applyAutoLayout(n, e);
60
+ setNodes(layoutedNodes);
61
+ setEdges(e);
62
+ }
63
+ isInitialMount.current = false;
64
+ }, [steps, setNodes, setEdges]);
65
+
66
+ const syncToParent = useCallback(() => {
67
+ if (isInitialMount.current) return;
68
+
69
+ if (syncTimeoutRef.current) {
70
+ clearTimeout(syncTimeoutRef.current);
71
+ }
72
+
73
+ syncTimeoutRef.current = setTimeout(() => {
74
+ const newSteps = flowToSteps(nodes, edges);
75
+ const newStepsJson = JSON.stringify(newSteps);
76
+ if (newStepsJson !== lastStepsRef.current) {
77
+ lastStepsRef.current = newStepsJson;
78
+ onStepsChange(newSteps);
79
+ }
80
+ }, 300);
81
+ }, [nodes, edges, onStepsChange]);
82
+
83
+ useEffect(() => {
84
+ syncToParent();
85
+ }, [nodes, edges, syncToParent]);
86
+
87
+ useEffect(() => {
88
+ return () => {
89
+ if (syncTimeoutRef.current) {
90
+ clearTimeout(syncTimeoutRef.current);
91
+ }
92
+ };
93
+ }, []);
94
+
95
+ const handleNodesChange: OnNodesChange = useCallback(
96
+ (changes) => {
97
+ const filteredChanges = changes.filter((change) => {
98
+ if (change.type === 'remove' && change.id === ENTRY_NODE_ID) {
99
+ return false;
100
+ }
101
+ return true;
102
+ });
103
+ onNodesChange(filteredChanges);
104
+ },
105
+ [onNodesChange]
106
+ );
107
+
108
+ const handleEdgesChange: OnEdgesChange = useCallback(
109
+ (changes) => {
110
+ onEdgesChange(changes);
111
+ },
112
+ [onEdgesChange]
113
+ );
114
+
115
+ const handleConnect: OnConnect = useCallback(
116
+ (connection: Connection) => {
117
+ if (!connection.source || !connection.target) return;
118
+
119
+ if (
120
+ !isValidConnection(
121
+ connection.source,
122
+ connection.sourceHandle || null,
123
+ connection.target,
124
+ nodes,
125
+ edges
126
+ )
127
+ ) {
128
+ return;
129
+ }
130
+
131
+ const sourceNode = nodes.find((n) => n.id === connection.source);
132
+ const isBranch = sourceNode?.data.stepType === 'branch';
133
+
134
+ const newEdge: FlowEdge = {
135
+ id: `${connection.source}-${connection.target}${
136
+ connection.sourceHandle ? `-${connection.sourceHandle}` : ''
137
+ }`,
138
+ source: connection.source,
139
+ target: connection.target,
140
+ sourceHandle: connection.sourceHandle || undefined,
141
+ targetHandle: connection.targetHandle || undefined,
142
+ type: isBranch ? 'labeled' : 'default',
143
+ };
144
+
145
+ setEdges((eds) => addEdge(newEdge, eds));
146
+ },
147
+ [nodes, edges, setEdges]
148
+ );
149
+
150
+ const handleNodeClick = useCallback((_: React.MouseEvent, node: FlowNode) => {
151
+ setSelectedNode(node);
152
+ }, []);
153
+
154
+ const handlePaneClick = useCallback(() => {
155
+ setSelectedNode(null);
156
+ }, []);
157
+
158
+ const handleAddNode = useCallback(
159
+ (type: StepType) => {
160
+ const position = getNextNodePosition(nodes);
161
+ const newNode = createNode(type, position);
162
+ setNodes((nds) => [...nds, newNode]);
163
+ },
164
+ [nodes, setNodes]
165
+ );
166
+
167
+ const handleAutoLayout = useCallback(() => {
168
+ const layoutedNodes = applyAutoLayout(nodes, edges);
169
+ setNodes(layoutedNodes);
170
+ }, [nodes, edges, setNodes]);
171
+
172
+ const handleNodeUpdate = useCallback(
173
+ (nodeId: string, data: Partial<FlowNodeData>) => {
174
+ setNodes((nds) =>
175
+ nds.map((node) => {
176
+ if (node.id === nodeId) {
177
+ return {
178
+ ...node,
179
+ data: {
180
+ ...node.data,
181
+ ...data,
182
+ config: {
183
+ ...node.data.config,
184
+ ...(data.config || {}),
185
+ },
186
+ },
187
+ };
188
+ }
189
+ return node;
190
+ })
191
+ );
192
+
193
+ if (selectedNode && selectedNode.id === nodeId) {
194
+ setSelectedNode((prev) =>
195
+ prev
196
+ ? {
197
+ ...prev,
198
+ data: {
199
+ ...prev.data,
200
+ ...data,
201
+ config: {
202
+ ...prev.data.config,
203
+ ...(data.config || {}),
204
+ },
205
+ },
206
+ }
207
+ : null
208
+ );
209
+ }
210
+ },
211
+ [selectedNode, setNodes]
212
+ );
213
+
214
+ const handleClosePanel = useCallback(() => {
215
+ setSelectedNode(null);
216
+ }, []);
217
+
218
+ return (
219
+ <Box
220
+ style={{
221
+ height: '100%',
222
+ border: error ? '1px solid #ee5e52' : '1px solid #dcdce4',
223
+ borderRadius: 4,
224
+ overflow: 'hidden',
225
+ position: 'relative',
226
+ }}
227
+ >
228
+ <ReactFlow
229
+ nodes={nodes}
230
+ edges={edges}
231
+ nodeTypes={nodeTypes}
232
+ edgeTypes={edgeTypes}
233
+ onNodesChange={handleNodesChange}
234
+ onEdgesChange={handleEdgesChange}
235
+ onConnect={handleConnect}
236
+ onNodeClick={handleNodeClick}
237
+ onPaneClick={handlePaneClick}
238
+ fitView
239
+ fitViewOptions={{ padding: 0.2 }}
240
+ deleteKeyCode={['Backspace', 'Delete']}
241
+ nodesDraggable={!disabled}
242
+ nodesConnectable={!disabled}
243
+ elementsSelectable={!disabled}
244
+ panOnScroll
245
+ selectionOnDrag
246
+ defaultEdgeOptions={{
247
+ type: 'smoothstep',
248
+ animated: false,
249
+ }}
250
+ >
251
+ <Background color="#f0f0f0" gap={20} />
252
+ <Controls showInteractive={false} />
253
+ <MiniMap
254
+ nodeColor={(node) => {
255
+ switch (node.data?.stepType) {
256
+ case 'entry':
257
+ return '#5cb176';
258
+ case 'message':
259
+ return '#0077cc';
260
+ case 'wait':
261
+ return '#e9b200';
262
+ case 'branch':
263
+ return '#7b61ff';
264
+ case 'exit':
265
+ return '#dc2626';
266
+ default:
267
+ return '#999';
268
+ }
269
+ }}
270
+ maskColor="rgba(0, 0, 0, 0.1)"
271
+ style={{ background: '#f7f7f7' }}
272
+ />
273
+ <FlowToolbar onAddNode={handleAddNode} onAutoLayout={handleAutoLayout} disabled={disabled} />
274
+ </ReactFlow>
275
+ <NodeEditPanel
276
+ node={selectedNode}
277
+ onClose={handleClosePanel}
278
+ onUpdate={handleNodeUpdate}
279
+ disabled={disabled}
280
+ />
281
+ </Box>
282
+ );
283
+ };
284
+
285
+ export default FlowCanvas;
@@ -0,0 +1 @@
1
+ export { default as FlowCanvas } from './FlowCanvas';
@@ -0,0 +1,198 @@
1
+ import React, { useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ Flex,
5
+ Typography,
6
+ Button,
7
+ SingleSelect,
8
+ SingleSelectOption,
9
+ EmptyStateLayout,
10
+ } from '@strapi/design-system';
11
+ import { Plus, Message, Clock, ArrowRight, Cross } from '@strapi/icons';
12
+ import type { FlowStep, StepType } from '../types';
13
+ import { NODE_COLORS, STEP_TYPE_LABELS, DEFAULT_STEP_CONFIG } from '../constants';
14
+ import { generateStepKey } from '../utils';
15
+ import StepFormItem from './StepFormItem';
16
+
17
+ interface FormBuilderProps {
18
+ steps: FlowStep[];
19
+ onStepsChange: (steps: FlowStep[]) => void;
20
+ disabled?: boolean;
21
+ }
22
+
23
+ const STEP_TYPE_OPTIONS: { type: StepType; label: string; icon: React.ReactNode }[] = [
24
+ { type: 'message', label: 'Message', icon: <Message style={{ width: 16, height: 16 }} /> },
25
+ { type: 'wait', label: 'Wait', icon: <Clock style={{ width: 16, height: 16 }} /> },
26
+ { type: 'branch', label: 'Branch', icon: <ArrowRight style={{ width: 16, height: 16 }} /> },
27
+ { type: 'exit', label: 'Exit', icon: <Cross style={{ width: 16, height: 16 }} /> },
28
+ ];
29
+
30
+ const FormBuilder: React.FC<FormBuilderProps> = ({ steps, onStepsChange, disabled }) => {
31
+ const handleAddStep = useCallback(
32
+ (type: StepType) => {
33
+ const stepKey = generateStepKey();
34
+ const defaultConfig = DEFAULT_STEP_CONFIG[type];
35
+ const newStep: FlowStep = {
36
+ stepKey,
37
+ name: `${STEP_TYPE_LABELS[type]} ${steps.length + 1}`,
38
+ stepType: type,
39
+ order: steps.length,
40
+ ...defaultConfig,
41
+ };
42
+
43
+ const updatedSteps = [...steps, newStep];
44
+
45
+ if (steps.length > 0) {
46
+ const lastStep = steps[steps.length - 1];
47
+ if (lastStep.stepType !== 'branch' && lastStep.stepType !== 'exit' && !lastStep.nextStep) {
48
+ updatedSteps[steps.length - 1] = {
49
+ ...lastStep,
50
+ nextStep: stepKey,
51
+ };
52
+ }
53
+ }
54
+
55
+ onStepsChange(updatedSteps);
56
+ },
57
+ [steps, onStepsChange]
58
+ );
59
+
60
+ const handleUpdateStep = useCallback(
61
+ (stepKey: string, updates: Partial<FlowStep>) => {
62
+ const updatedSteps = steps.map((step) =>
63
+ step.stepKey === stepKey ? { ...step, ...updates } : step
64
+ );
65
+ onStepsChange(updatedSteps);
66
+ },
67
+ [steps, onStepsChange]
68
+ );
69
+
70
+ const handleDeleteStep = useCallback(
71
+ (stepKey: string) => {
72
+ const updatedSteps = steps
73
+ .filter((step) => step.stepKey !== stepKey)
74
+ .map((step, idx) => {
75
+ const updated = { ...step, order: idx };
76
+ if (step.nextStep === stepKey) {
77
+ updated.nextStep = undefined;
78
+ }
79
+ if (step.yesNextStep === stepKey) {
80
+ updated.yesNextStep = undefined;
81
+ }
82
+ if (step.noNextStep === stepKey) {
83
+ updated.noNextStep = undefined;
84
+ }
85
+ return updated;
86
+ });
87
+ onStepsChange(updatedSteps);
88
+ },
89
+ [steps, onStepsChange]
90
+ );
91
+
92
+ const handleMoveStep = useCallback(
93
+ (stepKey: string, direction: 'up' | 'down') => {
94
+ const currentIndex = steps.findIndex((s) => s.stepKey === stepKey);
95
+ if (currentIndex === -1) return;
96
+
97
+ const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
98
+ if (newIndex < 0 || newIndex >= steps.length) return;
99
+
100
+ const updatedSteps = [...steps];
101
+ const [moved] = updatedSteps.splice(currentIndex, 1);
102
+ updatedSteps.splice(newIndex, 0, moved);
103
+
104
+ const reordered = updatedSteps.map((step, idx) => ({
105
+ ...step,
106
+ order: idx,
107
+ }));
108
+
109
+ onStepsChange(reordered);
110
+ },
111
+ [steps, onStepsChange]
112
+ );
113
+
114
+ return (
115
+ <Box>
116
+ <Box padding={3} background="neutral100" hasRadius style={{ marginBottom: 16 }}>
117
+ <Flex justifyContent="space-between" alignItems="center" wrap="wrap" gap={2}>
118
+ <Typography variant="sigma" textColor="neutral600">
119
+ Add Step:
120
+ </Typography>
121
+ <Flex gap={2} wrap="wrap">
122
+ {STEP_TYPE_OPTIONS.map((option) => {
123
+ const colors = NODE_COLORS[option.type];
124
+ return (
125
+ <Button
126
+ key={option.type}
127
+ variant="tertiary"
128
+ size="S"
129
+ startIcon={option.icon}
130
+ onClick={() => handleAddStep(option.type)}
131
+ disabled={disabled}
132
+ style={{
133
+ backgroundColor: colors.background,
134
+ color: colors.text,
135
+ borderColor: colors.border,
136
+ }}
137
+ >
138
+ {option.label}
139
+ </Button>
140
+ );
141
+ })}
142
+ </Flex>
143
+ </Flex>
144
+ </Box>
145
+
146
+ {steps.length === 0 ? (
147
+ <Box padding={6} background="neutral100" hasRadius>
148
+ <EmptyStateLayout
149
+ icon={<Message style={{ width: 48, height: 48, color: '#666687' }} />}
150
+ content="No steps configured yet. Add your first step to build the campaign journey."
151
+ action={
152
+ <Button
153
+ variant="secondary"
154
+ startIcon={<Plus />}
155
+ onClick={() => handleAddStep('message')}
156
+ disabled={disabled}
157
+ >
158
+ Add Message Step
159
+ </Button>
160
+ }
161
+ />
162
+ </Box>
163
+ ) : (
164
+ <Flex direction="column" gap={3}>
165
+ {steps.map((step, index) => (
166
+ <StepFormItem
167
+ key={step.stepKey}
168
+ step={step}
169
+ index={index}
170
+ steps={steps}
171
+ onUpdate={handleUpdateStep}
172
+ onDelete={handleDeleteStep}
173
+ onMove={handleMoveStep}
174
+ disabled={disabled}
175
+ />
176
+ ))}
177
+ </Flex>
178
+ )}
179
+
180
+ {steps.length > 0 && (
181
+ <Box paddingTop={4}>
182
+ <Flex justifyContent="center">
183
+ <Button
184
+ variant="tertiary"
185
+ startIcon={<Plus />}
186
+ onClick={() => handleAddStep('message')}
187
+ disabled={disabled}
188
+ >
189
+ Add Another Step
190
+ </Button>
191
+ </Flex>
192
+ </Box>
193
+ )}
194
+ </Box>
195
+ );
196
+ };
197
+
198
+ export default FormBuilder;