@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.
- package/dist/v2/core/client.d.ts +4 -0
- package/dist/v2/core/client.js +45 -1
- package/dist/v2/core/config/environment.d.ts +16 -2
- package/dist/v2/core/config/environment.js +40 -25
- package/dist/v2/core/resources/apiClient.d.ts +5 -0
- package/dist/v2/core/resources/apiClient.js +47 -0
- package/dist/v2/core/resources/funnel.d.ts +7 -0
- package/dist/v2/core/utils/pluginConfig.d.ts +1 -0
- package/dist/v2/core/utils/pluginConfig.js +58 -8
- package/dist/v2/react/components/ApplePayButton.js +1 -1
- package/dist/v2/react/hooks/useFunnel.d.ts +1 -0
- package/dist/v2/react/hooks/useFunnel.js +85 -86
- package/dist/v2/react/hooks/useOffersQuery.d.ts +18 -0
- package/dist/v2/react/hooks/useOffersQuery.js +110 -8
- package/dist/v2/react/providers/TagadaProvider.d.ts +5 -0
- package/package.json +1 -1
package/dist/v2/core/client.d.ts
CHANGED
|
@@ -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
|
package/dist/v2/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
|
91
|
-
*
|
|
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
|
-
//
|
|
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') ||
|
|
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
|
-
//
|
|
129
|
-
|
|
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
|
|
@@ -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
|
-
//
|
|
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
|
|
22
|
-
window.location.hostname.includes('.
|
|
23
|
-
|
|
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
|
|
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
|
|
207
|
+
return {
|
|
208
|
+
...localConfig,
|
|
209
|
+
staticResources: staticResources ?? undefined,
|
|
210
|
+
};
|
|
165
211
|
}
|
|
166
212
|
// Fall back to production config
|
|
167
|
-
|
|
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 = '
|
|
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
|
-
|
|
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(
|
|
245
|
+
setSessionCookie(enrichedContext.sessionId);
|
|
215
246
|
console.log(`✅ Funnel: Session initialized/loaded successfully`);
|
|
216
|
-
console.log(` - Session ID: ${
|
|
217
|
-
console.log(` - Funnel ID: ${
|
|
218
|
-
console.log(` - Current Step: ${
|
|
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(
|
|
252
|
+
void fetchFunnelDebugData(enrichedContext.sessionId);
|
|
222
253
|
}
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
+
// No navigation needed
|
|
471
506
|
break;
|
|
472
507
|
default:
|
|
473
|
-
console.warn(
|
|
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:
|
|
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
|
-
|
|
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;
|