@tagadapay/plugin-sdk 2.8.10 → 3.0.1

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 (50) hide show
  1. package/README.md +14 -14
  2. package/dist/index.js +1 -1
  3. package/dist/react/hooks/usePluginConfig.d.ts +1 -0
  4. package/dist/react/hooks/usePluginConfig.js +69 -18
  5. package/dist/react/providers/TagadaProvider.js +1 -4
  6. package/dist/v2/core/client.d.ts +18 -0
  7. package/dist/v2/core/client.js +45 -0
  8. package/dist/v2/core/config/environment.d.ts +8 -0
  9. package/dist/v2/core/config/environment.js +18 -0
  10. package/dist/v2/core/funnelClient.d.ts +84 -0
  11. package/dist/v2/core/funnelClient.js +252 -0
  12. package/dist/v2/core/index.d.ts +2 -0
  13. package/dist/v2/core/index.js +3 -0
  14. package/dist/v2/core/resources/apiClient.js +1 -1
  15. package/dist/v2/core/resources/funnel.d.ts +1 -0
  16. package/dist/v2/core/resources/offers.d.ts +182 -8
  17. package/dist/v2/core/resources/offers.js +25 -0
  18. package/dist/v2/core/resources/products.d.ts +5 -0
  19. package/dist/v2/core/resources/products.js +15 -1
  20. package/dist/v2/core/types.d.ts +1 -0
  21. package/dist/v2/core/utils/funnelQueryKeys.d.ts +23 -0
  22. package/dist/v2/core/utils/funnelQueryKeys.js +23 -0
  23. package/dist/v2/core/utils/index.d.ts +2 -0
  24. package/dist/v2/core/utils/index.js +2 -0
  25. package/dist/v2/core/utils/pluginConfig.js +44 -32
  26. package/dist/v2/core/utils/sessionStorage.d.ts +20 -0
  27. package/dist/v2/core/utils/sessionStorage.js +39 -0
  28. package/dist/v2/index.d.ts +3 -2
  29. package/dist/v2/index.js +1 -1
  30. package/dist/v2/react/hooks/__examples__/FunnelContextExample.d.ts +3 -0
  31. package/dist/v2/react/hooks/__examples__/FunnelContextExample.js +4 -3
  32. package/dist/v2/react/hooks/useClubOffers.d.ts +2 -2
  33. package/dist/v2/react/hooks/useFunnel.d.ts +27 -39
  34. package/dist/v2/react/hooks/useFunnel.js +22 -659
  35. package/dist/v2/react/hooks/useFunnelLegacy.d.ts +52 -0
  36. package/dist/v2/react/hooks/useFunnelLegacy.js +733 -0
  37. package/dist/v2/react/hooks/useOfferQuery.d.ts +109 -0
  38. package/dist/v2/react/hooks/useOfferQuery.js +483 -0
  39. package/dist/v2/react/hooks/useOffersQuery.d.ts +9 -75
  40. package/dist/v2/react/hooks/useProductsQuery.d.ts +1 -0
  41. package/dist/v2/react/hooks/useProductsQuery.js +10 -6
  42. package/dist/v2/react/index.d.ts +7 -4
  43. package/dist/v2/react/index.js +4 -2
  44. package/dist/v2/react/providers/TagadaProvider.d.ts +40 -2
  45. package/dist/v2/react/providers/TagadaProvider.js +116 -3
  46. package/dist/v2/standalone/index.d.ts +20 -0
  47. package/dist/v2/standalone/index.js +22 -0
  48. package/dist/v2/vue/index.d.ts +6 -0
  49. package/dist/v2/vue/index.js +10 -0
  50. package/package.json +6 -1
@@ -0,0 +1,733 @@
1
+ /**
2
+ * useFunnel Hook (v2) - TanStack Query-based funnel navigation
3
+ *
4
+ * Modern implementation using TanStack Query for state management
5
+ * and the v2 ApiClient for API calls.
6
+ */
7
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { FunnelActionType, FunnelResource } from '../../core/resources/funnel';
10
+ import { useTagadaContext } from '../providers/TagadaProvider';
11
+ import { getGlobalApiClient } from './useApiQuery';
12
+ // Query keys for funnel operations
13
+ const funnelQueryKeys = {
14
+ session: (sessionId) => ['funnel', 'session', sessionId],
15
+ context: (sessionId) => ['funnel', 'context', sessionId],
16
+ };
17
+ /**
18
+ * React Hook for Funnel Navigation (Plugin SDK v2)
19
+ *
20
+ * Modern funnel navigation using TanStack Query for state management
21
+ * and the v2 ApiClient architecture.
22
+ */
23
+ export function useFunnel(options = {}) {
24
+ const { auth, store, debugMode, updateFunnelDebugData, pluginConfig, environment } = useTagadaContext();
25
+ const queryClient = useQueryClient();
26
+ const apiClient = getGlobalApiClient();
27
+ const funnelResource = useMemo(() => new FunnelResource(apiClient), [apiClient]);
28
+ // Debug log only once on mount
29
+ const hasLoggedInit = useRef(false);
30
+ useEffect(() => {
31
+ if (!hasLoggedInit.current) {
32
+ console.log('🔍 [useFunnel] Hook initialized with:', {
33
+ hasPluginConfig: !!pluginConfig,
34
+ hasStaticResources: !!pluginConfig?.staticResources,
35
+ staticResourcesKeys: pluginConfig?.staticResources ? Object.keys(pluginConfig.staticResources) : [],
36
+ environmentType: environment.environment,
37
+ });
38
+ hasLoggedInit.current = true;
39
+ }
40
+ }, []); // Empty deps - log only once
41
+ /**
42
+ * Helper to merge backend context with local static resources
43
+ * Allows mocking static resources in local development via resources.static.json
44
+ */
45
+ const enrichContext = useCallback((ctx) => {
46
+ console.log('🔍 [useFunnel] enrichContext called:', {
47
+ sdkEnvironment: environment.environment,
48
+ backendEnvironment: ctx.environment,
49
+ hasLocalStaticResources: !!pluginConfig?.staticResources,
50
+ localStaticResourcesKeys: pluginConfig?.staticResources ? Object.keys(pluginConfig.staticResources) : [],
51
+ backendStaticKeys: ctx.static ? Object.keys(ctx.static) : [],
52
+ });
53
+ // Only use local static resources in local environment (based on SDK detection, not backend)
54
+ if (environment.environment !== 'local') {
55
+ console.log('⚠️ [useFunnel] Not in local environment, skipping local static resources');
56
+ return ctx;
57
+ }
58
+ // If we have local static resources (from resources.static.json), merge them
59
+ // Backend resources take precedence
60
+ const localStaticResources = pluginConfig?.staticResources || {};
61
+ if (Object.keys(localStaticResources).length === 0) {
62
+ console.log('⚠️ [useFunnel] No local static resources found in pluginConfig');
63
+ return ctx;
64
+ }
65
+ console.log('✅ [useFunnel] Merging local static resources:', localStaticResources);
66
+ const enriched = {
67
+ ...ctx,
68
+ static: {
69
+ ...localStaticResources,
70
+ ...(ctx.static || {})
71
+ }
72
+ };
73
+ console.log('✅ [useFunnel] Enriched context.static:', enriched.static);
74
+ return enriched;
75
+ }, [pluginConfig, environment.environment]);
76
+ // Local state
77
+ const [context, setContext] = useState(null);
78
+ const [initializationAttempted, setInitializationAttempted] = useState(false);
79
+ const [initializationError, setInitializationError] = useState(null);
80
+ // Track the last processed URL funnelId to avoid re-processing on context changes
81
+ const lastProcessedUrlFunnelIdRef = useRef(undefined);
82
+ // ✅ Track if initialization is currently in progress to prevent duplicate calls
83
+ const isInitializingRef = useRef(false);
84
+ // ✅ Track if we've already initialized for this component instance (guards against React Strict Mode)
85
+ const hasInitializedOnceRef = useRef(false);
86
+ // Check if we have an existing session in cookies (to avoid unnecessary re-initialization)
87
+ const hasExistingSessionCookie = useMemo(() => {
88
+ if (typeof document === 'undefined')
89
+ return false;
90
+ const funnelSessionCookie = document.cookie
91
+ .split('; ')
92
+ .find(row => row.startsWith('tgd-funnel-session-id='));
93
+ return !!funnelSessionCookie;
94
+ }, []); // Only check once on mount
95
+ const currentStepId = options.currentStepId || context?.currentStepId;
96
+ // Check for URL parameter overrides - recalculate on every render to detect URL changes
97
+ // This is cheap and ensures we always have the latest URL params
98
+ const [currentUrl, setCurrentUrl] = useState(() => typeof window !== 'undefined' ? window.location.href : '');
99
+ // Listen for URL changes (navigation)
100
+ useEffect(() => {
101
+ if (typeof window === 'undefined')
102
+ return;
103
+ const updateUrl = () => {
104
+ setCurrentUrl(window.location.href);
105
+ };
106
+ // Listen for popstate (back/forward buttons) and pushState/replaceState
107
+ window.addEventListener('popstate', updateUrl);
108
+ // Also update on any navigation
109
+ const originalPushState = window.history.pushState;
110
+ const originalReplaceState = window.history.replaceState;
111
+ window.history.pushState = function (...args) {
112
+ originalPushState.apply(window.history, args);
113
+ updateUrl();
114
+ };
115
+ window.history.replaceState = function (...args) {
116
+ originalReplaceState.apply(window.history, args);
117
+ updateUrl();
118
+ };
119
+ return () => {
120
+ window.removeEventListener('popstate', updateUrl);
121
+ window.history.pushState = originalPushState;
122
+ window.history.replaceState = originalReplaceState;
123
+ };
124
+ }, []);
125
+ const urlFunnelId = useMemo(() => {
126
+ if (typeof window === 'undefined')
127
+ return undefined;
128
+ const params = new URLSearchParams(window.location.search);
129
+ return params.get('funnelId') || undefined;
130
+ }, [currentUrl]); // Re-compute when URL changes
131
+ const effectiveFunnelId = urlFunnelId || options.funnelId;
132
+ /**
133
+ * Fetch debug data for the funnel debugger
134
+ */
135
+ const fetchFunnelDebugData = useCallback(async (sessionId) => {
136
+ if (!debugMode) {
137
+ console.log('🐛 [useFunnel V2] Debug mode is OFF, skipping debug data fetch');
138
+ return;
139
+ }
140
+ console.log('🐛 [useFunnel V2] Fetching debug data for session:', sessionId);
141
+ try {
142
+ const response = await funnelResource.getSession(sessionId, undefined, true);
143
+ console.log('🐛 [useFunnel V2] Debug data response:', response);
144
+ if (response.success && response.debugData) {
145
+ updateFunnelDebugData(response.debugData, null, false);
146
+ console.log('🐛 [useFunnel V2] Debug data updated successfully:', response.debugData);
147
+ }
148
+ else {
149
+ console.warn('🐛 [useFunnel V2] No debug data in response');
150
+ }
151
+ }
152
+ catch (error) {
153
+ console.error('🐛 [useFunnel V2] Failed to fetch funnel debug data:', error);
154
+ }
155
+ }, [funnelResource, debugMode, updateFunnelDebugData]);
156
+ // Session query - only enabled when we have a session ID
157
+ const { data: sessionData, isLoading: isSessionLoading, error: sessionError, refetch: refetchSession } = useQuery({
158
+ queryKey: funnelQueryKeys.session(context?.sessionId || ''),
159
+ queryFn: async () => {
160
+ if (!context?.sessionId) {
161
+ return null;
162
+ }
163
+ console.log('🌐 [useFunnel] Fetching session:', context.sessionId);
164
+ const response = await funnelResource.getSession(context.sessionId);
165
+ if (response.success && response.context) {
166
+ return enrichContext(response.context);
167
+ }
168
+ throw new Error(response.error || 'Failed to fetch session');
169
+ },
170
+ enabled: !!context?.sessionId && (options.enabled !== false),
171
+ staleTime: 30000, // 30 seconds
172
+ gcTime: 5 * 60 * 1000, // 5 minutes
173
+ refetchOnWindowFocus: false,
174
+ refetchOnMount: false, // Prevent refetch on mount if data exists in cache
175
+ retry: 1, // Only retry once to avoid multiple failed requests
176
+ });
177
+ // Initialize session mutation
178
+ const initializeMutation = useMutation({
179
+ mutationFn: async (entryStepId) => {
180
+ console.log('🚀 [useFunnel] initializeMutation.mutationFn called');
181
+ // ✅ Prevent duplicate initialization calls
182
+ if (isInitializingRef.current) {
183
+ console.log('🔒 [useFunnel] Initialization already in progress, throwing error');
184
+ throw new Error('Initialization already in progress');
185
+ }
186
+ console.log('✅ [useFunnel] Starting initialization process');
187
+ isInitializingRef.current = true;
188
+ if (!auth.session?.customerId || !store?.id) {
189
+ isInitializingRef.current = false;
190
+ throw new Error('Authentication required for funnel session');
191
+ }
192
+ // Get CURRENT URL params (not cached) to ensure we have latest values
193
+ const currentUrlParams = typeof window !== 'undefined'
194
+ ? new URLSearchParams(window.location.search)
195
+ : new URLSearchParams();
196
+ const currentUrlFunnelId = currentUrlParams.get('funnelId') || undefined;
197
+ const currentEffectiveFunnelId = currentUrlFunnelId || options.funnelId;
198
+ // Check for existing session ID in URL parameters
199
+ let existingSessionId = currentUrlParams.get('funnelSessionId') || undefined;
200
+ if (existingSessionId) {
201
+ console.log(`🍪 Funnel: Found session ID in URL params: ${existingSessionId}`);
202
+ }
203
+ else {
204
+ // Fallback to cookie for same-domain scenarios
205
+ const funnelSessionCookie = document.cookie
206
+ .split('; ')
207
+ .find(row => row.startsWith('tgd-funnel-session-id='));
208
+ existingSessionId = funnelSessionCookie ? funnelSessionCookie.split('=')[1] : undefined;
209
+ if (existingSessionId) {
210
+ console.log(`🍪 Funnel: Found existing session in cookie: ${existingSessionId}`);
211
+ }
212
+ }
213
+ // Log initialization strategy
214
+ console.log(`🍪 Funnel: Initialize request:`);
215
+ console.log(` - Existing session ID: ${existingSessionId || 'none'}`);
216
+ console.log(` - URL funnelId: ${currentUrlFunnelId || 'none'}`);
217
+ console.log(` - Hook funnelId: ${options.funnelId || 'none'}`);
218
+ console.log(` - Effective funnelId to send: ${currentEffectiveFunnelId || 'none (will use existing or backend default)'}`);
219
+ if (existingSessionId && !currentEffectiveFunnelId) {
220
+ console.log(` → Strategy: REUSE existing session from cookie`);
221
+ }
222
+ else if (existingSessionId && currentEffectiveFunnelId) {
223
+ console.log(` → Strategy: VALIDATE existing session matches funnelId, or create new`);
224
+ }
225
+ else {
226
+ console.log(` → Strategy: CREATE new session`);
227
+ }
228
+ // Send minimal CMS session data
229
+ const cmsSessionData = {
230
+ customerId: auth.session.customerId,
231
+ storeId: store.id,
232
+ sessionId: auth.session.sessionId,
233
+ accountId: auth.session.accountId
234
+ };
235
+ // Get current URL for session synchronization
236
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : undefined;
237
+ // Call API to initialize session - backend will restore existing or create new
238
+ const requestBody = {
239
+ cmsSession: cmsSessionData,
240
+ entryStepId, // Optional override
241
+ existingSessionId, // Pass existing session ID from URL or cookie
242
+ currentUrl // Include current URL for step tracking
243
+ };
244
+ // Only include funnelId if it's provided (for backend fallback)
245
+ if (currentEffectiveFunnelId) {
246
+ requestBody.funnelId = currentEffectiveFunnelId;
247
+ }
248
+ console.log(`📤 Funnel: Sending initialize request to backend:`);
249
+ console.log(` Request body:`, JSON.stringify({
250
+ ...requestBody,
251
+ cmsSession: { ...cmsSessionData, sessionId: `${cmsSessionData.sessionId.substring(0, 20)}...` }
252
+ }, null, 2));
253
+ const response = await funnelResource.initialize(requestBody);
254
+ console.log(`📥 Funnel: Received response from backend:`);
255
+ console.log(` Success: ${response.success}`);
256
+ if (response.context) {
257
+ console.log(` Returned session ID: ${response.context.sessionId}`);
258
+ console.log(` Returned funnel ID: ${response.context.funnelId}`);
259
+ console.log(` Is same session? ${response.context.sessionId === existingSessionId ? '✅ YES (reused)' : '❌ NO (new session created)'}`);
260
+ }
261
+ if (response.error) {
262
+ console.log(` Error: ${response.error}`);
263
+ }
264
+ if (response.success && response.context) {
265
+ return enrichContext(response.context);
266
+ }
267
+ else {
268
+ throw new Error(response.error || 'Failed to initialize funnel session');
269
+ }
270
+ },
271
+ onSuccess: (newContext) => {
272
+ // ✅ Reset initialization flag
273
+ isInitializingRef.current = false;
274
+ // Context is already enriched by mutationFn return, but safe to ensure
275
+ const enrichedContext = enrichContext(newContext);
276
+ setContext(enrichedContext);
277
+ setInitializationError(null);
278
+ // Set session cookie for persistence across page reloads
279
+ setSessionCookie(enrichedContext.sessionId);
280
+ console.log(`✅ Funnel: Session initialized/loaded successfully`);
281
+ console.log(` - Session ID: ${enrichedContext.sessionId}`);
282
+ console.log(` - Funnel ID: ${enrichedContext.funnelId}`);
283
+ console.log(` - Current Step: ${enrichedContext.currentStepId}`);
284
+ // Fetch debug data if in debug mode
285
+ if (debugMode) {
286
+ void fetchFunnelDebugData(enrichedContext.sessionId);
287
+ }
288
+ // Set the session query data directly (no need to refetch since initialize already returns full context)
289
+ queryClient.setQueryData(funnelQueryKeys.session(enrichedContext.sessionId), enrichedContext);
290
+ console.log(`✅ Funnel: Session query data populated from initialize response (no additional request needed)`);
291
+ },
292
+ onError: (error) => {
293
+ // ✅ Reset initialization flag on error
294
+ isInitializingRef.current = false;
295
+ const errorObj = error instanceof Error ? error : new Error(String(error));
296
+ setInitializationError(errorObj);
297
+ console.error('Error initializing funnel session:', error);
298
+ if (options.onError) {
299
+ options.onError(errorObj);
300
+ }
301
+ }
302
+ });
303
+ // Navigate mutation
304
+ const navigateMutation = useMutation({
305
+ mutationFn: async (event) => {
306
+ if (!context) {
307
+ throw new Error('Funnel session not initialized');
308
+ }
309
+ if (!context.sessionId) {
310
+ throw new Error('Funnel session ID missing - session may be corrupted');
311
+ }
312
+ // ✅ Automatically include currentUrl for URL-to-Step mapping
313
+ // User can override by providing event.currentUrl explicitly
314
+ const currentUrl = event.currentUrl || (typeof window !== 'undefined' ? window.location.href : undefined);
315
+ if (currentUrl) {
316
+ }
317
+ const requestBody = {
318
+ sessionId: context.sessionId,
319
+ event: {
320
+ type: event.type,
321
+ data: event.data,
322
+ timestamp: event.timestamp || new Date().toISOString(),
323
+ currentUrl: event.currentUrl // Preserve user override if provided
324
+ },
325
+ contextUpdates: {
326
+ lastActivityAt: Date.now()
327
+ },
328
+ currentUrl, // ✅ Send to backend for URL→Step mapping
329
+ funnelId: effectiveFunnelId || options.funnelId // ✅ Send for session recovery
330
+ };
331
+ const response = await funnelResource.navigate(requestBody);
332
+ if (response.success && response.result) {
333
+ return response.result;
334
+ }
335
+ else {
336
+ throw new Error(response.error || 'Navigation failed');
337
+ }
338
+ },
339
+ onSuccess: (result) => {
340
+ if (!context)
341
+ return;
342
+ // 🔄 Handle session recovery (if backend created a new session)
343
+ let recoveredSessionId;
344
+ if (result.sessionId && result.sessionId !== context.sessionId) {
345
+ console.warn(`🔄 Funnel: Session recovered! Old: ${context.sessionId}, New: ${result.sessionId}`);
346
+ recoveredSessionId = result.sessionId;
347
+ }
348
+ // Update local context
349
+ const newContext = {
350
+ ...context,
351
+ sessionId: recoveredSessionId || context.sessionId, // ✅ Use recovered session ID if provided
352
+ currentStepId: result.stepId,
353
+ previousStepId: context.currentStepId,
354
+ lastActivityAt: Date.now(),
355
+ metadata: {
356
+ ...context.metadata,
357
+ lastEvent: 'navigation',
358
+ lastTransition: new Date().toISOString(),
359
+ ...(recoveredSessionId ? { recovered: true, oldSessionId: context.sessionId } : {})
360
+ }
361
+ };
362
+ const enrichedContext = enrichContext(newContext);
363
+ setContext(enrichedContext);
364
+ // Update cookie with new session ID if recovered
365
+ if (recoveredSessionId) {
366
+ document.cookie = `funnelSessionId=${recoveredSessionId}; path=/; max-age=86400; SameSite=Lax`;
367
+ console.log(`🍪 Funnel: Updated cookie with recovered session ID: ${recoveredSessionId}`);
368
+ }
369
+ // Create typed navigation result
370
+ const navigationResult = {
371
+ stepId: result.stepId,
372
+ action: {
373
+ type: 'redirect', // Default action type
374
+ url: result.url
375
+ },
376
+ context: enrichedContext,
377
+ tracking: result.tracking
378
+ };
379
+ // Handle navigation callback with override capability
380
+ let shouldPerformDefaultNavigation = true;
381
+ if (options.onNavigate) {
382
+ const callbackResult = options.onNavigate(navigationResult);
383
+ if (callbackResult === false) {
384
+ shouldPerformDefaultNavigation = false;
385
+ }
386
+ }
387
+ // Perform default navigation if not overridden
388
+ if (shouldPerformDefaultNavigation && navigationResult.action.url) {
389
+ // Add URL parameters for cross-domain session continuity
390
+ const urlWithParams = addSessionParams(navigationResult.action.url, enrichedContext.sessionId, effectiveFunnelId || options.funnelId);
391
+ const updatedAction = { ...navigationResult.action, url: urlWithParams };
392
+ performNavigation(updatedAction);
393
+ }
394
+ // Skip background refreshes if we are navigating away (full page reload)
395
+ // This prevents "lingering" requests from the old page context
396
+ const isFullNavigation = shouldPerformDefaultNavigation &&
397
+ navigationResult.action.url &&
398
+ (navigationResult.action.type === 'redirect' || navigationResult.action.type === 'replace');
399
+ if (!isFullNavigation) {
400
+ // Fetch debug data if in debug mode
401
+ if (debugMode) {
402
+ void fetchFunnelDebugData(enrichedContext.sessionId);
403
+ }
404
+ // Invalidate and refetch session data
405
+ void queryClient.invalidateQueries({
406
+ queryKey: funnelQueryKeys.session(enrichedContext.sessionId)
407
+ });
408
+ }
409
+ },
410
+ onError: (error) => {
411
+ console.error('Funnel navigation error:', error);
412
+ if (options.onError) {
413
+ options.onError(error instanceof Error ? error : new Error(String(error)));
414
+ }
415
+ }
416
+ });
417
+ // Update context mutation
418
+ const updateContextMutation = useMutation({
419
+ mutationFn: async (updates) => {
420
+ if (!context) {
421
+ throw new Error('Funnel session not initialized');
422
+ }
423
+ const requestBody = {
424
+ contextUpdates: updates
425
+ };
426
+ const response = await funnelResource.updateContext(context.sessionId, requestBody);
427
+ if (response.success) {
428
+ return updates;
429
+ }
430
+ else {
431
+ throw new Error(response.error || 'Context update failed');
432
+ }
433
+ },
434
+ onSuccess: (updates) => {
435
+ if (!context)
436
+ return;
437
+ const updatedContext = {
438
+ ...context,
439
+ ...updates,
440
+ lastActivityAt: Date.now()
441
+ };
442
+ const enrichedContext = enrichContext(updatedContext);
443
+ setContext(enrichedContext);
444
+ console.log(`🍪 Funnel: Updated context for step ${context.currentStepId}`);
445
+ // Invalidate session query
446
+ void queryClient.invalidateQueries({
447
+ queryKey: funnelQueryKeys.session(context.sessionId)
448
+ });
449
+ },
450
+ onError: (error) => {
451
+ console.error('Error updating funnel context:', error);
452
+ if (options.onError) {
453
+ options.onError(error instanceof Error ? error : new Error(String(error)));
454
+ }
455
+ }
456
+ });
457
+ // End session mutation
458
+ const endSessionMutation = useMutation({
459
+ mutationFn: async () => {
460
+ if (!context)
461
+ return;
462
+ await funnelResource.endSession(context.sessionId);
463
+ },
464
+ onSuccess: () => {
465
+ if (!context)
466
+ return;
467
+ console.log(`🍪 Funnel: Ended session ${context.sessionId}`);
468
+ setContext(null);
469
+ // Reset the processed URL funnelId ref to allow re-initialization
470
+ lastProcessedUrlFunnelIdRef.current = undefined;
471
+ // Clear queries
472
+ queryClient.removeQueries({
473
+ queryKey: funnelQueryKeys.session(context.sessionId)
474
+ });
475
+ },
476
+ onError: (error) => {
477
+ console.error('Error ending funnel session:', error);
478
+ // Don't throw here - session ending is best effort
479
+ }
480
+ });
481
+ /**
482
+ * Set funnel session cookie
483
+ */
484
+ const setSessionCookie = useCallback((sessionId) => {
485
+ const maxAge = 24 * 60 * 60; // 24 hours
486
+ const expires = new Date(Date.now() + maxAge * 1000).toUTCString();
487
+ // Set cookie for same-domain scenarios
488
+ document.cookie = `tgd-funnel-session-id=${sessionId}; path=/; expires=${expires}; SameSite=Lax`;
489
+ console.log(`🍪 Funnel: Set session cookie: ${sessionId}`);
490
+ }, []);
491
+ /**
492
+ * Add session parameters to URL for cross-domain continuity
493
+ */
494
+ const addSessionParams = useCallback((url, sessionId, funnelId) => {
495
+ try {
496
+ // Handle relative URLs by using current origin
497
+ const urlObj = url.startsWith('http')
498
+ ? new URL(url)
499
+ : new URL(url, window.location.origin);
500
+ urlObj.searchParams.set('funnelSessionId', sessionId);
501
+ if (funnelId) {
502
+ urlObj.searchParams.set('funnelId', funnelId);
503
+ }
504
+ return urlObj.toString();
505
+ }
506
+ catch (error) {
507
+ console.warn('Failed to add session params to URL:', error);
508
+ return url; // Return original URL if parsing fails
509
+ }
510
+ }, []);
511
+ /**
512
+ * Perform navigation based on action type
513
+ */
514
+ const performNavigation = useCallback((action) => {
515
+ if (!action.url)
516
+ return;
517
+ // Handle relative URLs by making them absolute
518
+ let targetUrl = action.url;
519
+ if (targetUrl.startsWith('/') && !targetUrl.startsWith('//')) {
520
+ // Relative URL - use current origin
521
+ targetUrl = window.location.origin + targetUrl;
522
+ }
523
+ switch (action.type) {
524
+ case 'redirect':
525
+ window.location.href = targetUrl;
526
+ break;
527
+ case 'replace':
528
+ window.location.replace(targetUrl);
529
+ break;
530
+ case 'push':
531
+ window.history.pushState({}, '', targetUrl);
532
+ // Trigger a popstate event to update React Router
533
+ window.dispatchEvent(new PopStateEvent('popstate'));
534
+ break;
535
+ case 'external':
536
+ window.open(targetUrl, '_blank');
537
+ break;
538
+ case 'none':
539
+ // No navigation needed
540
+ break;
541
+ default:
542
+ console.warn(`Unknown navigation action type: ${action.type}`);
543
+ break;
544
+ }
545
+ }, []);
546
+ // Public API methods
547
+ const initializeSession = useCallback(async (entryStepId) => {
548
+ // ✅ Check ref before even starting (prevents React StrictMode double-invocation)
549
+ if (isInitializingRef.current) {
550
+ console.log('⏭️ Funnel: initializeSession called but already initializing');
551
+ return;
552
+ }
553
+ setInitializationAttempted(true);
554
+ await initializeMutation.mutateAsync(entryStepId);
555
+ }, [initializeMutation]);
556
+ const next = useCallback(async (event) => {
557
+ return navigateMutation.mutateAsync(event);
558
+ }, [navigateMutation]);
559
+ const goToStep = useCallback(async (stepId) => {
560
+ return next({
561
+ type: FunnelActionType.DIRECT_NAVIGATION,
562
+ data: { targetStepId: stepId },
563
+ timestamp: new Date().toISOString()
564
+ });
565
+ }, [next]);
566
+ const updateContext = useCallback(async (updates) => {
567
+ await updateContextMutation.mutateAsync(updates);
568
+ }, [updateContextMutation]);
569
+ const endSession = useCallback(async () => {
570
+ await endSessionMutation.mutateAsync();
571
+ }, [endSessionMutation]);
572
+ const retryInitialization = useCallback(async () => {
573
+ setInitializationAttempted(false);
574
+ setInitializationError(null);
575
+ await initializeSession();
576
+ }, [initializeSession]);
577
+ /**
578
+ * Auto-initialization effect
579
+ *
580
+ * Handles funnel session initialization with the following logic:
581
+ * 1. If no session exists => Initialize with URL funnelId or hook funnelId or backend default
582
+ * 2. If session exists + explicit funnelId provided that differs => Reset and create new
583
+ * 3. If session exists + no funnelId provided => Keep existing session
584
+ *
585
+ * IMPORTANT: Only resets session if an EXPLICIT funnelId is provided that differs
586
+ * from the current session. If no funnelId is provided, keeps the existing session.
587
+ *
588
+ * Priority: URL funnelId > Hook funnelId > Existing session funnelId > Backend default
589
+ */
590
+ useEffect(() => {
591
+ // Skip if auto-initialize is disabled
592
+ const autoInit = options.autoInitialize ?? true; // Default to true
593
+ if (!autoInit) {
594
+ return;
595
+ }
596
+ // Skip if required dependencies are not available
597
+ if (!auth.session?.customerId || !store?.id) {
598
+ return;
599
+ }
600
+ // Skip if already initializing (check both mutation state and ref)
601
+ if (initializeMutation.isPending || isInitializingRef.current) {
602
+ console.log('🔒 [useFunnel] Skipping initialization - already in progress');
603
+ return;
604
+ }
605
+ // Determine if we have an explicit funnelId request (URL has priority)
606
+ const explicitFunnelId = urlFunnelId || options.funnelId;
607
+ // Case 1: No session exists yet - need to initialize
608
+ if (!context) {
609
+ // Check if we've already attempted initialization OR already initialized once
610
+ // The hasInitializedOnceRef guards against React Strict Mode double-mounting
611
+ if (!initializationAttempted && !hasInitializedOnceRef.current) {
612
+ console.log('🚀 [useFunnel] Initializing funnel session for the first time');
613
+ setInitializationAttempted(true);
614
+ hasInitializedOnceRef.current = true; // Mark as initialized to prevent React Strict Mode duplicate
615
+ initializeSession().catch(error => {
616
+ console.error('❌ Funnel: Auto-initialization failed:', error);
617
+ hasInitializedOnceRef.current = false; // Reset on error to allow retry
618
+ });
619
+ }
620
+ else {
621
+ console.log('🔒 [useFunnel] Skipping initialization - already attempted or initialized once');
622
+ }
623
+ return;
624
+ }
625
+ // Case 2: Session exists - check if we need to reset it
626
+ // ONLY reset if an explicit funnelId is provided AND it differs from current session
627
+ if (explicitFunnelId && context.funnelId && explicitFunnelId !== context.funnelId) {
628
+ // Check if we've already processed this funnelId to prevent loops
629
+ if (lastProcessedUrlFunnelIdRef.current === explicitFunnelId) {
630
+ return;
631
+ }
632
+ console.log(`🔄 Funnel: Switching from funnel ${context.funnelId} to ${explicitFunnelId}`);
633
+ // Mark this funnelId as processed
634
+ lastProcessedUrlFunnelIdRef.current = explicitFunnelId;
635
+ // Clear existing session
636
+ setContext(null);
637
+ setInitializationAttempted(false);
638
+ setInitializationError(null);
639
+ // Clear session cookie
640
+ document.cookie = 'tgd-funnel-session-id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
641
+ // Clear queries for old session
642
+ queryClient.removeQueries({
643
+ queryKey: funnelQueryKeys.session(context.sessionId)
644
+ });
645
+ // Initialize new session with correct funnelId
646
+ initializeSession().catch(error => {
647
+ console.error('❌ Funnel: Failed to reset session:', error);
648
+ });
649
+ }
650
+ }, [
651
+ options.autoInitialize,
652
+ options.funnelId,
653
+ urlFunnelId,
654
+ context?.funnelId,
655
+ context?.sessionId,
656
+ initializationAttempted,
657
+ auth.session?.customerId,
658
+ store?.id,
659
+ initializeMutation.isPending,
660
+ initializeSession,
661
+ hasExistingSessionCookie,
662
+ queryClient
663
+ ]);
664
+ // Sync session data from query to local state
665
+ // Use a ref to track the last synced session ID to prevent loops
666
+ const lastSyncedSessionIdRef = useRef(null);
667
+ useEffect(() => {
668
+ if (sessionData && sessionData.sessionId !== lastSyncedSessionIdRef.current) {
669
+ setContext(sessionData);
670
+ lastSyncedSessionIdRef.current = sessionData.sessionId;
671
+ // Debug: Log the full context after sync
672
+ console.log('📦 [useFunnel] Context synced and updated:', {
673
+ sessionId: sessionData.sessionId,
674
+ currentStepId: sessionData.currentStepId,
675
+ hasStatic: !!sessionData.static,
676
+ staticKeys: sessionData.static ? Object.keys(sessionData.static) : [],
677
+ static: sessionData.static,
678
+ fullContext: sessionData,
679
+ });
680
+ }
681
+ }, [sessionData]); // Remove context from deps to prevent loop
682
+ // Debug: Log context whenever it changes
683
+ useEffect(() => {
684
+ if (context) {
685
+ console.log('🔄 [useFunnel] Context updated:', {
686
+ sessionId: context.sessionId,
687
+ currentStepId: context.currentStepId,
688
+ hasStatic: !!context.static,
689
+ staticKeys: context.static ? Object.keys(context.static) : [],
690
+ static: context.static,
691
+ });
692
+ }
693
+ }, [context]);
694
+ const isLoading = initializeMutation.isPending || navigateMutation.isPending || updateContextMutation.isPending;
695
+ const isInitialized = !!context;
696
+ const isNavigating = navigateMutation.isPending; // Explicit navigation state
697
+ return {
698
+ next,
699
+ goToStep,
700
+ updateContext,
701
+ currentStep: {
702
+ id: currentStepId || 'unknown'
703
+ },
704
+ context,
705
+ isLoading,
706
+ isInitialized,
707
+ isNavigating, // Expose isNavigating
708
+ initializeSession,
709
+ endSession,
710
+ retryInitialization,
711
+ initializationError,
712
+ isSessionLoading,
713
+ sessionError,
714
+ refetch: () => void refetchSession()
715
+ };
716
+ }
717
+ /**
718
+ * Simplified funnel hook for basic step tracking (v2)
719
+ */
720
+ export function useSimpleFunnel(funnelId, initialStepId) {
721
+ const funnel = useFunnel({
722
+ funnelId,
723
+ currentStepId: initialStepId,
724
+ autoInitialize: true
725
+ });
726
+ return {
727
+ currentStepId: funnel.currentStep.id,
728
+ next: funnel.next,
729
+ goToStep: funnel.goToStep,
730
+ isLoading: funnel.isLoading,
731
+ context: funnel.context
732
+ };
733
+ }