import { BotData, SendMessageRequest, StreamChatResponse, SessionTrackingRequest, TrackingResponse, } from "~/types/bot"; import { ProductCardData } from "~/types/product"; import { withAsyncErrorHandling, withAsyncErrorHandlingFilter, withErrorHandling, addBreadcrumb, captureApiError, captureStructuredError, devLog, createNetworkError, createHttpError, createParseError, getNetworkInfo, } from "./sentry"; import { retryRequest, fetchWithTimeout } from "./helpers"; import { generateSessionKey, WidgetType } from "./session-storage"; import { MOCK_MODE, mockStreamingResponse } from "./mock-api"; import { ExtendedWindow } from "@/types/window"; const base = "https://standardapi.dori.tech"; export const API_URLS = { demoSharingData: `${base}/bot-sharing-data/`, streamChat: `${base}/stream-chat-with-assistant/`, // V2 streaming endpoints runAssistant: `${base}/v2/run-assistant`, streamV2: `${base}/v2/stream`, searchProductLog: `${base}/search-selected-item-log`, getUploadUrl: `${base}/get-upload-url`, trackProductView: `${base}/roi/track-view`, trackSession: `${base}/roi/track-session`, trackPDPInteraction: `${base}/roi/track-pdp-interaction`, renderingEventsConfig: `${base}/widget/events/rendering`, generateWelcomeMessage: "https://widget-visibility-poc-ztxwv.ondigitalocean.app/generate-welcome-message", }; // In-flight request cache to prevent duplicate concurrent fetches const inFlightRequests = new Map>(); /** * Deduplicate concurrent requests for the same sharing ID * If a request is already in-flight, return the existing promise */ function deduplicateRequest(key: string, fn: () => Promise): Promise { // Check if request is already in-flight if (inFlightRequests.has(key)) { addBreadcrumb( `Request deduplicated for key: ${key}`, "api.request_deduplication", "info", { key } ); return inFlightRequests.get(key)!; } // Start new request and cache it const promise = fn() .then((result) => { // Remove from cache when done inFlightRequests.delete(key); return result; }) .catch((error) => { // Remove from cache on error too inFlightRequests.delete(key); throw error; }); inFlightRequests.set(key, promise); return promise; } export const fetchBotDataBySharingID = withAsyncErrorHandlingFilter( "fetchBotDataBySharingID", (error) => error.message.includes("404"), async ( sharingID: string, etag?: string ): Promise<{ data?: BotData; etag?: string; status?: number; error?: string; }> => { // Deduplicate concurrent requests for the same sharing ID return deduplicateRequest(`bot-data-${sharingID}`, async () => { const url = API_URLS.demoSharingData + sharingID; const startTime = Date.now(); // Development logging (no-op in production) devLog.request("fetchBotDataBySharingID", url, "GET", { sharing_id: sharingID, etag, }); devLog.networkInfo(); addBreadcrumb( `Fetching bot data for sharing ID: ${sharingID}${etag ? " with ETag" : ""}`, "api.bot_data", "info", { etag } ); let response; const headers: Record = {}; if (etag) { headers["If-None-Match"] = etag; } // Retry with exponential backoff for network failures try { response = await retryRequest( async () => { try { return await fetchWithTimeout(url, { headers }, 30000); } catch (error) { // Add breadcrumb for failed attempt const errorMsg = error instanceof Error ? error.message : String(error); addBreadcrumb( `Fetch attempt failed: ${errorMsg}`, "api.bot_data_retry", "warning", { sharing_id: sharingID, error: errorMsg } ); throw error; } }, { retries: 2, delays: [2000, 4000], shouldRetry: (error) => { // Don't retry on AbortError or if it's an HTTP error (we got a response) if (error instanceof Error) { if (error.name === "AbortError") { return false; } // If we got an HTTP response with 404, don't retry if (error.message.includes("404")) { return false; } } // Retry on network errors (Failed to fetch, NetworkError, Load failed, etc.) return true; }, } ); } catch (networkError) { // Network request failed after all retries const networkInfo = getNetworkInfo(); const errorDetails = { error_type: "network", error_name: (networkError as Error)?.name, error_message: (networkError as Error)?.message, endpoint: url, sharing_id: sharingID, request_duration_ms: Date.now() - startTime, ...networkInfo, timestamp: new Date().toISOString(), }; addBreadcrumb( "Network error during bot data fetch", "api.bot_data_error", "error", errorDetails ); // Create enhanced network error const enhancedError = createNetworkError( networkError as Error, "fetchBotDataBySharingID", url, "GET", startTime ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataBySharingID", enhancedError.toStructuredData() ); // Capture structured error with rich context captureApiError( enhancedError, "fetchBotDataBySharingID", url, "GET", startTime ); throw enhancedError; } const responseTime = Date.now() - startTime; if (response.status === 304) { return { status: 304, data: undefined, etag: undefined, error: undefined, }; } if (response.status !== 200) { const errorMsg = `Network response was not ok: ${response.status}`; let responseText = ""; try { responseText = await response.text(); } catch (textError) { responseText = "Unable to read response text"; addBreadcrumb( "Failed to read response body", "api.fetch_response", "warning", { status: response.status, statusText: response.statusText, error: (textError as Error)?.message || "Unknown error", url: url, } ); } // Optimized error details - use helper function const networkInfo = getNetworkInfo(); const errorDetails = { endpoint: url, status: response.status, status_text: response.statusText, sharing_id: sharingID, response_time_ms: responseTime, response_size_bytes: responseText.length, response_headers: Object.fromEntries(response.headers.entries()), response_body: responseText.substring(0, 1000), // First 1000 chars ...networkInfo, timestamp: new Date().toISOString(), }; addBreadcrumb(errorMsg, "api.bot_data_error", "warning", errorDetails); // Create enhanced HTTP error const enhancedError = createHttpError( response.status, response.statusText, "fetchBotDataBySharingID", url, "GET", startTime, responseText ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataBySharingID", enhancedError.toStructuredData() ); // Capture structured error for non-200 responses captureApiError( enhancedError, "fetchBotDataBySharingID", url, "GET", startTime, response ); throw enhancedError; } let data; let responseSize = 0; try { const responseText = await response.text(); responseSize = responseText.length; data = JSON.parse(responseText); } catch (parseError) { const parseErrorDetails = { error_type: "json_parse", error_name: (parseError as Error)?.name, error_message: (parseError as Error)?.message, error_stack: (parseError as Error)?.stack, endpoint: url, sharing_id: sharingID, response_time_ms: responseTime, response_size_bytes: responseSize, status: response.status, timestamp: new Date().toISOString(), }; addBreadcrumb( "JSON parse error in bot data response", "api.bot_data_error", "error", parseErrorDetails ); // Create enhanced parse error const enhancedError = createParseError( parseError as Error, "fetchBotDataBySharingID", url, "GET", startTime, responseSize ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataBySharingID", enhancedError.toStructuredData() ); // Capture structured error for JSON parse failures captureApiError( enhancedError, "fetchBotDataBySharingID", url, "GET", startTime, response ); throw enhancedError; } // Development success logging (no-op in production) devLog.success( "fetchBotDataBySharingID", responseTime, responseSize, response.status ); // Lightweight success breadcrumb (minimal overhead) addBreadcrumb("Bot data fetched successfully", "api.bot_data", "info", { sharing_id: sharingID, response_time_ms: responseTime, }); return { data, etag: response.headers.get("ETag") || undefined, status: 200, }; }); } ); export const fetchBotDataWithProduct = withAsyncErrorHandlingFilter( "fetchBotDataWithProduct", (error) => error.message.includes("404"), async ( sharingID: string, productId: string ): Promise<{ data?: BotData; error?: string; }> => { // Deduplicate concurrent requests for the same sharing ID + product ID return deduplicateRequest( `bot-data-product-${sharingID}-${productId}`, async () => { const url = `${API_URLS.demoSharingData}${sharingID}?product_id=${encodeURIComponent(productId)}`; const startTime = Date.now(); // Development logging (no-op in production) devLog.request("fetchBotDataWithProduct", url, "GET", { sharing_id: sharingID, product_id: productId, }); devLog.networkInfo(); addBreadcrumb( "Fetching bot data with product context", "api.bot_data_product", "info", { sharing_id: sharingID, product_id: productId, } ); let response; // Retry with exponential backoff for network failures try { response = await retryRequest( async () => { try { return await fetchWithTimeout(url, {}, 30000); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); addBreadcrumb( `Fetch attempt failed: ${errorMsg}`, "api.bot_data_product_retry", "warning", { sharing_id: sharingID, product_id: productId, error: errorMsg, } ); throw error; } }, { retries: 2, delays: [2000, 4000], shouldRetry: (error) => { if (error instanceof Error) { if (error.name === "AbortError") { return false; } if (error.message.includes("404")) { return false; } } return true; }, } ); } catch (networkError) { // Optimized error handling - only create detailed context on errors const networkInfo = getNetworkInfo(); const errorDetails = { error_type: "network", error_name: (networkError as Error)?.name, error_message: (networkError as Error)?.message, endpoint: url, sharing_id: sharingID, product_id: productId, request_duration_ms: Date.now() - startTime, ...networkInfo, timestamp: new Date().toISOString(), }; addBreadcrumb( "Network error during bot data fetch with product", "api.bot_data_product_error", "error", errorDetails ); // Create enhanced network error const enhancedError = createNetworkError( networkError as Error, "fetchBotDataWithProduct", url, "GET", startTime ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataWithProduct", enhancedError.toStructuredData() ); // Capture structured error with rich context captureApiError( enhancedError, "fetchBotDataWithProduct", url, "GET", startTime ); throw enhancedError; } const responseTime = Date.now() - startTime; if (response.status !== 200) { const errorMsg = `Network response was not ok: ${response.status}`; let responseText = ""; try { responseText = await response.text(); } catch (textError) { responseText = "Unable to read response text"; addBreadcrumb( "Failed to read response body", "api.fetch_response", "warning", { status: response.status, statusText: response.statusText, error: (textError as Error)?.message || "Unknown error", url: url, } ); } // Optimized error details - use helper function const networkInfo = getNetworkInfo(); const errorDetails = { endpoint: url, status: response.status, status_text: response.statusText, sharing_id: sharingID, product_id: productId, response_time_ms: responseTime, response_size_bytes: responseText.length, response_headers: Object.fromEntries(response.headers.entries()), response_body: responseText.substring(0, 1000), // First 1000 chars ...networkInfo, timestamp: new Date().toISOString(), }; addBreadcrumb( errorMsg, "api.bot_data_product_error", "warning", errorDetails ); // Create enhanced HTTP error const enhancedError = createHttpError( response.status, response.statusText, "fetchBotDataWithProduct", url, "GET", startTime, responseText ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataWithProduct", enhancedError.toStructuredData() ); // Capture structured error for non-200 responses captureApiError( enhancedError, "fetchBotDataWithProduct", url, "GET", startTime, response ); throw enhancedError; } let data; let responseSize = 0; try { const responseText = await response.text(); responseSize = responseText.length; data = JSON.parse(responseText); } catch (parseError) { const parseErrorDetails = { error_type: "json_parse", error_name: (parseError as Error)?.name, error_message: (parseError as Error)?.message, error_stack: (parseError as Error)?.stack, endpoint: url, sharing_id: sharingID, product_id: productId, response_time_ms: responseTime, response_size_bytes: responseSize, status: response.status, timestamp: new Date().toISOString(), }; addBreadcrumb( "JSON parse error in bot data with product response", "api.bot_data_product_error", "error", parseErrorDetails ); // Create enhanced parse error const enhancedError = createParseError( parseError as Error, "fetchBotDataWithProduct", url, "GET", startTime, responseSize ); // Development error logging with structured data devLog.error( enhancedError, "fetchBotDataWithProduct", enhancedError.toStructuredData() ); // Capture structured error for JSON parse failures captureApiError( enhancedError, "fetchBotDataWithProduct", url, "GET", startTime, response ); throw enhancedError; } // Development success logging (no-op in production) devLog.success( "fetchBotDataWithProduct", responseTime, responseSize, response.status ); // Lightweight success breadcrumb (minimal overhead) addBreadcrumb( "Bot data with product fetched successfully", "api.bot_data_product", "info", { sharing_id: sharingID, product_id: productId, response_time_ms: responseTime, } ); return data; } ); } ); export const validateThreadId = withAsyncErrorHandling( "validateThreadId", async (threadId: string, assistantId: string): Promise => { if (!threadId || !assistantId) { return false; } const requestBody = { assistant_id: assistantId, thread_id: threadId, message: "", }; let headers: Record = { Accept: "application/json", "Content-Type": "application/json", }; if (typeof window !== "undefined" && (window as ExtendedWindow).idToken) { headers["Authorization"] = `Bearer ${(window as ExtendedWindow).idToken}`; } try { const response = await fetchWithTimeout( API_URLS.runAssistant, { method: "POST", headers: headers, body: JSON.stringify(requestBody), }, 5000 ); if (response.status === 403 || response.status === 404) { let errorDetail = ""; try { const errorResponse = await response.json(); errorDetail = errorResponse.detail || ""; } catch {} if ( errorDetail === "Access denied for thread" || errorDetail === "Thread not found" ) { addBreadcrumb( "Thread validation failed", "api.validate_thread", "warning", { thread_id: threadId, status: response.status, error_detail: errorDetail, } ); return false; } } if (!response.ok) { addBreadcrumb( "Thread validation returned non-OK status", "api.validate_thread", "warning", { thread_id: threadId, status: response.status, } ); return false; } addBreadcrumb( "Thread validation successful", "api.validate_thread", "info", { thread_id: threadId, } ); return true; } catch (error) { const errorMessage = (error as Error)?.message || String(error); addBreadcrumb( "Thread validation error", "api.validate_thread_error", "warning", { thread_id: threadId, error_message: errorMessage, } ); return false; } } ); export const sendMessageToStreamAPI = withAsyncErrorHandling( "sendMessageToStreamAPI", async ( message: string, botData: BotData, threadId: string | null, onChunk: (chunk: StreamChatResponse) => void, onError: (error: string) => void, onComplete: () => void, abortController?: AbortController, imageUrl?: string, platform?: string, productId?: string, onThreadAccessError?: () => void ): Promise => { if (MOCK_MODE) { return mockStreamingResponse(onChunk, onComplete, abortController); } const ASSISTANT_ID = botData.assistant_id; // Step 1: POST to /v2/run-assistant to start the stream const requestBody = { assistant_id: ASSISTANT_ID, thread_id: threadId || undefined, message: message, src: typeof window !== "undefined" ? window.location.href : "", product_id: productId, platform: platform || "public_page", image_url: imageUrl, }; // Remove undefined values Object.keys(requestBody).forEach( (key) => requestBody[key as keyof typeof requestBody] === undefined && delete requestBody[key as keyof typeof requestBody] ); addBreadcrumb("Starting assistant run (v2)", "api.run_assistant", "info", { assistant_id: ASSISTANT_ID, has_image: !!imageUrl, thread_id: threadId, product_id: productId, }); let runResponse; let headers: Record = { Accept: "application/json", "Content-Type": "application/json", }; // Add Authorization header if idToken exists if (typeof window !== "undefined" && (window as ExtendedWindow).idToken) { headers["Authorization"] = `Bearer ${(window as ExtendedWindow).idToken}`; } try { runResponse = await fetch(API_URLS.runAssistant, { method: "POST", headers: headers, body: JSON.stringify(requestBody), signal: abortController?.signal, }); } catch (fetchError) { console.error("[Mori Widget] Run assistant request failed:", { error: fetchError, errorName: (fetchError as Error)?.name, errorMessage: (fetchError as Error)?.message, assistant_id: ASSISTANT_ID, thread_id: threadId, }); addBreadcrumb( "Run assistant request failed", "api.run_assistant_error", "error", { error_name: (fetchError as Error)?.name, error_message: (fetchError as Error)?.message, assistant_id: ASSISTANT_ID, } ); throw fetchError; } if (abortController?.signal.aborted) { return; } // Handle error responses if (runResponse.status === 403) { let errorDetail = ""; try { const errorResponse = await runResponse.json(); errorDetail = errorResponse.detail || ""; } catch {} if (errorDetail === "Access denied for thread") { onThreadAccessError?.(); addBreadcrumb( "Thread access denied - retrying without thread ID", "api.thread_access_retry", "warning", { thread_id: threadId, assistant_id: ASSISTANT_ID, } ); if (threadId) { await new Promise((resolve) => setTimeout(resolve, 1000)); try { return await sendMessageToStreamAPI( message, botData, null, onChunk, onError, onComplete, abortController, imageUrl, platform, productId, onThreadAccessError ); } catch (retryError) { addBreadcrumb( "Retry without thread ID failed", "api.thread_access_retry_failed", "error", { error_message: (retryError as Error)?.message || String(retryError), } ); onError("متأسفانه خطایی رخ داده است. لطفاً دوباره تلاش کنید."); return; } } onError( "دسترسی به این گفتگو امکان‌پذیر نیست. یک گفتگوی جدید شروع می‌شود." ); return; } } if (runResponse.status === 404) { let errorDetail = ""; try { const errorResponse = await runResponse.json(); errorDetail = errorResponse.detail || ""; } catch {} if (errorDetail === "Thread not found") { onThreadAccessError?.(); addBreadcrumb( "Thread not found - retrying without thread ID", "api.thread_not_found_retry", "warning", { thread_id: threadId, assistant_id: ASSISTANT_ID, } ); if (threadId) { await new Promise((resolve) => setTimeout(resolve, 1000)); try { return await sendMessageToStreamAPI( message, botData, null, onChunk, onError, onComplete, abortController, imageUrl, platform, productId, onThreadAccessError ); } catch (retryError) { addBreadcrumb( "Retry without thread ID failed", "api.thread_not_found_retry_failed", "error", { error_message: (retryError as Error)?.message || String(retryError), } ); onError("متأسفانه خطایی رخ داده است. لطفاً دوباره تلاش کنید."); return; } } onError("این گفتگو یافت نشد. یک گفتگوی جدید شروع می‌شود."); return; } } if (runResponse.status === 429) { console.error("[Mori Widget] API rate limit exceeded:", { status: 429, assistant_id: ASSISTANT_ID, thread_id: threadId, }); addBreadcrumb( "Rate limit exceeded", "api.run_assistant_error", "warning", { status: 429, assistant_id: ASSISTANT_ID, } ); onError( "شما از محدودیت تعداد درخواست‌ها عبور کرده‌اید. لطفاً حداقل ۲۴ ساعت صبر کنید و دوباره تلاش کنید." ); return; } if (runResponse.status === 402) { console.error("[Mori Widget] Subscription expired:", { status: 402, assistant_id: ASSISTANT_ID, thread_id: threadId, }); addBreadcrumb( "Subscription expired", "api.run_assistant_error", "warning", { status: 402, assistant_id: ASSISTANT_ID, } ); onError( "سلام هوش مصنوعی این کسب و کار بصورت موقت غیر فعال است؛ برای پرسیدن سوال‌هاتون لطفا با پشتیبانی تماس بگیرید. امیدواریم بزودی در خدمت شما باشیم!" ); return; } if (!runResponse.ok) { console.error("[Mori Widget] Run assistant response not OK:", { status: runResponse.status, statusText: runResponse.statusText, assistant_id: ASSISTANT_ID, thread_id: threadId, }); addBreadcrumb( "Run assistant response not OK", "api.run_assistant_error", "error", { status: runResponse.status, status_text: runResponse.statusText, assistant_id: ASSISTANT_ID, } ); throw new Error( `Run assistant request failed with status ${runResponse.status}: ${runResponse.statusText}` ); } // Parse the response to get the thread_id let runResult: { thread_id: string; status: string }; try { runResult = await runResponse.json(); } catch (parseError) { console.error( "[Mori Widget] Failed to parse run assistant response:", parseError ); throw new Error("Failed to parse run assistant response"); } const streamThreadId = runResult.thread_id; if (!streamThreadId) { throw new Error("No thread_id received from run assistant"); } addBreadcrumb("Assistant run started", "api.run_assistant", "info", { thread_id: streamThreadId, status: runResult.status, }); // Step 2: Connect to SSE stream using native EventSource const streamUrl = `${API_URLS.streamV2}/${streamThreadId}`; addBreadcrumb("Connecting to SSE stream", "api.stream_v2", "info", { thread_id: streamThreadId, url: streamUrl, }); return new Promise((resolve, reject) => { const eventSource = new EventSource(streamUrl); // Handle abort signal - close EventSource when aborted const abortHandler = () => { eventSource.close(); resolve(); }; if (abortController) { abortController.signal.addEventListener("abort", abortHandler); } // Cleanup function const cleanup = () => { if (abortController) { abortController.signal.removeEventListener("abort", abortHandler); } eventSource.close(); }; eventSource.onopen = () => { addBreadcrumb("SSE stream connected", "api.stream_v2", "info", { thread_id: streamThreadId, }); }; eventSource.onerror = () => { // Log but don't cleanup - let EventSource handle reconnection // EventSource will automatically retry on network errors console.log( "[Mori Widget] SSE stream error, EventSource will auto-reconnect", { thread_id: streamThreadId, readyState: eventSource.readyState, } ); addBreadcrumb( "SSE stream error (auto-reconnecting)", "api.stream_v2_error", "warning", { thread_id: streamThreadId, ready_state: eventSource.readyState, } ); // Don't call cleanup() here - EventSource handles reconnection automatically // Cleanup only happens on 'end' event or abort signal }; // Handle 'message' events (default event type) eventSource.onmessage = (event) => { try { const parsed = JSON.parse(event.data); // Convert SSE message format to StreamChatResponse format const chunk: StreamChatResponse = { thread_id: parsed.thread_id, text: parsed.text, wait: parsed.wait, tool_name: parsed.tool_name, products: parsed.products, status: parsed.status, suggestions: parsed.suggestions, }; // Remove undefined values Object.keys(chunk).forEach( (key) => chunk[key as keyof StreamChatResponse] === undefined && delete chunk[key as keyof StreamChatResponse] ); // Only call onChunk if there's meaningful data if (Object.keys(chunk).length > 0) { onChunk(chunk); } } catch (parseError) { const error = parseError as Error; console.error("[Mori Widget] SSE message parse error:", { error: error?.message, data: event.data, thread_id: streamThreadId, }); captureStructuredError(error, { operation: "sendMessageToStreamAPI", request: { url: streamUrl, method: "GET", }, additionalData: { context: "sse_message_parse", event_data_preview: event.data?.substring(0, 200), thread_id: streamThreadId, assistant_id: ASSISTANT_ID, }, }); } }; // Handle 'start' event eventSource.addEventListener("start", () => { addBreadcrumb("SSE stream started", "api.stream_v2", "info", { thread_id: streamThreadId, }); }); // Handle 'end' event eventSource.addEventListener("end", () => { addBreadcrumb("SSE stream ended", "api.stream_v2", "info", { thread_id: streamThreadId, }); cleanup(); onComplete(); resolve(); }); }); } ); export const trackProductClick = withAsyncErrorHandling( "trackProductClick", async ( product: ProductCardData, botData: BotData, threadId: string | null ) => { const requestBody = { log: { assistant_id: botData.assistant_id, thread_id: threadId, created_at: new Date().toISOString(), selected_item_id: product.id, tool_call_id: product.tool_call_id, }, }; await fetch(API_URLS.searchProductLog, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); } ); export const trackProductViewsBatch = withAsyncErrorHandling( "trackProductViewsBatch", async ( productIds: string[], threadId: string, sessionKey: string, platform: string, sharingId: string, customerEmail?: string, customerId?: string ) => { if (productIds.length === 0) return; const requestBody = { customer_email: customerEmail || null, customer_id: customerId || null, external_product_ids: productIds, thread_id: threadId, platform, session_key: sessionKey, sharing_id: sharingId, }; await fetch(API_URLS.trackProductView, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); } ); export const trackDoriSession = withAsyncErrorHandling( "trackDoriSession", async ( sharingId: string, threadId: string, platform: string, widgetType?: string, customerEmail?: string, customerId?: string ): Promise => { const sessionKey = generateSessionKey(widgetType as WidgetType); const requestBody: SessionTrackingRequest = { sharing_id: sharingId, thread_id: threadId, platform, customer_email: customerEmail, customer_id: customerId, session_key: sessionKey, }; addBreadcrumb("Tracking session", "api.track_session", "info", { sharing_id: sharingId, thread_id: threadId, widget_type: widgetType, }); const response = await fetch(API_URLS.trackSession, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorMsg = `Failed to track session: ${response.status}`; addBreadcrumb(errorMsg, "api.track_session_error", "warning", { status: response.status, sharing_id: sharingId, }); throw new Error(errorMsg); } addBreadcrumb("Session tracked successfully", "api.track_session", "info", { sharing_id: sharingId, thread_id: threadId, }); return await response.json(); } ); export const trackPDPInteraction = withAsyncErrorHandling( "trackPDPInteraction", async ( sharingId: string, threadId: string, externalProductId: string, question: string, platform: string, customerEmail?: string, customerId?: string ): Promise => { const requestBody = { sharing_id: sharingId, thread_id: threadId, external_product_id: externalProductId, question, customer_email: customerEmail || null, customer_id: customerId || null, platform, }; addBreadcrumb("Tracking PDP interaction", "api.track_pdp", "info", { sharing_id: sharingId, thread_id: threadId, product_id: externalProductId, }); const response = await fetch(API_URLS.trackPDPInteraction, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorMsg = `Failed to track PDP interaction: ${response.status}`; addBreadcrumb(errorMsg, "api.track_pdp_error", "warning", { status: response.status, sharing_id: sharingId, product_id: externalProductId, }); throw new Error(errorMsg); } addBreadcrumb( "PDP interaction tracked successfully", "api.track_pdp", "info", { sharing_id: sharingId, product_id: externalProductId, } ); return await response.json(); } ); // Cache for rendering events config to avoid repeated API calls let renderingEventsConfigCache: { enabled: boolean } | null = null; let renderingEventsConfigCacheTime: number | null = null; let renderingEventsConfigPromise: Promise<{ enabled: boolean }> | null = null; // 1 hour TTL - config changes are picked up within an hour without page refresh const RENDERING_EVENTS_CACHE_TTL_MS = 60 * 60 * 1000; export const fetchRenderingEventsConfig = async (): Promise<{ enabled: boolean; }> => { // Return cached result if available and not expired if ( renderingEventsConfigCache !== null && renderingEventsConfigCacheTime !== null && Date.now() - renderingEventsConfigCacheTime < RENDERING_EVENTS_CACHE_TTL_MS ) { return renderingEventsConfigCache; } // Return in-flight promise if already fetching if (renderingEventsConfigPromise !== null) { return renderingEventsConfigPromise; } // Start new fetch renderingEventsConfigPromise = (async () => { try { const response = await fetchWithTimeout( API_URLS.renderingEventsConfig, { method: "GET", headers: { Accept: "application/json", }, }, 5000 ); if (!response.ok) { // Default to disabled on error to avoid hitting PostHog limits renderingEventsConfigCache = { enabled: false }; renderingEventsConfigCacheTime = Date.now(); return renderingEventsConfigCache; } const data = await response.json(); renderingEventsConfigCache = { enabled: data.enabled ?? false }; renderingEventsConfigCacheTime = Date.now(); return renderingEventsConfigCache; } catch { // Default to disabled on error to avoid hitting PostHog limits renderingEventsConfigCache = { enabled: false }; renderingEventsConfigCacheTime = Date.now(); return renderingEventsConfigCache; } finally { renderingEventsConfigPromise = null; } })(); return renderingEventsConfigPromise; }; // Reset function for testing export function resetRenderingEventsConfig(): void { renderingEventsConfigCache = null; renderingEventsConfigCacheTime = null; renderingEventsConfigPromise = null; } // Dynamic welcome message generation export interface WelcomeMessageRequest { business_description: string; h1: string; lang: string; page_link: string; page_type: string; } export interface WelcomeMessageResponse { message: string; } export const generateDynamicWelcomeMessage = async ( request: WelcomeMessageRequest, abortController?: AbortController ): Promise => { const startTime = Date.now(); devLog.request( "generateDynamicWelcomeMessage", API_URLS.generateWelcomeMessage, "POST", { page_type: request.page_type, lang: request.lang, } ); try { const response = await fetchWithTimeout( API_URLS.generateWelcomeMessage, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify(request), signal: abortController?.signal, }, 10000 // 10 second timeout ); if (!response.ok) { devLog.request( "generateDynamicWelcomeMessage", API_URLS.generateWelcomeMessage, "POST", { status: response.status, error: "Response not OK" } ); return null; } const data: WelcomeMessageResponse = await response.json(); devLog.success( "generateDynamicWelcomeMessage", Date.now() - startTime, JSON.stringify(data).length, response.status ); return data.message || null; } catch (error) { // Fail silently - return null means no welcome message shown if ((error as Error)?.name !== "AbortError") { devLog.error(error as Error, "generateDynamicWelcomeMessage", { page_type: request.page_type, }); } return null; } };