@startsimpli/funnels 0.1.3 → 0.1.5
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/package.json +9 -31
- package/src/api/README.md +507 -0
- package/src/api/adapter.ts +106 -0
- package/src/api/client.test.ts +640 -0
- package/src/api/client.ts +385 -0
- package/src/api/default-adapter.ts +243 -0
- package/src/api/index.ts +24 -0
- package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
- package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
- package/src/components/FilterRuleEditor/README.md +291 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
- package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
- package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
- package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
- package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
- package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
- package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
- package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
- package/src/components/FilterRuleEditor/constants.ts +64 -0
- package/src/components/FilterRuleEditor/index.ts +14 -0
- package/src/components/FunnelCard/DESIGN.md +447 -0
- package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
- package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
- package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
- package/src/components/FunnelCard/FunnelCard.tsx +204 -0
- package/src/components/FunnelCard/FunnelStats.tsx +68 -0
- package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
- package/src/components/FunnelCard/INSTALLATION.md +304 -0
- package/src/components/FunnelCard/MatchBar.tsx +49 -0
- package/src/components/FunnelCard/README.md +294 -0
- package/src/components/FunnelCard/StageIndicator.tsx +62 -0
- package/src/components/FunnelCard/StatusBadge.tsx +52 -0
- package/src/components/FunnelCard/index.ts +14 -0
- package/src/components/FunnelPreview/EntityCard.tsx +72 -0
- package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
- package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
- package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
- package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
- package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
- package/src/components/FunnelPreview/README.md +337 -0
- package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
- package/src/components/FunnelPreview/example.tsx +286 -0
- package/src/components/FunnelPreview/index.ts +14 -0
- package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
- package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
- package/src/components/FunnelRunHistory/README.md +325 -0
- package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
- package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
- package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
- package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
- package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
- package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
- package/src/components/FunnelRunHistory/index.ts +51 -0
- package/src/components/FunnelRunHistory/types.ts +40 -0
- package/src/components/FunnelRunHistory/utils.test.ts +126 -0
- package/src/components/FunnelRunHistory/utils.ts +100 -0
- package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
- package/src/components/FunnelStageBuilder/README.md +341 -0
- package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
- package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
- package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
- package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
- package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
- package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
- package/src/components/FunnelStageBuilder/index.ts +21 -0
- package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
- package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
- package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
- package/src/components/FunnelVisualFlow/README.md +323 -0
- package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
- package/src/components/FunnelVisualFlow/example.tsx +227 -0
- package/src/components/FunnelVisualFlow/index.ts +10 -0
- package/src/components/index.ts +102 -0
- package/src/core/README.md +307 -0
- package/src/core/engine.test.ts +1087 -0
- package/src/core/engine.ts +329 -0
- package/src/core/evaluator.example.ts +353 -0
- package/src/core/evaluator.test.ts +639 -0
- package/src/core/evaluator.ts +261 -0
- package/src/core/field-resolver.example.ts +175 -0
- package/src/core/field-resolver.test.ts +541 -0
- package/src/core/field-resolver.ts +247 -0
- package/src/core/index.ts +34 -0
- package/src/core/operators.test.ts +539 -0
- package/src/core/operators.ts +241 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useDebouncedValue.ts +28 -0
- package/src/index.ts +155 -0
- package/src/store/README.md +342 -0
- package/src/store/create-funnel-store.test.ts +686 -0
- package/src/store/create-funnel-store.ts +538 -0
- package/src/store/index.ts +9 -0
- package/src/store/types.ts +294 -0
- package/src/stories/CrossDomain.stories.tsx +149 -0
- package/src/stories/Welcome.stories.tsx +81 -0
- package/src/stories/demo-data/index.ts +3 -0
- package/src/stories/demo-data/investors.ts +216 -0
- package/src/stories/demo-data/leads.ts +223 -0
- package/src/stories/demo-data/recipes.ts +217 -0
- package/src/test/setup.ts +5 -0
- package/src/types/index.ts +843 -0
- package/dist/client-3ESO2NHy.d.ts +0 -310
- package/dist/client-CZu03ACp.d.cts +0 -310
- package/dist/components/index.cjs +0 -3243
- package/dist/components/index.cjs.map +0 -1
- package/dist/components/index.css.map +0 -1
- package/dist/components/index.d.cts +0 -726
- package/dist/components/index.d.ts +0 -726
- package/dist/components/index.js +0 -3196
- package/dist/components/index.js.map +0 -1
- package/dist/core/index.cjs +0 -500
- package/dist/core/index.cjs.map +0 -1
- package/dist/core/index.d.cts +0 -359
- package/dist/core/index.d.ts +0 -359
- package/dist/core/index.js +0 -486
- package/dist/core/index.js.map +0 -1
- package/dist/hooks/index.cjs +0 -21
- package/dist/hooks/index.cjs.map +0 -1
- package/dist/hooks/index.d.cts +0 -11
- package/dist/hooks/index.d.ts +0 -11
- package/dist/hooks/index.js +0 -19
- package/dist/hooks/index.js.map +0 -1
- package/dist/index-BGDEXbuz.d.cts +0 -434
- package/dist/index-BGDEXbuz.d.ts +0 -434
- package/dist/index.cjs +0 -4499
- package/dist/index.cjs.map +0 -1
- package/dist/index.css +0 -198
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -99
- package/dist/index.d.ts +0 -99
- package/dist/index.js +0 -4421
- package/dist/index.js.map +0 -1
- package/dist/store/index.cjs +0 -391
- package/dist/store/index.cjs.map +0 -1
- package/dist/store/index.d.cts +0 -225
- package/dist/store/index.d.ts +0 -225
- package/dist/store/index.js +0 -388
- package/dist/store/index.js.map +0 -1
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Funnel Store Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates isolated Zustand store instances for funnel management.
|
|
5
|
+
* Factory pattern allows multiple editors to coexist without state conflicts.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Optimistic updates with automatic rollback on error
|
|
9
|
+
* - Dirty tracking for unsaved changes
|
|
10
|
+
* - Loading states for better UX
|
|
11
|
+
* - Error handling with automatic retry
|
|
12
|
+
* - Selection state management
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { create, StoreApi, UseBoundStore } from 'zustand';
|
|
18
|
+
import type { FunnelApiClient, FunnelListFilters } from '../api/client';
|
|
19
|
+
import type { FunnelStore } from './types';
|
|
20
|
+
import { createInitialState } from './types';
|
|
21
|
+
import type {
|
|
22
|
+
Funnel,
|
|
23
|
+
FunnelStage,
|
|
24
|
+
FunnelRun,
|
|
25
|
+
CreateFunnelInput,
|
|
26
|
+
UpdateFunnelInput,
|
|
27
|
+
CreateStageInput,
|
|
28
|
+
UpdateStageInput,
|
|
29
|
+
} from '../types';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create funnel store with API client
|
|
33
|
+
*
|
|
34
|
+
* Factory pattern - each call creates a new isolated store instance.
|
|
35
|
+
* Perfect for multi-editor scenarios or testing.
|
|
36
|
+
*
|
|
37
|
+
* @param apiClient - API client for server operations
|
|
38
|
+
* @returns Zustand store hook
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* const adapter = new FetchAdapter({ headers: { 'Authorization': 'Bearer token' } });
|
|
43
|
+
* const apiClient = new FunnelApiClient(adapter, 'https://api.example.com');
|
|
44
|
+
* const useFunnelStore = createFunnelStore(apiClient);
|
|
45
|
+
*
|
|
46
|
+
* // In React component
|
|
47
|
+
* function FunnelEditor() {
|
|
48
|
+
* const { funnels, loadFunnels, createFunnel } = useFunnelStore();
|
|
49
|
+
* // ...
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createFunnelStore<TEntity = any>(
|
|
54
|
+
apiClient: FunnelApiClient
|
|
55
|
+
): UseBoundStore<StoreApi<FunnelStore<TEntity>>> {
|
|
56
|
+
return create<FunnelStore<TEntity>>((set, get) => ({
|
|
57
|
+
// Initialize state
|
|
58
|
+
...createInitialState<TEntity>(),
|
|
59
|
+
|
|
60
|
+
// =========================================================================
|
|
61
|
+
// Funnel Actions
|
|
62
|
+
// =========================================================================
|
|
63
|
+
|
|
64
|
+
loadFunnels: async (filters?: FunnelListFilters) => {
|
|
65
|
+
set({ isLoading: true, error: null });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await apiClient.listFunnels<TEntity>(filters);
|
|
69
|
+
|
|
70
|
+
set({
|
|
71
|
+
funnels: response.results,
|
|
72
|
+
pagination: {
|
|
73
|
+
count: response.count,
|
|
74
|
+
next: response.next,
|
|
75
|
+
previous: response.previous,
|
|
76
|
+
currentPage: filters?.page || 1,
|
|
77
|
+
pageSize: filters?.page_size || 20,
|
|
78
|
+
},
|
|
79
|
+
isLoading: false,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
set({
|
|
83
|
+
error: error as Error,
|
|
84
|
+
isLoading: false,
|
|
85
|
+
});
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
selectFunnel: (id: string | null) => {
|
|
91
|
+
const { funnels } = get();
|
|
92
|
+
|
|
93
|
+
if (id === null) {
|
|
94
|
+
set({ selectedFunnel: null, selectedStage: null });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const funnel = funnels.find((f) => f.id === id);
|
|
99
|
+
set({
|
|
100
|
+
selectedFunnel: funnel || null,
|
|
101
|
+
selectedStage: null, // Clear stage selection when changing funnels
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
createFunnel: async (data: CreateFunnelInput<TEntity>) => {
|
|
106
|
+
set({ isLoading: true, error: null });
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const funnel = await apiClient.createFunnel<TEntity>(data);
|
|
110
|
+
|
|
111
|
+
set((state) => ({
|
|
112
|
+
funnels: [...state.funnels, funnel],
|
|
113
|
+
pagination: {
|
|
114
|
+
...state.pagination,
|
|
115
|
+
count: state.pagination.count + 1,
|
|
116
|
+
},
|
|
117
|
+
isLoading: false,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
return funnel;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
set({
|
|
123
|
+
error: error as Error,
|
|
124
|
+
isLoading: false,
|
|
125
|
+
});
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
updateFunnel: async (
|
|
131
|
+
id: string,
|
|
132
|
+
data: Partial<UpdateFunnelInput<TEntity>>
|
|
133
|
+
) => {
|
|
134
|
+
// Save state for rollback
|
|
135
|
+
get()._saveRollbackState();
|
|
136
|
+
|
|
137
|
+
// Optimistic update
|
|
138
|
+
set((state) => ({
|
|
139
|
+
funnels: state.funnels.map((f) =>
|
|
140
|
+
f.id === id ? ({ ...f, ...data } as Funnel<TEntity>) : f
|
|
141
|
+
),
|
|
142
|
+
selectedFunnel:
|
|
143
|
+
state.selectedFunnel?.id === id
|
|
144
|
+
? ({ ...state.selectedFunnel, ...data } as Funnel<TEntity>)
|
|
145
|
+
: state.selectedFunnel,
|
|
146
|
+
isDirty: false, // Clear dirty flag on save
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const updated = await apiClient.updateFunnel<TEntity>(id, data);
|
|
151
|
+
|
|
152
|
+
// Update with server response
|
|
153
|
+
set((state) => ({
|
|
154
|
+
funnels: state.funnels.map((f) => (f.id === id ? updated : f)),
|
|
155
|
+
selectedFunnel:
|
|
156
|
+
state.selectedFunnel?.id === id ? updated : state.selectedFunnel,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
get()._clearRollback();
|
|
160
|
+
return updated;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Rollback optimistic update
|
|
163
|
+
get()._rollback();
|
|
164
|
+
set({ error: error as Error });
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
deleteFunnel: async (id: string) => {
|
|
170
|
+
// Save state for rollback
|
|
171
|
+
get()._saveRollbackState();
|
|
172
|
+
|
|
173
|
+
// Optimistic update
|
|
174
|
+
set((state) => ({
|
|
175
|
+
funnels: state.funnels.filter((f) => f.id !== id),
|
|
176
|
+
selectedFunnel: state.selectedFunnel?.id === id ? null : state.selectedFunnel,
|
|
177
|
+
pagination: {
|
|
178
|
+
...state.pagination,
|
|
179
|
+
count: state.pagination.count - 1,
|
|
180
|
+
},
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await apiClient.deleteFunnel(id);
|
|
185
|
+
get()._clearRollback();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// Rollback optimistic update
|
|
188
|
+
get()._rollback();
|
|
189
|
+
set({ error: error as Error });
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
duplicateFunnel: async (id: string) => {
|
|
195
|
+
const { funnels } = get();
|
|
196
|
+
const funnel = funnels.find((f) => f.id === id);
|
|
197
|
+
|
|
198
|
+
if (!funnel) {
|
|
199
|
+
throw new Error(`Funnel ${id} not found`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Create copy with new name
|
|
203
|
+
const copy: CreateFunnelInput<TEntity> = {
|
|
204
|
+
name: `${funnel.name} (Copy)`,
|
|
205
|
+
description: funnel.description,
|
|
206
|
+
status: 'draft', // Always create as draft
|
|
207
|
+
input_type: funnel.input_type,
|
|
208
|
+
stages: funnel.stages.map((stage, index) => ({
|
|
209
|
+
...stage,
|
|
210
|
+
order: index, // Preserve order
|
|
211
|
+
})),
|
|
212
|
+
completion_tags: funnel.completion_tags,
|
|
213
|
+
metadata: funnel.metadata,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return get().createFunnel(copy);
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// =========================================================================
|
|
220
|
+
// Stage Actions
|
|
221
|
+
// =========================================================================
|
|
222
|
+
|
|
223
|
+
selectStage: (stageId: string | null) => {
|
|
224
|
+
const { selectedFunnel } = get();
|
|
225
|
+
|
|
226
|
+
if (!selectedFunnel) {
|
|
227
|
+
set({ selectedStage: null });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (stageId === null) {
|
|
232
|
+
set({ selectedStage: null });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stage = selectedFunnel.stages.find((s) => s.id === stageId);
|
|
237
|
+
set({ selectedStage: stage || null });
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
createStage: async (funnelId: string, data: CreateStageInput<TEntity>) => {
|
|
241
|
+
set({ isLoading: true, error: null });
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
const stage = await apiClient.createStage<TEntity>(funnelId, data);
|
|
245
|
+
|
|
246
|
+
// Update funnel with new stage
|
|
247
|
+
set((state) => ({
|
|
248
|
+
funnels: state.funnels.map((f) =>
|
|
249
|
+
f.id === funnelId
|
|
250
|
+
? { ...f, stages: [...f.stages, stage] }
|
|
251
|
+
: f
|
|
252
|
+
),
|
|
253
|
+
selectedFunnel:
|
|
254
|
+
state.selectedFunnel?.id === funnelId
|
|
255
|
+
? { ...state.selectedFunnel, stages: [...state.selectedFunnel.stages, stage] }
|
|
256
|
+
: state.selectedFunnel,
|
|
257
|
+
isLoading: false,
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
return stage;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
set({
|
|
263
|
+
error: error as Error,
|
|
264
|
+
isLoading: false,
|
|
265
|
+
});
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
updateStage: async (
|
|
271
|
+
funnelId: string,
|
|
272
|
+
stageId: string,
|
|
273
|
+
data: Partial<UpdateStageInput<TEntity>>
|
|
274
|
+
) => {
|
|
275
|
+
// Save state for rollback
|
|
276
|
+
get()._saveRollbackState();
|
|
277
|
+
|
|
278
|
+
// Optimistic update
|
|
279
|
+
set((state) => ({
|
|
280
|
+
funnels: state.funnels.map((f) =>
|
|
281
|
+
f.id === funnelId
|
|
282
|
+
? {
|
|
283
|
+
...f,
|
|
284
|
+
stages: f.stages.map((s) =>
|
|
285
|
+
s.id === stageId ? ({ ...s, ...data } as FunnelStage<TEntity>) : s
|
|
286
|
+
),
|
|
287
|
+
}
|
|
288
|
+
: f
|
|
289
|
+
),
|
|
290
|
+
selectedFunnel:
|
|
291
|
+
state.selectedFunnel?.id === funnelId
|
|
292
|
+
? {
|
|
293
|
+
...state.selectedFunnel,
|
|
294
|
+
stages: state.selectedFunnel.stages.map((s) =>
|
|
295
|
+
s.id === stageId ? ({ ...s, ...data } as FunnelStage<TEntity>) : s
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
: state.selectedFunnel,
|
|
299
|
+
selectedStage:
|
|
300
|
+
state.selectedStage?.id === stageId
|
|
301
|
+
? ({ ...state.selectedStage, ...data } as FunnelStage<TEntity>)
|
|
302
|
+
: state.selectedStage,
|
|
303
|
+
isDirty: false,
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const updated = await apiClient.updateStage<TEntity>(
|
|
308
|
+
funnelId,
|
|
309
|
+
stageId,
|
|
310
|
+
data
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Update with server response
|
|
314
|
+
set((state) => ({
|
|
315
|
+
funnels: state.funnels.map((f) =>
|
|
316
|
+
f.id === funnelId
|
|
317
|
+
? {
|
|
318
|
+
...f,
|
|
319
|
+
stages: f.stages.map((s) => (s.id === stageId ? updated : s)),
|
|
320
|
+
}
|
|
321
|
+
: f
|
|
322
|
+
),
|
|
323
|
+
selectedFunnel:
|
|
324
|
+
state.selectedFunnel?.id === funnelId
|
|
325
|
+
? {
|
|
326
|
+
...state.selectedFunnel,
|
|
327
|
+
stages: state.selectedFunnel.stages.map((s) =>
|
|
328
|
+
s.id === stageId ? updated : s
|
|
329
|
+
),
|
|
330
|
+
}
|
|
331
|
+
: state.selectedFunnel,
|
|
332
|
+
selectedStage:
|
|
333
|
+
state.selectedStage?.id === stageId ? updated : state.selectedStage,
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
get()._clearRollback();
|
|
337
|
+
return updated;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// Rollback optimistic update
|
|
340
|
+
get()._rollback();
|
|
341
|
+
set({ error: error as Error });
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
deleteStage: async (funnelId: string, stageId: string) => {
|
|
347
|
+
// Save state for rollback
|
|
348
|
+
get()._saveRollbackState();
|
|
349
|
+
|
|
350
|
+
// Optimistic update
|
|
351
|
+
set((state) => ({
|
|
352
|
+
funnels: state.funnels.map((f) =>
|
|
353
|
+
f.id === funnelId
|
|
354
|
+
? {
|
|
355
|
+
...f,
|
|
356
|
+
stages: f.stages.filter((s) => s.id !== stageId),
|
|
357
|
+
}
|
|
358
|
+
: f
|
|
359
|
+
),
|
|
360
|
+
selectedFunnel:
|
|
361
|
+
state.selectedFunnel?.id === funnelId
|
|
362
|
+
? {
|
|
363
|
+
...state.selectedFunnel,
|
|
364
|
+
stages: state.selectedFunnel.stages.filter((s) => s.id !== stageId),
|
|
365
|
+
}
|
|
366
|
+
: state.selectedFunnel,
|
|
367
|
+
selectedStage: state.selectedStage?.id === stageId ? null : state.selectedStage,
|
|
368
|
+
}));
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await apiClient.deleteStage(funnelId, stageId);
|
|
372
|
+
get()._clearRollback();
|
|
373
|
+
} catch (error) {
|
|
374
|
+
// Rollback optimistic update
|
|
375
|
+
get()._rollback();
|
|
376
|
+
set({ error: error as Error });
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
reorderStages: async (funnelId: string, stageIds: string[]) => {
|
|
382
|
+
const { selectedFunnel } = get();
|
|
383
|
+
|
|
384
|
+
if (!selectedFunnel || selectedFunnel.id !== funnelId) {
|
|
385
|
+
throw new Error('Funnel must be selected to reorder stages');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Save state for rollback
|
|
389
|
+
get()._saveRollbackState();
|
|
390
|
+
|
|
391
|
+
// Optimistic update - reorder stages
|
|
392
|
+
const reorderedStages = stageIds
|
|
393
|
+
.map((id, index) => {
|
|
394
|
+
const stage = selectedFunnel.stages.find((s) => s.id === id);
|
|
395
|
+
return stage ? { ...stage, order: index } : null;
|
|
396
|
+
})
|
|
397
|
+
.filter((s): s is FunnelStage<TEntity> => s !== null);
|
|
398
|
+
|
|
399
|
+
set((state) => ({
|
|
400
|
+
funnels: state.funnels.map((f) =>
|
|
401
|
+
f.id === funnelId ? { ...f, stages: reorderedStages } : f
|
|
402
|
+
),
|
|
403
|
+
selectedFunnel: { ...selectedFunnel, stages: reorderedStages },
|
|
404
|
+
isDirty: false,
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// Update each stage with new order
|
|
409
|
+
await Promise.all(
|
|
410
|
+
reorderedStages.map((stage) =>
|
|
411
|
+
apiClient.updateStage(funnelId, stage.id, { order: stage.order })
|
|
412
|
+
)
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
get()._clearRollback();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
// Rollback optimistic update
|
|
418
|
+
get()._rollback();
|
|
419
|
+
set({ error: error as Error });
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
// =========================================================================
|
|
425
|
+
// Run Actions
|
|
426
|
+
// =========================================================================
|
|
427
|
+
|
|
428
|
+
runFunnel: async (id: string, options?) => {
|
|
429
|
+
set({ isLoading: true, error: null });
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const run = await apiClient.runFunnel(id, options);
|
|
433
|
+
|
|
434
|
+
// Add to runs list
|
|
435
|
+
set((state) => ({
|
|
436
|
+
runs: [run, ...state.runs],
|
|
437
|
+
isLoading: false,
|
|
438
|
+
}));
|
|
439
|
+
|
|
440
|
+
return run;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
set({
|
|
443
|
+
error: error as Error,
|
|
444
|
+
isLoading: false,
|
|
445
|
+
});
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
loadRuns: async (funnelId: string, filters?) => {
|
|
451
|
+
set({ isLoading: true, error: null });
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const response = await apiClient.getFunnelRuns(funnelId, filters);
|
|
455
|
+
|
|
456
|
+
set({
|
|
457
|
+
runs: response.results,
|
|
458
|
+
isLoading: false,
|
|
459
|
+
});
|
|
460
|
+
} catch (error) {
|
|
461
|
+
set({
|
|
462
|
+
error: error as Error,
|
|
463
|
+
isLoading: false,
|
|
464
|
+
});
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
cancelRun: async (runId: string) => {
|
|
470
|
+
set({ isLoading: true, error: null });
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const run = await apiClient.cancelFunnelRun(runId);
|
|
474
|
+
|
|
475
|
+
// Update run in list
|
|
476
|
+
set((state) => ({
|
|
477
|
+
runs: state.runs.map((r) => (r.id === runId ? run : r)),
|
|
478
|
+
isLoading: false,
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
return run;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
set({
|
|
484
|
+
error: error as Error,
|
|
485
|
+
isLoading: false,
|
|
486
|
+
});
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
// =========================================================================
|
|
492
|
+
// UI State Actions
|
|
493
|
+
// =========================================================================
|
|
494
|
+
|
|
495
|
+
setDirty: (dirty: boolean) => {
|
|
496
|
+
set({ isDirty: dirty });
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
clearError: () => {
|
|
500
|
+
set({ error: null });
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
reset: () => {
|
|
504
|
+
set(createInitialState<TEntity>());
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
// =========================================================================
|
|
508
|
+
// Internal Actions (Optimistic Updates)
|
|
509
|
+
// =========================================================================
|
|
510
|
+
|
|
511
|
+
_saveRollbackState: () => {
|
|
512
|
+
const { funnels, selectedFunnel } = get();
|
|
513
|
+
set({
|
|
514
|
+
rollbackState: {
|
|
515
|
+
funnels: JSON.parse(JSON.stringify(funnels)) as Funnel<TEntity>[],
|
|
516
|
+
selectedFunnel: selectedFunnel
|
|
517
|
+
? (JSON.parse(JSON.stringify(selectedFunnel)) as Funnel<TEntity>)
|
|
518
|
+
: null,
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
_rollback: () => {
|
|
524
|
+
const { rollbackState } = get();
|
|
525
|
+
if (rollbackState) {
|
|
526
|
+
set({
|
|
527
|
+
funnels: rollbackState.funnels,
|
|
528
|
+
selectedFunnel: rollbackState.selectedFunnel,
|
|
529
|
+
rollbackState: null,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
_clearRollback: () => {
|
|
535
|
+
set({ rollbackState: null });
|
|
536
|
+
},
|
|
537
|
+
}));
|
|
538
|
+
}
|