@tagadapay/plugin-sdk 2.8.8 → 2.8.10

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 (43) hide show
  1. package/dist/react/config/environment.d.ts +1 -22
  2. package/dist/react/config/environment.js +1 -132
  3. package/dist/react/utils/deviceInfo.d.ts +1 -39
  4. package/dist/react/utils/deviceInfo.js +1 -163
  5. package/dist/react/utils/jwtDecoder.d.ts +1 -14
  6. package/dist/react/utils/jwtDecoder.js +1 -86
  7. package/dist/react/utils/tokenStorage.d.ts +1 -16
  8. package/dist/react/utils/tokenStorage.js +1 -53
  9. package/dist/v2/core/client.d.ts +96 -0
  10. package/dist/v2/core/client.js +430 -0
  11. package/dist/v2/core/config/environment.d.ts +36 -0
  12. package/dist/v2/core/config/environment.js +155 -0
  13. package/dist/v2/core/pathRemapping.js +61 -3
  14. package/dist/v2/core/resources/apiClient.d.ts +13 -0
  15. package/dist/v2/core/resources/apiClient.js +77 -9
  16. package/dist/v2/core/resources/funnel.d.ts +21 -0
  17. package/dist/v2/core/resources/payments.d.ts +23 -0
  18. package/dist/v2/core/types.d.ts +271 -0
  19. package/dist/v2/core/types.js +4 -0
  20. package/dist/v2/core/utils/deviceInfo.d.ts +39 -0
  21. package/dist/v2/core/utils/deviceInfo.js +162 -0
  22. package/dist/v2/core/utils/eventDispatcher.d.ts +10 -0
  23. package/dist/v2/core/utils/eventDispatcher.js +24 -0
  24. package/dist/v2/core/utils/jwtDecoder.d.ts +14 -0
  25. package/dist/v2/core/utils/jwtDecoder.js +85 -0
  26. package/dist/v2/core/utils/pluginConfig.d.ts +1 -0
  27. package/dist/v2/core/utils/pluginConfig.js +64 -8
  28. package/dist/v2/core/utils/tokenStorage.d.ts +19 -0
  29. package/dist/v2/core/utils/tokenStorage.js +52 -0
  30. package/dist/v2/react/components/ApplePayButton.js +1 -1
  31. package/dist/v2/react/components/DebugDrawer.js +90 -1
  32. package/dist/v2/react/hooks/__examples__/FunnelContextExample.d.ts +12 -0
  33. package/dist/v2/react/hooks/__examples__/FunnelContextExample.js +54 -0
  34. package/dist/v2/react/hooks/useFunnel.d.ts +2 -1
  35. package/dist/v2/react/hooks/useFunnel.js +245 -69
  36. package/dist/v2/react/hooks/useGoogleAutocomplete.js +26 -18
  37. package/dist/v2/react/hooks/useISOData.js +4 -2
  38. package/dist/v2/react/hooks/useOffersQuery.d.ts +42 -29
  39. package/dist/v2/react/hooks/useOffersQuery.js +266 -204
  40. package/dist/v2/react/hooks/usePaymentQuery.js +99 -6
  41. package/dist/v2/react/providers/TagadaProvider.d.ts +13 -21
  42. package/dist/v2/react/providers/TagadaProvider.js +79 -673
  43. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Example: Accessing Funnel Context & Resources
4
+ *
5
+ * This example demonstrates how to access data from previous steps
6
+ * using the funnel context, including order, customer, and other resources.
7
+ */
8
+ import { useFunnel } from '../useFunnel';
9
+ export function FunnelContextExample() {
10
+ const { context, isLoading, isInitialized } = useFunnel({
11
+ autoInitialize: true,
12
+ });
13
+ if (isLoading) {
14
+ return _jsx("div", { children: "Loading funnel session..." });
15
+ }
16
+ if (!isInitialized || !context) {
17
+ return _jsx("div", { children: "Funnel not initialized" });
18
+ }
19
+ // Access resources from context
20
+ const order = context.resources?.order;
21
+ const customer = context.resources?.customer;
22
+ const checkout = context.resources?.checkout;
23
+ const orders = context.resources?.orders || [];
24
+ return (_jsxs("div", { style: { padding: '20px', fontFamily: 'sans-serif' }, children: [_jsx("h1", { children: "Funnel Context Example" }), _jsxs("section", { style: { marginBottom: '30px' }, children: [_jsx("h2", { children: "Session Info" }), _jsxs("ul", { children: [_jsxs("li", { children: [_jsx("strong", { children: "Session ID:" }), " ", context.sessionId] }), _jsxs("li", { children: [_jsx("strong", { children: "Funnel ID:" }), " ", context.funnelId] }), _jsxs("li", { children: [_jsx("strong", { children: "Current Step:" }), " ", context.currentStepId] }), _jsxs("li", { children: [_jsx("strong", { children: "Previous Step:" }), " ", context.previousStepId || 'None'] }), _jsxs("li", { children: [_jsx("strong", { children: "Furthest Step:" }), " ", context.furthestStepId || 'None'] }), _jsxs("li", { children: [_jsx("strong", { children: "Environment:" }), " ", context.environment || 'Not set'] })] })] }), _jsxs("section", { style: { marginBottom: '30px' }, children: [_jsx("h2", { children: "Resources" }), order && (_jsxs("div", { style: { marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '5px' }, children: [_jsx("h3", { children: "Order (Hot Context)" }), _jsx("pre", { style: { background: '#f5f5f5', padding: '10px', borderRadius: '3px', overflow: 'auto' }, children: JSON.stringify(order, null, 2) })] })), customer && (_jsxs("div", { style: { marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '5px' }, children: [_jsx("h3", { children: "Customer" }), _jsx("pre", { style: { background: '#f5f5f5', padding: '10px', borderRadius: '3px', overflow: 'auto' }, children: JSON.stringify(customer, null, 2) })] })), checkout && (_jsxs("div", { style: { marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '5px' }, children: [_jsx("h3", { children: "Checkout" }), _jsx("pre", { style: { background: '#f5f5f5', padding: '10px', borderRadius: '3px', overflow: 'auto' }, children: JSON.stringify(checkout, null, 2) })] })), orders.length > 0 && (_jsxs("div", { style: { marginBottom: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '5px' }, children: [_jsxs("h3", { children: ["Orders Collection (", orders.length, " orders)"] }), orders.map((o, index) => (_jsxs("div", { style: { marginBottom: '10px' }, children: [_jsxs("strong", { children: ["Order ", index + 1, ":"] }), _jsx("pre", { style: {
25
+ background: '#f5f5f5',
26
+ padding: '10px',
27
+ borderRadius: '3px',
28
+ marginTop: '5px',
29
+ overflow: 'auto',
30
+ }, children: JSON.stringify(o, null, 2) })] }, o.id || index)))] })), _jsxs("details", { style: { marginTop: '20px' }, children: [_jsx("summary", { style: { cursor: 'pointer', fontWeight: 'bold' }, children: "Show All Resources (Raw)" }), _jsx("pre", { style: {
31
+ background: '#f5f5f5',
32
+ padding: '10px',
33
+ borderRadius: '3px',
34
+ marginTop: '10px',
35
+ overflow: 'auto',
36
+ }, children: JSON.stringify(context.resources, null, 2) })] })] }), context.metadata && Object.keys(context.metadata).length > 0 && (_jsxs("section", { style: { marginBottom: '30px' }, children: [_jsx("h2", { children: "Metadata" }), _jsx("pre", { style: { background: '#f5f5f5', padding: '10px', borderRadius: '3px', overflow: 'auto' }, children: JSON.stringify(context.metadata, null, 2) })] })), _jsxs("details", { children: [_jsx("summary", { style: { cursor: 'pointer', fontWeight: 'bold' }, children: "Show Full Context (Debug)" }), _jsx("pre", { style: {
37
+ background: '#f5f5f5',
38
+ padding: '10px',
39
+ borderRadius: '3px',
40
+ marginTop: '10px',
41
+ overflow: 'auto',
42
+ }, children: JSON.stringify(context, null, 2) })] })] }));
43
+ }
44
+ /**
45
+ * Simple example for documentation
46
+ * This can now be called with or without options
47
+ */
48
+ export function SimpleContextExample() {
49
+ const funnel = useFunnel(); // Works without params!
50
+ // Access data from previous steps
51
+ const order = funnel.context?.resources?.order;
52
+ const customer = funnel.context?.resources?.customer;
53
+ return (_jsxs("div", { children: [_jsxs("h1", { children: ["Order ", order?.id] }), _jsxs("p", { children: ["Customer: ", customer?.email] }), _jsxs("p", { children: ["Amount: $", ((order?.amount || 0) / 100).toFixed(2)] })] }));
54
+ }
@@ -23,6 +23,7 @@ export interface UseFunnelResult {
23
23
  context: SimpleFunnelContext | null;
24
24
  isLoading: boolean;
25
25
  isInitialized: boolean;
26
+ isNavigating: boolean;
26
27
  initializeSession: (entryStepId?: string) => Promise<void>;
27
28
  endSession: () => Promise<void>;
28
29
  retryInitialization: () => Promise<void>;
@@ -37,7 +38,7 @@ export interface UseFunnelResult {
37
38
  * Modern funnel navigation using TanStack Query for state management
38
39
  * and the v2 ApiClient architecture.
39
40
  */
40
- export declare function useFunnel(options: UseFunnelOptions): UseFunnelResult;
41
+ export declare function useFunnel(options?: UseFunnelOptions): UseFunnelResult;
41
42
  /**
42
43
  * Simplified funnel hook for basic step tracking (v2)
43
44
  */
@@ -5,7 +5,7 @@
5
5
  * and the v2 ApiClient for API calls.
6
6
  */
7
7
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
8
- import { useCallback, useEffect, useMemo, useState } from 'react';
8
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { FunnelActionType, FunnelResource } from '../../core/resources/funnel';
10
10
  import { useTagadaContext } from '../providers/TagadaProvider';
11
11
  import { getGlobalApiClient } from './useApiQuery';
@@ -20,19 +20,86 @@ const funnelQueryKeys = {
20
20
  * Modern funnel navigation using TanStack Query for state management
21
21
  * and the v2 ApiClient architecture.
22
22
  */
23
- export function useFunnel(options) {
24
- const { auth, store, debugMode, updateFunnelDebugData } = useTagadaContext();
23
+ export function useFunnel(options = {}) {
24
+ const { auth, store, debugMode, updateFunnelDebugData, pluginConfig } = useTagadaContext();
25
25
  const queryClient = useQueryClient();
26
26
  const apiClient = getGlobalApiClient();
27
27
  const funnelResource = useMemo(() => new FunnelResource(apiClient), [apiClient]);
28
+ /**
29
+ * Helper to merge backend context with local static resources
30
+ * Allows mocking static resources in local development via resources.static.json
31
+ */
32
+ const enrichContext = useCallback((ctx) => {
33
+ // If we have local static resources (from resources.static.json), merge them
34
+ // Backend resources take precedence
35
+ const localStaticResources = pluginConfig?.staticResources || {};
36
+ if (Object.keys(localStaticResources).length === 0) {
37
+ return ctx;
38
+ }
39
+ console.log('🛠️ Funnel: Merging local static resources:', localStaticResources);
40
+ const enriched = {
41
+ ...ctx,
42
+ static: {
43
+ ...localStaticResources,
44
+ ...(ctx.static || {})
45
+ }
46
+ };
47
+ console.log('🛠️ Funnel: Enriched context.static:', enriched.static);
48
+ return enriched;
49
+ }, [pluginConfig]);
28
50
  // Local state
29
51
  const [context, setContext] = useState(null);
30
52
  const [initializationAttempted, setInitializationAttempted] = useState(false);
31
53
  const [initializationError, setInitializationError] = useState(null);
54
+ // Track the last processed URL funnelId to avoid re-processing on context changes
55
+ const lastProcessedUrlFunnelIdRef = useRef(undefined);
56
+ // ✅ Track if initialization is currently in progress to prevent duplicate calls
57
+ const isInitializingRef = useRef(false);
58
+ // Check if we have an existing session in cookies (to avoid unnecessary re-initialization)
59
+ const hasExistingSessionCookie = useMemo(() => {
60
+ if (typeof document === 'undefined')
61
+ return false;
62
+ const funnelSessionCookie = document.cookie
63
+ .split('; ')
64
+ .find(row => row.startsWith('tgd-funnel-session-id='));
65
+ return !!funnelSessionCookie;
66
+ }, []); // Only check once on mount
32
67
  const currentStepId = options.currentStepId || context?.currentStepId;
33
- // Check for URL parameter overrides
34
- const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
35
- const urlFunnelId = urlParams.get('funnelId') || undefined;
68
+ // Check for URL parameter overrides - recalculate on every render to detect URL changes
69
+ // This is cheap and ensures we always have the latest URL params
70
+ const [currentUrl, setCurrentUrl] = useState(() => typeof window !== 'undefined' ? window.location.href : '');
71
+ // Listen for URL changes (navigation)
72
+ useEffect(() => {
73
+ if (typeof window === 'undefined')
74
+ return;
75
+ const updateUrl = () => {
76
+ setCurrentUrl(window.location.href);
77
+ };
78
+ // Listen for popstate (back/forward buttons) and pushState/replaceState
79
+ window.addEventListener('popstate', updateUrl);
80
+ // Also update on any navigation
81
+ const originalPushState = window.history.pushState;
82
+ const originalReplaceState = window.history.replaceState;
83
+ window.history.pushState = function (...args) {
84
+ originalPushState.apply(window.history, args);
85
+ updateUrl();
86
+ };
87
+ window.history.replaceState = function (...args) {
88
+ originalReplaceState.apply(window.history, args);
89
+ updateUrl();
90
+ };
91
+ return () => {
92
+ window.removeEventListener('popstate', updateUrl);
93
+ window.history.pushState = originalPushState;
94
+ window.history.replaceState = originalReplaceState;
95
+ };
96
+ }, []);
97
+ const urlFunnelId = useMemo(() => {
98
+ if (typeof window === 'undefined')
99
+ return undefined;
100
+ const params = new URLSearchParams(window.location.search);
101
+ return params.get('funnelId') || undefined;
102
+ }, [currentUrl]); // Re-compute when URL changes
36
103
  const effectiveFunnelId = urlFunnelId || options.funnelId;
37
104
  /**
38
105
  * Fetch debug data for the funnel debugger
@@ -63,19 +130,11 @@ export function useFunnel(options) {
63
130
  queryKey: funnelQueryKeys.session(context?.sessionId || ''),
64
131
  queryFn: async () => {
65
132
  if (!context?.sessionId) {
66
- console.warn('🍪 Funnel: No session ID available for query');
67
133
  return null;
68
134
  }
69
- // Automatically include currentUrl for session sync on page load/refresh
70
- const currentUrl = typeof window !== 'undefined' ? window.location.href : undefined;
71
- console.log(`🍪 Funnel: Fetching session data for ID: ${context.sessionId}`);
72
- if (currentUrl) {
73
- console.log(`🍪 Funnel: Including current URL for sync: ${currentUrl}`);
74
- }
75
- const response = await funnelResource.getSession(context.sessionId, currentUrl);
76
- console.log(`🍪 Funnel: Session fetch response:`, response);
135
+ const response = await funnelResource.getSession(context.sessionId);
77
136
  if (response.success && response.context) {
78
- return response.context;
137
+ return enrichContext(response.context);
79
138
  }
80
139
  throw new Error(response.error || 'Failed to fetch session');
81
140
  },
@@ -87,11 +146,23 @@ export function useFunnel(options) {
87
146
  // Initialize session mutation
88
147
  const initializeMutation = useMutation({
89
148
  mutationFn: async (entryStepId) => {
149
+ // ✅ Prevent duplicate initialization calls
150
+ if (isInitializingRef.current) {
151
+ throw new Error('Initialization already in progress');
152
+ }
153
+ isInitializingRef.current = true;
90
154
  if (!auth.session?.customerId || !store?.id) {
155
+ isInitializingRef.current = false;
91
156
  throw new Error('Authentication required for funnel session');
92
157
  }
158
+ // Get CURRENT URL params (not cached) to ensure we have latest values
159
+ const currentUrlParams = typeof window !== 'undefined'
160
+ ? new URLSearchParams(window.location.search)
161
+ : new URLSearchParams();
162
+ const currentUrlFunnelId = currentUrlParams.get('funnelId') || undefined;
163
+ const currentEffectiveFunnelId = currentUrlFunnelId || options.funnelId;
93
164
  // Check for existing session ID in URL parameters
94
- let existingSessionId = urlParams.get('funnelSessionId') || undefined;
165
+ let existingSessionId = currentUrlParams.get('funnelSessionId') || undefined;
95
166
  if (existingSessionId) {
96
167
  console.log(`🍪 Funnel: Found session ID in URL params: ${existingSessionId}`);
97
168
  }
@@ -102,11 +173,23 @@ export function useFunnel(options) {
102
173
  .find(row => row.startsWith('tgd-funnel-session-id='));
103
174
  existingSessionId = funnelSessionCookie ? funnelSessionCookie.split('=')[1] : undefined;
104
175
  if (existingSessionId) {
105
- console.log(`🍪 Funnel: Found session in cookie: ${existingSessionId}`);
176
+ console.log(`🍪 Funnel: Found existing session in cookie: ${existingSessionId}`);
106
177
  }
107
178
  }
108
- if (!existingSessionId) {
109
- console.log(`🍪 Funnel: No existing session found in URL params or cookie`);
179
+ // Log initialization strategy
180
+ console.log(`🍪 Funnel: Initialize request:`);
181
+ console.log(` - Existing session ID: ${existingSessionId || 'none'}`);
182
+ console.log(` - URL funnelId: ${currentUrlFunnelId || 'none'}`);
183
+ console.log(` - Hook funnelId: ${options.funnelId || 'none'}`);
184
+ console.log(` - Effective funnelId to send: ${currentEffectiveFunnelId || 'none (will use existing or backend default)'}`);
185
+ if (existingSessionId && !currentEffectiveFunnelId) {
186
+ console.log(` → Strategy: REUSE existing session from cookie`);
187
+ }
188
+ else if (existingSessionId && currentEffectiveFunnelId) {
189
+ console.log(` → Strategy: VALIDATE existing session matches funnelId, or create new`);
190
+ }
191
+ else {
192
+ console.log(` → Strategy: CREATE new session`);
110
193
  }
111
194
  // Send minimal CMS session data
112
195
  const cmsSessionData = {
@@ -115,40 +198,66 @@ export function useFunnel(options) {
115
198
  sessionId: auth.session.sessionId,
116
199
  accountId: auth.session.accountId
117
200
  };
201
+ // Get current URL for session synchronization
202
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : undefined;
118
203
  // Call API to initialize session - backend will restore existing or create new
119
204
  const requestBody = {
120
205
  cmsSession: cmsSessionData,
121
206
  entryStepId, // Optional override
122
- existingSessionId // Pass existing session ID from URL or cookie
207
+ existingSessionId, // Pass existing session ID from URL or cookie
208
+ currentUrl // Include current URL for step tracking
123
209
  };
124
210
  // Only include funnelId if it's provided (for backend fallback)
125
- if (effectiveFunnelId) {
126
- requestBody.funnelId = effectiveFunnelId;
211
+ if (currentEffectiveFunnelId) {
212
+ requestBody.funnelId = currentEffectiveFunnelId;
127
213
  }
214
+ console.log(`📤 Funnel: Sending initialize request to backend:`);
215
+ console.log(` Request body:`, JSON.stringify({
216
+ ...requestBody,
217
+ cmsSession: { ...cmsSessionData, sessionId: `${cmsSessionData.sessionId.substring(0, 20)}...` }
218
+ }, null, 2));
128
219
  const response = await funnelResource.initialize(requestBody);
220
+ console.log(`📥 Funnel: Received response from backend:`);
221
+ console.log(` Success: ${response.success}`);
222
+ if (response.context) {
223
+ console.log(` Returned session ID: ${response.context.sessionId}`);
224
+ console.log(` Returned funnel ID: ${response.context.funnelId}`);
225
+ console.log(` Is same session? ${response.context.sessionId === existingSessionId ? '✅ YES (reused)' : '❌ NO (new session created)'}`);
226
+ }
227
+ if (response.error) {
228
+ console.log(` Error: ${response.error}`);
229
+ }
129
230
  if (response.success && response.context) {
130
- return response.context;
231
+ return enrichContext(response.context);
131
232
  }
132
233
  else {
133
234
  throw new Error(response.error || 'Failed to initialize funnel session');
134
235
  }
135
236
  },
136
237
  onSuccess: (newContext) => {
137
- setContext(newContext);
238
+ // ✅ Reset initialization flag
239
+ isInitializingRef.current = false;
240
+ // Context is already enriched by mutationFn return, but safe to ensure
241
+ const enrichedContext = enrichContext(newContext);
242
+ setContext(enrichedContext);
138
243
  setInitializationError(null);
139
244
  // Set session cookie for persistence across page reloads
140
- setSessionCookie(newContext.sessionId);
141
- console.log(`🍪 Funnel: Initialized session for funnel ${effectiveFunnelId || 'default'}`, newContext);
245
+ setSessionCookie(enrichedContext.sessionId);
246
+ console.log(`✅ Funnel: Session initialized/loaded successfully`);
247
+ console.log(` - Session ID: ${enrichedContext.sessionId}`);
248
+ console.log(` - Funnel ID: ${enrichedContext.funnelId}`);
249
+ console.log(` - Current Step: ${enrichedContext.currentStepId}`);
142
250
  // Fetch debug data if in debug mode
143
251
  if (debugMode) {
144
- void fetchFunnelDebugData(newContext.sessionId);
252
+ void fetchFunnelDebugData(enrichedContext.sessionId);
145
253
  }
146
- // Invalidate session query to refetch with new session ID
147
- void queryClient.invalidateQueries({
148
- queryKey: funnelQueryKeys.session(newContext.sessionId)
149
- });
254
+ // Set the session query data directly (no need to refetch since initialize already returns full context)
255
+ queryClient.setQueryData(funnelQueryKeys.session(enrichedContext.sessionId), enrichedContext);
256
+ console.log(`✅ Funnel: Session query data populated from initialize response (no additional request needed)`);
150
257
  },
151
258
  onError: (error) => {
259
+ // ✅ Reset initialization flag on error
260
+ isInitializingRef.current = false;
152
261
  const errorObj = error instanceof Error ? error : new Error(String(error));
153
262
  setInitializationError(errorObj);
154
263
  console.error('Error initializing funnel session:', error);
@@ -169,9 +278,7 @@ export function useFunnel(options) {
169
278
  // ✅ Automatically include currentUrl for URL-to-Step mapping
170
279
  // User can override by providing event.currentUrl explicitly
171
280
  const currentUrl = event.currentUrl || (typeof window !== 'undefined' ? window.location.href : undefined);
172
- console.log(`🍪 Funnel: Navigating with session ID: ${context.sessionId}`);
173
281
  if (currentUrl) {
174
- console.log(`🍪 Funnel: Current URL for sync: ${currentUrl}`);
175
282
  }
176
283
  const requestBody = {
177
284
  sessionId: context.sessionId,
@@ -218,7 +325,8 @@ export function useFunnel(options) {
218
325
  ...(recoveredSessionId ? { recovered: true, oldSessionId: context.sessionId } : {})
219
326
  }
220
327
  };
221
- setContext(newContext);
328
+ const enrichedContext = enrichContext(newContext);
329
+ setContext(enrichedContext);
222
330
  // Update cookie with new session ID if recovered
223
331
  if (recoveredSessionId) {
224
332
  document.cookie = `funnelSessionId=${recoveredSessionId}; path=/; max-age=86400; SameSite=Lax`;
@@ -231,7 +339,7 @@ export function useFunnel(options) {
231
339
  type: 'redirect', // Default action type
232
340
  url: result.url
233
341
  },
234
- context: newContext,
342
+ context: enrichedContext,
235
343
  tracking: result.tracking
236
344
  };
237
345
  // Handle navigation callback with override capability
@@ -245,19 +353,25 @@ export function useFunnel(options) {
245
353
  // Perform default navigation if not overridden
246
354
  if (shouldPerformDefaultNavigation && navigationResult.action.url) {
247
355
  // Add URL parameters for cross-domain session continuity
248
- const urlWithParams = addSessionParams(navigationResult.action.url, newContext.sessionId, effectiveFunnelId || options.funnelId);
356
+ const urlWithParams = addSessionParams(navigationResult.action.url, enrichedContext.sessionId, effectiveFunnelId || options.funnelId);
249
357
  const updatedAction = { ...navigationResult.action, url: urlWithParams };
250
358
  performNavigation(updatedAction);
251
359
  }
252
- console.log(`🍪 Funnel: Navigated from ${context.currentStepId} to ${result.stepId}`);
253
- // Fetch debug data if in debug mode
254
- if (debugMode) {
255
- void fetchFunnelDebugData(newContext.sessionId);
360
+ // Skip background refreshes if we are navigating away (full page reload)
361
+ // This prevents "lingering" requests from the old page context
362
+ const isFullNavigation = shouldPerformDefaultNavigation &&
363
+ navigationResult.action.url &&
364
+ (navigationResult.action.type === 'redirect' || navigationResult.action.type === 'replace');
365
+ if (!isFullNavigation) {
366
+ // Fetch debug data if in debug mode
367
+ if (debugMode) {
368
+ void fetchFunnelDebugData(enrichedContext.sessionId);
369
+ }
370
+ // Invalidate and refetch session data
371
+ void queryClient.invalidateQueries({
372
+ queryKey: funnelQueryKeys.session(enrichedContext.sessionId)
373
+ });
256
374
  }
257
- // Invalidate and refetch session data
258
- void queryClient.invalidateQueries({
259
- queryKey: funnelQueryKeys.session(newContext.sessionId)
260
- });
261
375
  },
262
376
  onError: (error) => {
263
377
  console.error('Funnel navigation error:', error);
@@ -291,7 +405,8 @@ export function useFunnel(options) {
291
405
  ...updates,
292
406
  lastActivityAt: Date.now()
293
407
  };
294
- setContext(updatedContext);
408
+ const enrichedContext = enrichContext(updatedContext);
409
+ setContext(enrichedContext);
295
410
  console.log(`🍪 Funnel: Updated context for step ${context.currentStepId}`);
296
411
  // Invalidate session query
297
412
  void queryClient.invalidateQueries({
@@ -317,6 +432,8 @@ export function useFunnel(options) {
317
432
  return;
318
433
  console.log(`🍪 Funnel: Ended session ${context.sessionId}`);
319
434
  setContext(null);
435
+ // Reset the processed URL funnelId ref to allow re-initialization
436
+ lastProcessedUrlFunnelIdRef.current = undefined;
320
437
  // Clear queries
321
438
  queryClient.removeQueries({
322
439
  queryKey: funnelQueryKeys.session(context.sessionId)
@@ -342,14 +459,15 @@ export function useFunnel(options) {
342
459
  */
343
460
  const addSessionParams = useCallback((url, sessionId, funnelId) => {
344
461
  try {
345
- const urlObj = new URL(url);
462
+ // Handle relative URLs by using current origin
463
+ const urlObj = url.startsWith('http')
464
+ ? new URL(url)
465
+ : new URL(url, window.location.origin);
346
466
  urlObj.searchParams.set('funnelSessionId', sessionId);
347
467
  if (funnelId) {
348
468
  urlObj.searchParams.set('funnelId', funnelId);
349
469
  }
350
- const urlWithParams = urlObj.toString();
351
- console.log(`🍪 Funnel: Added session params to URL: ${url} → ${urlWithParams}`);
352
- return urlWithParams;
470
+ return urlObj.toString();
353
471
  }
354
472
  catch (error) {
355
473
  console.warn('Failed to add session params to URL:', error);
@@ -370,33 +488,34 @@ export function useFunnel(options) {
370
488
  }
371
489
  switch (action.type) {
372
490
  case 'redirect':
373
- console.log(`🍪 Funnel: Redirecting to ${targetUrl}`);
374
491
  window.location.href = targetUrl;
375
492
  break;
376
493
  case 'replace':
377
- console.log(`🍪 Funnel: Replacing current page with ${targetUrl}`);
378
494
  window.location.replace(targetUrl);
379
495
  break;
380
496
  case 'push':
381
- console.log(`🍪 Funnel: Pushing to history: ${targetUrl}`);
382
497
  window.history.pushState({}, '', targetUrl);
383
498
  // Trigger a popstate event to update React Router
384
499
  window.dispatchEvent(new PopStateEvent('popstate'));
385
500
  break;
386
501
  case 'external':
387
- console.log(`🍪 Funnel: Opening external URL: ${targetUrl}`);
388
502
  window.open(targetUrl, '_blank');
389
503
  break;
390
504
  case 'none':
391
- console.log(`🍪 Funnel: No navigation action required`);
505
+ // No navigation needed
392
506
  break;
393
507
  default:
394
- console.warn(`🍪 Funnel: Unknown navigation action type: ${action.type}`);
508
+ console.warn(`Unknown navigation action type: ${action.type}`);
395
509
  break;
396
510
  }
397
511
  }, []);
398
512
  // Public API methods
399
513
  const initializeSession = useCallback(async (entryStepId) => {
514
+ // ✅ Check ref before even starting (prevents React StrictMode double-invocation)
515
+ if (isInitializingRef.current) {
516
+ console.log('⏭️ Funnel: initializeSession called but already initializing');
517
+ return;
518
+ }
400
519
  setInitializationAttempted(true);
401
520
  await initializeMutation.mutateAsync(entryStepId);
402
521
  }, [initializeMutation]);
@@ -421,29 +540,84 @@ export function useFunnel(options) {
421
540
  setInitializationError(null);
422
541
  await initializeSession();
423
542
  }, [initializeSession]);
424
- // Auto-initialize if requested and not already initialized
543
+ /**
544
+ * Auto-initialization effect
545
+ *
546
+ * Handles funnel session initialization with the following logic:
547
+ * 1. If no session exists => Initialize with URL funnelId or hook funnelId or backend default
548
+ * 2. If session exists + explicit funnelId provided that differs => Reset and create new
549
+ * 3. If session exists + no funnelId provided => Keep existing session
550
+ *
551
+ * IMPORTANT: Only resets session if an EXPLICIT funnelId is provided that differs
552
+ * from the current session. If no funnelId is provided, keeps the existing session.
553
+ *
554
+ * Priority: URL funnelId > Hook funnelId > Existing session funnelId > Backend default
555
+ */
425
556
  useEffect(() => {
426
- if (options.autoInitialize &&
427
- !context &&
428
- !initializationAttempted &&
429
- !initializationError &&
430
- auth.session?.customerId &&
431
- store?.id &&
432
- !initializeMutation.isPending) {
433
- console.log('🍪 Funnel: Auto-initializing session...');
557
+ // Skip if auto-initialize is disabled
558
+ const autoInit = options.autoInitialize ?? true; // Default to true
559
+ if (!autoInit) {
560
+ return;
561
+ }
562
+ // Skip if required dependencies are not available
563
+ if (!auth.session?.customerId || !store?.id) {
564
+ return;
565
+ }
566
+ // Skip if already initializing (check both mutation state and ref)
567
+ if (initializeMutation.isPending || isInitializingRef.current) {
568
+ return;
569
+ }
570
+ // Determine if we have an explicit funnelId request (URL has priority)
571
+ const explicitFunnelId = urlFunnelId || options.funnelId;
572
+ // Case 1: No session exists yet - need to initialize
573
+ if (!context) {
574
+ // Check if we've already attempted initialization
575
+ if (!initializationAttempted) {
576
+ setInitializationAttempted(true);
577
+ initializeSession().catch(error => {
578
+ console.error('❌ Funnel: Auto-initialization failed:', error);
579
+ });
580
+ }
581
+ return;
582
+ }
583
+ // Case 2: Session exists - check if we need to reset it
584
+ // ONLY reset if an explicit funnelId is provided AND it differs from current session
585
+ if (explicitFunnelId && context.funnelId && explicitFunnelId !== context.funnelId) {
586
+ // Check if we've already processed this funnelId to prevent loops
587
+ if (lastProcessedUrlFunnelIdRef.current === explicitFunnelId) {
588
+ return;
589
+ }
590
+ console.log(`🔄 Funnel: Switching from funnel ${context.funnelId} to ${explicitFunnelId}`);
591
+ // Mark this funnelId as processed
592
+ lastProcessedUrlFunnelIdRef.current = explicitFunnelId;
593
+ // Clear existing session
594
+ setContext(null);
595
+ setInitializationAttempted(false);
596
+ setInitializationError(null);
597
+ // Clear session cookie
598
+ document.cookie = 'tgd-funnel-session-id=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
599
+ // Clear queries for old session
600
+ queryClient.removeQueries({
601
+ queryKey: funnelQueryKeys.session(context.sessionId)
602
+ });
603
+ // Initialize new session with correct funnelId
434
604
  initializeSession().catch(error => {
435
- console.error('Auto-initialization failed - will not retry:', error);
605
+ console.error(' Funnel: Failed to reset session:', error);
436
606
  });
437
607
  }
438
608
  }, [
439
609
  options.autoInitialize,
440
- context,
610
+ options.funnelId,
611
+ urlFunnelId,
612
+ context?.funnelId,
613
+ context?.sessionId,
441
614
  initializationAttempted,
442
- initializationError,
443
615
  auth.session?.customerId,
444
616
  store?.id,
445
617
  initializeMutation.isPending,
446
- initializeSession
618
+ initializeSession,
619
+ hasExistingSessionCookie,
620
+ queryClient
447
621
  ]);
448
622
  // Sync session data from query to local state
449
623
  useEffect(() => {
@@ -453,6 +627,7 @@ export function useFunnel(options) {
453
627
  }, [sessionData, context]);
454
628
  const isLoading = initializeMutation.isPending || navigateMutation.isPending || updateContextMutation.isPending;
455
629
  const isInitialized = !!context;
630
+ const isNavigating = navigateMutation.isPending; // Explicit navigation state
456
631
  return {
457
632
  next,
458
633
  goToStep,
@@ -463,6 +638,7 @@ export function useFunnel(options) {
463
638
  context,
464
639
  isLoading,
465
640
  isInitialized,
641
+ isNavigating, // Expose isNavigating
466
642
  initializeSession,
467
643
  endSession,
468
644
  retryInitialization,
@@ -15,6 +15,7 @@ export function useGoogleAutocomplete(options) {
15
15
  const autocompleteServiceRef = useRef(null);
16
16
  const placesServiceRef = useRef(null);
17
17
  const scriptLoadedRef = useRef(false);
18
+ const debounceTimeoutRef = useRef(null);
18
19
  // Inject Google Maps script
19
20
  useEffect(() => {
20
21
  if (!apiKey) {
@@ -97,25 +98,32 @@ export function useGoogleAutocomplete(options) {
97
98
  setPredictions([]);
98
99
  return;
99
100
  }
100
- setIsLoading(true);
101
- const request = {
102
- input,
103
- ...(countryRestriction && {
104
- componentRestrictions: { country: countryRestriction.toLowerCase() },
105
- }),
106
- };
107
- autocompleteServiceRef.current.getPlacePredictions(request, (results, status) => {
108
- setIsLoading(false);
109
- if (status === window.google.maps.places.PlacesServiceStatus.OK && results) {
110
- setPredictions(results);
111
- }
112
- else {
113
- setPredictions([]);
114
- if (status !== window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
115
- // Prediction failed but not zero results
101
+ // Clear existing timeout
102
+ if (debounceTimeoutRef.current) {
103
+ clearTimeout(debounceTimeoutRef.current);
104
+ }
105
+ // Set new timeout
106
+ debounceTimeoutRef.current = setTimeout(() => {
107
+ setIsLoading(true);
108
+ const request = {
109
+ input,
110
+ ...(countryRestriction && {
111
+ componentRestrictions: { country: countryRestriction.toLowerCase() },
112
+ }),
113
+ };
114
+ autocompleteServiceRef.current.getPlacePredictions(request, (results, status) => {
115
+ setIsLoading(false);
116
+ if (status === window.google.maps.places.PlacesServiceStatus.OK && results) {
117
+ setPredictions(results);
116
118
  }
117
- }
118
- });
119
+ else {
120
+ setPredictions([]);
121
+ if (status !== window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
122
+ // Prediction failed but not zero results
123
+ }
124
+ }
125
+ });
126
+ }, 300); // 300ms debounce
119
127
  }, [initializeServices, isScriptLoaded]);
120
128
  // Get detailed place information
121
129
  const getPlaceDetails = useCallback((placeId) => {