UNPKG

admesh-ui-sdk

Version:

Beautiful, modern React components for displaying AI-powered product recommendations with citation-based conversation ads, auto-triggered widgets, floating chat, conversational interfaces, persistent sidebar, and built-in tracking. Includes zero-code SDK

1 lines 410 kB
{"version":3,"file":"index.mjs","sources":["../src/utils/logger.ts","../src/utils/viewabilityTracker.ts","../src/hooks/useViewabilityTracker.ts","../src/components/AdMeshViewabilityTracker.tsx","../src/context/AdMeshContext.ts","../src/components/AdMeshTailAd.tsx","../src/hooks/useAdMesh.ts","../src/components/AdMeshBridgeFormat.tsx","../../node_modules/classnames/index.js","../src/components/AdMeshLinkTracker.tsx","../src/components/AdMeshEcommerceCards.tsx","../src/components/AdMeshLayout.tsx","../src/components/AdMeshFollowup.tsx","../src/sdk/AdMeshTracker.ts","../src/context/AdMeshProvider.tsx","../src/sdk/AdMeshRenderer.tsx","../src/sdk/AdMeshSDK.ts","../src/sdk/WeaveResponseProcessor.ts","../src/components/AdMeshRecommendations.tsx","../src/components/WeaveFallbackRecommendations.tsx","../src/context/WeaveAdFormatContext.tsx","../src/components/AdMeshBadge.tsx","../src/utils/streamingEvents.ts","../src/utils/inlineExposureTracker.ts","../src/components/WeaveAdFormatContainer.tsx","../src/utils/styleInjection.ts","../src/hooks/useAdMeshStyles.ts","../src/hooks/useWeaveAdFormat.ts","../src/index.ts"],"sourcesContent":["/**\n * Logger utility for AdMesh UI SDK\n * Disables all logs in production environment\n */\n\n// Check for production environment\n// Supports both Vite (import.meta.env) and standard Node.js (process.env)\nlet isProduction = false;\ntry {\n // Check for Vite's import.meta.env (only available in ESM modules)\n if (typeof (globalThis as any).importMeta !== 'undefined' && (globalThis as any).importMeta.env?.PROD) {\n isProduction = true;\n }\n} catch (e) {\n // import.meta not available, continue with other checks\n}\n\nif (!isProduction) {\n isProduction = \n (typeof process !== 'undefined' && process.env.NODE_ENV === 'production') ||\n (typeof process !== 'undefined' && process.env.ADMESH_ENV === 'production');\n}\n\nexport const logger = {\n log: (...args: any[]) => {\n if (!isProduction) {\n console.log(...args);\n }\n },\n \n warn: (...args: any[]) => {\n if (!isProduction) {\n console.warn(...args);\n }\n },\n \n error: (...args: any[]) => {\n // Errors are always logged, even in production, as they're critical\n console.error(...args);\n },\n \n info: (...args: any[]) => {\n if (!isProduction) {\n console.info(...args);\n }\n },\n \n debug: (...args: any[]) => {\n if (!isProduction) {\n console.debug(...args);\n }\n },\n};\n\n","/**\n * AdMesh UI SDK - MRC Viewability Tracker Utilities\n * Implements Media Rating Council (MRC) viewability standards\n */\n\nimport type {\n MRCViewabilityStandards,\n DeviceType,\n ViewabilityContextMetrics,\n ViewabilityAnalyticsEvent\n} from '../types/analytics';\nimport { logger } from './logger';\n\n/**\n * Calculate MRC viewability standards based on ad size\n */\nexport function calculateMRCStandards(\n adWidth: number,\n adHeight: number,\n customStandards?: Partial<MRCViewabilityStandards>\n): MRCViewabilityStandards {\n const adPixels = adWidth * adHeight;\n const isLargeAd = adPixels > 242500; // MRC threshold for large ads\n\n const defaults: MRCViewabilityStandards = {\n visibilityThreshold: isLargeAd ? 0.3 : 0.5, // 30% for large, 50% for standard\n minimumDuration: 1000, // 1 second in milliseconds\n isLargeAd\n };\n\n return { ...defaults, ...customStandards };\n}\n\n/**\n * Detect device type based on viewport width\n */\nexport function detectDeviceType(viewportWidth: number): DeviceType {\n if (viewportWidth < 768) return 'mobile';\n if (viewportWidth < 1024) return 'tablet';\n return 'desktop';\n}\n\n/**\n * Calculate visibility percentage of element in viewport\n */\nexport function calculateVisibilityPercentage(element: HTMLElement): number {\n const rect = element.getBoundingClientRect();\n const viewportHeight = window.innerHeight || document.documentElement.clientHeight;\n const viewportWidth = window.innerWidth || document.documentElement.clientWidth;\n\n // Element dimensions\n const elementHeight = rect.height;\n const elementWidth = rect.width;\n\n if (elementHeight === 0 || elementWidth === 0) return 0;\n\n // Calculate visible portion\n const visibleTop = Math.max(0, rect.top);\n const visibleBottom = Math.min(viewportHeight, rect.bottom);\n const visibleLeft = Math.max(0, rect.left);\n const visibleRight = Math.min(viewportWidth, rect.right);\n\n const visibleHeight = Math.max(0, visibleBottom - visibleTop);\n const visibleWidth = Math.max(0, visibleRight - visibleLeft);\n\n const visibleArea = visibleHeight * visibleWidth;\n const totalArea = elementHeight * elementWidth;\n\n return totalArea > 0 ? (visibleArea / totalArea) : 0;\n}\n\n/**\n * Calculate current scroll depth as percentage\n */\nexport function calculateScrollDepth(): number {\n const windowHeight = window.innerHeight;\n const documentHeight = document.documentElement.scrollHeight;\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n\n const scrollableHeight = documentHeight - windowHeight;\n if (scrollableHeight <= 0) return 100;\n\n return Math.min(100, (scrollTop / scrollableHeight) * 100);\n}\n\n/**\n * Get element position on page\n */\nexport function getElementPosition(element: HTMLElement): { top: number; left: number } {\n const rect = element.getBoundingClientRect();\n const scrollTop = window.pageYOffset || document.documentElement.scrollTop;\n const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;\n\n return {\n top: rect.top + scrollTop,\n left: rect.left + scrollLeft\n };\n}\n\n/**\n * Collect context metrics\n */\nexport function collectContextMetrics(element: HTMLElement): ViewabilityContextMetrics {\n const rect = element.getBoundingClientRect();\n const position = getElementPosition(element);\n const viewportWidth = window.innerWidth || document.documentElement.clientWidth;\n const viewportHeight = window.innerHeight || document.documentElement.clientHeight;\n\n // Detect dark mode\n const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;\n\n return {\n pageUrl: window.location.href,\n pageTitle: document.title,\n referrer: document.referrer,\n deviceType: detectDeviceType(viewportWidth),\n viewportWidth,\n viewportHeight,\n adWidth: rect.width,\n adHeight: rect.height,\n adPositionTop: position.top,\n adPositionLeft: position.left,\n isDarkMode,\n language: navigator.language,\n timezone: Intl.DateTimeFormat().resolvedOptions().timeZone\n };\n}\n\n/**\n * Generate unique session ID for INTERNAL viewability tracking only.\n * \n * IMPORTANT: This is NOT the main sessionId used for recommendations.\n * This is only used internally by the viewability tracker for tracking\n * viewability events. The main sessionId MUST be provided by the platform\n * and passed to AdMeshProvider and SDK methods.\n */\nexport function generateSessionId(): string {\n return `session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;\n}\n\n/**\n * Generate unique batch ID\n */\nexport function generateBatchId(): string {\n return `batch_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;\n}\n\n/**\n * Check if ad meets MRC viewability threshold\n */\nexport function meetsViewabilityThreshold(\n visibilityPercentage: number,\n visibleDuration: number,\n standards: MRCViewabilityStandards\n): boolean {\n return (\n visibilityPercentage >= standards.visibilityThreshold &&\n visibleDuration >= standards.minimumDuration\n );\n}\n\n/**\n * Format timestamp to ISO 8601\n */\nexport function formatTimestamp(date: Date = new Date()): string {\n return date.toISOString();\n}\n\n/**\n * Calculate average from array of numbers\n */\nexport function calculateAverage(numbers: number[]): number {\n if (numbers.length === 0) return 0;\n const sum = numbers.reduce((acc, num) => acc + num, 0);\n return sum / numbers.length;\n}\n\n/**\n * Debounce function for performance optimization\n */\nexport function debounce<T extends (...args: unknown[]) => unknown>(\n func: T,\n wait: number\n): (...args: Parameters<T>) => void {\n let timeout: NodeJS.Timeout | null = null;\n\n return function executedFunction(...args: Parameters<T>) {\n const later = () => {\n timeout = null;\n func(...args);\n };\n\n if (timeout) clearTimeout(timeout);\n timeout = setTimeout(later, wait);\n };\n}\n\n/**\n * Throttle function for performance optimization\n */\nexport function throttle<T extends (...args: unknown[]) => unknown>(\n func: T,\n limit: number\n): (...args: Parameters<T>) => void {\n let inThrottle: boolean;\n\n return function executedFunction(...args: Parameters<T>) {\n if (!inThrottle) {\n func(...args);\n inThrottle = true;\n setTimeout(() => (inThrottle = false), limit);\n }\n };\n}\n\n/**\n * Send analytics event to API\n *\n * NOTE: If apiEndpoint is empty, the event is silently discarded (no error).\n * This allows the SDK to collect analytics without sending them to a backend.\n */\nexport async function sendAnalyticsEvent(\n event: ViewabilityAnalyticsEvent,\n apiEndpoint: string,\n retryAttempts: number = 3,\n retryDelay: number = 1000\n): Promise<boolean> {\n // If no endpoint is configured, silently skip sending\n if (!apiEndpoint || apiEndpoint.trim() === '') {\n return true;\n }\n\n for (let attempt = 0; attempt < retryAttempts; attempt++) {\n try {\n const response = await fetch(apiEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(event),\n keepalive: true\n });\n\n if (response.ok) {\n return true;\n }\n\n // Log error details for debugging\n await response.text().catch(() => '');\n } catch (error) {\n // Error caught, will retry\n }\n\n // Wait before retry (exponential backoff)\n if (attempt < retryAttempts - 1) {\n await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));\n }\n }\n\n logger.error('[AdMesh Viewability] Failed to send analytics event');\n return false;\n}\n\n/**\n * Send batched analytics events to API\n *\n * NOTE: If apiEndpoint is empty, the batch is silently discarded (no error).\n * This allows the SDK to collect analytics without sending them to a backend.\n */\nexport async function sendAnalyticsBatch(\n events: ViewabilityAnalyticsEvent[],\n sessionId: string,\n apiEndpoint: string,\n retryAttempts: number = 3,\n retryDelay: number = 1000\n): Promise<boolean> {\n if (events.length === 0) return true;\n\n // If no endpoint is configured, silently skip sending\n if (!apiEndpoint || apiEndpoint.trim() === '') {\n return true;\n }\n\n const batch = {\n batchId: generateBatchId(),\n sessionId,\n createdAt: formatTimestamp(),\n events,\n eventCount: events.length\n };\n\n for (let attempt = 0; attempt < retryAttempts; attempt++) {\n try {\n const response = await fetch(apiEndpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(batch),\n keepalive: true\n });\n\n if (response.ok) {\n return true;\n }\n\n // Log error details for debugging\n await response.text().catch(() => '');\n } catch (error) {\n // Error caught, will retry\n }\n\n // Wait before retry (exponential backoff)\n if (attempt < retryAttempts - 1) {\n await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));\n }\n }\n\n logger.error('[AdMesh Viewability] Failed to send analytics batch');\n return false;\n}\n\n/**\n * Sanitize URL to remove PII (query parameters, fragments)\n */\nexport function sanitizeUrl(url: string): string {\n try {\n const urlObj = new URL(url);\n // Remove query parameters and hash\n return `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`;\n } catch {\n return url;\n }\n}\n\n/**\n * Check if element is in viewport\n */\nexport function isElementInViewport(element: HTMLElement): boolean {\n const rect = element.getBoundingClientRect();\n return (\n rect.top < (window.innerHeight || document.documentElement.clientHeight) &&\n rect.bottom > 0 &&\n rect.left < (window.innerWidth || document.documentElement.clientWidth) &&\n rect.right > 0\n );\n}\n","/**\n * AdMesh UI SDK - MRC Viewability Tracker Hook\n * React hook for tracking ad viewability according to MRC standards\n */\n\nimport { useState, useEffect, useRef, useCallback } from 'react';\nimport { logger } from '../utils/logger';\nimport type {\n ViewabilityTrackerConfig,\n ViewabilityTrackerState,\n ViewabilityAnalyticsEvent,\n ViewabilityEventType,\n MRCViewabilityStandards\n} from '../types/analytics';\nimport {\n calculateMRCStandards,\n calculateVisibilityPercentage,\n calculateScrollDepth,\n collectContextMetrics,\n generateSessionId,\n meetsViewabilityThreshold,\n formatTimestamp,\n calculateAverage,\n throttle\n} from '../utils/viewabilityTracker';\n\n// Default configuration\nconst DEFAULT_CONFIG: ViewabilityTrackerConfig = {\n enabled: true,\n // Analytics endpoint disabled - no analytics will be sent\n apiEndpoint: '', // Empty string disables analytics sending\n enableBatching: false, // Disabled since no endpoint\n batchSize: 10,\n batchTimeout: 5000, // 5 seconds\n debug: false,\n enableRetry: false,\n maxRetries: 3,\n retryDelay: 1000\n};\n\n// Global config that can be set by consuming application\nlet globalConfig: ViewabilityTrackerConfig = DEFAULT_CONFIG;\n\n// TEMPORARY: Global flag to disable all analytics sending\n// Set this to true to prevent any viewability analytics from being sent to the backend\n// This is a temporary measure and can be easily reverted by setting to false\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nlet ANALYTICS_DISABLED = false;\n\nexport const setViewabilityTrackerConfig = (config: Partial<ViewabilityTrackerConfig>) => {\n globalConfig = { ...globalConfig, ...config };\n};\n\n/**\n * TEMPORARY: Disable/enable all viewability analytics sending\n * @param disabled - Set to true to disable analytics, false to enable\n *\n * Usage:\n * disableViewabilityAnalytics(true); // Disable all analytics\n * disableViewabilityAnalytics(false); // Re-enable analytics\n */\nexport const disableViewabilityAnalytics = (disabled: boolean) => {\n ANALYTICS_DISABLED = disabled;\n if (disabled) {\n logger.warn('[AdMesh Viewability] Analytics sending is DISABLED - no data will be sent to backend');\n } else {\n logger.log('[AdMesh Viewability] Analytics sending is ENABLED');\n }\n};\n\ninterface UseViewabilityTrackerProps {\n /** Product ID */\n productId?: string;\n /** Offer ID */\n offerId?: string;\n /** Agent ID */\n agentId?: string;\n /** Recommendation ID (from recommendations collection) */\n recommendationId: string;\n /** HTML element to track */\n elementRef: React.RefObject<HTMLElement>;\n /** Custom configuration */\n config?: Partial<ViewabilityTrackerConfig>;\n}\n\nexport function useViewabilityTracker({\n productId,\n offerId,\n agentId,\n recommendationId,\n elementRef,\n config: customConfig\n}: UseViewabilityTrackerProps): ViewabilityTrackerState {\n const config = { ...globalConfig, ...customConfig };\n\n // Session ID (persists for component lifetime)\n const sessionId = useRef(generateSessionId());\n\n // State\n const [state, setState] = useState<ViewabilityTrackerState>({\n isVisible: false,\n isViewable: false,\n visibilityPercentage: 0,\n timeMetrics: {\n loadedAt: formatTimestamp(),\n totalVisibleDuration: 0,\n totalViewableDuration: 0,\n totalHoverDuration: 0,\n totalFocusDuration: 0\n },\n engagementMetrics: {\n currentScrollDepth: 0,\n viewportEnterCount: 0,\n viewportExitCount: 0,\n hoverCount: 0,\n wasClicked: false,\n maxVisibilityPercentage: 0,\n averageVisibilityPercentage: 0\n },\n isTracking: config.enabled\n });\n\n // Refs for tracking\n const mrcStandards = useRef<MRCViewabilityStandards | null>(null);\n const visibilityStartTime = useRef<number | null>(null);\n const viewableStartTime = useRef<number | null>(null);\n const hoverStartTime = useRef<number | null>(null);\n const focusStartTime = useRef<number | null>(null);\n const visibilityPercentages = useRef<number[]>([]);\n const eventBatch = useRef<ViewabilityAnalyticsEvent[]>([]);\n const batchTimeout = useRef<NodeJS.Timeout | null>(null);\n\n // Log helper\n const log = useCallback((message: string) => {\n if (config.debug) {\n logger.log(`[AdMesh Viewability] ${message}`);\n }\n }, [config.debug]);\n\n // Send event (analytics disabled - no events sent to backend)\n // Viewability tracking still works for exposure pixels (handled separately)\n const sendEvent = useCallback(async (eventType: ViewabilityEventType, additionalData?: Record<string, unknown>) => {\n if (!config.enabled || !elementRef.current || !mrcStandards.current) return;\n\n // Analytics disabled - no events sent to backend\n // Viewability tracking still works internally for exposure pixel firing\n log(`Analytics disabled - skipping event: ${eventType}`);\n \n // Call custom callback if provided (for local tracking)\n if (config.onEvent) {\n const contextMetrics = collectContextMetrics(elementRef.current);\n const event: ViewabilityAnalyticsEvent = {\n eventType,\n timestamp: formatTimestamp(),\n sessionId: sessionId.current,\n productId,\n offerId,\n agentId,\n recommendationId,\n timeMetrics: state.timeMetrics,\n engagementMetrics: state.engagementMetrics,\n contextMetrics,\n mrcStandards: mrcStandards.current,\n isViewable: state.isViewable,\n metadata: additionalData\n };\n config.onEvent(event);\n }\n }, [config, productId, offerId, agentId, recommendationId, elementRef, state, log]);\n\n // Flush event batch (disabled - analytics not sent)\n const flushBatch = useCallback(async () => {\n if (eventBatch.current.length === 0) return;\n\n // Analytics disabled - clear batch without sending\n log('Analytics disabled - clearing batch without sending');\n eventBatch.current = [];\n if (batchTimeout.current) {\n clearTimeout(batchTimeout.current);\n batchTimeout.current = null;\n }\n return;\n }, [log]);\n\n // Update visibility\n const updateVisibility = useCallback(throttle(() => {\n if (!elementRef.current) return;\n\n const visibilityPercentage = calculateVisibilityPercentage(elementRef.current);\n const now = Date.now();\n const loadTime = new Date(state.timeMetrics.loadedAt).getTime();\n\n setState(prev => {\n const newState = { ...prev };\n\n // Track visibility percentages for average calculation\n if (visibilityPercentage > 0) {\n visibilityPercentages.current.push(visibilityPercentage);\n }\n\n // Update visibility state\n const wasVisible = prev.isVisible;\n const isNowVisible = visibilityPercentage > 0;\n\n if (isNowVisible && !wasVisible) {\n // Became visible\n visibilityStartTime.current = now;\n newState.engagementMetrics.viewportEnterCount++;\n\n if (!newState.timeMetrics.timeToFirstVisible) {\n newState.timeMetrics.timeToFirstVisible = now - loadTime;\n newState.engagementMetrics.scrollDepthAtFirstVisible = calculateScrollDepth();\n sendEvent('ad_visible');\n }\n } else if (!isNowVisible && wasVisible) {\n // Became hidden\n if (visibilityStartTime.current) {\n const visibleDuration = now - visibilityStartTime.current;\n newState.timeMetrics.totalVisibleDuration += visibleDuration;\n visibilityStartTime.current = null;\n }\n newState.engagementMetrics.viewportExitCount++;\n sendEvent('ad_hidden');\n } else if (isNowVisible && wasVisible && visibilityStartTime.current) {\n // Still visible, update duration\n const visibleDuration = now - visibilityStartTime.current;\n newState.timeMetrics.totalVisibleDuration += visibleDuration;\n visibilityStartTime.current = now;\n }\n\n newState.isVisible = isNowVisible;\n newState.visibilityPercentage = visibilityPercentage;\n\n // Update max visibility\n if (visibilityPercentage > newState.engagementMetrics.maxVisibilityPercentage) {\n newState.engagementMetrics.maxVisibilityPercentage = visibilityPercentage;\n }\n\n // Update average visibility\n if (visibilityPercentages.current.length > 0) {\n newState.engagementMetrics.averageVisibilityPercentage = calculateAverage(visibilityPercentages.current);\n }\n\n // Check MRC viewability threshold\n if (mrcStandards.current) {\n const wasViewable = prev.isViewable;\n const isNowViewable = meetsViewabilityThreshold(\n visibilityPercentage,\n newState.timeMetrics.totalVisibleDuration,\n mrcStandards.current\n );\n\n if (isNowViewable && !wasViewable) {\n // Met viewability threshold\n newState.isViewable = true;\n newState.timeMetrics.timeToViewable = now - loadTime;\n viewableStartTime.current = now;\n sendEvent('ad_viewable');\n } else if (isNowViewable && wasViewable && viewableStartTime.current) {\n // Still viewable, update duration\n const viewableDuration = now - viewableStartTime.current;\n newState.timeMetrics.totalViewableDuration += viewableDuration;\n viewableStartTime.current = now;\n }\n }\n\n // Update scroll depth\n newState.engagementMetrics.currentScrollDepth = calculateScrollDepth();\n\n return newState;\n });\n }, 100), [elementRef, state.timeMetrics.loadedAt, sendEvent]);\n\n // Initialize MRC standards\n useEffect(() => {\n if (!elementRef.current) return;\n\n const rect = elementRef.current.getBoundingClientRect();\n mrcStandards.current = calculateMRCStandards(rect.width, rect.height, config.mrcStandards);\n\n log('Initialized MRC standards');\n sendEvent('ad_loaded');\n }, [elementRef, config.mrcStandards, log, sendEvent]);\n\n // Set up Intersection Observer\n useEffect(() => {\n if (!config.enabled || !elementRef.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach(() => {\n updateVisibility();\n });\n },\n {\n threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],\n rootMargin: '0px'\n }\n );\n\n observer.observe(elementRef.current);\n\n return () => {\n observer.disconnect();\n };\n }, [config.enabled, elementRef, updateVisibility]);\n\n // Track scroll events\n useEffect(() => {\n if (!config.enabled) return;\n\n const handleScroll = throttle(() => {\n updateVisibility();\n }, 100);\n\n window.addEventListener('scroll', handleScroll, { passive: true });\n return () => window.removeEventListener('scroll', handleScroll);\n }, [config.enabled, updateVisibility]);\n\n // Track hover events\n useEffect(() => {\n if (!config.enabled || !elementRef.current) return;\n\n const element = elementRef.current;\n\n const handleMouseEnter = () => {\n hoverStartTime.current = Date.now();\n setState(prev => ({\n ...prev,\n engagementMetrics: {\n ...prev.engagementMetrics,\n hoverCount: prev.engagementMetrics.hoverCount + 1\n }\n }));\n sendEvent('ad_hover_start');\n };\n\n const handleMouseLeave = () => {\n if (hoverStartTime.current) {\n const hoverDuration = Date.now() - hoverStartTime.current;\n setState(prev => ({\n ...prev,\n timeMetrics: {\n ...prev.timeMetrics,\n totalHoverDuration: prev.timeMetrics.totalHoverDuration + hoverDuration\n }\n }));\n hoverStartTime.current = null;\n sendEvent('ad_hover_end', { hoverDuration });\n }\n };\n\n element.addEventListener('mouseenter', handleMouseEnter);\n element.addEventListener('mouseleave', handleMouseLeave);\n\n return () => {\n element.removeEventListener('mouseenter', handleMouseEnter);\n element.removeEventListener('mouseleave', handleMouseLeave);\n };\n }, [config.enabled, elementRef, sendEvent]);\n\n // Track focus events\n useEffect(() => {\n if (!config.enabled || !elementRef.current) return;\n\n const element = elementRef.current;\n\n const handleFocus = () => {\n focusStartTime.current = Date.now();\n sendEvent('ad_focus');\n };\n\n const handleBlur = () => {\n if (focusStartTime.current) {\n const focusDuration = Date.now() - focusStartTime.current;\n setState(prev => ({\n ...prev,\n timeMetrics: {\n ...prev.timeMetrics,\n totalFocusDuration: prev.timeMetrics.totalFocusDuration + focusDuration\n }\n }));\n focusStartTime.current = null;\n sendEvent('ad_blur', { focusDuration });\n }\n };\n\n element.addEventListener('focus', handleFocus);\n element.addEventListener('blur', handleBlur);\n\n return () => {\n element.removeEventListener('focus', handleFocus);\n element.removeEventListener('blur', handleBlur);\n };\n }, [config.enabled, elementRef, sendEvent]);\n\n // Track click events\n useEffect(() => {\n if (!config.enabled || !elementRef.current) return;\n\n const element = elementRef.current;\n\n const handleClick = () => {\n setState(prev => ({\n ...prev,\n engagementMetrics: {\n ...prev.engagementMetrics,\n wasClicked: true\n }\n }));\n sendEvent('ad_click');\n };\n\n element.addEventListener('click', handleClick);\n\n return () => {\n element.removeEventListener('click', handleClick);\n };\n }, [config.enabled, elementRef, sendEvent]);\n\n // Cleanup on unmount\n useEffect(() => {\n return () => {\n // Calculate session duration\n const now = Date.now();\n const loadTime = new Date(state.timeMetrics.loadedAt).getTime();\n const sessionDuration = now - loadTime;\n\n setState(prev => ({\n ...prev,\n timeMetrics: {\n ...prev.timeMetrics,\n sessionDuration\n }\n }));\n\n // Send final event\n sendEvent('ad_unloaded', { sessionDuration });\n\n // Flush any remaining batched events\n flushBatch();\n };\n }, []);\n\n return state;\n}\n","/**\n * AdMesh Viewability Tracker Component\n * Wraps any ad component with MRC viewability tracking\n */\n\nimport React, { useRef, useEffect } from 'react';\nimport { logger } from '../utils/logger';\nimport { useViewabilityTracker } from '../hooks/useViewabilityTracker';\nimport type { ViewabilityTrackerConfig } from '../types/analytics';\n\nexport interface AdMeshViewabilityTrackerProps {\n /** Product ID */\n productId?: string;\n /** Offer ID */\n offerId?: string;\n /** Agent ID */\n agentId?: string;\n /** Recommendation ID (for exposure tracking) */\n recommendationId: string;\n /** Exposure URL (for MRC-compliant exposure pixel firing) */\n exposureUrl?: string;\n /** Session ID (for exposure tracking) */\n sessionId?: string;\n /** Children to wrap with viewability tracking */\n children: React.ReactNode;\n /** Custom viewability tracker configuration */\n config?: Partial<ViewabilityTrackerConfig>;\n /** CSS class name */\n className?: string;\n /** Inline styles */\n style?: React.CSSProperties;\n /** Callback when viewability state changes */\n onViewabilityChange?: (isViewable: boolean) => void;\n /** Callback when ad becomes visible */\n onVisible?: () => void;\n /** Callback when ad becomes viewable (meets MRC threshold) */\n onViewable?: () => void;\n /** Callback when ad is clicked */\n onClick?: () => void;\n}\n\n/**\n * AdMeshViewabilityTracker Component\n * \n * Wraps ad components with comprehensive MRC viewability tracking.\n * Automatically tracks:\n * - Viewability (50% visible for 1 second)\n * - Time metrics (time to viewable, total visible duration, etc.)\n * - Engagement metrics (hover, focus, clicks, scroll depth)\n * - Context metrics (device type, viewport size, ad position)\n * \n * @example\n * ```tsx\n * <AdMeshViewabilityTracker\n * recommendationId=\"rec_123\"\n * productId=\"prod_456\"\n * offerId=\"offer_789\"\n * onViewable={() => logger.log('Ad is viewable!')}\n * >\n * <YourAdComponent />\n * </AdMeshViewabilityTracker>\n * ```\n */\nexport const AdMeshViewabilityTracker: React.FC<AdMeshViewabilityTrackerProps> = ({\n productId,\n offerId,\n agentId,\n recommendationId,\n exposureUrl,\n sessionId,\n children,\n config,\n className,\n style,\n onViewabilityChange,\n onVisible,\n onViewable,\n onClick\n}) => {\n const elementRef = useRef<HTMLElement>(null);\n const exposureFired = useRef(false);\n\n // Use viewability tracker hook\n const viewabilityState = useViewabilityTracker({\n productId,\n offerId,\n agentId,\n recommendationId,\n elementRef: elementRef as React.RefObject<HTMLElement>,\n config\n });\n\n // Track viewability changes and fire exposure pixel\n const previousViewable = useRef(viewabilityState.isViewable);\n\n useEffect(() => {\n if (viewabilityState.isViewable !== previousViewable.current) {\n previousViewable.current = viewabilityState.isViewable;\n\n if (onViewabilityChange) {\n onViewabilityChange(viewabilityState.isViewable);\n }\n\n if (viewabilityState.isViewable && onViewable) {\n onViewable();\n }\n\n // Fire exposure pixel when ad becomes viewable (MRC-compliant)\n // Only fire if we have the required data and haven't fired yet\n if (viewabilityState.isViewable && !exposureFired.current) {\n logger.log('[AdMeshViewabilityTracker] 🎯 Ad is viewable, checking exposure pixel requirements:', {\n exposureUrl: exposureUrl ? 'present' : 'MISSING',\n sessionId: sessionId ? 'present' : 'MISSING',\n recommendationId\n });\n\n if (exposureUrl && sessionId) {\n exposureFired.current = true;\n\n logger.log('[AdMeshViewabilityTracker] 🔥 Firing exposure pixel:', exposureUrl);\n\n // Fire the exposure pixel using fetch with keepalive\n fetch(exposureUrl, { method: 'GET', keepalive: true })\n .then(() => {\n logger.log('[AdMesh] ✅ Exposure pixel fired successfully');\n })\n .catch((error) => {\n logger.warn('[AdMesh] ⚠️ Failed to fire exposure pixel:', error);\n // Reset flag to allow retry\n exposureFired.current = false;\n });\n } else {\n logger.warn('[AdMeshViewabilityTracker] ⚠️ Cannot fire exposure pixel - missing required data:', {\n hasExposureUrl: !!exposureUrl,\n hasSessionId: !!sessionId\n });\n }\n }\n }\n }, [viewabilityState.isViewable, onViewabilityChange, onViewable, exposureUrl, sessionId, recommendationId]);\n\n // Track visibility changes\n const previousVisible = useRef(viewabilityState.isVisible);\n \n useEffect(() => {\n if (viewabilityState.isVisible !== previousVisible.current) {\n previousVisible.current = viewabilityState.isVisible;\n \n if (viewabilityState.isVisible && onVisible) {\n onVisible();\n }\n }\n }, [viewabilityState.isVisible, onVisible]);\n\n // Handle click\n const handleClick = () => {\n if (onClick) {\n onClick();\n }\n \n // Allow event to propagate to children\n };\n\n return (\n <div\n ref={elementRef as React.RefObject<HTMLDivElement>}\n className={className}\n style={style}\n onClick={handleClick}\n data-admesh-viewability-tracker\n data-recommendation-id={recommendationId}\n data-is-viewable={viewabilityState.isViewable}\n data-is-visible={viewabilityState.isVisible}\n data-visibility-percentage={viewabilityState.visibilityPercentage.toFixed(2)}\n >\n {children}\n </div>\n );\n};\n\nAdMeshViewabilityTracker.displayName = 'AdMeshViewabilityTracker';\n","import React from 'react';\nimport { AdMeshSDK } from '../sdk/AdMeshSDK';\nimport type { AdMeshTheme } from '../types/index';\n\n/**\n * Context value provided by AdMeshProvider\n */\nexport interface AdMeshContextValue {\n // SDK instance\n sdk: AdMeshSDK | null;\n\n // Configuration\n apiKey: string;\n sessionId: string;\n theme?: AdMeshTheme;\n\n // UCP PlatformRequest fields (optional, passed from frontend)\n language?: string; // User language in BCP 47 format (e.g., \"en-US\")\n geo_country?: string; // User country code in ISO 3166-1 alpha-2 format (e.g., \"US\")\n userId?: string; // Anonymous hashed user ID\n model?: string; // AI model identifier (e.g., \"gpt-4o\")\n messages?: Array<{ role: string; content: string; id?: string }>; // Conversation history\n\n // Tracking state\n processedMessageIds: Set<string>;\n\n // Methods\n markMessageAsProcessed: (messageId: string) => void;\n isMessageProcessed: (messageId: string) => boolean;\n}\n\n/**\n * React Context for AdMesh SDK\n * \n * Provides SDK instance and tracking state to all child components\n */\nexport const AdMeshContext = React.createContext<AdMeshContextValue | undefined>(\n undefined\n);\n\n/**\n * Hook to access AdMesh context\n * \n * @throws Error if used outside of AdMeshProvider\n * @returns AdMeshContextValue\n */\nexport function useAdMeshContext(): AdMeshContextValue {\n const context = React.useContext(AdMeshContext);\n \n if (!context) {\n throw new Error(\n 'useAdMeshContext must be used within an <AdMeshProvider>. ' +\n 'Make sure your component is wrapped with <AdMeshProvider>.'\n );\n }\n \n return context;\n}\n\n","import React from 'react';\nimport { logger } from '../utils/logger';\nimport type { AdMeshRecommendation, AdMeshTheme } from '../types/index';\nimport { AdMeshViewabilityTracker } from './AdMeshViewabilityTracker';\nimport { AdMeshContext } from '../context/AdMeshContext';\n\nexport interface AdMeshTailAdProps {\n summaryText?: string; // The tail_summary from backend response (optional, not used in new UI)\n recommendations: AdMeshRecommendation[]; // Full recommendation objects\n theme?: AdMeshTheme;\n className?: string;\n style?: React.CSSProperties;\n onLinkClick?: (recommendation: AdMeshRecommendation) => void;\n sessionId?: string;\n}\n\n// Utility function to validate and normalize URLs\nconst isValidUrl = (url: string): boolean => {\n try {\n new URL(url);\n return true;\n } catch {\n return false;\n }\n};\n\n// Helper function to get CTA label from backend\nconst getCTALabel = (ctaLabel?: string): string => {\n // Use provided CTA label from backend if available\n if (ctaLabel && ctaLabel.trim()) {\n return ctaLabel.trim();\n }\n\n // Return empty string if no CTA label provided (will hide CTA button)\n return '';\n};\n\n// Process summary text with markdown links [Product Name](click_url) and brand name links\n// NOTE: This function is kept for backward compatibility but is no longer used in the new tail ad UI\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst processSummaryText = (_summaryText: string, _recommendations: AdMeshRecommendation[]): (string | React.ReactElement)[] => {\n // This function is no longer used - kept for backward compatibility only\n return [];\n};\n\nexport const AdMeshTailAd: React.FC<AdMeshTailAdProps> = ({\n recommendations,\n theme,\n className = '',\n style = {},\n sessionId\n}) => {\n // Try to get context values for feedback submission (optional - component can work without provider)\n const context = React.useContext(AdMeshContext);\n const contextUserId = context?.userId;\n const contextModel = context?.model;\n const contextSessionId = context?.sessionId;\n const sdk = context?.sdk || null;\n \n // Use prop sessionId if provided, otherwise use context sessionId\n const effectiveSessionId = sessionId || contextSessionId;\n \n // State for feedback and visibility\n const [isHidden, setIsHidden] = React.useState(false);\n const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false);\n const [isSubmittingFeedback, setIsSubmittingFeedback] = React.useState(false);\n \n // Validate inputs - return null if empty\n if (!recommendations || recommendations.length === 0) {\n logger.log('[AdMesh Tail Ad] No recommendations provided - not rendering');\n return null;\n }\n \n // Early return if component should be hidden (dislike clicked)\n if (isHidden) {\n return null;\n }\n\n // Get the first recommendation's data for CTA and tracking\n const firstRecommendation = recommendations[0];\n const productId = firstRecommendation?.product_id;\n const exposureUrl = firstRecommendation?.exposure_url;\n const recommendationId = firstRecommendation?.recommendation_id || '';\n \n // Get data from creative_input for tail format\n const creativeInput = firstRecommendation?.creative_input || {};\n const shortDescription = creativeInput.short_description || '';\n const offerSummary = creativeInput.offer_summary || '';\n const brandName = creativeInput.brand_name || firstRecommendation?.title || '';\n const productName = creativeInput.product_name || '';\n \n // Get logo_url from assets\n const assets = creativeInput.assets || {};\n const logoUrl = assets.logo_url || '';\n \n // Get click URL - prioritize click_url from recommendation (this is the tracking URL we need)\n const clickUrl = firstRecommendation?.click_url || \n firstRecommendation?.admesh_link || \n creativeInput.cta_url ||\n firstRecommendation?.url;\n \n // Get CTA label from backend\n const ctaLabel = getCTALabel(creativeInput.cta_label);\n\n // For tail format, we need at least brand name\n // But we still validate that we have at least one recommendation with data\n if (!brandName) {\n logger.log('[AdMesh Tail Ad] No valid recommendation data provided - not rendering', {\n reason: 'brandName is missing',\n brandName,\n hasFirstRecommendation: !!firstRecommendation,\n hasCreativeInput: !!creativeInput,\n creativeInputBrandName: creativeInput.brand_name,\n recommendationTitle: firstRecommendation?.title,\n recommendationId,\n recommendationKeys: firstRecommendation ? Object.keys(firstRecommendation) : []\n });\n return null;\n }\n \n // Build headline parts with priority:\n // 1. If offer_summary exists: \"Brand — Offer Summary\"\n // 2. If product_name exists: \"Brand — Product Name\"\n // 3. Otherwise: just \"Brand\"\n let headlineText = brandName;\n let headlineSuffix = '';\n \n if (offerSummary) {\n headlineSuffix = offerSummary;\n } else if (productName) {\n headlineSuffix = productName;\n }\n \n if (headlineSuffix) {\n headlineText = `${brandName} — ${headlineSuffix}`;\n }\n\n logger.debug('[AdMeshTailAd] 📊 Rendering with tracking data:', {\n recommendationId,\n productId,\n exposureUrl: exposureUrl ? 'present' : 'MISSING',\n sessionId: sessionId ? 'present' : 'MISSING',\n recommendationsCount: recommendations.length,\n clickUrl: clickUrl ? clickUrl : 'MISSING',\n clickUrlSource: firstRecommendation?.click_url ? 'click_url' : \n firstRecommendation?.admesh_link ? 'admesh_link' :\n creativeInput.cta_url ? 'cta_url' :\n firstRecommendation?.url ? 'url' : 'none',\n shortDescription: shortDescription ? 'present' : 'MISSING',\n offerSummary: offerSummary || 'MISSING',\n brandName,\n productName,\n headlineText\n });\n\n // Handler for tracking clicks on the entire tail ad\n const handleContainerClick = (source: string, e?: React.MouseEvent) => {\n if (e) {\n e.stopPropagation();\n }\n logger.log(`AdMesh tail ad ${source} clicked`);\n if (typeof window !== 'undefined' && (window as any).admeshTracker) {\n (window as any).admeshTracker.trackClick({\n recommendationId: firstRecommendation.recommendation_id,\n productId: firstRecommendation.product_id,\n clickUrl: clickUrl,\n source: source\n }).catch(() => {\n logger.error(`[AdMesh] Failed to track ${source} click`);\n });\n }\n };\n\n // Handler for brand name link click\n const handleBrandNameClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n handleContainerClick('tail_ad_brand_name');\n };\n\n // Handler for CTA link click\n const handleCTAClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n handleContainerClick('tail_ad_cta');\n };\n\n // Handler for logo click\n const handleLogoClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n handleContainerClick('tail_ad_logo');\n };\n\n // Get API base URL from SDK or window global\n const getApiBaseUrl = (): string => {\n // Try to get from SDK using the public getter method\n if (sdk && typeof (sdk as any).getApiBaseUrl === 'function') {\n return (sdk as any).getApiBaseUrl();\n }\n // Fallback to direct property access (for backward compatibility)\n if (sdk && (sdk as any).apiBaseUrl) {\n return (sdk as any).apiBaseUrl;\n }\n // Fallback to window global\n if (typeof window !== 'undefined' && (window as any).__ADMESH_API_BASE_URL__) {\n return (window as any).__ADMESH_API_BASE_URL__;\n }\n // Default to production\n return 'https://api.useadmesh.com';\n };\n\n // Handler for submitting feedback\n const submitFeedback = async (feedbackType: 'like' | 'dislike') => {\n // Prevent duplicate submissions\n if (feedbackSubmitted || isSubmittingFeedback) {\n return;\n }\n\n setIsSubmittingFeedback(true);\n\n try {\n const apiBaseUrl = getApiBaseUrl();\n const agentId = firstRecommendation?.agent_id || '';\n \n const payload = {\n message_index: 0, // Default to 0, can be enhanced later if message_index is available\n feedback: feedbackType,\n session_id: effectiveSessionId || null,\n user_id: contextUserId || null,\n agent_id: agentId || null,\n model_used: contextModel || null,\n recommendationId: recommendationId || null\n };\n\n const endpointUrl = `${apiBaseUrl}/user/feedback/submit`;\n logger.log(`[AdMesh Tail Ad] Submitting feedback: ${feedbackType}`, {\n endpoint: endpointUrl,\n payload\n });\n\n const response = await fetch(endpointUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(payload)\n });\n\n // Log response details for debugging\n logger.log(`[AdMesh Tail Ad] Feedback response status: ${response.status} ${response.statusText}`);\n\n if (!response.ok) {\n // Try to get error details from response\n let errorMessage = `Feedback submission failed: ${response.status} ${response.statusText}`;\n try {\n const errorData = await response.json().catch(() => null);\n if (errorData?.detail) {\n errorMessage = `Feedback submission failed: ${errorData.detail}`;\n }\n logger.error(`[AdMesh Tail Ad] Error response body:`, errorData);\n } catch (e) {\n // Failed to parse error response, use default message\n }\n throw new Error(errorMessage);\n }\n\n const result = await response.json();\n logger.log(`[AdMesh Tail Ad] ✅ Feedback submitted successfully: ${feedbackType}`, result);\n \n setFeedbackSubmitted(true);\n\n // If dislike, hide the component\n if (feedbackType === 'dislike') {\n setIsHidden(true);\n }\n } catch (error) {\n // Enhanced error logging\n const errorDetails = error instanceof Error ? {\n message: error.message,\n name: error.name,\n stack: error.stack\n } : String(error);\n \n logger.error(`[AdMesh Tail Ad] ❌ Failed to submit feedback: ${feedbackType}`, {\n error: errorDetails,\n apiBaseUrl: getApiBaseUrl(),\n endpoint: `${getApiBaseUrl()}/user/feedback/submit`\n });\n \n // Don't block UI on error - allow user to try again\n setIsSubmittingFeedback(false);\n }\n };\n\n // Handler for like button click\n const handleLikeClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n submitFeedback('like');\n };\n\n // Handler for dislike button click\n const handleDislikeClick = (e: React.MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n submitFeedback('dislike');\n };\n\n // State for logo load error\n const [logoError, setLogoError] = React.useState(false);\n\n // Get first letter of brand name for fallback\n const brandInitial = brandName ? brandName.charAt(0).toUpperCase() : 'B';\n\n // Determine if dark mode\n const isDarkMode = theme?.mode === 'dark' || \n (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);\n\n // Card styling with theme support\n const cardBackground = theme?.surfaceColor || theme?.components?.card?.backgroundColor || \n (isDarkMode ? '#1f2937' : '#ffffff');\n const cardBorder = theme?.borderColor || theme?.components?.card?.borderColor || \n (isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)');\n const cardBorderRadius = theme?.borderRadius || theme?.components?.card?.borderRadius || '8px';\n \n // Shadow styles - definitive shadow for floating feel\n const defaultShadow = isDarkMode \n ? '0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2)' \n : '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)';\n const hoverShadow = isDarkMode\n ? '0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3)'\n : '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)';\n \n const cardShadow = theme?.shadows?.medium || theme?.components?.card?.boxShadow || defaultShadow;\n const cardHoverShadow = theme?.shadows?.large || hoverShadow;\n\n return (\n <AdMeshViewabilityTracker\n productId={productId}\n recommendationId={recommendationId}\n exposureUrl={exposureUrl}\n sessionId={sessionId}\n className={`admesh-tail-ad ${className}`}\n style={{\n fontFamily: theme?.fontFamily || '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n ...style\n }}\n >\n <div \n className=\"tail-ad-container flex flex-row gap-3\"\n style={{\n backgroundColor: cardBackground,\n borderRadius: cardBorderRadius,\n padding: '16px',\n boxShadow: cardShadow,\n border: `1px solid ${cardBorder}`,\n transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',\n transform: 'translateY(0)',\n ...theme?.components?.card\n }}\n onMouseEnter={(e) => {\n e.currentTarget.style.boxShadow = cardHoverShadow;\n e.currentTarget.style.transform = 'translateY(-2px)';\n }}\n onMouseLeave={(e) => {\n e.currentTarget.style.boxShadow = cardShadow;\n e.currentTarget.style.transform = 'translateY(0)';\n }}\n >\n {/* Left Section: Logo Area (10% width, stacks on mobile) */}\n {logoUrl && (\n <div \n className=\"flex-shrink-0 flex items-center justify-center pr-2\"\n style={{\n width: '10%',\n minWidth: '48px'\n }}\n >\n {!logoError && isValidUrl(logoUrl) ? (\n <a\n href={clickUrl || '#'}\n target={clickUrl ? \"_blank\" : undefined}\n rel={clickUrl ? \"noopener noreferrer\" : undefined}\n onClick={clickUrl ? handleLogoClick : undefined}\n className=\"block\"\n style={{ \n cursor: clickUrl ? 'pointer' : 'default',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n width: '100%',\n maxWidth: '64px'\n }}\n >\n <img\n src={logoUrl}\n alt={`${brandName} logo`}\n className=\"object-cover\"\n style={{\n width: '100%',\n height: 'auto',\n maxWidth: '64px',\n maxHeight: '64px',\n objectFit: 'contain',\n borderRadius: '8px'\n }}\n onError={() => {\n setLogoError(true);\n logger.debug('[AdMesh Tail Ad] Logo failed to load, showing fallback');\n }}\n />\n </a>\n ) : (\n <div\n className=\"flex items-center justify-center text-lg font-semibold text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800\"\n style={{\n width: '100%',\n