@tagadapay/plugin-sdk 2.6.11 → 2.6.13

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.
@@ -0,0 +1,432 @@
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 { useState, useCallback, useEffect, useMemo } from 'react';
8
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
9
+ import { useTagadaContext } from '../providers/TagadaProvider';
10
+ import { FunnelResource } from '../../core/resources/funnel';
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 } = useTagadaContext();
25
+ const queryClient = useQueryClient();
26
+ const apiClient = getGlobalApiClient();
27
+ const funnelResource = useMemo(() => new FunnelResource(apiClient), [apiClient]);
28
+ // Local state
29
+ const [context, setContext] = useState(null);
30
+ const [initializationAttempted, setInitializationAttempted] = useState(false);
31
+ const [initializationError, setInitializationError] = useState(null);
32
+ 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;
36
+ const effectiveFunnelId = urlFunnelId || options.funnelId;
37
+ // Session query - only enabled when we have a session ID
38
+ const { data: sessionData, isLoading: isSessionLoading, error: sessionError, refetch: refetchSession } = useQuery({
39
+ queryKey: funnelQueryKeys.session(context?.sessionId || ''),
40
+ queryFn: async () => {
41
+ if (!context?.sessionId) {
42
+ console.warn('🍪 Funnel: No session ID available for query');
43
+ return null;
44
+ }
45
+ console.log(`🍪 Funnel: Fetching session data for ID: ${context.sessionId}`);
46
+ const response = await funnelResource.getSession(context.sessionId);
47
+ console.log(`🍪 Funnel: Session fetch response:`, response);
48
+ if (response.success && response.context) {
49
+ return response.context;
50
+ }
51
+ throw new Error(response.error || 'Failed to fetch session');
52
+ },
53
+ enabled: !!context?.sessionId && (options.enabled !== false),
54
+ staleTime: 30000, // 30 seconds
55
+ gcTime: 5 * 60 * 1000, // 5 minutes
56
+ refetchOnWindowFocus: false,
57
+ });
58
+ // Initialize session mutation
59
+ const initializeMutation = useMutation({
60
+ mutationFn: async (entryStepId) => {
61
+ if (!auth.session?.customerId || !store?.id) {
62
+ throw new Error('Authentication required for funnel session');
63
+ }
64
+ // Check for existing session ID in URL parameters
65
+ let existingSessionId = urlParams.get('funnelSessionId') || undefined;
66
+ if (existingSessionId) {
67
+ console.log(`🍪 Funnel: Found session ID in URL params: ${existingSessionId}`);
68
+ }
69
+ else {
70
+ // Fallback to cookie for same-domain scenarios
71
+ const funnelSessionCookie = document.cookie
72
+ .split('; ')
73
+ .find(row => row.startsWith('tgd-funnel-session-id='));
74
+ existingSessionId = funnelSessionCookie ? funnelSessionCookie.split('=')[1] : undefined;
75
+ if (existingSessionId) {
76
+ console.log(`🍪 Funnel: Found session in cookie: ${existingSessionId}`);
77
+ }
78
+ }
79
+ if (!existingSessionId) {
80
+ console.log(`🍪 Funnel: No existing session found in URL params or cookie`);
81
+ }
82
+ // Send minimal CMS session data
83
+ const cmsSessionData = {
84
+ customerId: auth.session.customerId,
85
+ storeId: store.id,
86
+ sessionId: auth.session.sessionId,
87
+ accountId: auth.session.accountId
88
+ };
89
+ // Call API to initialize session - backend will restore existing or create new
90
+ const requestBody = {
91
+ cmsSession: cmsSessionData,
92
+ entryStepId, // Optional override
93
+ existingSessionId // Pass existing session ID from URL or cookie
94
+ };
95
+ // Only include funnelId if it's provided (for backend fallback)
96
+ if (effectiveFunnelId) {
97
+ requestBody.funnelId = effectiveFunnelId;
98
+ }
99
+ const response = await funnelResource.initialize(requestBody);
100
+ if (response.success && response.context) {
101
+ return response.context;
102
+ }
103
+ else {
104
+ throw new Error(response.error || 'Failed to initialize funnel session');
105
+ }
106
+ },
107
+ onSuccess: (newContext) => {
108
+ setContext(newContext);
109
+ setInitializationError(null);
110
+ // Set session cookie for persistence across page reloads
111
+ setSessionCookie(newContext.sessionId);
112
+ console.log(`🍪 Funnel: Initialized session for funnel ${effectiveFunnelId || 'default'}`, newContext);
113
+ // Invalidate session query to refetch with new session ID
114
+ void queryClient.invalidateQueries({
115
+ queryKey: funnelQueryKeys.session(newContext.sessionId)
116
+ });
117
+ },
118
+ onError: (error) => {
119
+ const errorObj = error instanceof Error ? error : new Error(String(error));
120
+ setInitializationError(errorObj);
121
+ console.error('Error initializing funnel session:', error);
122
+ if (options.onError) {
123
+ options.onError(errorObj);
124
+ }
125
+ }
126
+ });
127
+ // Navigate mutation
128
+ const navigateMutation = useMutation({
129
+ mutationFn: async (event) => {
130
+ if (!context) {
131
+ throw new Error('Funnel session not initialized');
132
+ }
133
+ if (!context.sessionId) {
134
+ throw new Error('Funnel session ID missing - session may be corrupted');
135
+ }
136
+ console.log(`🍪 Funnel: Navigating with session ID: ${context.sessionId}`);
137
+ const requestBody = {
138
+ sessionId: context.sessionId,
139
+ event: {
140
+ type: event.type,
141
+ data: event.data,
142
+ timestamp: event.timestamp || new Date().toISOString()
143
+ },
144
+ contextUpdates: {
145
+ lastActivityAt: Date.now()
146
+ }
147
+ };
148
+ const response = await funnelResource.navigate(requestBody);
149
+ if (response.success && response.result) {
150
+ return response.result;
151
+ }
152
+ else {
153
+ throw new Error(response.error || 'Navigation failed');
154
+ }
155
+ },
156
+ onSuccess: (result) => {
157
+ if (!context)
158
+ return;
159
+ // Update local context
160
+ const newContext = {
161
+ ...context,
162
+ currentStepId: result.stepId,
163
+ previousStepId: context.currentStepId,
164
+ lastActivityAt: Date.now(),
165
+ metadata: {
166
+ ...context.metadata,
167
+ lastEvent: 'navigation',
168
+ lastTransition: new Date().toISOString()
169
+ }
170
+ };
171
+ setContext(newContext);
172
+ // Create typed navigation result
173
+ const navigationResult = {
174
+ stepId: result.stepId,
175
+ action: {
176
+ type: 'redirect', // Default action type
177
+ url: result.url
178
+ },
179
+ context: newContext,
180
+ tracking: result.tracking
181
+ };
182
+ // Handle navigation callback with override capability
183
+ let shouldPerformDefaultNavigation = true;
184
+ if (options.onNavigate) {
185
+ const callbackResult = options.onNavigate(navigationResult);
186
+ if (callbackResult === false) {
187
+ shouldPerformDefaultNavigation = false;
188
+ }
189
+ }
190
+ // Perform default navigation if not overridden
191
+ if (shouldPerformDefaultNavigation && navigationResult.action.url) {
192
+ // Add URL parameters for cross-domain session continuity
193
+ const urlWithParams = addSessionParams(navigationResult.action.url, newContext.sessionId, effectiveFunnelId || options.funnelId);
194
+ const updatedAction = { ...navigationResult.action, url: urlWithParams };
195
+ performNavigation(updatedAction);
196
+ }
197
+ console.log(`🍪 Funnel: Navigated from ${context.currentStepId} to ${result.stepId}`);
198
+ // Invalidate and refetch session data
199
+ void queryClient.invalidateQueries({
200
+ queryKey: funnelQueryKeys.session(newContext.sessionId)
201
+ });
202
+ },
203
+ onError: (error) => {
204
+ console.error('Funnel navigation error:', error);
205
+ if (options.onError) {
206
+ options.onError(error instanceof Error ? error : new Error(String(error)));
207
+ }
208
+ }
209
+ });
210
+ // Update context mutation
211
+ const updateContextMutation = useMutation({
212
+ mutationFn: async (updates) => {
213
+ if (!context) {
214
+ throw new Error('Funnel session not initialized');
215
+ }
216
+ const requestBody = {
217
+ contextUpdates: updates
218
+ };
219
+ const response = await funnelResource.updateContext(context.sessionId, requestBody);
220
+ if (response.success) {
221
+ return updates;
222
+ }
223
+ else {
224
+ throw new Error(response.error || 'Context update failed');
225
+ }
226
+ },
227
+ onSuccess: (updates) => {
228
+ if (!context)
229
+ return;
230
+ const updatedContext = {
231
+ ...context,
232
+ ...updates,
233
+ lastActivityAt: Date.now()
234
+ };
235
+ setContext(updatedContext);
236
+ console.log(`🍪 Funnel: Updated context for step ${context.currentStepId}`);
237
+ // Invalidate session query
238
+ void queryClient.invalidateQueries({
239
+ queryKey: funnelQueryKeys.session(context.sessionId)
240
+ });
241
+ },
242
+ onError: (error) => {
243
+ console.error('Error updating funnel context:', error);
244
+ if (options.onError) {
245
+ options.onError(error instanceof Error ? error : new Error(String(error)));
246
+ }
247
+ }
248
+ });
249
+ // End session mutation
250
+ const endSessionMutation = useMutation({
251
+ mutationFn: async () => {
252
+ if (!context)
253
+ return;
254
+ await funnelResource.endSession(context.sessionId);
255
+ },
256
+ onSuccess: () => {
257
+ if (!context)
258
+ return;
259
+ console.log(`🍪 Funnel: Ended session ${context.sessionId}`);
260
+ setContext(null);
261
+ // Clear queries
262
+ queryClient.removeQueries({
263
+ queryKey: funnelQueryKeys.session(context.sessionId)
264
+ });
265
+ },
266
+ onError: (error) => {
267
+ console.error('Error ending funnel session:', error);
268
+ // Don't throw here - session ending is best effort
269
+ }
270
+ });
271
+ /**
272
+ * Set funnel session cookie
273
+ */
274
+ const setSessionCookie = useCallback((sessionId) => {
275
+ const maxAge = 24 * 60 * 60; // 24 hours
276
+ const expires = new Date(Date.now() + maxAge * 1000).toUTCString();
277
+ // Set cookie for same-domain scenarios
278
+ document.cookie = `tgd-funnel-session-id=${sessionId}; path=/; expires=${expires}; SameSite=Lax`;
279
+ console.log(`🍪 Funnel: Set session cookie: ${sessionId}`);
280
+ }, []);
281
+ /**
282
+ * Add session parameters to URL for cross-domain continuity
283
+ */
284
+ const addSessionParams = useCallback((url, sessionId, funnelId) => {
285
+ try {
286
+ const urlObj = new URL(url);
287
+ urlObj.searchParams.set('funnelSessionId', sessionId);
288
+ if (funnelId) {
289
+ urlObj.searchParams.set('funnelId', funnelId);
290
+ }
291
+ const urlWithParams = urlObj.toString();
292
+ console.log(`🍪 Funnel: Added session params to URL: ${url} → ${urlWithParams}`);
293
+ return urlWithParams;
294
+ }
295
+ catch (error) {
296
+ console.warn('Failed to add session params to URL:', error);
297
+ return url; // Return original URL if parsing fails
298
+ }
299
+ }, []);
300
+ /**
301
+ * Perform navigation based on action type
302
+ */
303
+ const performNavigation = useCallback((action) => {
304
+ if (!action.url)
305
+ return;
306
+ // Handle relative URLs by making them absolute
307
+ let targetUrl = action.url;
308
+ if (targetUrl.startsWith('/') && !targetUrl.startsWith('//')) {
309
+ // Relative URL - use current origin
310
+ targetUrl = window.location.origin + targetUrl;
311
+ }
312
+ switch (action.type) {
313
+ case 'redirect':
314
+ console.log(`🍪 Funnel: Redirecting to ${targetUrl}`);
315
+ window.location.href = targetUrl;
316
+ break;
317
+ case 'replace':
318
+ console.log(`🍪 Funnel: Replacing current page with ${targetUrl}`);
319
+ window.location.replace(targetUrl);
320
+ break;
321
+ case 'push':
322
+ console.log(`🍪 Funnel: Pushing to history: ${targetUrl}`);
323
+ window.history.pushState({}, '', targetUrl);
324
+ // Trigger a popstate event to update React Router
325
+ window.dispatchEvent(new PopStateEvent('popstate'));
326
+ break;
327
+ case 'external':
328
+ console.log(`🍪 Funnel: Opening external URL: ${targetUrl}`);
329
+ window.open(targetUrl, '_blank');
330
+ break;
331
+ case 'none':
332
+ console.log(`🍪 Funnel: No navigation action required`);
333
+ break;
334
+ default:
335
+ console.warn(`🍪 Funnel: Unknown navigation action type: ${action.type}`);
336
+ break;
337
+ }
338
+ }, []);
339
+ // Public API methods
340
+ const initializeSession = useCallback(async (entryStepId) => {
341
+ setInitializationAttempted(true);
342
+ await initializeMutation.mutateAsync(entryStepId);
343
+ }, [initializeMutation]);
344
+ const next = useCallback(async (event) => {
345
+ return navigateMutation.mutateAsync(event);
346
+ }, [navigateMutation]);
347
+ const goToStep = useCallback(async (stepId) => {
348
+ return next({
349
+ type: 'direct_navigation',
350
+ data: { targetStepId: stepId },
351
+ timestamp: new Date().toISOString()
352
+ });
353
+ }, [next]);
354
+ const updateContext = useCallback(async (updates) => {
355
+ await updateContextMutation.mutateAsync(updates);
356
+ }, [updateContextMutation]);
357
+ const endSession = useCallback(async () => {
358
+ await endSessionMutation.mutateAsync();
359
+ }, [endSessionMutation]);
360
+ const retryInitialization = useCallback(async () => {
361
+ setInitializationAttempted(false);
362
+ setInitializationError(null);
363
+ await initializeSession();
364
+ }, [initializeSession]);
365
+ // Auto-initialize if requested and not already initialized
366
+ useEffect(() => {
367
+ if (options.autoInitialize &&
368
+ !context &&
369
+ !initializationAttempted &&
370
+ !initializationError &&
371
+ auth.session?.customerId &&
372
+ store?.id &&
373
+ !initializeMutation.isPending) {
374
+ console.log('🍪 Funnel: Auto-initializing session...');
375
+ initializeSession().catch(error => {
376
+ console.error('Auto-initialization failed - will not retry:', error);
377
+ });
378
+ }
379
+ }, [
380
+ options.autoInitialize,
381
+ context,
382
+ initializationAttempted,
383
+ initializationError,
384
+ auth.session?.customerId,
385
+ store?.id,
386
+ initializeMutation.isPending,
387
+ initializeSession
388
+ ]);
389
+ // Sync session data from query to local state
390
+ useEffect(() => {
391
+ if (sessionData && sessionData !== context) {
392
+ setContext(sessionData);
393
+ }
394
+ }, [sessionData, context]);
395
+ const isLoading = initializeMutation.isPending || navigateMutation.isPending || updateContextMutation.isPending;
396
+ const isInitialized = !!context;
397
+ return {
398
+ next,
399
+ goToStep,
400
+ updateContext,
401
+ currentStep: {
402
+ id: currentStepId || 'unknown'
403
+ },
404
+ context,
405
+ isLoading,
406
+ isInitialized,
407
+ initializeSession,
408
+ endSession,
409
+ retryInitialization,
410
+ initializationError,
411
+ isSessionLoading,
412
+ sessionError,
413
+ refetch: () => void refetchSession()
414
+ };
415
+ }
416
+ /**
417
+ * Simplified funnel hook for basic step tracking (v2)
418
+ */
419
+ export function useSimpleFunnel(funnelId, initialStepId) {
420
+ const funnel = useFunnel({
421
+ funnelId,
422
+ currentStepId: initialStepId,
423
+ autoInitialize: true
424
+ });
425
+ return {
426
+ currentStepId: funnel.currentStep.id,
427
+ next: funnel.next,
428
+ goToStep: funnel.goToStep,
429
+ isLoading: funnel.isLoading,
430
+ context: funnel.context
431
+ };
432
+ }
@@ -2,8 +2,8 @@
2
2
  * Order Hook using TanStack Query
3
3
  * Handles order creation and management with automatic caching
4
4
  */
5
+ import { useMutation, useQuery } from '@tanstack/react-query';
5
6
  import { useMemo } from 'react';
6
- import { useQuery, useMutation } from '@tanstack/react-query';
7
7
  import { OrdersResource } from '../../core/resources/orders';
8
8
  import { getGlobalApiClient } from './useApiQuery';
9
9
  export function useOrderQuery(options = {}) {
@@ -2,7 +2,7 @@
2
2
  * Post Purchases Hook using TanStack Query
3
3
  * Handles post-purchase offers with automatic caching
4
4
  */
5
- import { PostPurchaseOffer, PostPurchaseOfferItem, PostPurchaseOfferSummary, CheckoutSessionState, OrderSummary, CurrencyOptions } from '../../core/resources/postPurchases';
5
+ import { CheckoutSessionState, CurrencyOptions, OrderSummary, PostPurchaseOffer, PostPurchaseOfferItem, PostPurchaseOfferSummary } from '../../core/resources/postPurchases';
6
6
  export interface UsePostPurchasesQueryOptions {
7
7
  orderId: string;
8
8
  enabled?: boolean;
@@ -2,8 +2,8 @@
2
2
  * Post Purchases Hook using TanStack Query
3
3
  * Handles post-purchase offers with automatic caching
4
4
  */
5
- import { useMemo, useEffect, useState, useCallback } from 'react';
6
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
+ import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { PostPurchasesResource } from '../../core/resources/postPurchases';
8
8
  import { useTagadaContext } from '../providers/TagadaProvider';
9
9
  import { getGlobalApiClient } from './useApiQuery';
@@ -28,6 +28,7 @@ export { useStoreConfigQuery as useStoreConfig } from './hooks/useStoreConfigQue
28
28
  export { useThreeds } from './hooks/useThreeds';
29
29
  export { useThreedsModal } from './hooks/useThreedsModal';
30
30
  export { useVipOffersQuery as useVipOffers } from './hooks/useVipOffersQuery';
31
+ export { useFunnel, useSimpleFunnel } from './hooks/useFunnel';
31
32
  export type { UseCheckoutTokenOptions, UseCheckoutTokenResult } from './hooks/useCheckoutToken';
32
33
  export type { ExpressPaymentMethodsContextType, ExpressPaymentMethodsProviderProps } from './hooks/useExpressPaymentMethods';
33
34
  export type { ApplePayButtonProps } from './components/ApplePayButton';
@@ -49,5 +50,6 @@ export type { UseShippingRatesQueryOptions as UseShippingRatesOptions, UseShippi
49
50
  export type { UseStoreConfigQueryOptions as UseStoreConfigOptions, UseStoreConfigQueryResult as UseStoreConfigResult } from './hooks/useStoreConfigQuery';
50
51
  export type { PaymentInstrument, ThreedsChallenge, ThreedsHook, ThreedsOptions, ThreedsProvider, ThreedsSession } from './hooks/useThreeds';
51
52
  export type { UseVipOffersQueryOptions as UseVipOffersOptions, UseVipOffersQueryResult as UseVipOffersResult } from './hooks/useVipOffersQuery';
53
+ export type { UseFunnelOptions, UseFunnelResult, FunnelEvent, FunnelNavigationAction, FunnelNavigationResult, SimpleFunnelContext } from './hooks/useFunnel';
52
54
  export { formatMoney } from '../../react/utils/money';
53
55
  export type OrderItem = import('../core/utils/order').OrderLineItem;
@@ -32,5 +32,7 @@ export { useStoreConfigQuery as useStoreConfig } from './hooks/useStoreConfigQue
32
32
  export { useThreeds } from './hooks/useThreeds';
33
33
  export { useThreedsModal } from './hooks/useThreedsModal';
34
34
  export { useVipOffersQuery as useVipOffers } from './hooks/useVipOffersQuery';
35
+ // Funnel hooks
36
+ export { useFunnel, useSimpleFunnel } from './hooks/useFunnel';
35
37
  // Re-export utilities from main react
36
38
  export { formatMoney } from '../../react/utils/money';
@@ -41,11 +41,11 @@ const InitializationLoader = () => (_jsxs("div", { style: {
41
41
  borderTop: '1.5px solid #9ca3af',
42
42
  borderRadius: '50%',
43
43
  animation: 'tagada-spin 1s linear infinite',
44
- } }), _jsx("span", { children: "Loading..." }), _jsx("style", { children: `
45
- @keyframes tagada-spin {
46
- 0% { transform: rotate(0deg); }
47
- 100% { transform: rotate(360deg); }
48
- }
44
+ } }), _jsx("span", { children: "Loading..." }), _jsx("style", { children: `
45
+ @keyframes tagada-spin {
46
+ 0% { transform: rotate(0deg); }
47
+ 100% { transform: rotate(360deg); }
48
+ }
49
49
  ` })] }));
50
50
  const TagadaContext = createContext(null);
51
51
  // Global instance tracking for TagadaProvider