astro-tractstack 2.0.1 → 2.0.3
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 +1 -1
- package/templates/src/components/codehooks/EpinetDurationSelector.tsx +98 -179
- package/templates/src/components/codehooks/EpinetTableView.tsx +100 -75
- package/templates/src/components/codehooks/EpinetWrapper.tsx +72 -113
- package/templates/src/components/codehooks/SankeyDiagram.tsx +16 -10
- package/templates/src/components/storykeep/Dashboard_Analytics.tsx +37 -1
- package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +62 -32
- package/templates/src/components/storykeep/state/FetchAnalytics.tsx +21 -70
- package/templates/src/stores/analytics.ts +14 -0
|
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import * as d3 from 'd3';
|
|
3
3
|
import { sankey, sankeyLinkHorizontal } from 'd3-sankey';
|
|
4
4
|
|
|
5
|
-
const MAX_HEIGHT =
|
|
6
|
-
const COMPRESSED_HEIGHT =
|
|
5
|
+
const MAX_HEIGHT = 1600;
|
|
6
|
+
const COMPRESSED_HEIGHT = 256;
|
|
7
|
+
const MIN_DIAGRAM_WIDTH = 800; // Define a minimum width for the diagram
|
|
7
8
|
|
|
8
9
|
const colors = [
|
|
9
10
|
'#ef4444',
|
|
@@ -52,7 +53,7 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
52
53
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
53
54
|
const hasScrolledRef = useRef(false);
|
|
54
55
|
const [dimensions, setDimensions] = useState({
|
|
55
|
-
width:
|
|
56
|
+
width: MIN_DIAGRAM_WIDTH,
|
|
56
57
|
height: 500,
|
|
57
58
|
});
|
|
58
59
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
@@ -60,7 +61,11 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
60
61
|
useEffect(() => {
|
|
61
62
|
const updateDimensions = () => {
|
|
62
63
|
if (containerRef.current) {
|
|
63
|
-
|
|
64
|
+
// Ensure the diagram width is the larger of the container or our defined minimum
|
|
65
|
+
const containerWidth = Math.max(
|
|
66
|
+
MIN_DIAGRAM_WIDTH,
|
|
67
|
+
containerRef.current.offsetWidth
|
|
68
|
+
);
|
|
64
69
|
const nodeCount = data.nodes.length || 1;
|
|
65
70
|
const optimalHeight = nodeCount * (40 + 10) + 50;
|
|
66
71
|
const finalHeight = Math.min(MAX_HEIGHT, optimalHeight);
|
|
@@ -245,7 +250,6 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
245
250
|
|
|
246
251
|
return (
|
|
247
252
|
<div ref={containerRef} className="relative w-full">
|
|
248
|
-
{/* Expand/Compress Controls */}
|
|
249
253
|
<div className="mb-3 flex items-center justify-between">
|
|
250
254
|
<div className="text-sm text-gray-600">
|
|
251
255
|
{data.nodes.length} nodes • {data.links.length} connections
|
|
@@ -292,23 +296,21 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
292
296
|
</button>
|
|
293
297
|
</div>
|
|
294
298
|
|
|
295
|
-
{/* Compression Warning */}
|
|
296
299
|
{needsCompression && (
|
|
297
300
|
<div className="mb-2 rounded bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
298
301
|
<strong>Compressed view</strong> - click anywhere to expand!
|
|
299
302
|
</div>
|
|
300
303
|
)}
|
|
301
304
|
|
|
302
|
-
{/* SVG Container - Clickable when compressed */}
|
|
303
305
|
<div
|
|
304
|
-
className={`transition-all duration-300 ${
|
|
306
|
+
className={`overflow-x-auto transition-all duration-300 md:overflow-visible ${
|
|
305
307
|
needsCompression
|
|
306
308
|
? 'cursor-pointer hover:bg-gray-50 hover:shadow-md'
|
|
307
309
|
: ''
|
|
308
310
|
}`}
|
|
309
311
|
style={{
|
|
310
312
|
height: `${displayHeight}px`,
|
|
311
|
-
|
|
313
|
+
overflowY: 'hidden',
|
|
312
314
|
}}
|
|
313
315
|
onClick={needsCompression ? handleExpand : undefined}
|
|
314
316
|
role={needsCompression ? 'button' : undefined}
|
|
@@ -333,7 +335,7 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
333
335
|
height={dimensions.height}
|
|
334
336
|
style={{
|
|
335
337
|
display: 'block',
|
|
336
|
-
|
|
338
|
+
minWidth: `${dimensions.width}px`, // Ensure SVG itself doesn't shrink
|
|
337
339
|
height: `${dimensions.height}px`,
|
|
338
340
|
transform: needsCompression
|
|
339
341
|
? `scaleY(${displayHeight / dimensions.height})`
|
|
@@ -345,6 +347,10 @@ const SankeyDiagram = ({ data, isLoading = false }: SankeyDiagramProps) => {
|
|
|
345
347
|
></svg>
|
|
346
348
|
</div>
|
|
347
349
|
|
|
350
|
+
<div className="mt-2 text-center text-xs text-gray-500 md:hidden">
|
|
351
|
+
← Scroll to see full journey map →
|
|
352
|
+
</div>
|
|
353
|
+
|
|
348
354
|
{isLoading && (
|
|
349
355
|
<div className="absolute inset-0 flex items-center justify-center rounded bg-black bg-opacity-80">
|
|
350
356
|
<div className="flex items-center space-x-2 text-white">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback, useMemo, Component } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import { useStore } from '@nanostores/react';
|
|
4
4
|
import { epinetCustomFilters } from '@/stores/analytics';
|
|
@@ -104,6 +104,30 @@ export default function StoryKeepDashboard_Analytics({
|
|
|
104
104
|
error: null,
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
const handleBeliefFilterChange = (beliefSlug: string, value: string) => {
|
|
108
|
+
const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
|
|
109
|
+
const currentFilters = epinetCustomFilters.get();
|
|
110
|
+
let newFilters = [...(currentFilters.appliedFilters || [])];
|
|
111
|
+
|
|
112
|
+
if (value === 'All') {
|
|
113
|
+
newFilters = newFilters.filter((f) => f.beliefSlug !== beliefSlug);
|
|
114
|
+
} else {
|
|
115
|
+
const existingIndex = newFilters.findIndex(
|
|
116
|
+
(f) => f.beliefSlug === beliefSlug
|
|
117
|
+
);
|
|
118
|
+
if (existingIndex > -1) {
|
|
119
|
+
newFilters[existingIndex] = { beliefSlug, value };
|
|
120
|
+
} else {
|
|
121
|
+
newFilters.push({ beliefSlug, value });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
epinetCustomFilters.set(tenantId, {
|
|
126
|
+
...currentFilters,
|
|
127
|
+
appliedFilters: newFilters,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
107
131
|
// Duration helper for UI
|
|
108
132
|
const currentDurationHelper = useMemo(():
|
|
109
133
|
| 'daily'
|
|
@@ -440,6 +464,10 @@ export default function StoryKeepDashboard_Analytics({
|
|
|
440
464
|
analytics.isLoading || analytics.status === 'loading'
|
|
441
465
|
}
|
|
442
466
|
hourlyNodeActivity={analytics.hourlyNodeActivity}
|
|
467
|
+
// MODIFICATION: Read availableFilters from the store, not local state
|
|
468
|
+
availableFilters={$epinetCustomFilters.availableFilters}
|
|
469
|
+
appliedFilters={$epinetCustomFilters.appliedFilters}
|
|
470
|
+
onBeliefFilterChange={handleBeliefFilterChange}
|
|
443
471
|
/>
|
|
444
472
|
</div>
|
|
445
473
|
</ErrorBoundary>
|
|
@@ -455,6 +483,10 @@ export default function StoryKeepDashboard_Analytics({
|
|
|
455
483
|
analytics.isLoading || analytics.status === 'loading'
|
|
456
484
|
}
|
|
457
485
|
hourlyNodeActivity={analytics.hourlyNodeActivity}
|
|
486
|
+
// MODIFICATION: Read availableFilters from the store, not local state
|
|
487
|
+
availableFilters={$epinetCustomFilters.availableFilters}
|
|
488
|
+
appliedFilters={$epinetCustomFilters.appliedFilters}
|
|
489
|
+
onBeliefFilterChange={handleBeliefFilterChange}
|
|
458
490
|
/>
|
|
459
491
|
</>
|
|
460
492
|
)
|
|
@@ -468,6 +500,10 @@ export default function StoryKeepDashboard_Analytics({
|
|
|
468
500
|
fullContentMap={fullContentMap}
|
|
469
501
|
isLoading={analytics.isLoading || analytics.status === 'loading'}
|
|
470
502
|
hourlyNodeActivity={analytics.hourlyNodeActivity}
|
|
503
|
+
// MODIFICATION: Read availableFilters from the store, not local state
|
|
504
|
+
availableFilters={$epinetCustomFilters.availableFilters}
|
|
505
|
+
appliedFilters={$epinetCustomFilters.appliedFilters}
|
|
506
|
+
onBeliefFilterChange={handleBeliefFilterChange}
|
|
471
507
|
/>
|
|
472
508
|
</>
|
|
473
509
|
)}
|
|
@@ -39,36 +39,25 @@ const FIELD_TYPES = [
|
|
|
39
39
|
{ value: 'image', label: 'Image' },
|
|
40
40
|
];
|
|
41
41
|
|
|
42
|
-
const
|
|
42
|
+
const KnownResourceFormRenderer = ({
|
|
43
43
|
categorySlug,
|
|
44
44
|
contentMap,
|
|
45
45
|
onClose,
|
|
46
|
-
|
|
46
|
+
brandConfig,
|
|
47
|
+
}: KnownResourceFormProps & { brandConfig: BrandConfig }) => {
|
|
47
48
|
const [newFieldName, setNewFieldName] = useState('');
|
|
48
49
|
const [showAddField, setShowAddField] = useState(false);
|
|
49
|
-
const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
|
|
50
|
-
const [loading, setLoading] = useState(false);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (!brandConfig && !loading) {
|
|
54
|
-
setLoading(true);
|
|
55
|
-
getBrandConfig(window.TRACTSTACK_CONFIG?.tenantId || 'default')
|
|
56
|
-
.then(setBrandConfig)
|
|
57
|
-
.catch(console.error)
|
|
58
|
-
.finally(() => setLoading(false));
|
|
59
|
-
}
|
|
60
|
-
}, [brandConfig, loading]);
|
|
61
50
|
|
|
62
|
-
const knownResources = brandConfig?.KNOWN_RESOURCES || {};
|
|
63
51
|
const isCreate = categorySlug === 'new';
|
|
64
|
-
const
|
|
52
|
+
const knownResources = brandConfig.KNOWN_RESOURCES || {};
|
|
53
|
+
const currentCategory = isCreate ? {} : knownResources[categorySlug];
|
|
65
54
|
|
|
66
55
|
const hasExistingResources =
|
|
67
56
|
!isCreate && contentMap.some((item) => item.categorySlug === categorySlug);
|
|
68
57
|
|
|
69
58
|
const initialState: KnownResourceState = {
|
|
70
59
|
categorySlug: isCreate ? '' : categorySlug,
|
|
71
|
-
fields:
|
|
60
|
+
fields: currentCategory,
|
|
72
61
|
};
|
|
73
62
|
|
|
74
63
|
const validator = (state: KnownResourceState): FieldErrors => {
|
|
@@ -90,8 +79,6 @@ const KnownResourceForm = ({
|
|
|
90
79
|
validator,
|
|
91
80
|
onSave: async (data) => {
|
|
92
81
|
try {
|
|
93
|
-
// Update known resources in brand config
|
|
94
|
-
if (!brandConfig) throw new Error('Brand config not loaded');
|
|
95
82
|
const brandState = convertToLocalState(brandConfig);
|
|
96
83
|
const updatedKnownResources = {
|
|
97
84
|
...brandState.knownResources,
|
|
@@ -108,7 +95,6 @@ const KnownResourceForm = ({
|
|
|
108
95
|
updatedBrandState
|
|
109
96
|
);
|
|
110
97
|
|
|
111
|
-
// Call success callback after save (original pattern)
|
|
112
98
|
setTimeout(() => {
|
|
113
99
|
onClose?.(true);
|
|
114
100
|
}, 1000);
|
|
@@ -179,7 +165,6 @@ const KnownResourceForm = ({
|
|
|
179
165
|
|
|
180
166
|
return (
|
|
181
167
|
<div className="space-y-8">
|
|
182
|
-
{/* Header */}
|
|
183
168
|
<div className="border-b border-gray-200 pb-4">
|
|
184
169
|
<h2 className="text-2xl font-bold text-gray-900">
|
|
185
170
|
{isCreate ? 'Create Resource Category' : `Edit ${categorySlug}`}
|
|
@@ -200,7 +185,6 @@ const KnownResourceForm = ({
|
|
|
200
185
|
</div>
|
|
201
186
|
|
|
202
187
|
<div className="space-y-6">
|
|
203
|
-
{/* Category Name */}
|
|
204
188
|
<StringInput
|
|
205
189
|
label="Category Name"
|
|
206
190
|
value={formState.state.categorySlug}
|
|
@@ -214,7 +198,6 @@ const KnownResourceForm = ({
|
|
|
214
198
|
Must be lowercase with hyphens. Cannot be changed after creation.
|
|
215
199
|
</p>
|
|
216
200
|
|
|
217
|
-
{/* Fields Section */}
|
|
218
201
|
<div className="space-y-6">
|
|
219
202
|
<div className="flex items-center justify-between">
|
|
220
203
|
<h3 className="text-lg font-bold text-gray-900">Fields</h3>
|
|
@@ -228,7 +211,6 @@ const KnownResourceForm = ({
|
|
|
228
211
|
</button>
|
|
229
212
|
</div>
|
|
230
213
|
|
|
231
|
-
{/* Add Field Form */}
|
|
232
214
|
{showAddField && (
|
|
233
215
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
234
216
|
<h4 className="mb-3 text-sm font-bold text-gray-900">
|
|
@@ -264,7 +246,6 @@ const KnownResourceForm = ({
|
|
|
264
246
|
</div>
|
|
265
247
|
)}
|
|
266
248
|
|
|
267
|
-
{/* Existing Fields */}
|
|
268
249
|
{Object.keys(formState.state.fields).length === 0 ? (
|
|
269
250
|
<div className="py-6 text-center text-gray-500">
|
|
270
251
|
No fields defined yet. Click "Add Field" to create your first
|
|
@@ -275,7 +256,6 @@ const KnownResourceForm = ({
|
|
|
275
256
|
{Object.entries(formState.state.fields).map(
|
|
276
257
|
([fieldName, fieldDef]) => {
|
|
277
258
|
const locked = isFieldLocked(fieldName);
|
|
278
|
-
|
|
279
259
|
return (
|
|
280
260
|
<div
|
|
281
261
|
key={fieldName}
|
|
@@ -304,7 +284,6 @@ const KnownResourceForm = ({
|
|
|
304
284
|
</button>
|
|
305
285
|
)}
|
|
306
286
|
</div>
|
|
307
|
-
|
|
308
287
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
309
288
|
<EnumSelect
|
|
310
289
|
label="Type"
|
|
@@ -315,7 +294,6 @@ const KnownResourceForm = ({
|
|
|
315
294
|
options={FIELD_TYPES}
|
|
316
295
|
disabled={locked}
|
|
317
296
|
/>
|
|
318
|
-
|
|
319
297
|
<BooleanToggle
|
|
320
298
|
label="Optional"
|
|
321
299
|
value={fieldDef.optional || false}
|
|
@@ -324,7 +302,6 @@ const KnownResourceForm = ({
|
|
|
324
302
|
}
|
|
325
303
|
disabled={locked}
|
|
326
304
|
/>
|
|
327
|
-
|
|
328
305
|
{fieldDef.type === 'categoryReference' && (
|
|
329
306
|
<EnumSelect
|
|
330
307
|
label="Reference Category"
|
|
@@ -341,7 +318,6 @@ const KnownResourceForm = ({
|
|
|
341
318
|
disabled={locked}
|
|
342
319
|
/>
|
|
343
320
|
)}
|
|
344
|
-
|
|
345
321
|
{fieldDef.type === 'number' && (
|
|
346
322
|
<>
|
|
347
323
|
<NumberInput
|
|
@@ -372,7 +348,6 @@ const KnownResourceForm = ({
|
|
|
372
348
|
</div>
|
|
373
349
|
</div>
|
|
374
350
|
|
|
375
|
-
{/* Save/Cancel Bar */}
|
|
376
351
|
<UnsavedChangesBar
|
|
377
352
|
formState={formState}
|
|
378
353
|
message="You have unsaved resource category changes"
|
|
@@ -380,7 +355,6 @@ const KnownResourceForm = ({
|
|
|
380
355
|
cancelLabel="Discard Changes"
|
|
381
356
|
/>
|
|
382
357
|
|
|
383
|
-
{/* Cancel Navigation Button */}
|
|
384
358
|
<div className="flex justify-start">
|
|
385
359
|
<button
|
|
386
360
|
type="button"
|
|
@@ -394,4 +368,60 @@ const KnownResourceForm = ({
|
|
|
394
368
|
);
|
|
395
369
|
};
|
|
396
370
|
|
|
371
|
+
const KnownResourceForm = ({
|
|
372
|
+
categorySlug,
|
|
373
|
+
contentMap,
|
|
374
|
+
onClose,
|
|
375
|
+
}: KnownResourceFormProps) => {
|
|
376
|
+
const [brandConfig, setBrandConfig] = useState<BrandConfig | null>(null);
|
|
377
|
+
const [loading, setLoading] = useState(true);
|
|
378
|
+
const [error, setError] = useState<string | null>(null);
|
|
379
|
+
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
getBrandConfig(window.TRACTSTACK_CONFIG?.tenantId || 'default')
|
|
382
|
+
.then(setBrandConfig)
|
|
383
|
+
.catch((err) => {
|
|
384
|
+
console.error('Failed to load brand configuration:', err);
|
|
385
|
+
setError(
|
|
386
|
+
'Could not load resource category configuration. Please try again.'
|
|
387
|
+
);
|
|
388
|
+
})
|
|
389
|
+
.finally(() => setLoading(false));
|
|
390
|
+
}, []);
|
|
391
|
+
|
|
392
|
+
if (loading) {
|
|
393
|
+
return (
|
|
394
|
+
<div className="py-12 text-center text-gray-500">
|
|
395
|
+
Loading configuration...
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (error) {
|
|
401
|
+
return (
|
|
402
|
+
<div className="rounded-md bg-red-50 p-4 text-center text-red-700">
|
|
403
|
+
{error}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const isCreate = categorySlug === 'new';
|
|
409
|
+
if (!isCreate && !(brandConfig?.KNOWN_RESOURCES || {})[categorySlug]) {
|
|
410
|
+
return (
|
|
411
|
+
<div className="py-12 text-center text-gray-500">
|
|
412
|
+
Resource category "{categorySlug}" not found.
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<KnownResourceFormRenderer
|
|
419
|
+
categorySlug={categorySlug}
|
|
420
|
+
contentMap={contentMap}
|
|
421
|
+
onClose={onClose}
|
|
422
|
+
brandConfig={brandConfig!}
|
|
423
|
+
/>
|
|
424
|
+
);
|
|
425
|
+
};
|
|
426
|
+
|
|
397
427
|
export default KnownResourceForm;
|
|
@@ -20,13 +20,12 @@ interface FetchAnalyticsProps {
|
|
|
20
20
|
onAnalyticsUpdate: (analytics: AnalyticsState) => void;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// Global singleton state to prevent multi-component conflicts
|
|
24
23
|
class AnalyticsService {
|
|
25
24
|
private static instance: AnalyticsService;
|
|
26
25
|
private isInitialized = false;
|
|
27
26
|
private activeRequest: AbortController | null = null;
|
|
28
27
|
private requestCache = new Map<string, { data: any; timestamp: number }>();
|
|
29
|
-
private readonly CACHE_TTL = 5000;
|
|
28
|
+
private readonly CACHE_TTL = 5000;
|
|
30
29
|
private readonly DEBOUNCE_MS = 300;
|
|
31
30
|
private debounceTimer: NodeJS.Timeout | null = null;
|
|
32
31
|
private floodProtection = {
|
|
@@ -34,7 +33,7 @@ class AnalyticsService {
|
|
|
34
33
|
windowStart: 0,
|
|
35
34
|
isBlocked: false,
|
|
36
35
|
};
|
|
37
|
-
private readonly FLOOD_WINDOW_MS = 10000;
|
|
36
|
+
private readonly FLOOD_WINDOW_MS = 10000;
|
|
38
37
|
private readonly FLOOD_THRESHOLD = 5;
|
|
39
38
|
|
|
40
39
|
static getInstance(): AnalyticsService {
|
|
@@ -51,35 +50,25 @@ class AnalyticsService {
|
|
|
51
50
|
|
|
52
51
|
private isFloodBlocked(): boolean {
|
|
53
52
|
const now = Date.now();
|
|
54
|
-
|
|
55
|
-
// Reset window if needed
|
|
56
53
|
if (now - this.floodProtection.windowStart > this.FLOOD_WINDOW_MS) {
|
|
57
54
|
this.floodProtection.requestCount = 0;
|
|
58
55
|
this.floodProtection.windowStart = now;
|
|
59
56
|
this.floodProtection.isBlocked = false;
|
|
60
57
|
}
|
|
61
|
-
|
|
62
|
-
// Check if blocked
|
|
63
58
|
if (this.floodProtection.isBlocked) {
|
|
64
59
|
if (VERBOSE) console.log('🚫 Request blocked by flood protection');
|
|
65
60
|
return true;
|
|
66
61
|
}
|
|
67
|
-
|
|
68
|
-
// Increment counter and check threshold
|
|
69
62
|
this.floodProtection.requestCount++;
|
|
70
63
|
if (this.floodProtection.requestCount > this.FLOOD_THRESHOLD) {
|
|
71
64
|
this.floodProtection.isBlocked = true;
|
|
72
65
|
if (VERBOSE) console.log('🚨 Flood protection activated');
|
|
73
|
-
|
|
74
|
-
// Auto-unblock after delay
|
|
75
66
|
setTimeout(() => {
|
|
76
67
|
this.floodProtection.isBlocked = false;
|
|
77
68
|
if (VERBOSE) console.log('✅ Flood protection deactivated');
|
|
78
|
-
}, 30000);
|
|
79
|
-
|
|
69
|
+
}, 30000);
|
|
80
70
|
return true;
|
|
81
71
|
}
|
|
82
|
-
|
|
83
72
|
return false;
|
|
84
73
|
}
|
|
85
74
|
|
|
@@ -94,8 +83,6 @@ class AnalyticsService {
|
|
|
94
83
|
|
|
95
84
|
private setCachedResponse(cacheKey: string, data: any): void {
|
|
96
85
|
this.requestCache.set(cacheKey, { data, timestamp: Date.now() });
|
|
97
|
-
|
|
98
|
-
// Cleanup old cache entries
|
|
99
86
|
const cutoff = Date.now() - this.CACHE_TTL;
|
|
100
87
|
for (const [key, value] of this.requestCache.entries()) {
|
|
101
88
|
if (value.timestamp < cutoff) {
|
|
@@ -108,35 +95,29 @@ class AnalyticsService {
|
|
|
108
95
|
filters: any,
|
|
109
96
|
onUpdate: (data: AnalyticsState) => void
|
|
110
97
|
): Promise<void> {
|
|
111
|
-
// Flood protection
|
|
112
98
|
if (this.isFloodBlocked()) {
|
|
113
99
|
return;
|
|
114
100
|
}
|
|
115
101
|
|
|
116
102
|
try {
|
|
117
|
-
// Cancel any existing request
|
|
118
103
|
if (this.activeRequest) {
|
|
119
104
|
this.activeRequest.abort();
|
|
120
105
|
if (VERBOSE) console.log('🛑 Cancelled previous request');
|
|
121
106
|
}
|
|
122
107
|
|
|
123
|
-
// Create new abort controller
|
|
124
108
|
this.activeRequest = new AbortController();
|
|
125
109
|
|
|
126
|
-
// Build URL parameters
|
|
127
110
|
const params = new URLSearchParams();
|
|
128
111
|
if (filters.startTimeUTC && filters.endTimeUTC) {
|
|
129
112
|
const now = new Date();
|
|
130
113
|
const startTime = new Date(filters.startTimeUTC);
|
|
131
114
|
const endTime = new Date(filters.endTimeUTC);
|
|
132
|
-
|
|
133
115
|
const startHour = Math.ceil(
|
|
134
116
|
(now.getTime() - startTime.getTime()) / (1000 * 60 * 60)
|
|
135
117
|
);
|
|
136
118
|
const endHour = Math.floor(
|
|
137
119
|
(now.getTime() - endTime.getTime()) / (1000 * 60 * 60)
|
|
138
120
|
);
|
|
139
|
-
|
|
140
121
|
params.append('startHour', startHour.toString());
|
|
141
122
|
params.append('endHour', endHour.toString());
|
|
142
123
|
}
|
|
@@ -146,16 +127,18 @@ class AnalyticsService {
|
|
|
146
127
|
if (filters.selectedUserId)
|
|
147
128
|
params.append('userId', filters.selectedUserId);
|
|
148
129
|
|
|
149
|
-
|
|
130
|
+
// MODIFICATION: Properly format appliedFilters for the backend
|
|
131
|
+
if (filters.appliedFilters && filters.appliedFilters.length > 0) {
|
|
132
|
+
params.append('appliedFilters', JSON.stringify(filters.appliedFilters));
|
|
133
|
+
}
|
|
150
134
|
|
|
151
|
-
|
|
135
|
+
const cacheKey = this.getCacheKey(params);
|
|
152
136
|
const cachedData = this.getCachedResponse(cacheKey);
|
|
153
137
|
if (cachedData) {
|
|
154
138
|
onUpdate(cachedData);
|
|
155
139
|
return;
|
|
156
140
|
}
|
|
157
141
|
|
|
158
|
-
// Set loading state
|
|
159
142
|
onUpdate({
|
|
160
143
|
dashboard: null,
|
|
161
144
|
leads: null,
|
|
@@ -167,23 +150,17 @@ class AnalyticsService {
|
|
|
167
150
|
error: null,
|
|
168
151
|
});
|
|
169
152
|
|
|
170
|
-
// Make request using existing TractStackAPI
|
|
171
153
|
const api = new TractStackAPI(
|
|
172
154
|
window.TRACTSTACK_CONFIG?.tenantId || 'default'
|
|
173
155
|
);
|
|
174
156
|
const endpoint = `/api/v1/analytics/all${params.toString() ? `?${params.toString()}` : ''}`;
|
|
175
|
-
|
|
176
157
|
if (VERBOSE) console.log('🔥 Making API request', { endpoint });
|
|
177
158
|
|
|
178
159
|
const response = await api.get(endpoint);
|
|
179
|
-
|
|
180
|
-
if (!response.success) {
|
|
160
|
+
if (!response.success)
|
|
181
161
|
throw new Error(response.error || 'Failed to fetch analytics data');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
162
|
const data = response.data;
|
|
185
163
|
|
|
186
|
-
// Check if data is still loading - implement polling logic
|
|
187
164
|
const isStillLoading =
|
|
188
165
|
data?.status === 'loading' ||
|
|
189
166
|
data?.status === 'refreshing' ||
|
|
@@ -196,8 +173,6 @@ class AnalyticsService {
|
|
|
196
173
|
|
|
197
174
|
if (isStillLoading) {
|
|
198
175
|
if (VERBOSE) console.log('⏳ Backend data still loading, will poll...');
|
|
199
|
-
|
|
200
|
-
// Update with partial data but keep loading state
|
|
201
176
|
const partialAnalytics = {
|
|
202
177
|
dashboard: data.dashboard,
|
|
203
178
|
leads: data.leads,
|
|
@@ -208,27 +183,20 @@ class AnalyticsService {
|
|
|
208
183
|
error: null,
|
|
209
184
|
isLoading: true,
|
|
210
185
|
};
|
|
211
|
-
|
|
212
186
|
onUpdate(partialAnalytics);
|
|
213
187
|
|
|
214
|
-
// Schedule polling retry - use the cache key to prevent multiple polls
|
|
215
188
|
const pollKey = `poll_${cacheKey}`;
|
|
216
189
|
if (!this.requestCache.has(pollKey)) {
|
|
217
190
|
this.requestCache.set(pollKey, { data: null, timestamp: Date.now() });
|
|
218
|
-
|
|
219
191
|
setTimeout(() => {
|
|
220
192
|
this.requestCache.delete(pollKey);
|
|
221
|
-
// Clear the main cache entry to force fresh request
|
|
222
193
|
this.requestCache.delete(cacheKey);
|
|
223
|
-
// Retry the fetch
|
|
224
194
|
this.fetchAnalytics(filters, onUpdate);
|
|
225
195
|
}, 2000);
|
|
226
196
|
}
|
|
227
|
-
|
|
228
197
|
return;
|
|
229
198
|
}
|
|
230
199
|
|
|
231
|
-
// Process successful response
|
|
232
200
|
const analyticsData = {
|
|
233
201
|
dashboard: data.dashboard,
|
|
234
202
|
leads: data.leads,
|
|
@@ -240,23 +208,23 @@ class AnalyticsService {
|
|
|
240
208
|
isLoading: false,
|
|
241
209
|
};
|
|
242
210
|
|
|
243
|
-
// Cache the response
|
|
244
211
|
this.setCachedResponse(cacheKey, analyticsData);
|
|
245
|
-
|
|
246
|
-
// Update caller
|
|
247
212
|
onUpdate(analyticsData);
|
|
248
213
|
|
|
249
|
-
|
|
214
|
+
// MODIFICATION: Correctly extract top-level availableFilters and update the store
|
|
215
|
+
epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
|
|
216
|
+
...filters,
|
|
217
|
+
availableFilters: data.availableFilters || [],
|
|
218
|
+
});
|
|
250
219
|
|
|
220
|
+
if (VERBOSE) console.log('✅ Analytics request completed successfully');
|
|
251
221
|
this.activeRequest = null;
|
|
252
222
|
} catch (error) {
|
|
253
223
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
254
224
|
if (VERBOSE) console.log('🔄 Request aborted');
|
|
255
225
|
return;
|
|
256
226
|
}
|
|
257
|
-
|
|
258
227
|
console.error('❌ Analytics fetch error:', error);
|
|
259
|
-
|
|
260
228
|
onUpdate({
|
|
261
229
|
dashboard: null,
|
|
262
230
|
leads: null,
|
|
@@ -268,7 +236,6 @@ class AnalyticsService {
|
|
|
268
236
|
error instanceof Error ? error.message : 'Unknown error occurred',
|
|
269
237
|
isLoading: false,
|
|
270
238
|
});
|
|
271
|
-
|
|
272
239
|
this.activeRequest = null;
|
|
273
240
|
}
|
|
274
241
|
}
|
|
@@ -286,13 +253,9 @@ class AnalyticsService {
|
|
|
286
253
|
|
|
287
254
|
initializeFilters(tenantId: string): void {
|
|
288
255
|
if (this.isInitialized) return;
|
|
289
|
-
|
|
290
256
|
if (VERBOSE) console.log('🏁 Initializing analytics filters');
|
|
291
|
-
|
|
292
257
|
const nowUTC = new Date();
|
|
293
258
|
const oneWeekAgoUTC = new Date(nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
294
|
-
|
|
295
|
-
// Only set if not already initialized to prevent store churn
|
|
296
259
|
const current = epinetCustomFilters.get();
|
|
297
260
|
if (!current.enabled) {
|
|
298
261
|
epinetCustomFilters.set(tenantId, {
|
|
@@ -301,19 +264,15 @@ class AnalyticsService {
|
|
|
301
264
|
selectedUserId: null,
|
|
302
265
|
startTimeUTC: oneWeekAgoUTC.toISOString(),
|
|
303
266
|
endTimeUTC: nowUTC.toISOString(),
|
|
267
|
+
availableFilters: [],
|
|
268
|
+
appliedFilters: [],
|
|
304
269
|
});
|
|
305
270
|
}
|
|
306
|
-
|
|
307
271
|
this.isInitialized = true;
|
|
308
272
|
}
|
|
309
273
|
|
|
310
274
|
debouncedFetch(filters: any, onUpdate: (data: AnalyticsState) => void): void {
|
|
311
|
-
|
|
312
|
-
if (this.debounceTimer) {
|
|
313
|
-
clearTimeout(this.debounceTimer);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Set new debounced fetch
|
|
275
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
317
276
|
this.debounceTimer = setTimeout(() => {
|
|
318
277
|
this.fetchAnalytics(filters, onUpdate);
|
|
319
278
|
}, this.DEBOUNCE_MS);
|
|
@@ -329,29 +288,21 @@ export default function FetchAnalytics({
|
|
|
329
288
|
|
|
330
289
|
if (VERBOSE) {
|
|
331
290
|
console.log('🔄 FetchAnalytics render', {
|
|
332
|
-
filters: {
|
|
333
|
-
startTimeUTC: $epinetCustomFilters.startTimeUTC,
|
|
334
|
-
endTimeUTC: $epinetCustomFilters.endTimeUTC,
|
|
335
|
-
visitorType: $epinetCustomFilters.visitorType,
|
|
336
|
-
selectedUserId: $epinetCustomFilters.selectedUserId,
|
|
337
|
-
},
|
|
291
|
+
filters: { ...$epinetCustomFilters },
|
|
338
292
|
});
|
|
339
293
|
}
|
|
340
294
|
|
|
341
|
-
// Cleanup on unmount
|
|
342
295
|
useEffect(() => {
|
|
343
296
|
return () => {
|
|
344
297
|
analyticsService.current.cleanup();
|
|
345
298
|
};
|
|
346
299
|
}, []);
|
|
347
300
|
|
|
348
|
-
// Initialize filters once
|
|
349
301
|
useEffect(() => {
|
|
350
302
|
const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
|
|
351
303
|
analyticsService.current.initializeFilters(tenantId);
|
|
352
304
|
}, []);
|
|
353
305
|
|
|
354
|
-
// Debounced fetch when filters change
|
|
355
306
|
useEffect(() => {
|
|
356
307
|
if (
|
|
357
308
|
!$epinetCustomFilters.enabled ||
|
|
@@ -362,15 +313,14 @@ export default function FetchAnalytics({
|
|
|
362
313
|
return;
|
|
363
314
|
}
|
|
364
315
|
|
|
365
|
-
// Create stable filter signature to prevent unnecessary fetches
|
|
366
316
|
const filtersSignature = JSON.stringify({
|
|
367
317
|
startTimeUTC: $epinetCustomFilters.startTimeUTC,
|
|
368
318
|
endTimeUTC: $epinetCustomFilters.endTimeUTC,
|
|
369
319
|
visitorType: $epinetCustomFilters.visitorType,
|
|
370
320
|
selectedUserId: $epinetCustomFilters.selectedUserId,
|
|
321
|
+
appliedFilters: $epinetCustomFilters.appliedFilters,
|
|
371
322
|
});
|
|
372
323
|
|
|
373
|
-
// Skip if filters haven't actually changed
|
|
374
324
|
if (filtersSignature === lastFiltersRef.current) {
|
|
375
325
|
return;
|
|
376
326
|
}
|
|
@@ -389,6 +339,7 @@ export default function FetchAnalytics({
|
|
|
389
339
|
$epinetCustomFilters.selectedUserId,
|
|
390
340
|
$epinetCustomFilters.startTimeUTC,
|
|
391
341
|
$epinetCustomFilters.endTimeUTC,
|
|
342
|
+
$epinetCustomFilters.appliedFilters,
|
|
392
343
|
onAnalyticsUpdate,
|
|
393
344
|
]);
|
|
394
345
|
|