@tagadapay/plugin-sdk 2.8.9 → 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.
@@ -33,6 +33,10 @@ export declare class TagadaClient {
33
33
  private boundHandleStorageChange;
34
34
  private readonly config;
35
35
  private instanceId;
36
+ private isInitializingSession;
37
+ private lastSessionInitError;
38
+ private sessionInitRetryCount;
39
+ private readonly MAX_SESSION_INIT_RETRIES;
36
40
  constructor(config?: TagadaClientConfig);
37
41
  /**
38
42
  * Cleanup client resources
@@ -10,6 +10,11 @@ export class TagadaClient {
10
10
  this.eventDispatcher = new EventDispatcher();
11
11
  this.tokenPromise = null;
12
12
  this.tokenResolver = null;
13
+ // Track initialization state to prevent infinite loops
14
+ this.isInitializingSession = false;
15
+ this.lastSessionInitError = null;
16
+ this.sessionInitRetryCount = 0;
17
+ this.MAX_SESSION_INIT_RETRIES = 3;
13
18
  this.config = config;
14
19
  this.instanceId = Math.random().toString(36).substr(2, 9);
15
20
  this.boundHandleStorageChange = this.handleStorageChange.bind(this);
@@ -86,6 +91,20 @@ export class TagadaClient {
86
91
  }
87
92
  return;
88
93
  }
94
+ // Prevent infinite loop: Don't re-initialize if we're currently initializing
95
+ if (this.isInitializingSession) {
96
+ if (this.state.debugMode) {
97
+ console.log(`[TagadaClient ${this.instanceId}] Session initialization in progress, skipping storage change`);
98
+ }
99
+ return;
100
+ }
101
+ // Prevent infinite loop: Don't retry if we've hit max retries
102
+ if (this.sessionInitRetryCount >= this.MAX_SESSION_INIT_RETRIES && this.lastSessionInitError) {
103
+ if (this.state.debugMode) {
104
+ console.error(`[TagadaClient ${this.instanceId}] Max session init retries reached, giving up`, this.lastSessionInitError);
105
+ }
106
+ return;
107
+ }
89
108
  // Re-run initialization when token may have changed
90
109
  if (this.state.debugMode) {
91
110
  console.log(`[TagadaClient ${this.instanceId}] Storage changed, re-initializing token...`);
@@ -259,6 +278,13 @@ export class TagadaClient {
259
278
  * Create anonymous token
260
279
  */
261
280
  async createAnonymousToken(storeId) {
281
+ // Prevent concurrent anonymous token creation
282
+ if (this.isInitializingSession) {
283
+ if (this.state.debugMode) {
284
+ console.log(`[TagadaClient ${this.instanceId}] Session initialization in progress, skipping anonymous token creation`);
285
+ }
286
+ return;
287
+ }
262
288
  try {
263
289
  if (this.state.debugMode)
264
290
  console.log('[TagadaClient] Creating anonymous token for store:', storeId);
@@ -282,6 +308,14 @@ export class TagadaClient {
282
308
  * Initialize session
283
309
  */
284
310
  async initializeSession(sessionData) {
311
+ // Prevent concurrent initialization attempts
312
+ if (this.isInitializingSession) {
313
+ if (this.state.debugMode) {
314
+ console.log(`[TagadaClient ${this.instanceId}] Session initialization already in progress, skipping`);
315
+ }
316
+ return;
317
+ }
318
+ this.isInitializingSession = true;
285
319
  try {
286
320
  if (this.state.debugMode) {
287
321
  console.log(`[TagadaClient ${this.instanceId}] Initializing session...`, { sessionId: sessionData.sessionId });
@@ -311,6 +345,9 @@ export class TagadaClient {
311
345
  timeZone: deviceInfo.timeZone,
312
346
  };
313
347
  const response = await this.apiClient.post('/api/v1/cms/session/init', sessionInitData);
348
+ // Success - reset error tracking
349
+ this.lastSessionInitError = null;
350
+ this.sessionInitRetryCount = 0;
314
351
  // Update state with session data
315
352
  this.updateSessionState(response, sessionData);
316
353
  this.updateState({
@@ -322,12 +359,19 @@ export class TagadaClient {
322
359
  console.log('[TagadaClient] Session initialized successfully');
323
360
  }
324
361
  catch (error) {
325
- console.error('[TagadaClient] Error initializing session:', error);
362
+ // Track error and increment retry count
363
+ this.lastSessionInitError = error;
364
+ this.sessionInitRetryCount++;
365
+ console.error(`[TagadaClient] Error initializing session (attempt ${this.sessionInitRetryCount}/${this.MAX_SESSION_INIT_RETRIES}):`, error);
326
366
  this.updateState({
327
367
  isInitialized: true,
328
368
  isLoading: false,
329
369
  });
330
370
  }
371
+ finally {
372
+ // Always release the lock
373
+ this.isInitializingSession = false;
374
+ }
331
375
  }
332
376
  updateSessionState(response, sessionData) {
333
377
  // Update Store
@@ -1,4 +1,17 @@
1
1
  import { ApiConfig, Environment, EnvironmentConfig } from '../types';
2
+ /**
3
+ * ⚠️ IMPORTANT: Runtime Environment Detection
4
+ *
5
+ * This SDK uses RUNTIME hostname detection, NOT build-time environment variables.
6
+ * This ensures the SDK always connects to the correct API based on where it's deployed.
7
+ *
8
+ * - Production domains → production API
9
+ * - Dev/staging domains → development API
10
+ * - Localhost/local IPs → local API (with optional override via window.__TAGADA_ENV__)
11
+ *
12
+ * Build-time .env variables (VITE_*, REACT_APP_*, NEXT_PUBLIC_*) are IGNORED
13
+ * to prevent incorrect API connections when plugins are deployed to different environments.
14
+ */
2
15
  /**
3
16
  * Environment configurations for different deployment environments
4
17
  */
@@ -16,7 +29,8 @@ export declare function buildApiUrl(config: EnvironmentConfig, endpointPath: str
16
29
  */
17
30
  export declare function getEndpointUrl(config: EnvironmentConfig, category: keyof ApiConfig['endpoints'], endpoint: string): string;
18
31
  /**
19
- * Auto-detect environment based on hostname, URL patterns, and deployment context
20
- * Works with any build tool or deployment system
32
+ * Auto-detect environment based on hostname and URL patterns at RUNTIME
33
+ * ⚠️ IMPORTANT: Ignores build-time .env variables to ensure correct detection in all environments
34
+ * .env variables are ONLY used for local development via window.__TAGADA_ENV__
21
35
  */
22
36
  export declare function detectEnvironment(): Environment;
@@ -1,4 +1,16 @@
1
- import { resolveEnvValue } from '../utils/env';
1
+ /**
2
+ * ⚠️ IMPORTANT: Runtime Environment Detection
3
+ *
4
+ * This SDK uses RUNTIME hostname detection, NOT build-time environment variables.
5
+ * This ensures the SDK always connects to the correct API based on where it's deployed.
6
+ *
7
+ * - Production domains → production API
8
+ * - Dev/staging domains → development API
9
+ * - Localhost/local IPs → local API (with optional override via window.__TAGADA_ENV__)
10
+ *
11
+ * Build-time .env variables (VITE_*, REACT_APP_*, NEXT_PUBLIC_*) are IGNORED
12
+ * to prevent incorrect API connections when plugins are deployed to different environments.
13
+ */
2
14
  /**
3
15
  * Environment configurations for different deployment environments
4
16
  */
@@ -87,33 +99,45 @@ export function getEndpointUrl(config, category, endpoint) {
87
99
  return buildApiUrl(config, endpointPath);
88
100
  }
89
101
  /**
90
- * Auto-detect environment based on hostname, URL patterns, and deployment context
91
- * Works with any build tool or deployment system
102
+ * Auto-detect environment based on hostname and URL patterns at RUNTIME
103
+ * ⚠️ IMPORTANT: Ignores build-time .env variables to ensure correct detection in all environments
104
+ * .env variables are ONLY used for local development via window.__TAGADA_ENV__
92
105
  */
93
106
  export function detectEnvironment() {
94
- // 1. Check environment variables first
95
- const envVar = resolveEnvValue('TAGADA_ENVIRONMENT') || resolveEnvValue('TAGADA_ENV');
96
- if (envVar) {
97
- const normalized = envVar.toLowerCase();
98
- if (normalized === 'production' || normalized === 'development' || normalized === 'local') {
99
- return normalized;
100
- }
101
- }
102
107
  // Check if we're in browser
103
108
  if (typeof window === 'undefined') {
104
109
  return 'local'; // SSR fallback
105
110
  }
106
111
  const hostname = window.location.hostname;
107
112
  const href = window.location.href;
108
- // Production: deployed to production domains
113
+ // 1. Check for LOCAL environment first (highest priority for dev)
114
+ // Local: localhost, local IPs, or local domains
115
+ if (hostname === 'localhost' ||
116
+ hostname.startsWith('127.') ||
117
+ hostname.startsWith('192.168.') ||
118
+ hostname.startsWith('10.') ||
119
+ hostname.includes('.local') ||
120
+ hostname === '' ||
121
+ hostname === '0.0.0.0') {
122
+ // For local development, allow override via window.__TAGADA_ENV__ (injected by dev server)
123
+ if (typeof window !== 'undefined' && window?.__TAGADA_ENV__?.TAGADA_ENVIRONMENT) {
124
+ const override = window.__TAGADA_ENV__.TAGADA_ENVIRONMENT.toLowerCase();
125
+ if (override === 'production' || override === 'development' || override === 'local') {
126
+ console.log(`[SDK] Local override detected: ${override}`);
127
+ return override;
128
+ }
129
+ }
130
+ return 'local';
131
+ }
132
+ // 2. Production: deployed to production domains
109
133
  if (hostname === 'app.tagadapay.com' ||
110
134
  hostname.includes('tagadapay.com') ||
111
135
  hostname.includes('yourproductiondomain.com')) {
112
136
  return 'production';
113
137
  }
114
- // Development: deployed to staging/dev domains or has dev indicators
138
+ // 3. Development: deployed to staging/dev domains or has dev indicators
115
139
  if (hostname === 'app.tagadapay.dev' ||
116
- hostname.includes('tagadapay.dev') || // ✅ app.tagadapay.dev and subdomains
140
+ hostname.includes('tagadapay.dev') ||
117
141
  hostname.includes('vercel.app') ||
118
142
  hostname.includes('netlify.app') ||
119
143
  hostname.includes('surge.sh') ||
@@ -125,16 +149,7 @@ export function detectEnvironment() {
125
149
  href.includes('#dev')) {
126
150
  return 'development';
127
151
  }
128
- // Local: localhost, local IPs, or local domains
129
- if (hostname === 'localhost' ||
130
- hostname.startsWith('127.') ||
131
- hostname.startsWith('192.168.') ||
132
- hostname.startsWith('10.') ||
133
- hostname.includes('.local') ||
134
- hostname === '' ||
135
- hostname === '0.0.0.0') {
136
- return 'local';
137
- }
138
- // Default fallback for unknown domains (safer to use development)
152
+ // 4. Default fallback for unknown domains (production is safest)
153
+ console.warn(`[SDK] Unknown domain: ${hostname}, defaulting to production`);
139
154
  return 'production';
140
155
  }
@@ -18,6 +18,9 @@ export declare class ApiClient {
18
18
  axios: AxiosInstance;
19
19
  private currentToken;
20
20
  private tokenProvider;
21
+ private requestHistory;
22
+ private readonly WINDOW_MS;
23
+ private readonly MAX_REQUESTS;
21
24
  constructor(config: ApiClientConfig);
22
25
  setTokenProvider(provider: TokenProvider): void;
23
26
  get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T>;
@@ -30,4 +33,6 @@ export declare class ApiClient {
30
33
  updateToken(token: string | null): void;
31
34
  getCurrentToken(): string | null;
32
35
  updateConfig(config: Partial<ApiClientConfig>): void;
36
+ private checkRequestLimit;
37
+ private cleanupHistory;
33
38
  }
@@ -7,6 +7,10 @@ export class ApiClient {
7
7
  constructor(config) {
8
8
  this.currentToken = null;
9
9
  this.tokenProvider = null;
10
+ // Circuit breaker state
11
+ this.requestHistory = new Map();
12
+ this.WINDOW_MS = 5000; // 5 seconds window
13
+ this.MAX_REQUESTS = 5; // Max 5 requests per endpoint in window
10
14
  this.axios = axios.create({
11
15
  baseURL: config.baseURL,
12
16
  timeout: config.timeout || 30000,
@@ -15,8 +19,22 @@ export class ApiClient {
15
19
  ...config.headers,
16
20
  },
17
21
  });
22
+ // Cleanup interval for circuit breaker history
23
+ if (typeof setInterval !== 'undefined') {
24
+ setInterval(() => this.cleanupHistory(), 10000);
25
+ }
18
26
  // Request interceptor for logging and auth
19
27
  this.axios.interceptors.request.use(async (config) => {
28
+ // Circuit Breaker Check
29
+ if (config.url) {
30
+ try {
31
+ this.checkRequestLimit(`${config.method?.toUpperCase()}:${config.url}`);
32
+ }
33
+ catch (error) {
34
+ console.error('[SDK] 🛑 Request blocked by Circuit Breaker:', error);
35
+ return Promise.reject(error);
36
+ }
37
+ }
20
38
  // Check if we need to wait for token
21
39
  if (!config.skipAuth && !this.currentToken && this.tokenProvider) {
22
40
  try {
@@ -113,4 +131,33 @@ export class ApiClient {
113
131
  }
114
132
  console.log('[SDK] ApiClient configuration updated');
115
133
  }
134
+ // Circuit Breaker Implementation
135
+ checkRequestLimit(key) {
136
+ const now = Date.now();
137
+ const history = this.requestHistory.get(key);
138
+ if (!history) {
139
+ this.requestHistory.set(key, { count: 1, firstRequestTime: now });
140
+ return;
141
+ }
142
+ if (now - history.firstRequestTime > this.WINDOW_MS) {
143
+ // Window expired, reset
144
+ this.requestHistory.set(key, { count: 1, firstRequestTime: now });
145
+ return;
146
+ }
147
+ history.count++;
148
+ if (history.count > this.MAX_REQUESTS) {
149
+ const error = new Error(`Circuit Breaker: Too many requests to ${key} (${history.count} in ${this.WINDOW_MS}ms)`);
150
+ // Add a property to identify this as a circuit breaker error
151
+ error.isCircuitBreaker = true;
152
+ throw error;
153
+ }
154
+ }
155
+ cleanupHistory() {
156
+ const now = Date.now();
157
+ for (const [key, history] of this.requestHistory.entries()) {
158
+ if (now - history.firstRequestTime > this.WINDOW_MS) {
159
+ this.requestHistory.delete(key);
160
+ }
161
+ }
162
+ }
116
163
  }
@@ -408,6 +408,13 @@ export interface SimpleFunnelContext<TCustom = {}> {
408
408
  * - Standard keys provide IntelliSense, custom keys always allowed
409
409
  */
410
410
  resources?: FunnelResourceMap<TCustom>;
411
+ /**
412
+ * Static resources from plugin manifest (type: "static")
413
+ * - Configured in funnel editor's Static Resources tab
414
+ * - Available at runtime as context.static
415
+ * - Example: context.static.offer.id for statically configured offers
416
+ */
417
+ static?: Record<string, any>;
411
418
  /**
412
419
  * Legacy/Custom metadata
413
420
  * For backward compatibility and flexible unstructured data
@@ -7,6 +7,7 @@ export type PluginConfig<TConfig = Record<string, any>> = {
7
7
  accountId?: string;
8
8
  basePath?: string;
9
9
  config?: any;
10
+ staticResources?: Record<string, any>;
10
11
  productId?: string;
11
12
  variants?: Record<string, string>;
12
13
  prices?: Record<string, any>;
@@ -14,13 +14,13 @@ const loadLocalDevConfig = async (configVariant = 'default') => {
14
14
  // Skip local config loading if explicitly in production mode
15
15
  return null;
16
16
  }
17
- // Only try to load local config in development
18
- // Use hostname-based detection for better Vite compatibility
17
+ // Only try to load local config in TRUE local development (not deployed CDN instances)
18
+ // Exclude CDN subdomains (e.g., instance-id.cdn.localhost) by checking for 'cdn.' prefix
19
19
  const isLocalDev = typeof window !== 'undefined' &&
20
20
  (window.location.hostname === 'localhost' ||
21
- window.location.hostname.includes('ngrok-free.app') ||
22
- window.location.hostname.includes('.localhost') ||
23
- window.location.hostname.includes('127.0.0.1'));
21
+ window.location.hostname === '127.0.0.1' ||
22
+ window.location.hostname.includes('ngrok-free.app')) &&
23
+ !window.location.hostname.includes('.cdn.');
24
24
  if (!isLocalDev) {
25
25
  return null;
26
26
  }
@@ -87,6 +87,43 @@ const loadLocalDevConfig = async (configVariant = 'default') => {
87
87
  return null;
88
88
  }
89
89
  };
90
+ /**
91
+ * Load static resources for local development
92
+ * Loads /config/resources.static.json
93
+ */
94
+ const loadStaticResources = async () => {
95
+ try {
96
+ // Check for explicit environment override
97
+ const env = resolveEnvValue('TAGADA_ENV') || resolveEnvValue('TAGADA_ENVIRONMENT');
98
+ if (env === 'production') {
99
+ return null;
100
+ }
101
+ // Only try to load in TRUE local development (not deployed CDN instances)
102
+ // Exclude CDN subdomains (e.g., instance-id.cdn.localhost) by checking for 'cdn.' prefix
103
+ const isLocalDev = typeof window !== 'undefined' &&
104
+ (window.location.hostname === 'localhost' ||
105
+ window.location.hostname === '127.0.0.1' ||
106
+ window.location.hostname.includes('ngrok-free.app')) &&
107
+ !window.location.hostname.includes('.cdn.');
108
+ if (!isLocalDev) {
109
+ return null;
110
+ }
111
+ // Load static resources file
112
+ console.log('🛠️ Attempting to load /config/resources.static.json...');
113
+ const response = await fetch('/config/resources.static.json');
114
+ if (!response.ok) {
115
+ console.log('🛠️ resources.static.json not found or failed to load');
116
+ return null;
117
+ }
118
+ const staticResources = await response.json();
119
+ console.log('🛠️ ✅ Loaded local static resources:', staticResources);
120
+ return staticResources;
121
+ }
122
+ catch (error) {
123
+ console.error('🛠️ ❌ Error loading static resources:', error);
124
+ return null;
125
+ }
126
+ };
90
127
  /**
91
128
  * Helper to get content from meta tag
92
129
  */
@@ -143,6 +180,8 @@ const loadProductionConfig = async () => {
143
180
  * Handles local dev, production, and raw config
144
181
  */
145
182
  export const loadPluginConfig = async (configVariant = 'default', rawConfig) => {
183
+ // Load static resources first (only in local dev)
184
+ const staticResources = await loadStaticResources();
146
185
  // If raw config is provided, use it directly
147
186
  if (rawConfig) {
148
187
  return {
@@ -150,21 +189,32 @@ export const loadPluginConfig = async (configVariant = 'default', rawConfig) =>
150
189
  accountId: rawConfig.accountId,
151
190
  basePath: rawConfig.basePath ?? '/',
152
191
  config: rawConfig.config ?? {},
192
+ staticResources: staticResources ?? undefined,
153
193
  };
154
194
  }
155
195
  else {
156
196
  const rawConfig = await createRawPluginConfig();
157
197
  if (rawConfig) {
158
- return rawConfig;
198
+ return {
199
+ ...rawConfig,
200
+ staticResources: staticResources ?? undefined,
201
+ };
159
202
  }
160
203
  }
161
204
  // Try local development config
162
205
  const localConfig = await loadLocalDevConfig(configVariant);
163
206
  if (localConfig) {
164
- return localConfig;
207
+ return {
208
+ ...localConfig,
209
+ staticResources: staticResources ?? undefined,
210
+ };
165
211
  }
166
212
  // Fall back to production config
167
- return loadProductionConfig();
213
+ const productionConfig = await loadProductionConfig();
214
+ return {
215
+ ...productionConfig,
216
+ staticResources: staticResources ?? undefined,
217
+ };
168
218
  };
169
219
  /**
170
220
  * Helper to load local config file for development (from /config directory)
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  */
6
6
  import { useCallback, useEffect, useState } from 'react';
7
7
  import { useExpressPaymentMethods } from '../hooks/useExpressPaymentMethods';
8
- export const ApplePayButton = ({ className = '', disabled = false, onSuccess, onError, onCancel, storeName, currencyCode = 'USD', variant = 'outline', size = 'lg', checkout, }) => {
8
+ export const ApplePayButton = ({ className = '', disabled = false, onSuccess, onError, onCancel, storeName, currencyCode = 'USD', variant = 'default', size = 'lg', checkout, }) => {
9
9
  const { applePayPaymentMethod, shippingMethods, lineItems, handleAddExpressId, updateCheckoutSessionValues, updateCustomerEmail, reComputeOrderSummary, setError: setContextError, } = useExpressPaymentMethods();
10
10
  const [processingPayment, setProcessingPayment] = useState(false);
11
11
  const [isApplePayAvailable, setIsApplePayAvailable] = useState(false);
@@ -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>;
@@ -21,16 +21,40 @@ const funnelQueryKeys = {
21
21
  * and the v2 ApiClient architecture.
22
22
  */
23
23
  export function useFunnel(options = {}) {
24
- const { auth, store, debugMode, updateFunnelDebugData } = useTagadaContext();
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);
32
54
  // Track the last processed URL funnelId to avoid re-processing on context changes
33
55
  const lastProcessedUrlFunnelIdRef = useRef(undefined);
56
+ // ✅ Track if initialization is currently in progress to prevent duplicate calls
57
+ const isInitializingRef = useRef(false);
34
58
  // Check if we have an existing session in cookies (to avoid unnecessary re-initialization)
35
59
  const hasExistingSessionCookie = useMemo(() => {
36
60
  if (typeof document === 'undefined')
@@ -106,14 +130,11 @@ export function useFunnel(options = {}) {
106
130
  queryKey: funnelQueryKeys.session(context?.sessionId || ''),
107
131
  queryFn: async () => {
108
132
  if (!context?.sessionId) {
109
- console.warn('🍪 Funnel: No session ID available for query');
110
133
  return null;
111
134
  }
112
- console.log(`🍪 Funnel: Fetching session data for ID: ${context.sessionId}`);
113
135
  const response = await funnelResource.getSession(context.sessionId);
114
- console.log(`🍪 Funnel: Session fetch response:`, response);
115
136
  if (response.success && response.context) {
116
- return response.context;
137
+ return enrichContext(response.context);
117
138
  }
118
139
  throw new Error(response.error || 'Failed to fetch session');
119
140
  },
@@ -125,7 +146,13 @@ export function useFunnel(options = {}) {
125
146
  // Initialize session mutation
126
147
  const initializeMutation = useMutation({
127
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;
128
154
  if (!auth.session?.customerId || !store?.id) {
155
+ isInitializingRef.current = false;
129
156
  throw new Error('Authentication required for funnel session');
130
157
  }
131
158
  // Get CURRENT URL params (not cached) to ensure we have latest values
@@ -201,31 +228,36 @@ export function useFunnel(options = {}) {
201
228
  console.log(` Error: ${response.error}`);
202
229
  }
203
230
  if (response.success && response.context) {
204
- return response.context;
231
+ return enrichContext(response.context);
205
232
  }
206
233
  else {
207
234
  throw new Error(response.error || 'Failed to initialize funnel session');
208
235
  }
209
236
  },
210
237
  onSuccess: (newContext) => {
211
- 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);
212
243
  setInitializationError(null);
213
244
  // Set session cookie for persistence across page reloads
214
- setSessionCookie(newContext.sessionId);
245
+ setSessionCookie(enrichedContext.sessionId);
215
246
  console.log(`✅ Funnel: Session initialized/loaded successfully`);
216
- console.log(` - Session ID: ${newContext.sessionId}`);
217
- console.log(` - Funnel ID: ${newContext.funnelId}`);
218
- console.log(` - Current Step: ${newContext.currentStepId}`);
247
+ console.log(` - Session ID: ${enrichedContext.sessionId}`);
248
+ console.log(` - Funnel ID: ${enrichedContext.funnelId}`);
249
+ console.log(` - Current Step: ${enrichedContext.currentStepId}`);
219
250
  // Fetch debug data if in debug mode
220
251
  if (debugMode) {
221
- void fetchFunnelDebugData(newContext.sessionId);
252
+ void fetchFunnelDebugData(enrichedContext.sessionId);
222
253
  }
223
- // Invalidate session query to refetch with new session ID
224
- void queryClient.invalidateQueries({
225
- queryKey: funnelQueryKeys.session(newContext.sessionId)
226
- });
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)`);
227
257
  },
228
258
  onError: (error) => {
259
+ // ✅ Reset initialization flag on error
260
+ isInitializingRef.current = false;
229
261
  const errorObj = error instanceof Error ? error : new Error(String(error));
230
262
  setInitializationError(errorObj);
231
263
  console.error('Error initializing funnel session:', error);
@@ -246,9 +278,7 @@ export function useFunnel(options = {}) {
246
278
  // ✅ Automatically include currentUrl for URL-to-Step mapping
247
279
  // User can override by providing event.currentUrl explicitly
248
280
  const currentUrl = event.currentUrl || (typeof window !== 'undefined' ? window.location.href : undefined);
249
- console.log(`🍪 Funnel: Navigating with session ID: ${context.sessionId}`);
250
281
  if (currentUrl) {
251
- console.log(`🍪 Funnel: Current URL for sync: ${currentUrl}`);
252
282
  }
253
283
  const requestBody = {
254
284
  sessionId: context.sessionId,
@@ -295,7 +325,8 @@ export function useFunnel(options = {}) {
295
325
  ...(recoveredSessionId ? { recovered: true, oldSessionId: context.sessionId } : {})
296
326
  }
297
327
  };
298
- setContext(newContext);
328
+ const enrichedContext = enrichContext(newContext);
329
+ setContext(enrichedContext);
299
330
  // Update cookie with new session ID if recovered
300
331
  if (recoveredSessionId) {
301
332
  document.cookie = `funnelSessionId=${recoveredSessionId}; path=/; max-age=86400; SameSite=Lax`;
@@ -308,7 +339,7 @@ export function useFunnel(options = {}) {
308
339
  type: 'redirect', // Default action type
309
340
  url: result.url
310
341
  },
311
- context: newContext,
342
+ context: enrichedContext,
312
343
  tracking: result.tracking
313
344
  };
314
345
  // Handle navigation callback with override capability
@@ -322,19 +353,25 @@ export function useFunnel(options = {}) {
322
353
  // Perform default navigation if not overridden
323
354
  if (shouldPerformDefaultNavigation && navigationResult.action.url) {
324
355
  // Add URL parameters for cross-domain session continuity
325
- const urlWithParams = addSessionParams(navigationResult.action.url, newContext.sessionId, effectiveFunnelId || options.funnelId);
356
+ const urlWithParams = addSessionParams(navigationResult.action.url, enrichedContext.sessionId, effectiveFunnelId || options.funnelId);
326
357
  const updatedAction = { ...navigationResult.action, url: urlWithParams };
327
358
  performNavigation(updatedAction);
328
359
  }
329
- console.log(`🍪 Funnel: Navigated from ${context.currentStepId} to ${result.stepId}`);
330
- // Fetch debug data if in debug mode
331
- if (debugMode) {
332
- 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
+ });
333
374
  }
334
- // Invalidate and refetch session data
335
- void queryClient.invalidateQueries({
336
- queryKey: funnelQueryKeys.session(newContext.sessionId)
337
- });
338
375
  },
339
376
  onError: (error) => {
340
377
  console.error('Funnel navigation error:', error);
@@ -368,7 +405,8 @@ export function useFunnel(options = {}) {
368
405
  ...updates,
369
406
  lastActivityAt: Date.now()
370
407
  };
371
- setContext(updatedContext);
408
+ const enrichedContext = enrichContext(updatedContext);
409
+ setContext(enrichedContext);
372
410
  console.log(`🍪 Funnel: Updated context for step ${context.currentStepId}`);
373
411
  // Invalidate session query
374
412
  void queryClient.invalidateQueries({
@@ -421,14 +459,15 @@ export function useFunnel(options = {}) {
421
459
  */
422
460
  const addSessionParams = useCallback((url, sessionId, funnelId) => {
423
461
  try {
424
- 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);
425
466
  urlObj.searchParams.set('funnelSessionId', sessionId);
426
467
  if (funnelId) {
427
468
  urlObj.searchParams.set('funnelId', funnelId);
428
469
  }
429
- const urlWithParams = urlObj.toString();
430
- console.log(`🍪 Funnel: Added session params to URL: ${url} → ${urlWithParams}`);
431
- return urlWithParams;
470
+ return urlObj.toString();
432
471
  }
433
472
  catch (error) {
434
473
  console.warn('Failed to add session params to URL:', error);
@@ -449,33 +488,34 @@ export function useFunnel(options = {}) {
449
488
  }
450
489
  switch (action.type) {
451
490
  case 'redirect':
452
- console.log(`🍪 Funnel: Redirecting to ${targetUrl}`);
453
491
  window.location.href = targetUrl;
454
492
  break;
455
493
  case 'replace':
456
- console.log(`🍪 Funnel: Replacing current page with ${targetUrl}`);
457
494
  window.location.replace(targetUrl);
458
495
  break;
459
496
  case 'push':
460
- console.log(`🍪 Funnel: Pushing to history: ${targetUrl}`);
461
497
  window.history.pushState({}, '', targetUrl);
462
498
  // Trigger a popstate event to update React Router
463
499
  window.dispatchEvent(new PopStateEvent('popstate'));
464
500
  break;
465
501
  case 'external':
466
- console.log(`🍪 Funnel: Opening external URL: ${targetUrl}`);
467
502
  window.open(targetUrl, '_blank');
468
503
  break;
469
504
  case 'none':
470
- console.log(`🍪 Funnel: No navigation action required`);
505
+ // No navigation needed
471
506
  break;
472
507
  default:
473
- console.warn(`🍪 Funnel: Unknown navigation action type: ${action.type}`);
508
+ console.warn(`Unknown navigation action type: ${action.type}`);
474
509
  break;
475
510
  }
476
511
  }, []);
477
512
  // Public API methods
478
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
+ }
479
519
  setInitializationAttempted(true);
480
520
  await initializeMutation.mutateAsync(entryStepId);
481
521
  }, [initializeMutation]);
@@ -514,75 +554,40 @@ export function useFunnel(options = {}) {
514
554
  * Priority: URL funnelId > Hook funnelId > Existing session funnelId > Backend default
515
555
  */
516
556
  useEffect(() => {
517
- console.log('🔍 Funnel: Auto-init effect triggered');
518
- console.log(` - autoInitialize: ${options.autoInitialize ?? true}`);
519
- console.log(` - hasAuth: ${!!auth.session?.customerId}`);
520
- console.log(` - hasStore: ${!!store?.id}`);
521
- console.log(` - isPending: ${initializeMutation.isPending}`);
522
- console.log(` - hasContext: ${!!context}`);
523
- console.log(` - context.sessionId: ${context?.sessionId || 'none'}`);
524
- console.log(` - context.funnelId: ${context?.funnelId || 'none'}`);
525
- console.log(` - urlFunnelId: ${urlFunnelId || 'none'}`);
526
- console.log(` - options.funnelId: ${options.funnelId || 'none'}`);
527
- console.log(` - initializationAttempted: ${initializationAttempted}`);
528
- console.log(` - hasExistingSessionCookie: ${hasExistingSessionCookie}`);
529
557
  // Skip if auto-initialize is disabled
530
558
  const autoInit = options.autoInitialize ?? true; // Default to true
531
559
  if (!autoInit) {
532
- console.log('⏭️ Funnel: Skipping - auto-initialize disabled');
533
560
  return;
534
561
  }
535
562
  // Skip if required dependencies are not available
536
563
  if (!auth.session?.customerId || !store?.id) {
537
- console.log('⏭️ Funnel: Skipping - missing auth or store');
538
564
  return;
539
565
  }
540
- // Skip if already initializing
541
- if (initializeMutation.isPending) {
542
- console.log('⏭️ Funnel: Skipping - already initializing');
566
+ // Skip if already initializing (check both mutation state and ref)
567
+ if (initializeMutation.isPending || isInitializingRef.current) {
543
568
  return;
544
569
  }
545
570
  // Determine if we have an explicit funnelId request (URL has priority)
546
571
  const explicitFunnelId = urlFunnelId || options.funnelId;
547
- console.log(` - explicitFunnelId: ${explicitFunnelId || 'none'}`);
548
572
  // Case 1: No session exists yet - need to initialize
549
573
  if (!context) {
550
- console.log('📍 Funnel: Case 1 - No context exists');
551
574
  // Check if we've already attempted initialization
552
575
  if (!initializationAttempted) {
553
- if (hasExistingSessionCookie) {
554
- console.log('🍪 Funnel: Loading existing session from cookie...');
555
- }
556
- else {
557
- console.log('🍪 Funnel: No session found - creating new session...');
558
- }
559
- if (explicitFunnelId) {
560
- console.log(` with funnelId: ${explicitFunnelId}`);
561
- }
562
576
  setInitializationAttempted(true);
563
577
  initializeSession().catch(error => {
564
578
  console.error('❌ Funnel: Auto-initialization failed:', error);
565
579
  });
566
580
  }
567
- else {
568
- console.log('⏭️ Funnel: Skipping - already attempted initialization');
569
- }
570
581
  return;
571
582
  }
572
- console.log('📍 Funnel: Case 2 - Context exists, checking for reset needs');
573
583
  // Case 2: Session exists - check if we need to reset it
574
584
  // ONLY reset if an explicit funnelId is provided AND it differs from current session
575
585
  if (explicitFunnelId && context.funnelId && explicitFunnelId !== context.funnelId) {
576
- console.log(`🔍 Funnel: Mismatch check - explicitFunnelId: ${explicitFunnelId}, context.funnelId: ${context.funnelId}`);
577
586
  // Check if we've already processed this funnelId to prevent loops
578
587
  if (lastProcessedUrlFunnelIdRef.current === explicitFunnelId) {
579
- console.log('⏭️ Funnel: Skipping - already processed this funnelId');
580
588
  return;
581
589
  }
582
- console.log(`🔄 Funnel: Explicit funnelId mismatch detected!`);
583
- console.log(` Current session funnelId: ${context.funnelId}`);
584
- console.log(` Requested funnelId: ${explicitFunnelId}`);
585
- console.log(` Resetting session...`);
590
+ console.log(`🔄 Funnel: Switching from funnel ${context.funnelId} to ${explicitFunnelId}`);
586
591
  // Mark this funnelId as processed
587
592
  lastProcessedUrlFunnelIdRef.current = explicitFunnelId;
588
593
  // Clear existing session
@@ -596,18 +601,10 @@ export function useFunnel(options = {}) {
596
601
  queryKey: funnelQueryKeys.session(context.sessionId)
597
602
  });
598
603
  // Initialize new session with correct funnelId
599
- console.log(`🍪 Funnel: Creating new session with funnelId: ${explicitFunnelId}`);
600
604
  initializeSession().catch(error => {
601
605
  console.error('❌ Funnel: Failed to reset session:', error);
602
606
  });
603
607
  }
604
- else {
605
- // Case 3: Session exists and no conflicting funnelId - keep using it
606
- console.log('✅ Funnel: Case 3 - Keeping existing session (no reset needed)');
607
- console.log(` - explicitFunnelId: ${explicitFunnelId || 'none (will keep existing)'}`);
608
- console.log(` - context.funnelId: ${context.funnelId}`);
609
- console.log(` - Match or no explicit request: keeping session ${context.sessionId}`);
610
- }
611
608
  }, [
612
609
  options.autoInitialize,
613
610
  options.funnelId,
@@ -630,6 +627,7 @@ export function useFunnel(options = {}) {
630
627
  }, [sessionData, context]);
631
628
  const isLoading = initializeMutation.isPending || navigateMutation.isPending || updateContextMutation.isPending;
632
629
  const isInitialized = !!context;
630
+ const isNavigating = navigateMutation.isPending; // Explicit navigation state
633
631
  return {
634
632
  next,
635
633
  goToStep,
@@ -640,6 +638,7 @@ export function useFunnel(options = {}) {
640
638
  context,
641
639
  isLoading,
642
640
  isInitialized,
641
+ isNavigating, // Expose isNavigating
643
642
  initializeSession,
644
643
  endSession,
645
644
  retryInitialization,
@@ -22,11 +22,29 @@ export interface UseOffersQueryOptions {
22
22
  * Order ID to associate with the offers (required for payments)
23
23
  */
24
24
  orderId?: string;
25
+ /**
26
+ * The ID of the currently active offer to preview/fetch summary for.
27
+ * If provided, the hook will automatically fetch and manage the summary for this offer.
28
+ */
29
+ activeOfferId?: string;
30
+ /**
31
+ * Whether to skip auto-preview fetching (e.g. during navigation or processing)
32
+ */
33
+ skipPreview?: boolean;
25
34
  }
26
35
  export interface UseOffersQueryResult {
27
36
  offers: Offer[];
28
37
  isLoading: boolean;
29
38
  error: Error | null;
39
+ /**
40
+ * Summary for the active offer (if activeOfferId is provided)
41
+ * Automatically falls back to static summary while loading dynamic data
42
+ */
43
+ activeSummary: OrderSummary | OfferSummary | null;
44
+ /**
45
+ * Whether the active offer summary is currently loading
46
+ */
47
+ isActiveSummaryLoading: boolean;
30
48
  /**
31
49
  * Pay for an offer
32
50
  * Initializes a checkout session and pays it
@@ -3,18 +3,26 @@
3
3
  * Handles offers with automatic caching
4
4
  */
5
5
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
6
- import { useCallback, useMemo, useRef, useState } from 'react';
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { OffersResource } from '../../core/resources/offers';
8
8
  import { useTagadaContext } from '../providers/TagadaProvider';
9
9
  import { getGlobalApiClient } from './useApiQuery';
10
10
  import { usePluginConfig } from './usePluginConfig';
11
11
  export function useOffersQuery(options = {}) {
12
- const { offerIds = [], enabled = true, returnUrl, orderId: defaultOrderId } = options;
12
+ const { offerIds = [], enabled = true, returnUrl, orderId: defaultOrderId, activeOfferId, skipPreview = false } = options;
13
13
  const { storeId } = usePluginConfig();
14
14
  const { isSessionInitialized, session } = useTagadaContext();
15
15
  const _queryClient = useQueryClient();
16
+ // Version identifier for debugging
17
+ console.log('[useOffersQuery] Hook initialized - VERSION: 2.2-production-ready', {
18
+ activeOfferId,
19
+ skipPreview,
20
+ isSessionInitialized,
21
+ });
16
22
  // State for checkout sessions per offer (similar to postPurchases)
17
23
  const [checkoutSessions, setCheckoutSessions] = useState({});
24
+ const [isActiveSummaryLoading, setIsActiveSummaryLoading] = useState(false);
25
+ const lastPreviewedOfferRef = useRef(null);
18
26
  // Use ref to break dependency cycles in callbacks
19
27
  const checkoutSessionsRef = useRef(checkoutSessions);
20
28
  // Update ref on every render
@@ -30,6 +38,8 @@ export function useOffersQuery(options = {}) {
30
38
  }, []);
31
39
  // Helper function for fetching order summary (similar to postPurchases)
32
40
  const fetchOrderSummary = useCallback(async (offerId, sessionId) => {
41
+ if (!isSessionInitialized)
42
+ return null;
33
43
  try {
34
44
  // Set updating state
35
45
  setCheckoutSessions(prev => ({
@@ -77,7 +87,7 @@ export function useOffersQuery(options = {}) {
77
87
  }));
78
88
  throw error;
79
89
  }
80
- }, [offersResource]);
90
+ }, [offersResource, isSessionInitialized]);
81
91
  // Create query key based on options
82
92
  const queryKey = useMemo(() => ['offers', { storeId, offerIds }], [storeId, offerIds]);
83
93
  // Use TanStack Query for fetching offers
@@ -120,6 +130,9 @@ export function useOffersQuery(options = {}) {
120
130
  return await payWithCheckoutSessionAsync({ checkoutSessionId, orderId });
121
131
  }, [payWithCheckoutSessionAsync]);
122
132
  const initCheckoutSession = useCallback(async (offerId, orderId, customerId) => {
133
+ if (!isSessionInitialized) {
134
+ throw new Error('Cannot initialize checkout session: CMS session is not initialized');
135
+ }
123
136
  // Use customerId from session context if not provided
124
137
  const effectiveCustomerId = customerId || session?.customerId;
125
138
  if (!effectiveCustomerId) {
@@ -130,8 +143,11 @@ export function useOffersQuery(options = {}) {
130
143
  orderId,
131
144
  customerId: effectiveCustomerId
132
145
  });
133
- }, [initCheckoutSessionAsync, session?.customerId]);
146
+ }, [initCheckoutSessionAsync, session?.customerId, isSessionInitialized]);
134
147
  const payOffer = useCallback(async (offerId, orderId) => {
148
+ if (!isSessionInitialized) {
149
+ throw new Error('Cannot pay offer: CMS session is not initialized');
150
+ }
135
151
  const effectiveOrderId = orderId || defaultOrderId;
136
152
  const effectiveCustomerId = session?.customerId;
137
153
  if (!effectiveOrderId) {
@@ -144,18 +160,26 @@ export function useOffersQuery(options = {}) {
144
160
  const { checkoutSessionId } = await initCheckoutSession(offerId, effectiveOrderId, effectiveCustomerId);
145
161
  // 2. Pay
146
162
  await payWithCheckoutSession(checkoutSessionId, effectiveOrderId);
147
- }, [initCheckoutSession, payWithCheckoutSession, defaultOrderId, session?.customerId]);
163
+ }, [initCheckoutSession, payWithCheckoutSession, defaultOrderId, session?.customerId, isSessionInitialized]);
148
164
  const preview = useCallback(async (offerId) => {
165
+ console.log('[useOffersQuery] preview() called for offer:', offerId);
166
+ if (!isSessionInitialized) {
167
+ console.log('[useOffersQuery] preview() - session not initialized, returning null');
168
+ // Return null silently to avoid errors during auto-initialization phases
169
+ return null;
170
+ }
149
171
  const effectiveOrderId = defaultOrderId;
150
172
  const effectiveCustomerId = session?.customerId;
151
173
  // Use ref to check current state without creating dependency
152
174
  const currentSessions = checkoutSessionsRef.current;
153
175
  // If we already have a summary in state, return it
154
176
  if (currentSessions[offerId]?.orderSummary) {
177
+ console.log('[useOffersQuery] preview() - using cached summary for offer:', offerId);
155
178
  return currentSessions[offerId].orderSummary;
156
179
  }
157
180
  // Prevent duplicate initialization if already has a session and is updating
158
181
  if (currentSessions[offerId]?.checkoutSessionId && currentSessions[offerId]?.isUpdatingSummary) {
182
+ console.log('[useOffersQuery] preview() - already updating, skipping for offer:', offerId);
159
183
  return null;
160
184
  }
161
185
  // If we don't have orderId, fallback to static summary from offer object
@@ -189,7 +213,71 @@ export function useOffersQuery(options = {}) {
189
213
  console.error("Failed to preview offer", e);
190
214
  return null;
191
215
  }
192
- }, [offers, defaultOrderId, session?.customerId, initCheckoutSession, fetchOrderSummary]); // Removed checkoutSessions dependency
216
+ }, [offers, defaultOrderId, session?.customerId, initCheckoutSession, fetchOrderSummary, isSessionInitialized]); // Removed checkoutSessions dependency
217
+ // Auto-preview effect for activeOfferId
218
+ useEffect(() => {
219
+ console.log('[useOffersQuery v2.2] Auto-preview effect triggered:', {
220
+ activeOfferId,
221
+ skipPreview,
222
+ isSessionInitialized,
223
+ lastPreviewed: lastPreviewedOfferRef.current,
224
+ });
225
+ if (!activeOfferId || skipPreview || !isSessionInitialized) {
226
+ console.log('[useOffersQuery] Skipping auto-preview - conditions not met');
227
+ setIsActiveSummaryLoading(false); // Reset loading state if conditions not met
228
+ // Reset the ref when conditions are not met
229
+ if (!activeOfferId) {
230
+ lastPreviewedOfferRef.current = null;
231
+ }
232
+ return;
233
+ }
234
+ // Skip if we've already previewed this exact offer
235
+ if (lastPreviewedOfferRef.current === activeOfferId) {
236
+ console.log('[useOffersQuery] Skipping auto-preview - already previewed:', activeOfferId);
237
+ setIsActiveSummaryLoading(false); // Ensure loading is false
238
+ return;
239
+ }
240
+ console.log('[useOffersQuery] Starting auto-preview for offer:', activeOfferId);
241
+ let isMounted = true;
242
+ setIsActiveSummaryLoading(true); // Set loading immediately
243
+ // Debounce the preview call
244
+ const timer = setTimeout(() => {
245
+ if (!isMounted) {
246
+ console.log('[useOffersQuery] Component unmounted before preview call');
247
+ return;
248
+ }
249
+ console.log('[useOffersQuery] Calling preview for offer:', activeOfferId);
250
+ preview(activeOfferId)
251
+ .then(() => {
252
+ if (isMounted) {
253
+ console.log('[useOffersQuery] Preview successful for offer:', activeOfferId);
254
+ lastPreviewedOfferRef.current = activeOfferId; // Mark as previewed on success
255
+ }
256
+ })
257
+ .catch(err => {
258
+ console.error('[useOffersQuery] Failed to auto-preview offer:', activeOfferId, err);
259
+ // Don't mark as previewed on error, to avoid infinite retry loop
260
+ })
261
+ .finally(() => {
262
+ if (isMounted) {
263
+ console.log('[useOffersQuery] Preview finished for offer:', activeOfferId);
264
+ setIsActiveSummaryLoading(false);
265
+ }
266
+ });
267
+ }, 50);
268
+ return () => {
269
+ console.log('[useOffersQuery] Cleaning up auto-preview effect for offer:', activeOfferId);
270
+ isMounted = false;
271
+ clearTimeout(timer);
272
+ setIsActiveSummaryLoading(false); // Reset loading on unmount/cleanup
273
+ };
274
+ }, [activeOfferId, skipPreview, isSessionInitialized]); // FIXED: Removed 'preview' from dependencies to prevent infinite loop
275
+ const activeSummary = useMemo(() => {
276
+ if (!activeOfferId)
277
+ return null;
278
+ // Return dynamic summary if available, otherwise fallback to static summary
279
+ return checkoutSessions[activeOfferId]?.orderSummary || offers.find(o => o.id === activeOfferId)?.summaries?.[0] || null;
280
+ }, [activeOfferId, checkoutSessions, offers]);
193
281
  const getAvailableVariants = useCallback((offerId, productId) => {
194
282
  const sessionState = checkoutSessions[offerId]; // This hook needs to react to state changes
195
283
  if (!sessionState?.orderSummary?.options?.[productId])
@@ -208,6 +296,9 @@ export function useOffersQuery(options = {}) {
208
296
  return checkoutSessions[offerId]?.loadingVariants?.[productId] ?? false;
209
297
  }, [checkoutSessions]);
210
298
  const selectVariant = useCallback(async (offerId, productId, variantId) => {
299
+ if (!isSessionInitialized) {
300
+ throw new Error('Cannot select variant: CMS session is not initialized');
301
+ }
211
302
  // Use ref for initial check to avoid dependency but we might need latest state for logic
212
303
  // Actually for actions it's better to use ref or just dependency if action is not called in useEffect
213
304
  const currentSessions = checkoutSessionsRef.current;
@@ -286,12 +377,14 @@ export function useOffersQuery(options = {}) {
286
377
  }
287
378
  }));
288
379
  }
289
- }, [offersResource, fetchOrderSummary]); // Removed checkoutSessions dependency
290
- return {
380
+ }, [offersResource, fetchOrderSummary, isSessionInitialized]); // Removed checkoutSessions dependency
381
+ const result = {
291
382
  // Query data
292
383
  offers,
293
384
  isLoading,
294
385
  error,
386
+ activeSummary,
387
+ isActiveSummaryLoading,
295
388
  // Actions
296
389
  payOffer,
297
390
  preview,
@@ -299,4 +392,13 @@ export function useOffersQuery(options = {}) {
299
392
  selectVariant,
300
393
  isLoadingVariants,
301
394
  };
395
+ console.log('[useOffersQuery] Returning result:', {
396
+ offersCount: offers.length,
397
+ isLoading,
398
+ hasError: !!error,
399
+ activeOfferId,
400
+ hasActiveSummary: !!activeSummary,
401
+ isActiveSummaryLoading,
402
+ });
403
+ return result;
302
404
  }
@@ -42,6 +42,11 @@ interface TagadaContextValue extends TagadaState {
42
42
  }
43
43
  interface TagadaProviderProps {
44
44
  children: ReactNode;
45
+ /**
46
+ * Optional environment override.
47
+ * ⚠️ Leave undefined for automatic runtime detection (recommended).
48
+ * Only set this if you need to force a specific environment for testing.
49
+ */
45
50
  environment?: Environment;
46
51
  customApiConfig?: Partial<EnvironmentConfig>;
47
52
  debugMode?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tagadapay/plugin-sdk",
3
- "version": "2.8.9",
3
+ "version": "2.8.10",
4
4
  "description": "Modern React SDK for building Tagada Pay plugins",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",