@tagadapay/plugin-sdk 3.0.12 โ†’ 3.0.15

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 (31) hide show
  1. package/dist/external-tracker.js +3645 -115
  2. package/dist/external-tracker.min.js +25 -2
  3. package/dist/external-tracker.min.js.map +4 -4
  4. package/dist/react/types.d.ts +2 -0
  5. package/dist/v2/core/client.d.ts +5 -0
  6. package/dist/v2/core/client.js +135 -26
  7. package/dist/v2/core/config/environment.js +6 -0
  8. package/dist/v2/core/funnelClient.d.ts +27 -1
  9. package/dist/v2/core/funnelClient.js +124 -23
  10. package/dist/v2/core/resources/checkout.d.ts +76 -1
  11. package/dist/v2/core/resources/checkout.js +86 -1
  12. package/dist/v2/core/resources/funnel.d.ts +45 -4
  13. package/dist/v2/core/resources/offers.d.ts +26 -0
  14. package/dist/v2/core/resources/offers.js +37 -0
  15. package/dist/v2/core/types.d.ts +3 -1
  16. package/dist/v2/core/utils/authHandoff.d.ts +60 -0
  17. package/dist/v2/core/utils/authHandoff.js +154 -0
  18. package/dist/v2/core/utils/deviceInfo.d.ts +20 -3
  19. package/dist/v2/core/utils/deviceInfo.js +62 -94
  20. package/dist/v2/core/utils/previewMode.d.ts +4 -0
  21. package/dist/v2/core/utils/previewMode.js +4 -0
  22. package/dist/v2/react/hooks/useCheckoutQuery.d.ts +0 -1
  23. package/dist/v2/react/hooks/useCheckoutQuery.js +12 -4
  24. package/dist/v2/react/hooks/useFunnelLegacy.js +39 -11
  25. package/dist/v2/react/hooks/usePreviewOffer.d.ts +3 -3
  26. package/dist/v2/react/hooks/usePreviewOffer.js +20 -15
  27. package/dist/v2/react/hooks/useTranslation.js +12 -4
  28. package/dist/v2/react/providers/TagadaProvider.js +61 -1
  29. package/dist/v2/standalone/index.d.ts +2 -1
  30. package/dist/v2/standalone/index.js +2 -1
  31. package/package.json +3 -1
@@ -7,7 +7,9 @@ export interface ApiConfig {
7
7
  endpoints: {
8
8
  checkout: {
9
9
  sessionInit: string;
10
+ sessionInitAsync: string;
10
11
  sessionStatus: string;
12
+ asyncStatus: string;
11
13
  };
12
14
  customer: {
13
15
  profile: string;
@@ -58,6 +58,7 @@ export declare class TagadaClient {
58
58
  private tokenPromise;
59
59
  private tokenResolver;
60
60
  private boundHandleStorageChange;
61
+ private boundHandlePageshow;
61
62
  private readonly config;
62
63
  private instanceId;
63
64
  private isInitializingSession;
@@ -101,6 +102,10 @@ export declare class TagadaClient {
101
102
  * Initialize token and session
102
103
  */
103
104
  private initializeToken;
105
+ /**
106
+ * Normal token initialization flow (no cross-domain handoff)
107
+ */
108
+ private fallbackToNormalFlow;
104
109
  /**
105
110
  * Set token and resolve waiting requests
106
111
  */
@@ -8,6 +8,7 @@ import { decodeJWTClient, isTokenExpired } from './utils/jwtDecoder';
8
8
  import { loadPluginConfig } from './utils/pluginConfig';
9
9
  import { handlePreviewMode, isDraftMode, setDraftMode } from './utils/previewMode';
10
10
  import { getClientToken, setClientToken } from './utils/tokenStorage';
11
+ import { shouldResolveAuthCode, resolveAuthHandoff } from './utils/authHandoff';
11
12
  export class TagadaClient {
12
13
  constructor(config = {}) {
13
14
  /**
@@ -25,9 +26,36 @@ export class TagadaClient {
25
26
  this.config = config;
26
27
  this.instanceId = Math.random().toString(36).substr(2, 9);
27
28
  this.boundHandleStorageChange = this.handleStorageChange.bind(this);
28
- if (this.config.debugMode) {
29
- console.log(`[TagadaClient ${this.instanceId}] Initializing...`);
30
- }
29
+ this.boundHandlePageshow = (event) => {
30
+ if (event.persisted) {
31
+ if (this.state.debugMode) {
32
+ console.log(`[TagadaClient ${this.instanceId}] Page restored from BFcache (back button), re-initializing funnel...`);
33
+ }
34
+ // If we have an active session and store, we only need to re-initialize the funnel
35
+ // This ensures tracking is correct and the session is fresh on the backend
36
+ if (this.funnel && this.state.session && this.state.store) {
37
+ this.funnel.resetInitialization();
38
+ const accountId = this.getAccountId();
39
+ const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
40
+ const funnelId = urlParams.get('funnelId') || undefined;
41
+ this.funnel.autoInitialize({ customerId: this.state.session.customerId, sessionId: this.state.session.sessionId }, { id: this.state.store.id, accountId }, funnelId).catch((err) => {
42
+ console.error('[TagadaClient] Funnel re-initialization failed:', err);
43
+ });
44
+ }
45
+ else {
46
+ // If state is missing, perform a full initialization
47
+ this.sessionInitRetryCount = 0;
48
+ this.initialize();
49
+ }
50
+ }
51
+ };
52
+ console.log(`[TagadaClient ${this.instanceId}] Initializing...`);
53
+ console.log(`[TagadaClient ${this.instanceId}] Config:`, {
54
+ debugMode: config.debugMode,
55
+ hasRawPluginConfig: !!config.rawPluginConfig,
56
+ rawPluginConfig: config.rawPluginConfig,
57
+ features: config.features,
58
+ });
31
59
  // Handle preview mode FIRST - clears state if needed
32
60
  // This ensures clean state when CRM previews pages
33
61
  const previewModeActive = handlePreviewMode(this.config.debugMode);
@@ -77,10 +105,14 @@ export class TagadaClient {
77
105
  isInitialized: false,
78
106
  isSessionInitialized: false,
79
107
  pluginConfig: { basePath: '/', config: {} },
80
- pluginConfigLoading: !config.rawPluginConfig,
108
+ pluginConfigLoading: true, // Always true - loadPluginConfig will process rawPluginConfig
81
109
  debugMode: config.debugMode ?? env !== 'production',
82
110
  token: null,
83
111
  };
112
+ console.log(`[TagadaClient ${this.instanceId}] Initial state:`, {
113
+ pluginConfigLoading: this.state.pluginConfigLoading,
114
+ hasRawPluginConfig: !!config.rawPluginConfig,
115
+ });
84
116
  // Initialize API Client
85
117
  this.apiClient = new ApiClient({
86
118
  baseURL: envConfig.apiConfig.baseUrl,
@@ -96,6 +128,9 @@ export class TagadaClient {
96
128
  pluginConfig: this.state.pluginConfig,
97
129
  environment: this.state.environment,
98
130
  autoRedirect: funnelConfig.autoRedirect,
131
+ // Pass funnelId and stepId from rawPluginConfig to enable config-based initialization
132
+ funnelId: config.rawPluginConfig?.funnelId,
133
+ stepId: config.rawPluginConfig?.stepId,
99
134
  });
100
135
  }
101
136
  // Setup token waiting mechanism
@@ -103,6 +138,7 @@ export class TagadaClient {
103
138
  // Listen for storage changes (cross-tab sync)
104
139
  if (typeof window !== 'undefined') {
105
140
  window.addEventListener('storage', this.boundHandleStorageChange);
141
+ window.addEventListener('pageshow', this.boundHandlePageshow);
106
142
  }
107
143
  // Setup config hot-reload listener (for live config editing)
108
144
  this.setupConfigHotReload();
@@ -115,6 +151,7 @@ export class TagadaClient {
115
151
  destroy() {
116
152
  if (typeof window !== 'undefined') {
117
153
  window.removeEventListener('storage', this.boundHandleStorageChange);
154
+ window.removeEventListener('pageshow', this.boundHandlePageshow);
118
155
  }
119
156
  if (this.state.debugMode) {
120
157
  console.log(`[TagadaClient ${this.instanceId}] Destroyed`);
@@ -206,11 +243,19 @@ export class TagadaClient {
206
243
  * Load plugin configuration
207
244
  */
208
245
  async initializePluginConfig() {
209
- if (!this.state.pluginConfigLoading)
246
+ console.log(`[TagadaClient ${this.instanceId}] initializePluginConfig called`, {
247
+ pluginConfigLoading: this.state.pluginConfigLoading,
248
+ hasRawPluginConfig: !!this.config.rawPluginConfig,
249
+ });
250
+ if (!this.state.pluginConfigLoading) {
251
+ console.log(`[TagadaClient ${this.instanceId}] Plugin config already loading or loaded, skipping...`);
210
252
  return;
253
+ }
211
254
  try {
212
255
  const configVariant = this.config.localConfig || 'default';
256
+ console.log(`[TagadaClient ${this.instanceId}] Loading plugin config with variant: ${configVariant}`);
213
257
  const config = await loadPluginConfig(configVariant, this.config.rawPluginConfig);
258
+ console.log(`[TagadaClient ${this.instanceId}] Plugin config loaded:`, config);
214
259
  this.updateState({
215
260
  pluginConfig: config,
216
261
  pluginConfigLoading: false,
@@ -222,9 +267,6 @@ export class TagadaClient {
222
267
  environment: this.state.environment,
223
268
  });
224
269
  }
225
- if (this.state.debugMode) {
226
- console.log('[TagadaClient] Plugin config loaded:', config);
227
- }
228
270
  }
229
271
  catch (error) {
230
272
  console.error('[TagadaClient] Failed to load plugin config:', error);
@@ -238,16 +280,69 @@ export class TagadaClient {
238
280
  * Initialize token and session
239
281
  */
240
282
  async initializeToken() {
283
+ // ๐Ÿ” PRIORITY 1: Check for authCode (cross-domain handoff)
284
+ // This ALWAYS takes precedence over existing tokens
285
+ if (shouldResolveAuthCode()) {
286
+ const storeId = this.state.pluginConfig.storeId;
287
+ if (!storeId) {
288
+ console.error('[TagadaClient] Cannot resolve authCode: storeId not found in config');
289
+ return this.fallbackToNormalFlow();
290
+ }
291
+ console.log(`[TagadaClient ${this.instanceId}] ๐Ÿ” Cross-domain auth detected, resolving...`);
292
+ try {
293
+ const authCode = new URLSearchParams(window.location.search).get('authCode');
294
+ if (!authCode) {
295
+ return this.fallbackToNormalFlow();
296
+ }
297
+ // Resolve the handoff
298
+ const handoffData = await resolveAuthHandoff(authCode, storeId, this.state.environment.apiConfig.baseUrl, this.state.debugMode);
299
+ console.log(`[TagadaClient ${this.instanceId}] โœ… Auth handoff resolved:`, {
300
+ customerId: handoffData.customer.id,
301
+ role: handoffData.customer.role,
302
+ hasContext: Object.keys(handoffData.context).length > 0,
303
+ });
304
+ // Set the new token (already stored by resolveAuthHandoff)
305
+ this.setToken(handoffData.token);
306
+ // Decode session from token
307
+ const decodedSession = decodeJWTClient(handoffData.token);
308
+ if (decodedSession) {
309
+ this.updateState({ session: decodedSession });
310
+ await this.initializeSession(decodedSession);
311
+ // If context has funnelSessionId, restore it
312
+ if (handoffData.context?.funnelSessionId && this.funnel) {
313
+ if (this.state.debugMode) {
314
+ console.log(`[TagadaClient ${this.instanceId}] Restoring funnel session from handoff context:`, handoffData.context.funnelSessionId);
315
+ }
316
+ // The funnel client will pick this up during auto-initialization
317
+ }
318
+ }
319
+ else {
320
+ console.error('[TagadaClient] Failed to decode token from handoff');
321
+ this.updateState({ isInitialized: true, isLoading: false });
322
+ }
323
+ return; // โœ… Auth handoff resolved successfully, exit early
324
+ }
325
+ catch (error) {
326
+ console.error(`[TagadaClient ${this.instanceId}] โŒ Auth handoff failed, falling back to normal flow:`, error);
327
+ // Fall through to normal initialization
328
+ }
329
+ }
330
+ // Continue with normal flow if no authCode or resolution failed
331
+ await this.fallbackToNormalFlow();
332
+ }
333
+ /**
334
+ * Normal token initialization flow (no cross-domain handoff)
335
+ */
336
+ async fallbackToNormalFlow() {
241
337
  // Check for existing token in URL or storage
242
338
  const existingToken = getClientToken();
243
339
  const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
244
340
  const queryToken = urlParams.get('token');
245
- if (this.state.debugMode) {
246
- console.log(`[TagadaClient ${this.instanceId}] Initializing token...`, {
247
- hasExistingToken: !!existingToken,
248
- hasQueryToken: !!queryToken
249
- });
250
- }
341
+ console.log(`[TagadaClient ${this.instanceId}] Initializing token (normal flow)...`, {
342
+ hasExistingToken: !!existingToken,
343
+ hasQueryToken: !!queryToken,
344
+ storeId: this.state.pluginConfig.storeId,
345
+ });
251
346
  let tokenToUse = null;
252
347
  let shouldPersist = false;
253
348
  if (queryToken) {
@@ -280,13 +375,15 @@ export class TagadaClient {
280
375
  else {
281
376
  // Create anonymous token
282
377
  const storeId = this.state.pluginConfig.storeId;
378
+ console.log(`[TagadaClient ${this.instanceId}] No existing token, creating anonymous token...`, {
379
+ hasStoreId: !!storeId,
380
+ storeId,
381
+ });
283
382
  if (storeId) {
284
- if (this.state.debugMode) {
285
- console.log(`[TagadaClient ${this.instanceId}] Creating anonymous token for store:`, storeId);
286
- }
287
383
  await this.createAnonymousToken(storeId);
288
384
  }
289
385
  else {
386
+ console.warn(`[TagadaClient ${this.instanceId}] No storeId in plugin config, skipping anonymous token creation`);
290
387
  this.updateState({ isInitialized: true, isLoading: false });
291
388
  }
292
389
  }
@@ -404,12 +501,22 @@ export class TagadaClient {
404
501
  osVersion: deviceInfo.userAgent.os.version,
405
502
  deviceType: deviceInfo.userAgent.device?.type,
406
503
  deviceModel: deviceInfo.userAgent.device?.model,
504
+ deviceVendor: deviceInfo.userAgent.device?.vendor,
505
+ userAgent: deviceInfo.userAgent.name,
506
+ engineName: deviceInfo.userAgent.engine.name,
507
+ engineVersion: deviceInfo.userAgent.engine.version,
508
+ cpuArchitecture: deviceInfo.userAgent.cpu.architecture,
509
+ isBot: deviceInfo.flags?.isBot ?? false,
510
+ isChromeFamily: deviceInfo.flags?.isChromeFamily ?? false,
511
+ isStandalonePWA: deviceInfo.flags?.isStandalonePWA ?? false,
512
+ isAppleSilicon: deviceInfo.flags?.isAppleSilicon ?? false,
407
513
  screenWidth: deviceInfo.screenResolution.width,
408
514
  screenHeight: deviceInfo.screenResolution.height,
409
515
  timeZone: deviceInfo.timeZone,
410
516
  draft, // ๐ŸŽฏ Pass draft mode to session init
517
+ fetchMessages: false,
411
518
  };
412
- const response = await this.apiClient.post('/api/v1/cms/session/init', sessionInitData);
519
+ const response = await this.apiClient.post('/api/v1/cms/session/v2/init', sessionInitData);
413
520
  // Success - reset error tracking
414
521
  this.lastSessionInitError = null;
415
522
  this.sessionInitRetryCount = 0;
@@ -451,14 +558,16 @@ export class TagadaClient {
451
558
  };
452
559
  this.updateState({ store: storeConfig });
453
560
  }
454
- // Update Locale
455
- const localeConfig = {
456
- locale: response.locale,
457
- language: response.locale.split('-')[0],
458
- region: response.locale.split('-')[1] ?? 'US',
459
- messages: response.messages ?? {},
460
- };
461
- this.updateState({ locale: localeConfig });
561
+ // Update Locale (only if provided - V2 endpoint doesn't return locale)
562
+ if (response.locale) {
563
+ const localeConfig = {
564
+ locale: response.locale,
565
+ language: response.locale.split('-')[0],
566
+ region: response.locale.split('-')[1] ?? 'US',
567
+ messages: response.messages ?? {},
568
+ };
569
+ this.updateState({ locale: localeConfig });
570
+ }
462
571
  // Update Currency
463
572
  if (response.store) {
464
573
  const currencyConfig = {
@@ -37,7 +37,9 @@ export const ENVIRONMENT_CONFIGS = {
37
37
  endpoints: {
38
38
  checkout: {
39
39
  sessionInit: '/api/v1/checkout/session/init',
40
+ sessionInitAsync: '/api/v1/checkout/session/init-async',
40
41
  sessionStatus: '/api/v1/checkout/session/status',
42
+ asyncStatus: '/api/public/v1/checkout/async-status',
41
43
  },
42
44
  customer: {
43
45
  profile: '/api/v1/customer/profile',
@@ -53,7 +55,9 @@ export const ENVIRONMENT_CONFIGS = {
53
55
  endpoints: {
54
56
  checkout: {
55
57
  sessionInit: '/api/v1/checkout/session/init',
58
+ sessionInitAsync: '/api/v1/checkout/session/init-async',
56
59
  sessionStatus: '/api/v1/checkout/session/status',
60
+ asyncStatus: '/api/public/v1/checkout/async-status',
57
61
  },
58
62
  customer: {
59
63
  profile: '/api/v1/customer/profile',
@@ -69,7 +73,9 @@ export const ENVIRONMENT_CONFIGS = {
69
73
  endpoints: {
70
74
  checkout: {
71
75
  sessionInit: '/api/v1/checkout/session/init',
76
+ sessionInitAsync: '/api/v1/checkout/session/init-async',
72
77
  sessionStatus: '/api/v1/checkout/session/status',
78
+ asyncStatus: '/api/public/v1/checkout/async-status',
73
79
  },
74
80
  customer: {
75
81
  profile: '/api/v1/customer/profile',
@@ -13,6 +13,14 @@ export interface FunnelClientConfig {
13
13
  * Set to false if you want to handle navigation manually
14
14
  */
15
15
  autoRedirect?: boolean;
16
+ /**
17
+ * Override funnelId from rawPluginConfig
18
+ */
19
+ funnelId?: string;
20
+ /**
21
+ * Override stepId from rawPluginConfig
22
+ */
23
+ stepId?: string;
16
24
  }
17
25
  export interface FunnelState {
18
26
  context: SimpleFunnelContext | null;
@@ -42,6 +50,15 @@ export declare class FunnelClient {
42
50
  * Get current state
43
51
  */
44
52
  getState(): FunnelState;
53
+ /**
54
+ * Get the session ID that would be used for initialization (URL params or cookie)
55
+ * This allows getting the session ID even before the client is fully initialized.
56
+ */
57
+ getDetectedSessionId(): string | null;
58
+ /**
59
+ * Reset initialization state (used for back-button restores)
60
+ */
61
+ resetInitialization(): void;
45
62
  /**
46
63
  * Initialize session with automatic detection (cookies, URL, etc.)
47
64
  */
@@ -64,8 +81,17 @@ export declare class FunnelClient {
64
81
  }, funnelId?: string, entryStepId?: string): Promise<SimpleFunnelContext<{}>>;
65
82
  /**
66
83
  * Navigate
84
+ * @param event - Navigation event/action
85
+ * @param options - Navigation options
86
+ * @param options.fireAndForget - If true, queues navigation to QStash and returns immediately without waiting for result
87
+ * @param options.customerTags - Customer tags to set (merged with existing customer tags)
88
+ * @param options.deviceId - Device ID for geo/device tag enrichment (optional, rarely needed)
67
89
  */
68
- navigate(event: FunnelAction): Promise<FunnelNavigationResult>;
90
+ navigate(event: FunnelAction, options?: {
91
+ fireAndForget?: boolean;
92
+ customerTags?: string[];
93
+ deviceId?: string;
94
+ }): Promise<FunnelNavigationResult>;
69
95
  /**
70
96
  * Go to a specific step (direct navigation)
71
97
  */
@@ -92,6 +92,37 @@ export class FunnelClient {
92
92
  getState() {
93
93
  return this.state;
94
94
  }
95
+ /**
96
+ * Get the session ID that would be used for initialization (URL params or cookie)
97
+ * This allows getting the session ID even before the client is fully initialized.
98
+ */
99
+ getDetectedSessionId() {
100
+ // Priority 1: Already initialized session
101
+ if (this.state.context?.sessionId) {
102
+ return this.state.context.sessionId;
103
+ }
104
+ if (typeof window === 'undefined')
105
+ return null;
106
+ // Priority 2: URL params
107
+ const params = new URLSearchParams(window.location.search);
108
+ const urlSessionId = params.get('funnelSessionId');
109
+ if (urlSessionId)
110
+ return urlSessionId;
111
+ // Priority 3: Cookie
112
+ return getFunnelSessionCookie() || null;
113
+ }
114
+ /**
115
+ * Reset initialization state (used for back-button restores)
116
+ */
117
+ resetInitialization() {
118
+ this.initializationAttempted = false;
119
+ this.isInitializing = false;
120
+ // Clear context to force a fresh autoInitialize call to hit the backend
121
+ this.updateState({
122
+ context: null,
123
+ isInitialized: false,
124
+ });
125
+ }
95
126
  /**
96
127
  * Initialize session with automatic detection (cookies, URL, etc.)
97
128
  */
@@ -106,31 +137,33 @@ export class FunnelClient {
106
137
  this.isInitializing = true;
107
138
  this.updateState({ isLoading: true, error: null });
108
139
  try {
109
- // URL params
140
+ // ๐ŸŽฏ Get detected session ID
141
+ const existingSessionId = this.getDetectedSessionId();
142
+ // URL params for funnelId
110
143
  const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
111
144
  const urlFunnelId = params.get('funnelId');
112
145
  const effectiveFunnelId = urlFunnelId || funnelId;
113
- let existingSessionId = params.get('funnelSessionId');
114
- // Cookie fallback
115
- if (!existingSessionId) {
116
- existingSessionId = getFunnelSessionCookie() || null;
117
- }
118
146
  // ๐ŸŽฏ Read funnel tracking data from injected HTML
119
147
  const injectedFunnelId = getAssignedFunnelId(); // Funnel ID from server
120
148
  const funnelVariantId = getAssignedFunnelVariant(); // A/B test variant ID
121
- const funnelStepId = getAssignedFunnelStep(); // Current step ID
149
+ const injectedStepId = getAssignedFunnelStep(); // Current step ID
122
150
  // ๐ŸŽฏ Get SDK override parameters (draft, funnelTracking, etc.)
123
151
  const sdkParams = getSDKParams();
124
- // Prefer injected funnelId over URL/prop funnelId (more reliable)
125
- const finalFunnelId = injectedFunnelId || effectiveFunnelId;
152
+ // Priority: config override > injected > URL/prop
153
+ const finalFunnelId = this.config.funnelId || injectedFunnelId || effectiveFunnelId;
154
+ const finalStepId = this.config.stepId || injectedStepId;
126
155
  if (this.config.debugMode) {
127
156
  console.log('๐Ÿš€ [FunnelClient] Auto-initializing...', {
128
157
  existingSessionId,
129
158
  effectiveFunnelId: finalFunnelId,
130
159
  funnelVariantId, // ๐ŸŽฏ Log variant ID for debugging
131
- funnelStepId, // ๐ŸŽฏ Log step ID for debugging
160
+ funnelStepId: finalStepId, // ๐ŸŽฏ Log step ID for debugging
132
161
  draft: sdkParams.draft, // ๐ŸŽฏ Log draft mode
133
162
  funnelTracking: sdkParams.funnelTracking, // ๐ŸŽฏ Log tracking flag
163
+ source: {
164
+ funnelId: this.config.funnelId ? 'config' : injectedFunnelId ? 'injected' : effectiveFunnelId ? 'url/prop' : 'none',
165
+ stepId: this.config.stepId ? 'config' : injectedStepId ? 'injected' : 'none',
166
+ },
134
167
  });
135
168
  }
136
169
  // Note: We proceed even without funnelId/sessionId - the backend will create a new anonymous session if needed
@@ -145,7 +178,7 @@ export class FunnelClient {
145
178
  existingSessionId: existingSessionId || undefined,
146
179
  currentUrl: typeof window !== 'undefined' ? window.location.href : undefined,
147
180
  funnelVariantId, // ๐ŸŽฏ Pass A/B test variant ID to backend
148
- funnelStepId, // ๐ŸŽฏ Pass step ID to backend
181
+ funnelStepId: finalStepId, // ๐ŸŽฏ Pass step ID to backend (with config override)
149
182
  draft: sdkParams.draft, // ๐ŸŽฏ Pass draft mode explicitly (more robust than URL parsing)
150
183
  funnelTracking: sdkParams.funnelTracking, // ๐ŸŽฏ Pass funnel tracking flag explicitly
151
184
  });
@@ -215,32 +248,100 @@ export class FunnelClient {
215
248
  }
216
249
  /**
217
250
  * Navigate
251
+ * @param event - Navigation event/action
252
+ * @param options - Navigation options
253
+ * @param options.fireAndForget - If true, queues navigation to QStash and returns immediately without waiting for result
254
+ * @param options.customerTags - Customer tags to set (merged with existing customer tags)
255
+ * @param options.deviceId - Device ID for geo/device tag enrichment (optional, rarely needed)
218
256
  */
219
- async navigate(event) {
257
+ async navigate(event, options) {
220
258
  if (!this.state.context?.sessionId)
221
259
  throw new Error('No active session');
222
260
  this.updateState({ isNavigating: true, isLoading: true });
223
261
  try {
262
+ // Get current funnel state from injected HTML/meta tags
263
+ let funnelVariantId = getAssignedFunnelVariant();
264
+ let funnelStepId = getAssignedFunnelStep();
265
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : undefined;
266
+ // โœ… FALLBACK: Use config values if injection not available (e.g., on Shopify storefront)
267
+ if (!funnelStepId && this.config.stepId) {
268
+ funnelStepId = this.config.stepId;
269
+ if (this.config.debugMode) {
270
+ console.log('๐Ÿ” [FunnelClient.navigate] Using stepId from config (no injection):', funnelStepId);
271
+ }
272
+ }
273
+ if (!funnelVariantId && this.config.variantId) {
274
+ funnelVariantId = this.config.variantId;
275
+ if (this.config.debugMode) {
276
+ console.log('๐Ÿ” [FunnelClient.navigate] Using variantId from config (no injection):', funnelVariantId);
277
+ }
278
+ }
279
+ // โœ… DEBUG: Log what we're sending
280
+ if (this.config.debugMode) {
281
+ console.log('๐Ÿ” [FunnelClient.navigate] Sending to backend:', {
282
+ sessionId: this.state.context.sessionId,
283
+ currentUrl,
284
+ funnelStepId: funnelStepId || '(not found)',
285
+ funnelVariantId: funnelVariantId || '(not found)',
286
+ hasInjectedStepId: !!getAssignedFunnelStep(),
287
+ hasInjectedVariantId: !!getAssignedFunnelVariant(),
288
+ usedConfigFallback: !getAssignedFunnelStep() && !!this.config.stepId,
289
+ customerTags: options?.customerTags || '(none)',
290
+ deviceId: options?.deviceId || '(none)',
291
+ });
292
+ }
293
+ const fireAndForget = options?.fireAndForget || false;
224
294
  const response = await this.resource.navigate({
225
295
  sessionId: this.state.context.sessionId,
226
- event
296
+ event,
297
+ currentUrl,
298
+ funnelStepId,
299
+ funnelVariantId,
300
+ fireAndForget,
301
+ customerTags: options?.customerTags,
302
+ deviceId: options?.deviceId,
227
303
  });
228
- if (response.success && response.result) {
229
- // Refresh session to get updated context
230
- await this.refreshSession();
304
+ if (!response.success || !response.result) {
305
+ throw new Error(response.error || 'Navigation failed');
306
+ }
307
+ const result = response.result;
308
+ // ๐Ÿ”ฅ Fire-and-forget mode: Just return acknowledgment, skip everything else
309
+ if (result.queued) {
231
310
  this.updateState({ isNavigating: false, isLoading: false });
232
- const result = response.result;
233
- // Auto-redirect if enabled (default: true) and result has a URL
234
- const shouldAutoRedirect = this.config.autoRedirect !== false; // Default to true
235
- if (shouldAutoRedirect && result?.url && typeof window !== 'undefined') {
311
+ // Update session ID if it changed (session recovery)
312
+ if (result.sessionId && result.sessionId !== this.state.context?.sessionId) {
236
313
  if (this.config.debugMode) {
237
- console.log('๐Ÿš€ [FunnelClient] Auto-redirecting to:', result.url);
314
+ console.log(`๐Ÿ”ฅ [FunnelClient] Session ID updated: ${this.state.context?.sessionId} โ†’ ${result.sessionId}`);
315
+ }
316
+ // Update context session ID
317
+ if (this.state.context) {
318
+ this.state.context.sessionId = result.sessionId;
238
319
  }
239
- window.location.href = result.url;
320
+ }
321
+ if (this.config.debugMode) {
322
+ console.log('๐Ÿ”ฅ [FunnelClient] Navigation queued (fire-and-forget mode)');
240
323
  }
241
324
  return result;
242
325
  }
243
- throw new Error(response.error || 'Navigation failed');
326
+ // Normal navigation: handle redirect
327
+ const shouldAutoRedirect = this.config.autoRedirect !== false; // Default to true
328
+ // Skip refreshSession if auto-redirecting (next page will initialize with fresh state)
329
+ // Only refresh if staying on same page (autoRedirect: false)
330
+ if (!shouldAutoRedirect) {
331
+ if (this.config.debugMode) {
332
+ console.log('๐Ÿ”„ [FunnelClient] Refreshing session (no auto-redirect)');
333
+ }
334
+ await this.refreshSession();
335
+ }
336
+ this.updateState({ isNavigating: false, isLoading: false });
337
+ // Auto-redirect if enabled and result has a URL
338
+ if (shouldAutoRedirect && result?.url && typeof window !== 'undefined') {
339
+ if (this.config.debugMode) {
340
+ console.log('๐Ÿš€ [FunnelClient] Auto-redirecting to:', result.url, '(skipped session refresh - next page will initialize)');
341
+ }
342
+ window.location.href = result.url;
343
+ }
344
+ return result;
244
345
  }
245
346
  catch (error) {
246
347
  const err = error instanceof Error ? error : new Error(String(error));
@@ -180,17 +180,92 @@ export declare class CheckoutResource {
180
180
  private apiClient;
181
181
  constructor(apiClient: ApiClient);
182
182
  /**
183
- * Initialize a new checkout session
183
+ * Initialize a new checkout session (sync mode)
184
+ * Response time: 2-5 seconds (full processing)
184
185
  */
185
186
  initCheckout(params: CheckoutInitParams): Promise<{
186
187
  checkoutUrl: string;
187
188
  checkoutSession: CheckoutSession;
188
189
  checkoutToken: string;
189
190
  }>;
191
+ /**
192
+ * Initialize a new checkout session (async mode) โšก
193
+ * Response time: ~50ms (20-50x faster!)
194
+ *
195
+ * Returns checkoutToken immediately, background job completes processing.
196
+ * Use getCheckout() to fetch full session data - it auto-waits for completion.
197
+ *
198
+ * @example
199
+ * // Fast init
200
+ * const { checkoutToken } = await checkout.initCheckoutAsync(params);
201
+ *
202
+ * // Redirect user immediately
203
+ * window.location.href = `/checkout/${checkoutToken}/op`;
204
+ *
205
+ * // By the time page loads, background processing is usually complete
206
+ */
207
+ initCheckoutAsync(params: CheckoutInitParams): Promise<{
208
+ checkoutToken: string;
209
+ customerId: string;
210
+ status: 'processing';
211
+ }>;
212
+ /**
213
+ * Preload checkout session (ultra-fast background pre-computation) โšกโšกโšก
214
+ *
215
+ * This is the recommended way to handle cart changes or "Buy Now" intent.
216
+ * It pre-computes everything (checkoutToken, navigation URL, CMS session)
217
+ * before the user even clicks the checkout button.
218
+ *
219
+ * The SDK automatically gets funnelSessionId from FunnelClient if provided.
220
+ * Only FunnelClient knows how to properly extract funnelSessionId (from state, URL, cookies, etc.)
221
+ *
222
+ * @param params - Checkout and funnel parameters
223
+ * @param getFunnelSessionId - Optional function to get funnelSessionId from FunnelClient
224
+ * This maintains separation of concerns - only FunnelClient knows how to get it
225
+ *
226
+ * @returns { checkoutToken, customerId, navigationUrl }
227
+ */
228
+ preloadCheckout(params: CheckoutInitParams & {
229
+ funnelSessionId?: string;
230
+ currentUrl?: string;
231
+ funnelStepId?: string;
232
+ funnelVariantId?: string;
233
+ navigationEvent?: string | {
234
+ type: string;
235
+ data?: any;
236
+ };
237
+ navigationOptions?: any;
238
+ }, getFunnelSessionId?: () => string | null | undefined): Promise<{
239
+ checkoutToken: string;
240
+ customerId: string;
241
+ navigationUrl: string | null;
242
+ funnelStepId?: string;
243
+ }>;
244
+ /**
245
+ * Check async checkout processing status (instant, no waiting)
246
+ * Perfect for polling or checking if background job completed
247
+ */
248
+ checkAsyncStatus(checkoutToken: string): Promise<{
249
+ isAsync: boolean;
250
+ isProcessing: boolean;
251
+ isComplete: boolean;
252
+ hasError: boolean;
253
+ error?: string;
254
+ }>;
190
255
  /**
191
256
  * Get checkout session by token
257
+ *
258
+ * SDK automatically waits for async completion (skipAsyncWait=false) for seamless UX.
259
+ * This ensures async checkout sessions are fully processed before returning data.
260
+ *
261
+ * If you need to skip waiting (e.g., for manual polling), use getCheckoutRaw() instead.
192
262
  */
193
263
  getCheckout(checkoutToken: string, currency?: string): Promise<CheckoutData>;
264
+ /**
265
+ * Get checkout session by token without waiting for async completion
266
+ * Useful for manual polling scenarios
267
+ */
268
+ getCheckoutRaw(checkoutToken: string, currency?: string): Promise<CheckoutData>;
194
269
  /**
195
270
  * Update checkout address
196
271
  */