@tagadapay/plugin-sdk 3.0.9 → 3.0.14

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 +3802 -195
  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 +4 -0
  6. package/dist/v2/core/client.js +314 -123
  7. package/dist/v2/core/config/environment.js +6 -0
  8. package/dist/v2/core/funnelClient.d.ts +18 -1
  9. package/dist/v2/core/funnelClient.js +90 -17
  10. package/dist/v2/core/resources/checkout.d.ts +44 -1
  11. package/dist/v2/core/resources/checkout.js +48 -1
  12. package/dist/v2/core/resources/funnel.d.ts +44 -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/components/DebugDrawer.js +68 -46
  23. package/dist/v2/react/hooks/useCheckoutQuery.d.ts +0 -1
  24. package/dist/v2/react/hooks/useCheckoutQuery.js +12 -4
  25. package/dist/v2/react/hooks/useFunnelLegacy.js +39 -11
  26. package/dist/v2/react/hooks/usePreviewOffer.d.ts +3 -3
  27. package/dist/v2/react/hooks/usePreviewOffer.js +20 -15
  28. package/dist/v2/react/hooks/useTranslation.js +12 -4
  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
@@ -118,19 +118,24 @@ export class FunnelClient {
118
118
  // 🎯 Read funnel tracking data from injected HTML
119
119
  const injectedFunnelId = getAssignedFunnelId(); // Funnel ID from server
120
120
  const funnelVariantId = getAssignedFunnelVariant(); // A/B test variant ID
121
- const funnelStepId = getAssignedFunnelStep(); // Current step ID
121
+ const injectedStepId = getAssignedFunnelStep(); // Current step ID
122
122
  // 🎯 Get SDK override parameters (draft, funnelTracking, etc.)
123
123
  const sdkParams = getSDKParams();
124
- // Prefer injected funnelId over URL/prop funnelId (more reliable)
125
- const finalFunnelId = injectedFunnelId || effectiveFunnelId;
124
+ // Priority: config override > injected > URL/prop
125
+ const finalFunnelId = this.config.funnelId || injectedFunnelId || effectiveFunnelId;
126
+ const finalStepId = this.config.stepId || injectedStepId;
126
127
  if (this.config.debugMode) {
127
128
  console.log('🚀 [FunnelClient] Auto-initializing...', {
128
129
  existingSessionId,
129
130
  effectiveFunnelId: finalFunnelId,
130
131
  funnelVariantId, // 🎯 Log variant ID for debugging
131
- funnelStepId, // 🎯 Log step ID for debugging
132
+ funnelStepId: finalStepId, // 🎯 Log step ID for debugging
132
133
  draft: sdkParams.draft, // 🎯 Log draft mode
133
134
  funnelTracking: sdkParams.funnelTracking, // 🎯 Log tracking flag
135
+ source: {
136
+ funnelId: this.config.funnelId ? 'config' : injectedFunnelId ? 'injected' : effectiveFunnelId ? 'url/prop' : 'none',
137
+ stepId: this.config.stepId ? 'config' : injectedStepId ? 'injected' : 'none',
138
+ },
134
139
  });
135
140
  }
136
141
  // Note: We proceed even without funnelId/sessionId - the backend will create a new anonymous session if needed
@@ -145,7 +150,7 @@ export class FunnelClient {
145
150
  existingSessionId: existingSessionId || undefined,
146
151
  currentUrl: typeof window !== 'undefined' ? window.location.href : undefined,
147
152
  funnelVariantId, // 🎯 Pass A/B test variant ID to backend
148
- funnelStepId, // 🎯 Pass step ID to backend
153
+ funnelStepId: finalStepId, // 🎯 Pass step ID to backend (with config override)
149
154
  draft: sdkParams.draft, // 🎯 Pass draft mode explicitly (more robust than URL parsing)
150
155
  funnelTracking: sdkParams.funnelTracking, // 🎯 Pass funnel tracking flag explicitly
151
156
  });
@@ -215,32 +220,100 @@ export class FunnelClient {
215
220
  }
216
221
  /**
217
222
  * Navigate
223
+ * @param event - Navigation event/action
224
+ * @param options - Navigation options
225
+ * @param options.fireAndForget - If true, queues navigation to QStash and returns immediately without waiting for result
226
+ * @param options.customerTags - Customer tags to set (merged with existing customer tags)
227
+ * @param options.deviceId - Device ID for geo/device tag enrichment (optional, rarely needed)
218
228
  */
219
- async navigate(event) {
229
+ async navigate(event, options) {
220
230
  if (!this.state.context?.sessionId)
221
231
  throw new Error('No active session');
222
232
  this.updateState({ isNavigating: true, isLoading: true });
223
233
  try {
234
+ // Get current funnel state from injected HTML/meta tags
235
+ let funnelVariantId = getAssignedFunnelVariant();
236
+ let funnelStepId = getAssignedFunnelStep();
237
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : undefined;
238
+ // ✅ FALLBACK: Use config values if injection not available (e.g., on Shopify storefront)
239
+ if (!funnelStepId && this.config.stepId) {
240
+ funnelStepId = this.config.stepId;
241
+ if (this.config.debugMode) {
242
+ console.log('🔍 [FunnelClient.navigate] Using stepId from config (no injection):', funnelStepId);
243
+ }
244
+ }
245
+ if (!funnelVariantId && this.config.variantId) {
246
+ funnelVariantId = this.config.variantId;
247
+ if (this.config.debugMode) {
248
+ console.log('🔍 [FunnelClient.navigate] Using variantId from config (no injection):', funnelVariantId);
249
+ }
250
+ }
251
+ // ✅ DEBUG: Log what we're sending
252
+ if (this.config.debugMode) {
253
+ console.log('🔍 [FunnelClient.navigate] Sending to backend:', {
254
+ sessionId: this.state.context.sessionId,
255
+ currentUrl,
256
+ funnelStepId: funnelStepId || '(not found)',
257
+ funnelVariantId: funnelVariantId || '(not found)',
258
+ hasInjectedStepId: !!getAssignedFunnelStep(),
259
+ hasInjectedVariantId: !!getAssignedFunnelVariant(),
260
+ usedConfigFallback: !getAssignedFunnelStep() && !!this.config.stepId,
261
+ customerTags: options?.customerTags || '(none)',
262
+ deviceId: options?.deviceId || '(none)',
263
+ });
264
+ }
265
+ const fireAndForget = options?.fireAndForget || false;
224
266
  const response = await this.resource.navigate({
225
267
  sessionId: this.state.context.sessionId,
226
- event
268
+ event,
269
+ currentUrl,
270
+ funnelStepId,
271
+ funnelVariantId,
272
+ fireAndForget,
273
+ customerTags: options?.customerTags,
274
+ deviceId: options?.deviceId,
227
275
  });
228
- if (response.success && response.result) {
229
- // Refresh session to get updated context
230
- await this.refreshSession();
276
+ if (!response.success || !response.result) {
277
+ throw new Error(response.error || 'Navigation failed');
278
+ }
279
+ const result = response.result;
280
+ // 🔥 Fire-and-forget mode: Just return acknowledgment, skip everything else
281
+ if (result.queued) {
231
282
  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') {
283
+ // Update session ID if it changed (session recovery)
284
+ if (result.sessionId && result.sessionId !== this.state.context?.sessionId) {
236
285
  if (this.config.debugMode) {
237
- console.log('🚀 [FunnelClient] Auto-redirecting to:', result.url);
286
+ console.log(`🔥 [FunnelClient] Session ID updated: ${this.state.context?.sessionId} → ${result.sessionId}`);
287
+ }
288
+ // Update context session ID
289
+ if (this.state.context) {
290
+ this.state.context.sessionId = result.sessionId;
238
291
  }
239
- window.location.href = result.url;
292
+ }
293
+ if (this.config.debugMode) {
294
+ console.log('🔥 [FunnelClient] Navigation queued (fire-and-forget mode)');
240
295
  }
241
296
  return result;
242
297
  }
243
- throw new Error(response.error || 'Navigation failed');
298
+ // Normal navigation: handle redirect
299
+ const shouldAutoRedirect = this.config.autoRedirect !== false; // Default to true
300
+ // Skip refreshSession if auto-redirecting (next page will initialize with fresh state)
301
+ // Only refresh if staying on same page (autoRedirect: false)
302
+ if (!shouldAutoRedirect) {
303
+ if (this.config.debugMode) {
304
+ console.log('🔄 [FunnelClient] Refreshing session (no auto-redirect)');
305
+ }
306
+ await this.refreshSession();
307
+ }
308
+ this.updateState({ isNavigating: false, isLoading: false });
309
+ // Auto-redirect if enabled and result has a URL
310
+ if (shouldAutoRedirect && result?.url && typeof window !== 'undefined') {
311
+ if (this.config.debugMode) {
312
+ console.log('🚀 [FunnelClient] Auto-redirecting to:', result.url, '(skipped session refresh - next page will initialize)');
313
+ }
314
+ window.location.href = result.url;
315
+ }
316
+ return result;
244
317
  }
245
318
  catch (error) {
246
319
  const err = error instanceof Error ? error : new Error(String(error));
@@ -180,17 +180,60 @@ 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
+ * Check async checkout processing status (instant, no waiting)
214
+ * Perfect for polling or checking if background job completed
215
+ */
216
+ checkAsyncStatus(checkoutToken: string): Promise<{
217
+ isAsync: boolean;
218
+ isProcessing: boolean;
219
+ isComplete: boolean;
220
+ hasError: boolean;
221
+ error?: string;
222
+ }>;
190
223
  /**
191
224
  * Get checkout session by token
225
+ *
226
+ * SDK automatically waits for async completion (skipAsyncWait=false) for seamless UX.
227
+ * This ensures async checkout sessions are fully processed before returning data.
228
+ *
229
+ * If you need to skip waiting (e.g., for manual polling), use getCheckoutRaw() instead.
192
230
  */
193
231
  getCheckout(checkoutToken: string, currency?: string): Promise<CheckoutData>;
232
+ /**
233
+ * Get checkout session by token without waiting for async completion
234
+ * Useful for manual polling scenarios
235
+ */
236
+ getCheckoutRaw(checkoutToken: string, currency?: string): Promise<CheckoutData>;
194
237
  /**
195
238
  * Update checkout address
196
239
  */
@@ -7,20 +7,67 @@ export class CheckoutResource {
7
7
  this.apiClient = apiClient;
8
8
  }
9
9
  /**
10
- * Initialize a new checkout session
10
+ * Initialize a new checkout session (sync mode)
11
+ * Response time: 2-5 seconds (full processing)
11
12
  */
12
13
  async initCheckout(params) {
13
14
  // Pass all params including customerId to prevent duplicate customer creation
14
15
  return this.apiClient.post('/api/v1/checkout/session/init', params);
15
16
  }
17
+ /**
18
+ * Initialize a new checkout session (async mode) ⚡
19
+ * Response time: ~50ms (20-50x faster!)
20
+ *
21
+ * Returns checkoutToken immediately, background job completes processing.
22
+ * Use getCheckout() to fetch full session data - it auto-waits for completion.
23
+ *
24
+ * @example
25
+ * // Fast init
26
+ * const { checkoutToken } = await checkout.initCheckoutAsync(params);
27
+ *
28
+ * // Redirect user immediately
29
+ * window.location.href = `/checkout/${checkoutToken}/op`;
30
+ *
31
+ * // By the time page loads, background processing is usually complete
32
+ */
33
+ async initCheckoutAsync(params) {
34
+ return this.apiClient.post('/api/v1/checkout/session/init-async', params);
35
+ }
36
+ /**
37
+ * Check async checkout processing status (instant, no waiting)
38
+ * Perfect for polling or checking if background job completed
39
+ */
40
+ async checkAsyncStatus(checkoutToken) {
41
+ return this.apiClient.get(`/api/public/v1/checkout/async-status/${checkoutToken}`);
42
+ }
16
43
  /**
17
44
  * Get checkout session by token
45
+ *
46
+ * SDK automatically waits for async completion (skipAsyncWait=false) for seamless UX.
47
+ * This ensures async checkout sessions are fully processed before returning data.
48
+ *
49
+ * If you need to skip waiting (e.g., for manual polling), use getCheckoutRaw() instead.
18
50
  */
19
51
  async getCheckout(checkoutToken, currency) {
20
52
  const queryParams = new URLSearchParams();
21
53
  if (currency) {
22
54
  queryParams.set('currency', currency);
23
55
  }
56
+ // SDK explicitly waits for async completion (default is skip=true for retro compat)
57
+ queryParams.set('skipAsyncWait', 'false');
58
+ const url = `/api/v1/checkout-sessions/${checkoutToken}/v2${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
59
+ return this.apiClient.get(url);
60
+ }
61
+ /**
62
+ * Get checkout session by token without waiting for async completion
63
+ * Useful for manual polling scenarios
64
+ */
65
+ async getCheckoutRaw(checkoutToken, currency) {
66
+ const queryParams = new URLSearchParams();
67
+ if (currency) {
68
+ queryParams.set('currency', currency);
69
+ }
70
+ queryParams.set('skipAsyncWait', 'true');
24
71
  const url = `/api/v1/checkout-sessions/${checkoutToken}/v2${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
25
72
  return this.apiClient.get(url);
26
73
  }
@@ -343,16 +343,25 @@ export interface FunnelNavigationAction {
343
343
  data?: any;
344
344
  }
345
345
  export interface FunnelNavigationResult {
346
- stepId: string;
346
+ stepId?: string;
347
347
  url?: string;
348
- action: FunnelNavigationAction;
349
- context: SimpleFunnelContext;
348
+ action?: FunnelNavigationAction;
349
+ context?: SimpleFunnelContext;
350
350
  tracking?: {
351
351
  from: string;
352
352
  to: string;
353
353
  event: string;
354
354
  timestamp: string;
355
355
  };
356
+ /**
357
+ * Fire-and-forget response: indicates navigation was queued
358
+ * When true, stepId, url, action, and context will be undefined
359
+ */
360
+ queued?: boolean;
361
+ /**
362
+ * Session ID (may be different if session was recovered)
363
+ */
364
+ sessionId?: string;
356
365
  }
357
366
  /**
358
367
  * Funnel context available to plugins
@@ -480,11 +489,37 @@ export interface FunnelNavigateRequest {
480
489
  * If session is not found and funnelId is provided, a new session will be created
481
490
  */
482
491
  funnelId?: string;
492
+ /**
493
+ * Funnel step ID (from SDK injection/URL params)
494
+ * Used to sync session state before navigation
495
+ */
496
+ funnelStepId?: string;
497
+ /**
498
+ * Funnel variant ID (from SDK injection/URL params for A/B testing)
499
+ * Used to sync session state before navigation
500
+ */
501
+ funnelVariantId?: string;
502
+ /**
503
+ * Fire and forget mode - queues navigation to QStash and returns immediately
504
+ * No response data needed, just acknowledgment that request was queued
505
+ * When true, result will only contain queued status and sessionId (no URL or stepId)
506
+ */
507
+ fireAndForget?: boolean;
508
+ /**
509
+ * ✅ Customer tags to set (merged with existing customer tags)
510
+ * @example ['segment:vip', 'cart_value:high']
511
+ */
512
+ customerTags?: string[];
513
+ /**
514
+ * ✅ Device ID for geo/device tag enrichment (optional)
515
+ * @example 'dev_abc123xyz'
516
+ */
517
+ deviceId?: string;
483
518
  }
484
519
  export interface FunnelNavigateResponse {
485
520
  success: boolean;
486
521
  result?: {
487
- stepId: string;
522
+ stepId?: string;
488
523
  url?: string;
489
524
  /**
490
525
  * New session ID if session was recovered (expired/removed)
@@ -497,6 +532,11 @@ export interface FunnelNavigateResponse {
497
532
  event: string;
498
533
  timestamp: string;
499
534
  };
535
+ /**
536
+ * Fire-and-forget response: indicates navigation was queued
537
+ * When present, stepId and url will be undefined
538
+ */
539
+ queued?: boolean;
500
540
  };
501
541
  error?: string;
502
542
  }
@@ -303,6 +303,32 @@ export declare class OffersResource {
303
303
  checkoutSessionId?: string;
304
304
  customerId?: string;
305
305
  }>;
306
+ /**
307
+ * Transform offer to checkout session (async mode) ⚡
308
+ * Response time: ~50ms (20-50x faster!)
309
+ *
310
+ * Returns checkoutToken immediately, background job completes processing.
311
+ * Use getCheckout() to fetch full session data - it auto-waits for completion.
312
+ *
313
+ * @example
314
+ * // Fast transform
315
+ * const { checkoutToken } = await offers.toCheckoutAsync(offerId, currency, lineItems, returnUrl, mainOrderId);
316
+ *
317
+ * // Redirect user immediately
318
+ * window.location.href = `/checkout/${checkoutToken}/op`;
319
+ *
320
+ * // By the time page loads, background processing is usually complete
321
+ */
322
+ toCheckoutAsync(offerId: string, currency?: string, lineItems?: Array<{
323
+ lineItemId?: string;
324
+ productId?: string;
325
+ variantId: string;
326
+ quantity: number;
327
+ }>, returnUrl?: string, mainOrderId?: string): Promise<{
328
+ checkoutToken: string;
329
+ customerId: string;
330
+ status: 'processing';
331
+ }>;
306
332
  /**
307
333
  * @deprecated Use transformToCheckoutSession instead
308
334
  * Transform offer to checkout session with dynamic variant selection
@@ -202,6 +202,43 @@ export class OffersResource {
202
202
  customerId: response.customerId,
203
203
  };
204
204
  }
205
+ /**
206
+ * Transform offer to checkout session (async mode) ⚡
207
+ * Response time: ~50ms (20-50x faster!)
208
+ *
209
+ * Returns checkoutToken immediately, background job completes processing.
210
+ * Use getCheckout() to fetch full session data - it auto-waits for completion.
211
+ *
212
+ * @example
213
+ * // Fast transform
214
+ * const { checkoutToken } = await offers.toCheckoutAsync(offerId, currency, lineItems, returnUrl, mainOrderId);
215
+ *
216
+ * // Redirect user immediately
217
+ * window.location.href = `/checkout/${checkoutToken}/op`;
218
+ *
219
+ * // By the time page loads, background processing is usually complete
220
+ */
221
+ async toCheckoutAsync(offerId, currency = 'USD', lineItems, returnUrl, mainOrderId) {
222
+ console.log('🛒 [OffersResource] Calling to-checkout-async API:', {
223
+ offerId,
224
+ currency,
225
+ lineItems,
226
+ returnUrl,
227
+ mainOrderId,
228
+ endpoint: `/api/v1/offers/${offerId}/to-checkout-async`,
229
+ });
230
+ const response = await this.apiClient.post(`/api/v1/offers/${offerId}/to-checkout-async`, {
231
+ offerId,
232
+ lineItems: lineItems?.map(item => ({
233
+ variantId: item.variantId,
234
+ quantity: item.quantity,
235
+ })) || [],
236
+ returnUrl: returnUrl || (typeof window !== 'undefined' ? window.location.href : ''),
237
+ mainOrderId: mainOrderId || '',
238
+ });
239
+ console.log('📥 [OffersResource] To-checkout-async API response:', response);
240
+ return response;
241
+ }
205
242
  /**
206
243
  * @deprecated Use transformToCheckoutSession instead
207
244
  * Transform offer to checkout session with dynamic variant selection
@@ -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;
@@ -262,7 +264,7 @@ export interface CustomerInfos {
262
264
  }
263
265
  export interface SessionInitResponse {
264
266
  store: Store;
265
- locale: string;
267
+ locale?: string;
266
268
  messages?: Record<string, string>;
267
269
  customer?: Customer;
268
270
  session?: Session;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cross-Domain Auth Handoff Utilities
3
+ *
4
+ * Handles automatic resolution of authCode query parameters for seamless
5
+ * cross-domain authentication.
6
+ *
7
+ * Flow:
8
+ * 1. Check URL for authCode parameter
9
+ * 2. If present, resolve it with backend
10
+ * 3. Store the returned token (overrides any existing token)
11
+ * 4. Clean the URL (remove authCode)
12
+ * 5. Return resolved customer and context
13
+ */
14
+ export interface AuthHandoffResolveResponse {
15
+ sessionId: string;
16
+ token: string;
17
+ customer: {
18
+ id: string;
19
+ email?: string;
20
+ firstName?: string;
21
+ lastName?: string;
22
+ role: 'authenticated' | 'anonymous';
23
+ };
24
+ context: Record<string, unknown>;
25
+ }
26
+ /**
27
+ * Check if authCode is present in URL
28
+ */
29
+ export declare function hasAuthCode(): boolean;
30
+ /**
31
+ * Get authCode from URL
32
+ */
33
+ export declare function getAuthCode(): string | null;
34
+ /**
35
+ * Check if a code has already been resolved
36
+ */
37
+ export declare function isCodeAlreadyResolved(code: string): boolean;
38
+ /**
39
+ * Resolve auth handoff and return token + customer info
40
+ *
41
+ * This function:
42
+ * 1. Calls POST /api/v1/cms/auth/resolve-handoff
43
+ * 2. Stores the returned token (overrides existing)
44
+ * 3. Cleans the URL (removes authCode)
45
+ * 4. Returns customer and context data
46
+ *
47
+ * 🔒 Deduplication: Multiple calls with the same code will return the same promise
48
+ * to prevent duplicate API requests (e.g., React StrictMode double-mounting)
49
+ */
50
+ export declare function resolveAuthHandoff(authCode: string, storeId: string, apiBaseUrl: string, debugMode?: boolean): Promise<AuthHandoffResolveResponse>;
51
+ /**
52
+ * Remove authCode from URL without page reload
53
+ * Uses history.replaceState to update URL cleanly
54
+ */
55
+ export declare function cleanAuthCodeFromUrl(debugMode?: boolean): void;
56
+ /**
57
+ * Check if we should resolve authCode
58
+ * Returns true if authCode is present and valid format
59
+ */
60
+ export declare function shouldResolveAuthCode(): boolean;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Cross-Domain Auth Handoff Utilities
3
+ *
4
+ * Handles automatic resolution of authCode query parameters for seamless
5
+ * cross-domain authentication.
6
+ *
7
+ * Flow:
8
+ * 1. Check URL for authCode parameter
9
+ * 2. If present, resolve it with backend
10
+ * 3. Store the returned token (overrides any existing token)
11
+ * 4. Clean the URL (remove authCode)
12
+ * 5. Return resolved customer and context
13
+ */
14
+ import { setClientToken } from './tokenStorage';
15
+ // Track in-flight and completed resolutions to prevent duplicates
16
+ const resolutionCache = new Map();
17
+ const resolvedCodes = new Set();
18
+ /**
19
+ * Check if authCode is present in URL
20
+ */
21
+ export function hasAuthCode() {
22
+ if (typeof window === 'undefined')
23
+ return false;
24
+ const urlParams = new URLSearchParams(window.location.search);
25
+ return urlParams.has('authCode');
26
+ }
27
+ /**
28
+ * Get authCode from URL
29
+ */
30
+ export function getAuthCode() {
31
+ if (typeof window === 'undefined')
32
+ return null;
33
+ const urlParams = new URLSearchParams(window.location.search);
34
+ return urlParams.get('authCode');
35
+ }
36
+ /**
37
+ * Check if a code has already been resolved
38
+ */
39
+ export function isCodeAlreadyResolved(code) {
40
+ return resolvedCodes.has(code);
41
+ }
42
+ /**
43
+ * Resolve auth handoff and return token + customer info
44
+ *
45
+ * This function:
46
+ * 1. Calls POST /api/v1/cms/auth/resolve-handoff
47
+ * 2. Stores the returned token (overrides existing)
48
+ * 3. Cleans the URL (removes authCode)
49
+ * 4. Returns customer and context data
50
+ *
51
+ * 🔒 Deduplication: Multiple calls with the same code will return the same promise
52
+ * to prevent duplicate API requests (e.g., React StrictMode double-mounting)
53
+ */
54
+ export async function resolveAuthHandoff(authCode, storeId, apiBaseUrl, debugMode = false) {
55
+ // Check if already resolved
56
+ if (resolvedCodes.has(authCode)) {
57
+ if (debugMode) {
58
+ console.log('[AuthHandoff] Code already resolved, skipping duplicate request');
59
+ }
60
+ throw new Error('Auth code already resolved');
61
+ }
62
+ // Check if resolution is in-flight
63
+ const inFlightResolution = resolutionCache.get(authCode);
64
+ if (inFlightResolution) {
65
+ if (debugMode) {
66
+ console.log('[AuthHandoff] Resolution already in progress, waiting for existing request');
67
+ }
68
+ return inFlightResolution;
69
+ }
70
+ if (debugMode) {
71
+ console.log('[AuthHandoff] Resolving authCode:', authCode.substring(0, 15) + '...');
72
+ }
73
+ // Create resolution promise
74
+ const resolutionPromise = (async () => {
75
+ try {
76
+ // Call resolve endpoint (no authentication required)
77
+ const response = await fetch(`${apiBaseUrl}/api/v1/cms/auth/resolve-handoff`, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ },
82
+ body: JSON.stringify({
83
+ code: authCode,
84
+ storeId,
85
+ }),
86
+ });
87
+ if (!response.ok) {
88
+ const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
89
+ throw new Error(errorData.message || `Failed to resolve auth handoff: ${response.status}`);
90
+ }
91
+ const data = await response.json();
92
+ if (debugMode) {
93
+ console.log('[AuthHandoff] ✅ Resolved successfully:', {
94
+ customerId: data.customer.id,
95
+ role: data.customer.role,
96
+ hasContext: Object.keys(data.context).length > 0,
97
+ });
98
+ }
99
+ // Store token (overrides any existing token)
100
+ if (debugMode) {
101
+ console.log('[AuthHandoff] Storing new token (overriding existing)');
102
+ }
103
+ setClientToken(data.token);
104
+ // Clean URL (remove authCode parameter)
105
+ cleanAuthCodeFromUrl(debugMode);
106
+ // Mark as resolved
107
+ resolvedCodes.add(authCode);
108
+ return data;
109
+ }
110
+ catch (error) {
111
+ console.error('[AuthHandoff] ❌ Failed to resolve:', error);
112
+ throw error;
113
+ }
114
+ finally {
115
+ // Remove from in-flight cache after completion (success or failure)
116
+ resolutionCache.delete(authCode);
117
+ }
118
+ })();
119
+ // Cache the in-flight promise
120
+ resolutionCache.set(authCode, resolutionPromise);
121
+ return resolutionPromise;
122
+ }
123
+ /**
124
+ * Remove authCode from URL without page reload
125
+ * Uses history.replaceState to update URL cleanly
126
+ */
127
+ export function cleanAuthCodeFromUrl(debugMode = false) {
128
+ if (typeof window === 'undefined')
129
+ return;
130
+ const url = new URL(window.location.href);
131
+ if (url.searchParams.has('authCode')) {
132
+ url.searchParams.delete('authCode');
133
+ // Use replaceState to update URL without reload
134
+ window.history.replaceState({}, '', url.pathname + url.search + url.hash);
135
+ if (debugMode) {
136
+ console.log('[AuthHandoff] Cleaned authCode from URL');
137
+ }
138
+ }
139
+ }
140
+ /**
141
+ * Check if we should resolve authCode
142
+ * Returns true if authCode is present and valid format
143
+ */
144
+ export function shouldResolveAuthCode() {
145
+ const authCode = getAuthCode();
146
+ if (!authCode || !authCode.startsWith('ah_')) {
147
+ return false;
148
+ }
149
+ // Don't resolve if already resolved
150
+ if (resolvedCodes.has(authCode)) {
151
+ return false;
152
+ }
153
+ return true;
154
+ }