astro-tractstack 2.0.0-rc.8 → 2.0.0

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 (141) hide show
  1. package/LICENSE +8 -97
  2. package/README.md +7 -5
  3. package/bin/create-tractstack.js +35 -11
  4. package/dist/index.js +106 -29
  5. package/package.json +10 -5
  6. package/templates/css/frontend.css +1 -1
  7. package/templates/custom/minimal/CodeHook.astro +13 -12
  8. package/templates/custom/minimal/CustomRoutes.astro +25 -31
  9. package/templates/custom/with-examples/CodeHook.astro +22 -11
  10. package/templates/custom/with-examples/CustomRoutes.astro +4 -8
  11. package/templates/custom/with-examples/ProductCard.astro +29 -0
  12. package/templates/custom/with-examples/ProductCardWrapper.astro +43 -0
  13. package/templates/custom/with-examples/ProductGrid.astro +64 -0
  14. package/templates/custom/with-examples/pages/Collections.astro +58 -98
  15. package/templates/gitignore +42 -0
  16. package/templates/prettierignore +5 -0
  17. package/templates/prettierrc +19 -0
  18. package/templates/src/client/app.js +127 -0
  19. package/templates/src/client/htmx.min.js +3519 -0
  20. package/templates/src/client/view.js +429 -0
  21. package/templates/src/components/Footer.astro +4 -9
  22. package/templates/src/components/Header.astro +67 -60
  23. package/templates/src/components/Menu.tsx +188 -52
  24. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  25. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +9 -13
  26. package/templates/src/components/codehooks/EpinetTableView.tsx +11 -7
  27. package/templates/src/components/codehooks/EpinetWrapper.tsx +1 -0
  28. package/templates/src/components/codehooks/FeaturedArticle.astro +105 -0
  29. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +318 -0
  30. package/templates/src/components/codehooks/ListContent.astro +32 -162
  31. package/templates/src/components/codehooks/ListContentSetup.tsx +43 -138
  32. package/templates/src/components/codehooks/ProductCardSetup.tsx +152 -0
  33. package/templates/src/components/codehooks/ProductGridSetup.tsx +274 -0
  34. package/templates/src/components/codehooks/SearchWidget.tsx +453 -0
  35. package/templates/src/components/compositor/Node.tsx +3 -6
  36. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +21 -11
  37. package/templates/src/components/compositor/elements/BunnyVideo.tsx +21 -20
  38. package/templates/src/components/compositor/nodes/Pane.tsx +51 -21
  39. package/templates/src/components/compositor/nodes/RenderChildren.tsx +6 -1
  40. package/templates/src/components/compositor/nodes/Widget.tsx +16 -2
  41. package/templates/src/components/compositor/preview/FeaturedArticlePreview.tsx +155 -0
  42. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +20 -1
  43. package/templates/src/components/edit/Header.tsx +10 -4
  44. package/templates/src/components/edit/PanelSwitch.tsx +11 -7
  45. package/templates/src/components/edit/SettingsPanel.tsx +29 -18
  46. package/templates/src/components/edit/ToolBar.tsx +1 -28
  47. package/templates/src/components/edit/ToolMode.tsx +45 -32
  48. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +12 -2
  49. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +8 -2
  50. package/templates/src/components/edit/pane/AddPanePanel_newAICopy_modal.tsx +1 -1
  51. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +17 -27
  52. package/templates/src/components/edit/pane/PageGenSelector.tsx +16 -16
  53. package/templates/src/components/edit/pane/PageGenSpecial.tsx +26 -49
  54. package/templates/src/components/edit/pane/PageGen_preview.tsx +17 -2
  55. package/templates/src/components/edit/pane/PanePanel_path.tsx +2 -4
  56. package/templates/src/components/edit/pane/PanePanel_title.tsx +243 -76
  57. package/templates/src/components/edit/panels/StyleBreakPanel.tsx +17 -19
  58. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +48 -37
  59. package/templates/src/components/edit/panels/StyleElementPanel_add.tsx +60 -55
  60. package/templates/src/components/edit/panels/StyleImagePanel_add.tsx +56 -50
  61. package/templates/src/components/edit/panels/StyleLiElementPanel_add.tsx +54 -47
  62. package/templates/src/components/edit/panels/StyleLinkPanel_add.tsx +54 -44
  63. package/templates/src/components/edit/panels/StyleLinkPanel_config.tsx +113 -138
  64. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +54 -40
  65. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +3 -3
  66. package/templates/src/components/edit/panels/StyleWidgetPanel_add.tsx +56 -49
  67. package/templates/src/components/edit/panels/StyleWidgetPanel_config.tsx +14 -5
  68. package/templates/src/components/edit/state/SaveModal.tsx +316 -169
  69. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_og.tsx +1 -1
  70. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_slug.tsx +56 -55
  71. package/templates/src/components/edit/widgets/BunnyWidget.tsx +538 -59
  72. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +656 -0
  73. package/templates/src/components/edit/widgets/ToggleWidget.tsx +9 -16
  74. package/templates/src/components/fields/ArtpackImage.tsx +4 -1
  75. package/templates/src/components/fields/BackgroundImage.tsx +1 -1
  76. package/templates/src/components/fields/BackgroundImageWrapper.tsx +127 -35
  77. package/templates/src/components/fields/ColorPickerCombo.tsx +66 -62
  78. package/templates/src/components/fields/ImageUpload.tsx +1 -1
  79. package/templates/src/components/fields/ViewportComboBox.tsx +59 -42
  80. package/templates/src/components/form/ActionBuilderBeliefSelector.tsx +117 -0
  81. package/templates/src/components/form/ActionBuilderField.tsx +306 -87
  82. package/templates/src/components/search/SearchModal.tsx +420 -0
  83. package/templates/src/components/search/SearchResults.tsx +367 -0
  84. package/templates/src/components/search/SearchWrapper.tsx +46 -0
  85. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -1
  86. package/templates/src/components/storykeep/Dashboard_Analytics.tsx +34 -8
  87. package/templates/src/components/storykeep/Dashboard_Content.tsx +6 -0
  88. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +87 -0
  89. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +37 -33
  90. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +55 -7
  91. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +17 -2
  92. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +5 -8
  93. package/templates/src/components/storykeep/state/FetchAnalytics.tsx +274 -228
  94. package/templates/src/components/storykeep/widgets/Wizard.tsx +14 -7
  95. package/templates/src/components/tenant/RegistrationForm.tsx +1 -1
  96. package/templates/src/components/widgets/ImpressionWrapper.tsx +0 -1
  97. package/templates/src/constants/shapes.ts +9 -0
  98. package/templates/src/constants.ts +2121 -16
  99. package/templates/src/hooks/useSearch.ts +228 -0
  100. package/templates/src/layouts/Layout.astro +213 -104
  101. package/templates/src/lib/storyData.ts +4 -1
  102. package/templates/src/pages/[...slug]/edit.astro +14 -14
  103. package/templates/src/pages/[...slug].astro +82 -21
  104. package/templates/src/pages/api/orphan-analysis.ts +0 -1
  105. package/templates/src/pages/api/tailwind.ts +23 -21
  106. package/templates/src/pages/context/[...contextSlug]/edit.astro +14 -14
  107. package/templates/src/pages/context/[...contextSlug].astro +7 -2
  108. package/templates/src/pages/storykeep/advanced.astro +5 -4
  109. package/templates/src/pages/storykeep/branding.astro +5 -4
  110. package/templates/src/pages/storykeep/content.astro +5 -4
  111. package/templates/src/pages/storykeep/init.astro +40 -1
  112. package/templates/src/pages/storykeep/login.astro +1 -1
  113. package/templates/src/pages/storykeep.astro +5 -4
  114. package/templates/src/stores/nodes.ts +59 -88
  115. package/templates/src/stores/orphanAnalysis.ts +19 -21
  116. package/templates/src/stores/storykeep.ts +7 -0
  117. package/templates/src/types/compositorTypes.ts +6 -0
  118. package/templates/src/types/tractstack.ts +17 -0
  119. package/templates/src/utils/actions/lispLexer.ts +2 -2
  120. package/templates/src/utils/actions/preParse_Action.ts +3 -0
  121. package/templates/src/utils/api/beliefHelpers.ts +12 -36
  122. package/templates/src/utils/api/menuHelpers.ts +2 -2
  123. package/templates/src/utils/api.ts +26 -0
  124. package/templates/src/utils/compositor/TemplateNodes.ts +7 -0
  125. package/templates/src/utils/compositor/allowInsert.ts +5 -3
  126. package/templates/src/utils/compositor/nodesHelper.ts +4 -0
  127. package/templates/src/utils/compositor/processMarkdown.ts +16 -2
  128. package/templates/src/utils/compositor/reduceNodesClassNames.ts +4 -0
  129. package/templates/src/utils/compositor/templateMarkdownStyles.ts +13 -13
  130. package/templates/src/utils/compositor/typeGuards.ts +1 -0
  131. package/templates/src/utils/customHelpers.ts +38 -0
  132. package/templates/src/utils/helpers.ts +2 -2
  133. package/templates/src/utils/layout.ts +65 -144
  134. package/utils/inject-files.ts +95 -18
  135. package/templates/src/client/analytics-events.js +0 -207
  136. package/templates/src/client/belief-events.js +0 -191
  137. package/templates/src/client/sse.js +0 -613
  138. package/templates/src/components/codehooks/FeaturedContent.astro +0 -273
  139. package/templates/src/components/codehooks/FeaturedContentSetup.tsx +0 -738
  140. package/templates/src/components/compositor/preview/FeaturedContentPreview.tsx +0 -128
  141. package/templates/src/components/edit/pane/PanePanel_slug.tsx +0 -219
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useRef, useState } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
3
  import { epinetCustomFilters } from '@/stores/analytics';
4
4
  import { TractStackAPI } from '@/utils/api';
@@ -20,88 +20,115 @@ interface FetchAnalyticsProps {
20
20
  onAnalyticsUpdate: (analytics: AnalyticsState) => void;
21
21
  }
22
22
 
23
- export default function FetchAnalytics({
24
- onAnalyticsUpdate,
25
- }: FetchAnalyticsProps) {
26
- const $epinetCustomFilters = useStore(epinetCustomFilters);
27
- const isInitialized = useRef<boolean>(false);
28
- const isInitializing = useRef<boolean>(false);
29
- const fetchCount = useRef<number>(0);
30
-
31
- // Add polling state
32
- const [pollingTimer, setPollingTimer] = useState<NodeJS.Timeout | null>(null);
33
- const [pollingAttempts, setPollingAttempts] = useState(0);
34
- const [pollingStartTime, setPollingStartTime] = useState<number | null>(null);
35
- const MAX_POLLING_ATTEMPTS = 3;
36
- const POLLING_DELAYS = [2000, 5000, 10000]; // 2s, 5s, 10s
37
- const MAX_POLLING_TIME = 30000; // 30 seconds total max
38
-
39
- if (VERBOSE)
40
- console.log('🔄 FetchAnalytics RENDER', {
41
- renderCount: ++fetchCount.current,
42
- filters: {
43
- startTimeUTC: $epinetCustomFilters.startTimeUTC,
44
- endTimeUTC: $epinetCustomFilters.endTimeUTC,
45
- visitorType: $epinetCustomFilters.visitorType,
46
- selectedUserId: $epinetCustomFilters.selectedUserId,
47
- },
48
- isInitialized: isInitialized.current,
49
- storeObjectRef: $epinetCustomFilters,
50
- });
23
+ // Global singleton state to prevent multi-component conflicts
24
+ class AnalyticsService {
25
+ private static instance: AnalyticsService;
26
+ private isInitialized = false;
27
+ private activeRequest: AbortController | null = null;
28
+ private requestCache = new Map<string, { data: any; timestamp: number }>();
29
+ private readonly CACHE_TTL = 5000; // 5 seconds
30
+ private readonly DEBOUNCE_MS = 300;
31
+ private debounceTimer: NodeJS.Timeout | null = null;
32
+ private floodProtection = {
33
+ requestCount: 0,
34
+ windowStart: 0,
35
+ isBlocked: false,
36
+ };
37
+ private readonly FLOOD_WINDOW_MS = 10000; // 10 seconds
38
+ private readonly FLOOD_THRESHOLD = 5;
39
+
40
+ static getInstance(): AnalyticsService {
41
+ if (!AnalyticsService.instance) {
42
+ AnalyticsService.instance = new AnalyticsService();
43
+ }
44
+ return AnalyticsService.instance;
45
+ }
46
+
47
+ private getCacheKey(params: URLSearchParams): string {
48
+ const sorted = new URLSearchParams([...params.entries()].sort());
49
+ return sorted.toString();
50
+ }
51
+
52
+ private isFloodBlocked(): boolean {
53
+ const now = Date.now();
54
+
55
+ // Reset window if needed
56
+ if (now - this.floodProtection.windowStart > this.FLOOD_WINDOW_MS) {
57
+ this.floodProtection.requestCount = 0;
58
+ this.floodProtection.windowStart = now;
59
+ this.floodProtection.isBlocked = false;
60
+ }
51
61
 
52
- // Clear polling timer on unmount
53
- useEffect(() => {
54
- return () => {
55
- if (pollingTimer) {
56
- clearTimeout(pollingTimer);
62
+ // Check if blocked
63
+ if (this.floodProtection.isBlocked) {
64
+ if (VERBOSE) console.log('🚫 Request blocked by flood protection');
65
+ return true;
66
+ }
67
+
68
+ // Increment counter and check threshold
69
+ this.floodProtection.requestCount++;
70
+ if (this.floodProtection.requestCount > this.FLOOD_THRESHOLD) {
71
+ this.floodProtection.isBlocked = true;
72
+ if (VERBOSE) console.log('🚨 Flood protection activated');
73
+
74
+ // Auto-unblock after delay
75
+ setTimeout(() => {
76
+ this.floodProtection.isBlocked = false;
77
+ if (VERBOSE) console.log('✅ Flood protection deactivated');
78
+ }, 30000); // 30 second cooldown
79
+
80
+ return true;
81
+ }
82
+
83
+ return false;
84
+ }
85
+
86
+ private getCachedResponse(cacheKey: string): any | null {
87
+ const cached = this.requestCache.get(cacheKey);
88
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
89
+ if (VERBOSE) console.log('📦 Using cached response');
90
+ return cached.data;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ private setCachedResponse(cacheKey: string, data: any): void {
96
+ this.requestCache.set(cacheKey, { data, timestamp: Date.now() });
97
+
98
+ // Cleanup old cache entries
99
+ const cutoff = Date.now() - this.CACHE_TTL;
100
+ for (const [key, value] of this.requestCache.entries()) {
101
+ if (value.timestamp < cutoff) {
102
+ this.requestCache.delete(key);
57
103
  }
58
- };
59
- }, [pollingTimer]);
60
-
61
- // Fetch all analytics data
62
- const fetchAllAnalytics = useCallback(async () => {
63
- if (VERBOSE)
64
- console.log('🚀 fetchAllAnalytics CALLED', {
65
- timestamp: new Date().toISOString(),
66
- filters: {
67
- startTimeUTC: $epinetCustomFilters.startTimeUTC,
68
- endTimeUTC: $epinetCustomFilters.endTimeUTC,
69
- visitorType: $epinetCustomFilters.visitorType,
70
- selectedUserId: $epinetCustomFilters.selectedUserId,
71
- },
72
- });
104
+ }
105
+ }
106
+
107
+ async fetchAnalytics(
108
+ filters: any,
109
+ onUpdate: (data: AnalyticsState) => void
110
+ ): Promise<void> {
111
+ // Flood protection
112
+ if (this.isFloodBlocked()) {
113
+ return;
114
+ }
73
115
 
74
116
  try {
75
- // Clear existing timer
76
- if (pollingTimer) {
77
- clearTimeout(pollingTimer);
78
- setPollingTimer(null);
117
+ // Cancel any existing request
118
+ if (this.activeRequest) {
119
+ this.activeRequest.abort();
120
+ if (VERBOSE) console.log('🛑 Cancelled previous request');
79
121
  }
80
122
 
81
- // Set loading state
82
- if (VERBOSE) console.log('📤 Setting loading state');
83
- onAnalyticsUpdate({
84
- dashboard: null,
85
- leads: null,
86
- epinet: null,
87
- userCounts: [],
88
- hourlyNodeActivity: {},
89
- isLoading: true,
90
- status: 'loading',
91
- error: null,
92
- });
93
-
94
- const { startTimeUTC, endTimeUTC, visitorType, selectedUserId } =
95
- $epinetCustomFilters;
123
+ // Create new abort controller
124
+ this.activeRequest = new AbortController();
96
125
 
97
- // Build URL parameters for TractStackAPI
126
+ // Build URL parameters
98
127
  const params = new URLSearchParams();
99
-
100
- if (startTimeUTC && endTimeUTC) {
101
- // Convert UTC timestamps to hours-back integers (what backend expects)
128
+ if (filters.startTimeUTC && filters.endTimeUTC) {
102
129
  const now = new Date();
103
- const startTime = new Date(startTimeUTC);
104
- const endTime = new Date(endTimeUTC);
130
+ const startTime = new Date(filters.startTimeUTC);
131
+ const endTime = new Date(filters.endTimeUTC);
105
132
 
106
133
  const startHour = Math.ceil(
107
134
  (now.getTime() - startTime.getTime()) / (1000 * 60 * 60)
@@ -112,27 +139,42 @@ export default function FetchAnalytics({
112
139
 
113
140
  params.append('startHour', startHour.toString());
114
141
  params.append('endHour', endHour.toString());
142
+ }
143
+
144
+ if (filters.visitorType)
145
+ params.append('visitorType', filters.visitorType);
146
+ if (filters.selectedUserId)
147
+ params.append('userId', filters.selectedUserId);
115
148
 
116
- if (VERBOSE)
117
- console.log('⏰ Time calculations', {
118
- now: now.toISOString(),
119
- startTime: startTime.toISOString(),
120
- endTime: endTime.toISOString(),
121
- startHour,
122
- endHour,
123
- });
149
+ const cacheKey = this.getCacheKey(params);
150
+
151
+ // Check cache first
152
+ const cachedData = this.getCachedResponse(cacheKey);
153
+ if (cachedData) {
154
+ onUpdate(cachedData);
155
+ return;
124
156
  }
125
157
 
126
- if (visitorType) params.append('visitorType', visitorType);
127
- if (selectedUserId) params.append('userId', selectedUserId);
158
+ // Set loading state
159
+ onUpdate({
160
+ dashboard: null,
161
+ leads: null,
162
+ epinet: null,
163
+ userCounts: [],
164
+ hourlyNodeActivity: {},
165
+ isLoading: true,
166
+ status: 'loading',
167
+ error: null,
168
+ });
128
169
 
129
- // Use TractStackAPI
170
+ // Make request using existing TractStackAPI
130
171
  const api = new TractStackAPI(
131
172
  window.TRACTSTACK_CONFIG?.tenantId || 'default'
132
173
  );
133
174
  const endpoint = `/api/v1/analytics/all${params.toString() ? `?${params.toString()}` : ''}`;
134
175
 
135
- if (VERBOSE) console.log('📡 Making API request', { endpoint });
176
+ if (VERBOSE) console.log('🔥 Making API request', { endpoint });
177
+
136
178
  const response = await api.get(endpoint);
137
179
 
138
180
  if (!response.success) {
@@ -140,15 +182,8 @@ export default function FetchAnalytics({
140
182
  }
141
183
 
142
184
  const data = response.data;
143
- if (VERBOSE)
144
- console.log('✅ API response received', {
145
- hasData: !!data,
146
- dataKeys: data ? Object.keys(data) : [],
147
- userCountsLength: data?.userCounts?.length || 0,
148
- hasHourlyNodeActivity: !!data?.hourlyNodeActivity,
149
- });
150
-
151
- // Check if data is still loading - add polling logic here
185
+
186
+ // Check if data is still loading - implement polling logic
152
187
  const isStillLoading =
153
188
  data?.status === 'loading' ||
154
189
  data?.status === 'refreshing' ||
@@ -159,63 +194,42 @@ export default function FetchAnalytics({
159
194
  data?.epinet?.status === 'loading' ||
160
195
  data?.epinet?.status === 'refreshing';
161
196
 
162
- if (VERBOSE) {
163
- console.log('🔍 Loading status check', {
164
- overallStatus: data?.status,
165
- dashboardStatus: data?.dashboard?.status,
166
- leadsStatus: data?.leads?.status,
167
- epinetStatus: data?.epinet?.status,
168
- isStillLoading,
169
- pollingAttempts,
170
- });
171
- }
172
-
173
- if (isStillLoading && pollingAttempts < MAX_POLLING_ATTEMPTS) {
174
- // Check if we've been polling too long
175
- const now = Date.now();
176
- if (pollingStartTime && now - pollingStartTime > MAX_POLLING_TIME) {
177
- if (VERBOSE) console.log('⏰ Max polling time reached, stopping');
178
- setPollingStartTime(null);
179
- // Continue with data even if still loading
180
- } else {
181
- if (VERBOSE)
182
- console.log('⏳ Analytics data still loading, polling...', {
183
- attempt: pollingAttempts + 1,
184
- });
185
-
186
- // Set start time if this is the first poll
187
- if (!pollingStartTime) {
188
- setPollingStartTime(now);
189
- }
190
-
191
- const delayMs =
192
- POLLING_DELAYS[pollingAttempts] ||
193
- POLLING_DELAYS[POLLING_DELAYS.length - 1];
194
-
195
- const newTimer = setTimeout(() => {
196
- setPollingAttempts(pollingAttempts + 1);
197
- fetchAllAnalytics();
198
- }, delayMs);
199
-
200
- setPollingTimer(newTimer);
201
-
202
- // Update with partial data but keep loading state
203
- onAnalyticsUpdate({
204
- dashboard: data.dashboard,
205
- leads: data.leads,
206
- epinet: data.epinet,
207
- userCounts: data.userCounts || [],
208
- hourlyNodeActivity: data.hourlyNodeActivity || {},
209
- status: 'loading',
210
- error: null,
211
- isLoading: true,
212
- });
213
-
214
- return;
197
+ if (isStillLoading) {
198
+ if (VERBOSE) console.log(' Backend data still loading, will poll...');
199
+
200
+ // Update with partial data but keep loading state
201
+ const partialAnalytics = {
202
+ dashboard: data.dashboard,
203
+ leads: data.leads,
204
+ epinet: data.epinet,
205
+ userCounts: data.userCounts || [],
206
+ hourlyNodeActivity: data.hourlyNodeActivity || {},
207
+ status: 'loading',
208
+ error: null,
209
+ isLoading: true,
210
+ };
211
+
212
+ onUpdate(partialAnalytics);
213
+
214
+ // Schedule polling retry - use the cache key to prevent multiple polls
215
+ const pollKey = `poll_${cacheKey}`;
216
+ if (!this.requestCache.has(pollKey)) {
217
+ this.requestCache.set(pollKey, { data: null, timestamp: Date.now() });
218
+
219
+ setTimeout(() => {
220
+ this.requestCache.delete(pollKey);
221
+ // Clear the main cache entry to force fresh request
222
+ this.requestCache.delete(cacheKey);
223
+ // Retry the fetch
224
+ this.fetchAnalytics(filters, onUpdate);
225
+ }, 2000);
215
226
  }
227
+
228
+ return;
216
229
  }
217
230
 
218
- const newAnalytics = {
231
+ // Process successful response
232
+ const analyticsData = {
219
233
  dashboard: data.dashboard,
220
234
  leads: data.leads,
221
235
  epinet: data.epinet,
@@ -226,124 +240,156 @@ export default function FetchAnalytics({
226
240
  isLoading: false,
227
241
  };
228
242
 
229
- if (VERBOSE) console.log('📤 Calling onAnalyticsUpdate');
230
- onAnalyticsUpdate(newAnalytics);
231
-
232
- // Update epinetCustomFilters with additional data from response
233
- if (VERBOSE)
234
- console.log('🔄 BEFORE store update', {
235
- currentStoreRef: epinetCustomFilters.get(),
236
- aboutToSet: {
237
- userCounts: data.userCounts?.length || 0,
238
- hourlyNodeActivity: !!data.hourlyNodeActivity,
239
- },
240
- });
241
-
242
- epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
243
- ...$epinetCustomFilters,
244
- userCounts: data.userCounts || [],
245
- hourlyNodeActivity: data.hourlyNodeActivity || {},
246
- });
243
+ // Cache the response
244
+ this.setCachedResponse(cacheKey, analyticsData);
245
+
246
+ // Update caller
247
+ onUpdate(analyticsData);
247
248
 
248
- // Reset polling attempts and start time on success
249
- setPollingAttempts(0);
250
- setPollingStartTime(null);
249
+ if (VERBOSE) console.log('✅ Analytics request completed successfully');
251
250
 
252
- if (VERBOSE)
253
- console.log('🔄 AFTER store update', {
254
- newStoreRef: epinetCustomFilters.get(),
255
- });
251
+ this.activeRequest = null;
256
252
  } catch (error) {
257
- console.error('❌ Analytics fetch error:', error);
253
+ if (error instanceof Error && error.name === 'AbortError') {
254
+ if (VERBOSE) console.log('🔄 Request aborted');
255
+ return;
256
+ }
258
257
 
259
- const errorMessage =
260
- error instanceof Error ? error.message : 'Unknown error occurred';
258
+ console.error('❌ Analytics fetch error:', error);
261
259
 
262
- onAnalyticsUpdate({
260
+ onUpdate({
263
261
  dashboard: null,
264
262
  leads: null,
265
263
  epinet: null,
266
264
  userCounts: [],
267
265
  hourlyNodeActivity: {},
268
266
  status: 'error',
269
- error: errorMessage,
267
+ error:
268
+ error instanceof Error ? error.message : 'Unknown error occurred',
270
269
  isLoading: false,
271
270
  });
272
271
 
273
- // Schedule retry if we haven't reached max attempts
274
- if (pollingAttempts < MAX_POLLING_ATTEMPTS) {
275
- if (VERBOSE)
276
- console.log(
277
- '🔄 Scheduling retry due to error, attempt',
278
- pollingAttempts + 1
279
- );
280
-
281
- const delayMs =
282
- POLLING_DELAYS[pollingAttempts] ||
283
- POLLING_DELAYS[POLLING_DELAYS.length - 1];
284
-
285
- const newTimer = setTimeout(() => {
286
- setPollingAttempts(pollingAttempts + 1);
287
- fetchAllAnalytics();
288
- }, delayMs);
272
+ this.activeRequest = null;
273
+ }
274
+ }
289
275
 
290
- setPollingTimer(newTimer);
291
- }
276
+ cleanup(): void {
277
+ if (this.activeRequest) {
278
+ this.activeRequest.abort();
279
+ this.activeRequest = null;
292
280
  }
293
- }, [
294
- $epinetCustomFilters.startTimeUTC,
295
- $epinetCustomFilters.endTimeUTC,
296
- $epinetCustomFilters.visitorType,
297
- $epinetCustomFilters.selectedUserId,
298
- pollingAttempts,
299
- ]);
281
+ if (this.debounceTimer) {
282
+ clearTimeout(this.debounceTimer);
283
+ this.debounceTimer = null;
284
+ }
285
+ }
300
286
 
301
- // Initialize on first mount
302
- useEffect(() => {
303
- if (!isInitialized.current && !isInitializing.current) {
304
- if (VERBOSE) console.log('🏁 Initializing FetchAnalytics');
305
- isInitializing.current = true;
287
+ initializeFilters(tenantId: string): void {
288
+ if (this.isInitialized) return;
306
289
 
307
- const nowUTC = new Date();
308
- const oneWeekAgoUTC = new Date(
309
- nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000
310
- );
290
+ if (VERBOSE) console.log('🏁 Initializing analytics filters');
291
+
292
+ const nowUTC = new Date();
293
+ const oneWeekAgoUTC = new Date(nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000);
311
294
 
312
- epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
295
+ // Only set if not already initialized to prevent store churn
296
+ const current = epinetCustomFilters.get();
297
+ if (!current.enabled) {
298
+ epinetCustomFilters.set(tenantId, {
313
299
  enabled: true,
314
300
  visitorType: 'all',
315
301
  selectedUserId: null,
316
302
  startTimeUTC: oneWeekAgoUTC.toISOString(),
317
303
  endTimeUTC: nowUTC.toISOString(),
318
- userCounts: [],
319
- hourlyNodeActivity: {},
320
304
  });
305
+ }
306
+
307
+ this.isInitialized = true;
308
+ }
321
309
 
322
- isInitialized.current = true;
323
- isInitializing.current = false;
310
+ debouncedFetch(filters: any, onUpdate: (data: AnalyticsState) => void): void {
311
+ // Clear existing debounce timer
312
+ if (this.debounceTimer) {
313
+ clearTimeout(this.debounceTimer);
324
314
  }
315
+
316
+ // Set new debounced fetch
317
+ this.debounceTimer = setTimeout(() => {
318
+ this.fetchAnalytics(filters, onUpdate);
319
+ }, this.DEBOUNCE_MS);
320
+ }
321
+ }
322
+
323
+ export default function FetchAnalytics({
324
+ onAnalyticsUpdate,
325
+ }: FetchAnalyticsProps) {
326
+ const $epinetCustomFilters = useStore(epinetCustomFilters);
327
+ const analyticsService = useRef(AnalyticsService.getInstance());
328
+ const lastFiltersRef = useRef<string>('');
329
+
330
+ if (VERBOSE) {
331
+ console.log('🔄 FetchAnalytics render', {
332
+ filters: {
333
+ startTimeUTC: $epinetCustomFilters.startTimeUTC,
334
+ endTimeUTC: $epinetCustomFilters.endTimeUTC,
335
+ visitorType: $epinetCustomFilters.visitorType,
336
+ selectedUserId: $epinetCustomFilters.selectedUserId,
337
+ },
338
+ });
339
+ }
340
+
341
+ // Cleanup on unmount
342
+ useEffect(() => {
343
+ return () => {
344
+ analyticsService.current.cleanup();
345
+ };
346
+ }, []);
347
+
348
+ // Initialize filters once
349
+ useEffect(() => {
350
+ const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
351
+ analyticsService.current.initializeFilters(tenantId);
325
352
  }, []);
326
353
 
327
- // Fetch when filters change
354
+ // Debounced fetch when filters change
328
355
  useEffect(() => {
329
356
  if (
330
- isInitialized.current &&
331
- $epinetCustomFilters.enabled &&
332
- $epinetCustomFilters.visitorType !== null &&
333
- $epinetCustomFilters.startTimeUTC !== null &&
334
- $epinetCustomFilters.endTimeUTC !== null
357
+ !$epinetCustomFilters.enabled ||
358
+ $epinetCustomFilters.visitorType === null ||
359
+ $epinetCustomFilters.startTimeUTC === null ||
360
+ $epinetCustomFilters.endTimeUTC === null
335
361
  ) {
336
- if (VERBOSE) console.log('🔄 Filters changed, fetching analytics');
337
- setPollingAttempts(0); // Reset polling attempts when filters change
338
- setPollingStartTime(null); // Reset polling start time
339
- fetchAllAnalytics();
362
+ return;
340
363
  }
364
+
365
+ // Create stable filter signature to prevent unnecessary fetches
366
+ const filtersSignature = JSON.stringify({
367
+ startTimeUTC: $epinetCustomFilters.startTimeUTC,
368
+ endTimeUTC: $epinetCustomFilters.endTimeUTC,
369
+ visitorType: $epinetCustomFilters.visitorType,
370
+ selectedUserId: $epinetCustomFilters.selectedUserId,
371
+ });
372
+
373
+ // Skip if filters haven't actually changed
374
+ if (filtersSignature === lastFiltersRef.current) {
375
+ return;
376
+ }
377
+
378
+ lastFiltersRef.current = filtersSignature;
379
+
380
+ if (VERBOSE) console.log('🔄 Filters changed, debouncing fetch');
381
+
382
+ analyticsService.current.debouncedFetch(
383
+ $epinetCustomFilters,
384
+ onAnalyticsUpdate
385
+ );
341
386
  }, [
342
387
  $epinetCustomFilters.enabled,
343
388
  $epinetCustomFilters.visitorType,
344
389
  $epinetCustomFilters.selectedUserId,
345
390
  $epinetCustomFilters.startTimeUTC,
346
391
  $epinetCustomFilters.endTimeUTC,
392
+ onAnalyticsUpdate,
347
393
  ]);
348
394
 
349
395
  return null;
@@ -1,12 +1,13 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
3
  import { skipWizard } from '@/stores/navigation';
4
+ import { fullContentMapStore } from '@/stores/analytics';
4
5
  import type { FullContentMapItem, BrandConfig } from '@/types/tractstack';
5
6
 
6
7
  interface StoryKeepWizardProps {
7
8
  fullContentMap: FullContentMapItem[];
8
9
  homeSlug: string;
9
- brandConfig: BrandConfig; // Now received as prop instead of fetched
10
+ brandConfig: BrandConfig;
10
11
  }
11
12
 
12
13
  interface WizardData {
@@ -50,7 +51,7 @@ const wizardSteps: WizardStep[] = [
50
51
  key: 'hasAnyMenu',
51
52
  message: "A menu helps visitors navigate. Let's create one now.",
52
53
  buttonText: 'Create a Menu',
53
- href: '/storykeep/content/menus/create',
54
+ href: '/storykeep/content?create-menu',
54
55
  },
55
56
  {
56
57
  key: 'hasMenu',
@@ -128,11 +129,18 @@ export default function Wizard({
128
129
  const [wizardData, setWizardData] = useState<WizardData | null>(null);
129
130
  const [loading, setLoading] = useState(true);
130
131
  const $skipWizard = useStore(skipWizard);
132
+ const $clientContentMap = useStore(fullContentMapStore);
133
+ const activeContentMap =
134
+ $clientContentMap?.data?.length > 0
135
+ ? $clientContentMap.data
136
+ : fullContentMap;
131
137
 
132
138
  useEffect(() => {
133
139
  const buildWizardData = async () => {
134
140
  try {
135
- const homePage = fullContentMap.find((item) => item.slug === homeSlug);
141
+ const homePage = activeContentMap.find(
142
+ (item) => item.slug === homeSlug
143
+ );
136
144
 
137
145
  let homeData = null;
138
146
  if (homePage) {
@@ -165,7 +173,7 @@ export default function Wizard({
165
173
  hasPanes: !!homePage?.panes?.length,
166
174
  hasSeo: !!homePage?.description,
167
175
  hasMenu: !!homeData?.menuId,
168
- hasAnyMenu: fullContentMap.some((item) => item.type === 'Menu'),
176
+ hasAnyMenu: activeContentMap.some((item) => item.type === 'Menu'),
169
177
  };
170
178
 
171
179
  setWizardData(data);
@@ -176,11 +184,10 @@ export default function Wizard({
176
184
  }
177
185
  };
178
186
 
179
- // Only build wizard data if we have brandConfig
180
187
  if (brandConfig) {
181
188
  buildWizardData();
182
189
  }
183
- }, [fullContentMap, homeSlug, brandConfig]); // Added brandConfig to dependencies
190
+ }, [activeContentMap, homeSlug, brandConfig]);
184
191
 
185
192
  if (loading || !wizardData || !brandConfig || $skipWizard) {
186
193
  return null;
@@ -252,7 +259,7 @@ export default function Wizard({
252
259
  <div className="mt-3">
253
260
  <a
254
261
  href={currentStep.href}
255
- className="inline-flex items-center rounded-md bg-cyan-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-cyan-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600"
262
+ className="inline-flex items-center rounded-md bg-cyan-600 px-3 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600"
256
263
  >
257
264
  {currentStep.buttonText}
258
265
  <svg