import type { ExtendedWindow } from "~/types/window"; import { BotData, ChatState } from "~/types/bot"; import { Message, ConversationStep } from "~/types/chat"; import { detectInstallationSource, InstallationSource, } from "./installation-source"; import { captureStructuredError } from "./sentry"; // Define widget type export enum WidgetType { EMBEDDED = "embedded", FLOATING = "floating", } export enum ServerPlatformEnum { PUBLIC_PAGE = "public_page", INSTAGRAM = "instagram", WHATSAPP = "whatsapp", FACEBOOK = "facebook", TELEGRAM = "telegram", WIDGET = "default_widget", SHOPIFY_WIDGET = "shopify_widget", SHOPIFY_PDP = "shopify_pdp", WORDPRESS_WIDGET = "wordpress_widget", WORDPRESS_PDP = "wordpress_pdp", } export function getWidgetPlatformType( widgetType: WidgetType ): ServerPlatformEnum { if (detectInstallationSource() == InstallationSource.WORDPRESS_PLUGIN) { return widgetType === WidgetType.EMBEDDED ? ServerPlatformEnum.WORDPRESS_PDP : ServerPlatformEnum.WORDPRESS_WIDGET; } return ServerPlatformEnum.WIDGET; } // Base storage keys const BASE_STORAGE_KEYS = { BOT_DATA: "mori_widget_bot_data", CHAT_STATE: "mori_widget_chat_state", MESSAGES: "mori_widget_messages", CURRENT_STEP: "mori_widget_current_step", SESSION_TIMESTAMP: "mori_widget_session_timestamp", FAB_ANIMATION_TIMESTAMP: "mori_widget_fab_animation_timestamp", MORI_THREAD_ID: "mori_thread_id", MORI_SESSION_KEY: "mori_session_key", LOADED_FROM_STORAGE: "mori_loaded_from_storage", } as const; // Get widget-specific storage keys const getStorageKeys = (widgetType: WidgetType) => { const suffix = widgetType === WidgetType.EMBEDDED ? "_embedded" : "_floating"; const keys: Record = {}; Object.entries(BASE_STORAGE_KEYS).forEach(([key, value]) => { keys[key] = `${value}${suffix}`; }); return keys as typeof BASE_STORAGE_KEYS; }; // Default to floating widget storage keys for backward compatibility const STORAGE_KEYS = getStorageKeys(WidgetType.FLOATING); const SESSION_EXPIRY_TIME = 24 * 60 * 60 * 1000; export const THREAD_ID_EXPIRY_TIME = 24 * 60 * 60 * 1000; interface StoredSessionData { botData: BotData | null; chatState: ChatState; messages: Message[]; currentStep: ConversationStep; sessionTimestamp: number; } export function isSessionStorageAvailable(): boolean { try { const test = "__test__"; sessionStorage.setItem(test, test); sessionStorage.removeItem(test); return true; } catch { return false; } } export function isLocalStorageAvailable(): boolean { try { const test = "__test__"; localStorage.setItem(test, test); localStorage.removeItem(test); return true; } catch { return false; } } // Helper function to get the appropriate storage key function getStorageKey( key: keyof typeof BASE_STORAGE_KEYS, widgetType?: WidgetType ): string { if (!widgetType) { return STORAGE_KEYS[key]; } const keys = getStorageKeys(widgetType); return keys[key]; } export function markLoadedFromStorage(widgetType?: WidgetType): void { if (isSessionStorageAvailable()) { sessionStorage.setItem( getStorageKey("LOADED_FROM_STORAGE", widgetType), "true" ); } } export function clearLoadedFromStorageFlag(widgetType?: WidgetType): void { if (isSessionStorageAvailable()) { sessionStorage.removeItem(getStorageKey("LOADED_FROM_STORAGE", widgetType)); } } export function isLoadedFromStorage(widgetType?: WidgetType): boolean { if (!isSessionStorageAvailable()) { return false; } return ( sessionStorage.getItem(getStorageKey("LOADED_FROM_STORAGE", widgetType)) === "true" ); } function isSessionValid(widgetType?: WidgetType): boolean { try { const timestamp = sessionStorage.getItem( getStorageKey("SESSION_TIMESTAMP", widgetType) ); if (!timestamp) return false; const sessionTime = parseInt(timestamp, 10); const now = Date.now(); return now - sessionTime < SESSION_EXPIRY_TIME; } catch { return false; } } export function clearSessionStorage(widgetType?: WidgetType): void { if (!isSessionStorageAvailable()) return; try { const keys = widgetType ? getStorageKeys(widgetType) : STORAGE_KEYS; Object.values(keys).forEach((key) => { sessionStorage.removeItem(key); }); } catch { // SessionStorage access errors are intentionally ignored // to prevent application interruption } } export function saveBotData( _botData: BotData | null, _widgetType?: WidgetType ): void { // No-op: we don't want to save bot data to session storage anymore } export function loadBotData(_widgetType?: WidgetType): BotData | null { return null; } export function saveChatState( chatState: ChatState, widgetType?: WidgetType ): void { if (!isSessionStorageAvailable()) return; try { sessionStorage.setItem( getStorageKey("CHAT_STATE", widgetType), JSON.stringify(chatState) ); } catch (error) { console.error("[Mori Widget] Failed to save chat state:", error); } } export function loadChatState(widgetType?: WidgetType): ChatState | null { if (!isSessionStorageAvailable() || !isSessionValid(widgetType)) return null; try { const stored = sessionStorage.getItem( getStorageKey("CHAT_STATE", widgetType) ); if (!stored) return null; try { return JSON.parse(stored); } catch (parseError) { const error = parseError as Error; captureStructuredError(error, { operation: "loadChatState", additionalData: { context: "JSON.parse", storageKey: getStorageKey("CHAT_STATE", widgetType), widgetType, rawDataLength: stored.length, rawDataPreview: stored.substring(0, 100), }, }); return null; } } catch (accessError) { const error = accessError as Error; captureStructuredError(error, { operation: "loadChatState", additionalData: { context: "sessionStorage.getItem", storageKey: getStorageKey("CHAT_STATE", widgetType), widgetType, }, }); return null; } } export function saveMessages( messages: Message[], widgetType?: WidgetType ): void { const isFloatingWidget = !widgetType || widgetType === WidgetType.FLOATING; // Use localStorage for floating widget, sessionStorage for embedded widget const storage = isFloatingWidget ? localStorage : sessionStorage; const isStorageAvailable = isFloatingWidget ? isLocalStorageAvailable : isSessionStorageAvailable; if (!isStorageAvailable()) return; const serializableMessages = messages.map((msg) => ({ ...msg, timestamp: msg.timestamp instanceof Date ? msg.timestamp.toISOString() : msg.timestamp, })); try { storage.setItem( getStorageKey("MESSAGES", widgetType), JSON.stringify(serializableMessages) ); } catch (error) { // Capture storage errors but don't interrupt the application if (error instanceof Error && error.name === "QuotaExceededError") { // Try to clear some messages and retry (keep only last 50) try { const trimmedMessages = messages.slice(-50); const serializableTrimmed = trimmedMessages.map((msg) => ({ ...msg, timestamp: msg.timestamp instanceof Date ? msg.timestamp.toISOString() : msg.timestamp, })); storage.setItem( getStorageKey("MESSAGES", widgetType), JSON.stringify(serializableTrimmed) ); } catch (retryError) { console.error( "[Mori Widget] Failed to save messages after trimming:", retryError ); } } else { console.error("[Mori Widget] Failed to save messages:", error); } } } export function loadMessages(widgetType?: WidgetType): Message[] { const isFloatingWidget = !widgetType || widgetType === WidgetType.FLOATING; // Use localStorage for floating widget, sessionStorage for embedded widget const storage = isFloatingWidget ? localStorage : sessionStorage; const isStorageAvailable = isFloatingWidget ? isLocalStorageAvailable : isSessionStorageAvailable; // For floating widget, no session validity check needed (localStorage persists) // For embedded widget, maintain session validity check if ( !isStorageAvailable() || (!isFloatingWidget && !isSessionValid(widgetType)) ) return []; try { const stored = storage.getItem(getStorageKey("MESSAGES", widgetType)); if (!stored) return []; let messages; try { messages = JSON.parse(stored); } catch (parseError) { const error = parseError as Error; captureStructuredError(error, { operation: "loadMessages", additionalData: { context: "JSON.parse", storageKey: getStorageKey("MESSAGES", widgetType), widgetType, storageType: isFloatingWidget ? "localStorage" : "sessionStorage", rawDataLength: stored.length, rawDataPreview: stored.substring(0, 100), }, }); return []; } try { return messages.map( (msg: Omit & { timestamp: string }) => ({ ...msg, timestamp: new Date(msg.timestamp), }) ); } catch (mapError) { return []; } } catch (accessError) { const error = accessError as Error; captureStructuredError(error, { operation: "loadMessages", additionalData: { context: "storage.getItem", storageKey: getStorageKey("MESSAGES", widgetType), widgetType, storageType: isFloatingWidget ? "localStorage" : "sessionStorage", }, }); return []; } } export function saveCurrentStep( currentStep: ConversationStep, widgetType?: WidgetType ): void { if (!isSessionStorageAvailable()) return; try { sessionStorage.setItem( getStorageKey("CURRENT_STEP", widgetType), currentStep ); } catch (error) { console.error("[Mori Widget] Failed to save current step:", error); } } export function loadCurrentStep(widgetType?: WidgetType): ConversationStep { if (!isSessionStorageAvailable() || !isSessionValid(widgetType)) return "welcome"; try { const stored = sessionStorage.getItem( getStorageKey("CURRENT_STEP", widgetType) ); return (stored as ConversationStep) || "welcome"; } catch { return "welcome"; } } export function saveSessionData( data: StoredSessionData, widgetType?: WidgetType ): void { if (!isSessionStorageAvailable()) return; try { sessionStorage.setItem( getStorageKey("SESSION_TIMESTAMP", widgetType), Date.now().toString() ); saveBotData(data.botData, widgetType); saveChatState(data.chatState, widgetType); saveMessages(data.messages, widgetType); saveCurrentStep(data.currentStep, widgetType); } catch { // SessionStorage access errors are intentionally ignored // to prevent application interruption } } export function loadSessionData( widgetType?: WidgetType ): StoredSessionData | null { if (!isSessionStorageAvailable() || !isSessionValid(widgetType)) return null; try { const botData = loadBotData(widgetType); const chatState = loadChatState(widgetType); const messages = loadMessages(widgetType); const currentStep = loadCurrentStep(widgetType); // Only check for messages since botData is no longer cached if (messages.length > 0) { return { botData, chatState: chatState || { repliedProduct: null, threadId: null, isRequestInProgress: false, }, messages, currentStep, sessionTimestamp: Date.now(), }; } return null; } catch { return null; } } export function hasValidSession(widgetType?: WidgetType): boolean { if (!isSessionStorageAvailable() || !isSessionValid(widgetType)) return false; try { const messages = loadMessages(widgetType); // Only check for messages since botData is no longer cached return messages.length > 0; } catch { return false; } } export function updateSessionTimestamp(widgetType?: WidgetType): void { if (!isSessionStorageAvailable()) return; try { sessionStorage.setItem( getStorageKey("SESSION_TIMESTAMP", widgetType), Date.now().toString() ); } catch { // SessionStorage access errors are intentionally ignored // to prevent application interruption } } export function saveFabAnimationTimestamp(widgetType?: WidgetType): void { if (!isSessionStorageAvailable()) return; try { sessionStorage.setItem( getStorageKey("FAB_ANIMATION_TIMESTAMP", widgetType), Date.now().toString() ); } catch { // SessionStorage access errors are intentionally ignored // to prevent application interruption } } export function loadFabAnimationTimestamp( widgetType?: WidgetType ): number | null { if (!isSessionStorageAvailable() || !isSessionValid(widgetType)) return null; try { const stored = sessionStorage.getItem( getStorageKey("FAB_ANIMATION_TIMESTAMP", widgetType) ); return stored ? parseInt(stored, 10) : null; } catch { return null; } } export function shouldTriggerFabAnimation(widgetType?: WidgetType): boolean { const lastAnimationTime = loadFabAnimationTimestamp(widgetType); if (!lastAnimationTime) { return true; } const now = Date.now(); const FAB_ANIMATION_INTERVAL = 3 * 60 * 1000; // 3 minutes const timeSinceLast = now - lastAnimationTime; const shouldAnimate = timeSinceLast >= FAB_ANIMATION_INTERVAL; return shouldAnimate; } export function generateSessionKey(widgetType?: WidgetType): string { if (!isLocalStorageAvailable()) return ""; try { // Use shared key without widget type suffix for unified session const sharedStorageKey = BASE_STORAGE_KEYS.MORI_SESSION_KEY; let sessionKey = localStorage.getItem(sharedStorageKey); if (!sessionKey) { // Use shared prefix for both widgets const prefix = "dori_session_"; sessionKey = prefix + Date.now() + "_" + Math.random().toString(36).substr(2, 9); localStorage.setItem(sharedStorageKey, sessionKey); } // Cookie expires in 30 days, path=/ for site-wide access document.cookie = `dori_session_key=${sessionKey}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; return sessionKey; } catch { return ""; } } export function storeDoriThreadId( threadId: string, widgetType?: WidgetType ): void { if (!isLocalStorageAvailable() || !threadId) return; try { localStorage.setItem(getStorageKey("MORI_THREAD_ID", widgetType), threadId); } catch (error) { console.error("[Mori Widget] Failed to store thread ID:", error); } } export function storeThreadIdWithTimestamp( threadId: string, widgetType?: WidgetType ): void { if (!isLocalStorageAvailable() || !threadId) return; try { const timestamp = Date.now(); const data = { threadId, timestamp, }; localStorage.setItem( getStorageKey("MORI_THREAD_ID", widgetType), JSON.stringify(data) ); } catch (error) { console.error("[Mori Widget] Failed to store thread ID with timestamp:", error); } } export function getThreadIdWithTimestamp( widgetType?: WidgetType ): { threadId: string; timestamp: number } | null { if (!isLocalStorageAvailable()) return null; try { const stored = localStorage.getItem( getStorageKey("MORI_THREAD_ID", widgetType) ); if (!stored) return null; try { const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && parsed.threadId && parsed.timestamp ) { return { threadId: parsed.threadId, timestamp: parsed.timestamp, }; } } catch {} const now = Date.now(); if (now - parseInt(stored, 10) < THREAD_ID_EXPIRY_TIME) { return { threadId: stored, timestamp: now, }; } return null; } catch { return null; } } export function getDoriThreadId(widgetType?: WidgetType): string | null { if (!isLocalStorageAvailable()) return null; try { const stored = localStorage.getItem( getStorageKey("MORI_THREAD_ID", widgetType) ); if (!stored) return null; try { const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && parsed.threadId && parsed.timestamp ) { const now = Date.now(); if (now - parsed.timestamp < THREAD_ID_EXPIRY_TIME) { return parsed.threadId; } return null; } } catch {} return stored; } catch { return null; } } export function clearDoriThreadId(widgetType?: WidgetType): void { if (!isLocalStorageAvailable()) return; try { localStorage.removeItem(getStorageKey("MORI_THREAD_ID", widgetType)); } catch { // LocalStorage access errors are intentionally ignored } } /** * Save bot data with ETag to localStorage */ export function saveBotDataWithETag( botData: BotData, etag: string | null, widgetType?: WidgetType, sharingId?: string ): void { if (!isLocalStorageAvailable()) return; // Only cache for floating widget if (widgetType !== WidgetType.FLOATING) return; try { // Cache key no longer includes widget type - bot data is shared between widgets const cacheKey = `mori_widget_bot_data_cache_${sharingId}`; const etagKey = `mori_widget_bot_data_etag_${sharingId}`; const cacheData = { data: botData, timestamp: Date.now(), }; localStorage.setItem(cacheKey, JSON.stringify(cacheData)); if (etag !== null) { localStorage.setItem(etagKey, etag); } } catch (error) { // Handle QuotaExceededError or other storage errors if (error instanceof Error && error.name === "QuotaExceededError") { console.warn("[Mori Widget] LocalStorage quota exceeded, clearing cache"); clearBotDataCache(widgetType, sharingId); } } } /** * Load bot data and ETag from localStorage */ export function loadBotDataWithETag( widgetType?: WidgetType, sharingId?: string ): { botData: BotData | null; etag: string | null; } { if (!isLocalStorageAvailable()) return { botData: null, etag: null }; // Only use cache for floating widget if (widgetType !== WidgetType.FLOATING) return { botData: null, etag: null }; // Cache key no longer includes widget type - bot data is shared between widgets const cacheKey = `mori_widget_bot_data_cache_${sharingId}`; const etagKey = `mori_widget_bot_data_etag_${sharingId}`; try { const storedData = localStorage.getItem(cacheKey); const storedEtag = localStorage.getItem(etagKey); if (!storedData || !storedEtag) { return { botData: null, etag: null }; } try { const { data } = JSON.parse(storedData); // No TTL check - use cache for immediate rendering // Background validation will handle staleness via ETag return { botData: data, etag: storedEtag }; } catch (parseError) { console.error("[Mori Widget] Failed to parse bot data cache:", parseError); captureStructuredError(parseError as Error, { operation: "loadBotDataWithETag", additionalData: { context: "JSON.parse", widgetType, sharingId, rawDataLength: storedData.length, rawDataPreview: storedData.substring(0, 100), }, }); clearBotDataCache(widgetType, sharingId); return { botData: null, etag: null }; } } catch (error) { console.error("[Mori Widget] Failed to load bot data cache:", error); return { botData: null, etag: null }; } } /** * Clear bot data and ETag from localStorage */ export function clearBotDataCache( widgetType?: WidgetType, sharingId?: string ): void { if (!isLocalStorageAvailable()) return; // Only clear for floating widget (or all if no type specified) if (widgetType && widgetType !== WidgetType.FLOATING) return; try { // Cache key no longer includes widget type - bot data is shared between widgets const cacheKey = `mori_widget_bot_data_cache_${sharingId}`; const etagKey = `mori_widget_bot_data_etag_${sharingId}`; localStorage.removeItem(cacheKey); localStorage.removeItem(etagKey); } catch { // Ignore errors } } export function storeThreadInWooCommerceSession( threadId: string, widgetType?: WidgetType ): void { if (typeof window === "undefined") { return; } const extendedWindow = window as ExtendedWindow; const chatbotData = extendedWindow?.chatbotData; if (!chatbotData?.ajaxurl || !chatbotData?.nonce) { return; } const hasWooCommerce = document.body.classList.contains("woocommerce") || document.body.classList.contains("woocommerce-page") || typeof (window as unknown as Record) .wc_add_to_cart_params !== "undefined"; if (!hasWooCommerce) { return; } // Check if WordPress session has already been stored to prevent overwriting const wpSessionStoredKey = "mori_wp_session_stored"; const sessionKey = generateSessionKey(widgetType); if (localStorage.getItem(wpSessionStoredKey)) { return; } // Mark that we're storing the session in WordPress localStorage.setItem(wpSessionStoredKey, "true"); fetch(chatbotData?.ajaxurl || "", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ action: "dori_store_thread_session", thread_id: threadId, session_key: sessionKey, nonce: chatbotData.nonce, }), }) .then((response) => response.json()) .catch(() => { // Silently fail - non-critical operation }); }