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 • 285 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","sources":["../src/utils/viewabilityTracker.ts","../src/hooks/useViewabilityTracker.ts","../src/components/AdMeshViewabilityTracker.tsx","../src/components/AdMeshSummaryUnit.tsx","../../node_modules/classnames/index.js","../src/hooks/useAdMeshTracker.ts","../src/components/AdMeshLinkTracker.tsx","../src/utils/styleInjection.ts","../src/hooks/useAdMeshStyles.ts","../src/utils/disclosureUtils.ts","../src/components/AdMeshProductCard.tsx","../src/components/AdMeshSummaryLayout.tsx","../src/components/AdMeshLayout.tsx","../src/sdk/AdMeshTracker.ts","../src/sdk/AdMeshRenderer.tsx","../src/sdk/WeaveResponseProcessor.ts","../src/sdk/AdMeshSDK.ts","../src/context/AdMeshContext.ts","../src/context/AdMeshProvider.tsx","../src/hooks/useAdMesh.ts","../src/components/AdMeshRecommendations.tsx","../src/components/WeaveFallbackRecommendations.tsx","../src/context/WeaveAdFormatContext.tsx","../src/components/AdMeshEcommerceCards.tsx","../src/components/AdMeshInlineCard.tsx","../src/components/AdMeshBadge.tsx","../src/utils/streamingEvents.ts","../src/components/WeaveAdFormatContainer.tsx","../src/hooks/useWeaveAdFormat.ts","../src/index.ts"],"sourcesContent":["/**\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';\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\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 let lastError: Error | null = null;\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 const errorText = await response.text().catch(() => '');\n lastError = new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);\n } catch (error) {\n lastError = error as Error;\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 console.error('[AdMesh Viewability] Failed to send analytics event:', lastError);\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 let lastError: Error | null = null;\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 const errorText = await response.text().catch(() => '');\n lastError = new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);\n } catch (error) {\n lastError = error as Error;\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 console.error('[AdMesh Viewability] Failed to send analytics batch:', lastError);\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","/**\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 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 sendAnalyticsEvent,\n sendAnalyticsBatch,\n throttle\n} from '../utils/viewabilityTracker';\n\n// Default configuration\nconst DEFAULT_CONFIG: ViewabilityTrackerConfig = {\n enabled: true,\n // NOTE: Viewability tracking endpoint for storing analytics in recommendations collection\n // This endpoint stores MRC-compliant viewability analytics for each recommendation\n // Uses batch endpoint to handle multiple events efficiently\n apiEndpoint: 'https://api.useadmesh.com/api/recommendations/viewability/batch',\n enableBatching: true,\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\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 console.warn('[AdMesh Viewability] Analytics sending is DISABLED - no data will be sent to backend');\n } else {\n console.log('[AdMesh Viewability] Analytics sending is ENABLED');\n }\n};\n\ninterface UseViewabilityTrackerProps {\n /** Ad ID */\n adId: string;\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 adId,\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, data?: unknown) => {\n if (config.debug) {\n console.log(`[AdMesh Viewability] ${message}`, data);\n }\n }, [config.debug]);\n\n // Send event (batched or immediate)\n // NOTE: Only sends critical events (ad_viewable, ad_click) for cost optimization\n // Other events are tracked internally but not sent to backend\n // TEMPORARY: Can be disabled globally via disableViewabilityAnalytics()\n const sendEvent = useCallback(async (eventType: ViewabilityEventType, additionalData?: Record<string, unknown>) => {\n if (!config.enabled || !elementRef.current || !mrcStandards.current) return;\n\n // TEMPORARY: Check if analytics are disabled globally\n if (ANALYTICS_DISABLED) {\n log(`Analytics disabled - skipping event: ${eventType}`);\n return;\n }\n\n // OPTIMIZATION: Only send critical events to reduce storage costs by 85-95%\n const criticalEvents = ['ad_viewable', 'ad_click'];\n if (!criticalEvents.includes(eventType)) {\n log(`Skipping non-critical event: ${eventType} (only ad_viewable and ad_click are sent)`);\n return;\n }\n\n const contextMetrics = collectContextMetrics(elementRef.current);\n\n const event: ViewabilityAnalyticsEvent = {\n eventType,\n timestamp: formatTimestamp(),\n sessionId: sessionId.current,\n adId,\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\n log(`Sending critical event: ${eventType}`, event);\n\n // Call custom callback if provided\n if (config.onEvent) {\n config.onEvent(event);\n }\n\n if (config.enableBatching) {\n // Add to batch\n eventBatch.current.push(event);\n\n // Send batch if size limit reached\n if (eventBatch.current.length >= config.batchSize) {\n await flushBatch();\n } else {\n // Set timeout to send batch\n if (batchTimeout.current) clearTimeout(batchTimeout.current);\n batchTimeout.current = setTimeout(flushBatch, config.batchTimeout);\n }\n } else {\n // Send immediately\n await sendAnalyticsEvent(event, config.apiEndpoint, config.maxRetries, config.retryDelay);\n }\n }, [config, adId, productId, offerId, agentId, elementRef, state, log]);\n\n // Flush event batch\n const flushBatch = useCallback(async () => {\n if (eventBatch.current.length === 0) return;\n\n // TEMPORARY: If analytics are disabled, clear batch without sending\n if (ANALYTICS_DISABLED) {\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 }\n\n const events = [...eventBatch.current];\n eventBatch.current = [];\n\n if (batchTimeout.current) {\n clearTimeout(batchTimeout.current);\n batchTimeout.current = null;\n }\n\n const success = await sendAnalyticsBatch(\n events,\n sessionId.current,\n config.apiEndpoint,\n config.maxRetries,\n config.retryDelay\n );\n\n if (success && config.onBatchSent) {\n config.onBatchSent({\n batchId: `batch_${Date.now()}`,\n sessionId: sessionId.current,\n createdAt: formatTimestamp(),\n events,\n eventCount: events.length\n });\n }\n }, [config, 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', mrcStandards.current);\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","/**\n * AdMesh Viewability Tracker Component\n * Wraps any ad component with MRC viewability tracking\n */\n\nimport React, { useRef, useEffect } from 'react';\nimport { useViewabilityTracker } from '../hooks/useViewabilityTracker';\nimport type { ViewabilityTrackerConfig } from '../types/analytics';\n\nexport interface AdMeshViewabilityTrackerProps {\n /** Ad ID */\n adId: string;\n /** Product ID */\n productId?: string;\n /** Offer ID */\n offerId?: string;\n /** Agent ID */\n agentId?: 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 * adId=\"ad_123\"\n * productId=\"prod_456\"\n * offerId=\"offer_789\"\n * onViewable={() => console.log('Ad is viewable!')}\n * >\n * <YourAdComponent />\n * </AdMeshViewabilityTracker>\n * ```\n */\nexport const AdMeshViewabilityTracker: React.FC<AdMeshViewabilityTrackerProps> = ({\n adId,\n productId,\n offerId,\n agentId,\n children,\n config,\n className,\n style,\n onViewabilityChange,\n onVisible,\n onViewable,\n onClick\n}) => {\n const elementRef = useRef<HTMLElement>(null);\n\n // Use viewability tracker hook\n const viewabilityState = useViewabilityTracker({\n adId,\n productId,\n offerId,\n agentId,\n elementRef: elementRef as React.RefObject<HTMLElement>,\n config\n });\n\n // Track viewability changes\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 }, [viewabilityState.isViewable, onViewabilityChange, onViewable]);\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-ad-id={adId}\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\n","import React from 'react';\nimport type { AdMeshRecommendation, AdMeshTheme } from '../types/index';\nimport { AdMeshViewabilityTracker } from './AdMeshViewabilityTracker';\n\nexport interface AdMeshSummaryUnitProps {\n summaryText: string; // The citation_summary from backend response\n recommendations: AdMeshRecommendation[]; // Full recommendation objects\n theme?: AdMeshTheme;\n className?: string;\n style?: React.CSSProperties;\n onLinkClick?: (recommendation: AdMeshRecommendation) => void;\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// Process summary text with markdown links [Product Name](click_url)\nconst processSummaryText = (summaryText: string, recommendations: AdMeshRecommendation[]) => {\n // Create lookup map for recommendations by click_url\n const clickUrlToRecMap = new Map<string, AdMeshRecommendation>();\n\n recommendations.forEach(rec => {\n if (rec.click_url) {\n clickUrlToRecMap.set(rec.click_url, rec);\n }\n });\n\n // Debug: Log all available links in recommendations\n console.log('[AdMesh Summary] Processing recommendations:', {\n totalRecommendations: recommendations.length,\n recommendationFields: recommendations.map(rec => ({\n ad_id: rec.ad_id,\n click_url: rec.click_url,\n product_title: rec.product_title\n }))\n });\n\n if (clickUrlToRecMap.size > 0) {\n console.log('[AdMesh Summary] Available recommendation links:', {\n click_urls: Array.from(clickUrlToRecMap.keys())\n });\n } else {\n console.warn('[AdMesh Summary] No click_url found in recommendations!');\n }\n\n // Find markdown links and replace with JSX elements\n const markdownLinkRegex = /\\[([^\\]]+)\\]\\(([^)]+)\\)/g;\n const parts: (string | React.ReactElement)[] = [];\n let lastIndex = 0;\n let match;\n let linkCounter = 0;\n\n while ((match = markdownLinkRegex.exec(summaryText)) !== null) {\n const [fullMatch, linkText, url] = match;\n\n console.log('[AdMesh Summary] Processing markdown link:', {\n linkText,\n url,\n urlLength: url.length\n });\n\n // Try to find recommendation by exact URL match (click_url)\n let recommendation = clickUrlToRecMap.get(url);\n\n console.log('[AdMesh Summary] URL match result:', {\n found: !!recommendation,\n matchedBy: recommendation ? 'exact_match' : 'none'\n });\n\n // If exact match not found, try to match by ad_id\n if (!recommendation) {\n recommendation = recommendations.find(rec =>\n (rec.ad_id && url.includes(rec.ad_id))\n );\n if (recommendation) {\n console.log('[AdMesh Summary] Found by ad_id match');\n }\n }\n\n // Add text before the link\n if (match.index > lastIndex) {\n parts.push(summaryText.slice(lastIndex, match.index));\n }\n\n if (recommendation) {\n linkCounter++;\n // Use the URL from markdown (click_url)\n const linkUrl = url || recommendation.click_url;\n\n // Create clickable link element\n parts.push(\n <a\n key={`summary-link-${linkCounter}`}\n href={linkUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline decoration-blue-600 dark:decoration-blue-400 hover:decoration-blue-800 dark:hover:decoration-blue-300 transition-colors duration-200 font-medium\"\n style={{\n color: '#2563eb', // Force blue color\n textDecoration: 'underline',\n textDecorationColor: '#2563eb',\n textUnderlineOffset: '2px'\n }}\n onClick={() => {\n // Log comprehensive click data\n console.log('AdMesh summary link clicked:', {\n adId: recommendation.ad_id,\n productId: recommendation.product_id,\n title: recommendation.product_title,\n clickUrl: recommendation.click_url,\n source: 'summary'\n });\n\n // Fire tracking asynchronously WITHOUT blocking navigation\n if (typeof window !== 'undefined' && (window as any).admeshTracker) {\n (window as any).admeshTracker.trackClick({\n adId: recommendation.ad_id,\n productId: recommendation.product_id,\n clickUrl: recommendation.click_url,\n source: 'summary'\n }).catch((error: Error) => {\n console.error('[AdMesh] Failed to track summary link click:', error);\n });\n }\n }}\n >\n {linkText}\n </a>\n );\n } else {\n // Create a regular link even if no recommendation found\n console.warn(`[AdMesh Summary] No recommendation found for link: [${linkText}](${url}), creating regular link`, {\n availableLinks: Array.from(clickUrlToRecMap.keys()),\n totalRecommendations: recommendations.length,\n urlToMatch: url\n });\n\n if (isValidUrl(url)) {\n linkCounter++;\n parts.push(\n <a\n key={`summary-link-${linkCounter}`}\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline decoration-blue-600 dark:decoration-blue-400 hover:decoration-blue-800 dark:hover:decoration-blue-300 transition-colors duration-200 font-medium\"\n style={{\n color: '#2563eb', // Force blue color\n textDecoration: 'underline',\n textDecorationColor: '#2563eb',\n textUnderlineOffset: '2px'\n }}\n onClick={() => {\n // Log click for unmatched links\n console.log('AdMesh summary unmatched link clicked:', {\n linkText,\n url,\n source: 'summary'\n });\n\n // Fire tracking asynchronously WITHOUT blocking navigation\n if (typeof window !== 'undefined' && (window as any).admeshTracker) {\n (window as any).admeshTracker.trackClick({\n url,\n linkText,\n source: 'summary_unmatched'\n }).catch((error: Error) => {\n console.error('[AdMesh] Failed to track unmatched link click:', error);\n });\n }\n }}\n >\n {linkText}\n </a>\n );\n } else {\n // If URL is invalid, just show the link text without markdown\n console.warn(`[AdMesh Summary] Invalid URL in markdown link: [${linkText}](${url})`);\n parts.push(linkText);\n }\n }\n\n lastIndex = match.index + fullMatch.length;\n }\n\n // Add remaining text\n if (lastIndex < summaryText.length) {\n parts.push(summaryText.slice(lastIndex));\n }\n\n return parts;\n};\n\nexport const AdMeshSummaryUnit: React.FC<AdMeshSummaryUnitProps> = ({\n summaryText,\n recommendations,\n theme,\n className = '',\n style = {}\n}) => {\n // Validate inputs\n if (!summaryText || !summaryText.trim()) {\n console.warn('[AdMesh Summary] No summary text provided');\n return null;\n }\n\n if (!recommendations || recommendations.length === 0) {\n console.warn('[AdMesh Summary] No recommendations provided');\n // Still process markdown links even without recommendations\n const processedContent = processSummaryText(summaryText, []);\n return (\n <div className={`admesh-summary-unit ${className}`} style={style}>\n <p className=\"text-gray-700 dark:text-gray-300 leading-relaxed\">\n {processedContent.map((part, index) => (\n <React.Fragment key={index}>{part}</React.Fragment>\n ))}\n </p>\n </div>\n );\n }\n\n // Process the summary text to create clickable links\n const processedContent = processSummaryText(summaryText, recommendations);\n\n // Get the first recommendation's ad ID for viewability tracking\n // (Summary unit contains multiple recommendations, but we track the unit as a whole)\n const adId = recommendations[0]?.ad_id || '';\n\n return (\n <AdMeshViewabilityTracker\n adId={adId}\n className={`admesh-summary-unit ${className}`}\n style={{\n fontFamily: theme?.fontFamily || '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n ...style\n }}\n >\n <div>\n {/* Summary Content */}\n <div className=\"summary-content\">\n <p className=\"text-gray-700 dark:text-gray-300 leading-relaxed text-base\">\n {processedContent.map((part, index) => (\n <React.Fragment key={index}>{part}</React.Fragment>\n ))}\n </p>\n </div>\n\n {/* Disclosure */}\n <div className=\"mt-3 pt-2 border-t border-gray-200 dark:border-gray-700\">\n <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n Sponsored\n </p>\n </div>\n </div>\n </AdMeshViewabilityTracker>\n );\n};\n\nexport default AdMeshSummaryUnit;\n","/*!\n\tCopyright (c) 2018 Jed Watson.\n\tLicensed under the MIT License (MIT), see\n\thttp://jedwatson.github.io/classnames\n*/\n/* global define */\n\n(function () {\n\t'use strict';\n\n\tvar hasOwn = {}.hasOwnProperty;\n\n\tfunction classNames () {\n\t\tvar classes = '';\n\n\t\tfor (var i = 0; i < arguments.length; i++) {\n\t\t\tvar arg = arguments[i];\n\t\t\tif (arg) {\n\t\t\t\tclasses = appendClass(classes, parseValue(arg));\n\t\t\t}\n\t\t}\n\n\t\treturn classes;\n\t}\n\n\tfunction parseValue (arg) {\n\t\tif (typeof arg === 'string' || typeof arg === 'number') {\n\t\t\treturn arg;\n\t\t}\n\n\t\tif (typeof arg !== 'object') {\n\t\t\treturn '';\n\t\t}\n\n\t\tif (Array.isArray(arg)) {\n\t\t\treturn classNames.apply(null, arg);\n\t\t}\n\n\t\tif (arg.toString !== Object.prototype.toString && !arg.toString.toString().includes('[native code]')) {\n\t\t\treturn arg.toString();\n\t\t}\n\n\t\tvar classes = '';\n\n\t\tfor (var key in arg) {\n\t\t\tif (hasOwn.call(arg, key) && arg[key]) {\n\t\t\t\tclasses = appendClass(classes, key);\n\t\t\t}\n\t\t}\n\n\t\treturn classes;\n\t}\n\n\tfunction appendClass (value, newClass) {\n\t\tif (!newClass) {\n\t\t\treturn value;\n\t\t}\n\t\n\t\tif (value) {\n\t\t\treturn value + ' ' + newClass;\n\t\t}\n\t\n\t\treturn value + newClass;\n\t}\n\n\tif (typeof module !== 'undefined' && module.exports) {\n\t\tclassNames.default = classNames;\n\t\tmodule.exports = classNames;\n\t} else if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {\n\t\t// register as 'classnames', consistent with npm package name\n\t\tdefine('classnames', [], function () {\n\t\t\treturn classNames;\n\t\t});\n\t} else {\n\t\twindow.classNames = classNames;\n\t}\n}());\n","import { useState, useCallback, useMemo } from 'react';\nimport type { TrackingData, UseAdMeshTrackerReturn } from '../types/index';\n\n// Default tracking endpoint\nconst DEFAULT_TRACKING_URL = 'https://api.useadmesh.com/track';\n\ninterface TrackingConfig {\n enabled?: boolean;\n retryAttempts?: number;\n retryDelay?: number;\n}\n\n// Global config that can be set by the consuming application\nlet globalConfig: TrackingConfig = {\n enabled: true,\n retryAttempts: 3,\n retryDelay: 1000\n};\n\nexport const setAdMeshTrackerConfig = (config: Partial<TrackingConfig>) => {\n globalConfig = { ...globalConfig, ...config };\n};\n\nexport const useAdMeshTracker = (config?: Partial<TrackingConfig>): UseAdMeshTrackerReturn => {\n const [isTracking, setIsTracking] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const mergedConfig = useMemo(() => ({ ...globalConfig, ...config }), [config]);\n\n const sendTrackingEvent = useCallback(async (\n eventType: 'click' | 'view' | 'conversion',\n data: TrackingData\n ): Promise<void> => {\n if (!mergedConfig.enabled) {\n return;\n }\n\n if (!data.adId || !data.admeshLink) {\n const errorMsg = 'Missing required tracking data: adId and admeshLink are required';\n setError(errorMsg);\n return;\n }\n\n setIsTracking(true);\n setError(null);\n\n const payload = {\n event_type: eventType,\n ad_id: data.adId,\n admesh_link: data.admeshLink,\n product_id: data.productId,\n user_id: data.userId,\n session_id: data.sessionId,\n revenue: data.revenue,\n conversion_type: data.conversionType,\n metadata: data.metadata,\n timestamp: new Date().toISOString(),\n user_agent: navigator.userAgent,\n referrer: document.referrer,\n page_url: window.location.href\n };\n\n let lastError: Error | null = null;\n\n for (let attempt = 1; attempt <= (mergedConfig.retryAttempts || 3); attempt++) {\n try {\n const response = await fetch(`${DEFAULT_TRACKING_URL}`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(payload),\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n await response.json();\n setIsTracking(false);\n return;\n\n } catch (err) {\n lastError = err as Error;\n\n if (attempt < (mergedConfig.retryAttempts || 3)) {\n await new Promise(resolve =>\n setTimeout(resolve, (mergedConfig.retryDelay || 1000) * attempt)\n );\n }\n }\n }\n\n // All attempts failed\n const errorMsg = `Failed to track ${eventType} event after ${mergedConfig.retryAttempts} attempts: ${lastError?.message}`;\n setError(errorMsg);\n setIsTracking(false);\n }, [mergedConfig]);\n\n const trackClick = useCallback(async (data: TrackingData): Promise<void> => {\n return sendTrackingEvent('click', data);\n }, [sendTrackingEvent]);\n\n const trackView = useCallback(async (data: TrackingData): Promise<void> => {\n return sendTrackingEvent('view', data);\n }, [sendTrackingEvent]);\n\n const trackConversion = useCallback(async (data: TrackingData): Promise<void> => {\n return sendTrackingEvent('conversion', data);\n }, [sendTrackingEvent]);\n\n return {\n trackClick,\n trackView,\n trackConversion,\n isTracking,\n error\n };\n};\n\n// Utility function to build admesh_link with tracking parameters\nexport const buildAdMeshLink = (\n baseLink: string, \n adId: string, \n additionalParams?: Record<string, string>\n): string => {\n try {\n const url = new URL(baseLink);\n url.searchParams.set('ad_id', adId);\n url.searchParams.set('utm_source', 'admesh');\n url.searchParams.set('utm_medium', 'recommendation');\n \n if (additionalParams) {\n Object.entries(additionalParams).forEach(([key, value]) => {\n url.searchParams.set(key, value);\n });\n }\n \n return url.toString();\n } catch (err) {\n console.warn('[AdMesh] Invalid URL provided to buildAdMeshLink:', baseLink, err);\n return baseLink;\n }\n};\n\n// Helper function to extract tracking data from recommendation\nexport const extractTrackingData = (\n recommendation: { ad_id: string; admesh_link: string; product_id: string },\n additionalData?: Partial<TrackingData>\n): TrackingData => {\n return {\n adId: recommendation.ad_id,\n admeshLink: recommendation.admesh_link,\n productId: recommendation.product_id,\n ...additionalData\n };\n};\n","import React, { useCallback, useEffect, useRef } from 'react';\nimport type { AdMeshLinkTrackerProps } from '../types/index';\nimport { useAdMeshTracker } from '../hooks/useAdMeshTracker';\n\nexport const AdMeshLinkTracker: React.FC<AdMeshLinkTrackerProps> = ({\n adId,\n admeshLink,\n productId,\n children,\n trackingData,\n className,\n style\n}) => {\n const { trackClick, trackView } = useAdMeshTracker();\n const elementRef = useRef<HTMLDivElement>(null);\n const hasTrackedView = useRef(false);\n\n // Track view when component becomes visible\n useEffect(() => {\n if (!elementRef.current || hasTrackedView.current) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting && !hasTrackedView.current) {\n hasTrackedView.current = true;\n trackView({\n adId,\n admeshLink,\n productId,\n ...trackingData\n }).catch(console.error);\n }\n });\n },\n {\n threshold: 0.5, // Track when 50% of the element is visible\n rootMargin: '0px'\n }\n );\n\n observer.observe(elementRef.current);\n\n return () => {\n observer.disconnect();\n };\n }, [adId, admeshLink, productId, trackingData, trackView]);\n\n const handleClick = useCallback((event: React.MouseEvent) => {\n // Fire tracking asynchronously WITHOUT blocking navigation\n // This ensures the link opens immediately\n trackClick({\n adId,\n admeshLink,\n productId,\n ...trackingData\n }).catch(error => {\n // Log error but don't block navigation\n console.error('[AdMesh] Failed to track click:', error);\n });\n\n // If the children contain a link, let the browser handle navigation\n // Otherwise, navigate programmatically\n const target = event.target as HTMLElement;\n const link = target.closest('a');\n\n if (!link) {\n // No link found, navigate programmatically\n window.open(admeshLink, '_blank', 'noopener,noreferrer');\n }\n // If there's a link, let the browser handle it naturally\n }, [adId, admeshLink, productId, trackingData, trackClick]);\n\n return (\n <div\n ref={elementRef}\n className={className}\n onClick={handleClick}\n style={{\n cursor: 'pointer',\n ...style\n }}\n >\n {children}\n </div>\n );\n};\n\nAdMeshLinkTracker.displayName = 'AdMeshLinkTracker';\n","/**\n * AdMesh Style Injection System\n * \n * Provides platform-agnostic, isolated styling for AdMesh components\n * that pr