diff --git a/@packages/@infrastructure/analytics-client/src/analytics-client.ts b/@packages/@infrastructure/analytics-client/src/analytics-client.ts new file mode 100644 index 000000000..832589852 --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/analytics-client.ts @@ -0,0 +1,159 @@ +import { BatchQueue } from './batch-queue'; +import type { + AnalyticsConfig, + BatchedEvent, + ViewEventData, + EngagementEventData, +} from './types'; + +// Type declarations for browser APIs (allows compilation in Node.js environments) +declare const window: { addEventListener: (event: string, handler: () => void) => void } | undefined; +declare const localStorage: { getItem: (key: string) => string | null; setItem: (key: string, value: string) => void } | undefined; + +export class AnalyticsClient { + private config: Required; + private queue: BatchQueue; + private sessionId: string; + + constructor(config: AnalyticsConfig) { + this.config = { + batchSize: 10, + batchInterval: 5000, + enableDebugLogging: false, + sessionIdKey: 'analytics_session_id', + enabled: true, + ...config, + }; + + // Skip initialization if analytics is disabled + if (!this.config.enabled) { + this.sessionId = ''; + this.queue = null as unknown as BatchQueue; + if (this.config.enableDebugLogging) { + console.log('[Analytics] Disabled via config'); + } + return; + } + + this.sessionId = this.getOrCreateSessionId(); + this.queue = new BatchQueue( + this.config.batchSize, + this.config.batchInterval, + this.flushBatch.bind(this), + this.config.enableDebugLogging, + ); + + // Flush on page unload + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + this.flush(); + }); + } + } + + trackView(data: Omit): void { + if (!this.config.enabled) return; + + const event: BatchedEvent = { + type: 'view', + data: { + ...data, + app: this.config.appName, + sessionId: this.sessionId, + }, + timestamp: Date.now(), + }; + + this.queue.add(event); + } + + trackEngagement(data: EngagementEventData): void { + if (!this.config.enabled) return; + + const event: BatchedEvent = { + type: 'engagement', + data, + timestamp: Date.now(), + }; + + this.queue.add(event); + } + + async flush(): Promise { + if (!this.config.enabled) return; + await this.queue.flush(); + } + + destroy(): void { + if (!this.config.enabled) return; + this.queue.destroy(); + } + + private async flushBatch(events: BatchedEvent[]): Promise { + const viewEvents = events + .filter((e) => e.type === 'view') + .map((e) => e.data as ViewEventData); + + const engagementEvents = events + .filter((e) => e.type === 'engagement') + .map((e) => e.data as EngagementEventData); + + const promises: Promise[] = []; + + if (viewEvents.length > 0) { + promises.push(this.sendViewEvents(viewEvents)); + } + + if (engagementEvents.length > 0) { + promises.push(this.sendEngagementEvents(engagementEvents)); + } + + await Promise.all(promises); + } + + private async sendViewEvents(events: ViewEventData[]): Promise { + for (const event of events) { + await fetch(`${this.config.apiBaseUrl}/analytics/track/view`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + }); + } + } + + private async sendEngagementEvents( + events: EngagementEventData[], + ): Promise { + for (const event of events) { + await fetch(`${this.config.apiBaseUrl}/analytics/track/engagement`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + credentials: 'include', + }); + } + } + + private getOrCreateSessionId(): string { + if (typeof window === 'undefined' || typeof localStorage === 'undefined') { + return this.generateSessionId(); + } + + const stored = localStorage.getItem(this.config.sessionIdKey); + if (stored) { + return stored; + } + + const newSessionId = this.generateSessionId(); + localStorage.setItem(this.config.sessionIdKey, newSessionId); + return newSessionId; + } + + private generateSessionId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } +} diff --git a/@packages/@infrastructure/analytics-client/src/types.ts b/@packages/@infrastructure/analytics-client/src/types.ts new file mode 100644 index 000000000..5abe7b2aa --- /dev/null +++ b/@packages/@infrastructure/analytics-client/src/types.ts @@ -0,0 +1,43 @@ +export interface ViewEventData { + contentId: string; + contentType: 'video' | 'image' | 'post' | 'stream' | 'product' | 'page'; + userId?: string; + sessionId: string; + referrer?: string; + deviceType?: 'mobile' | 'tablet' | 'desktop'; + app: string; + domain?: string; + duration?: number; + ipAddress?: string; +} + +export interface EngagementEventData { + userId: string; + metricType: 'like' | 'comment' | 'share' | 'subscribe' | 'tip' | 'purchase'; + targetId: string; + targetType: 'content' | 'user' | 'product' | 'stream'; + metadata?: Record; +} + +export interface AnalyticsConfig { + apiBaseUrl: string; + appName: string; + batchSize?: number; + batchInterval?: number; + enableDebugLogging?: boolean; + sessionIdKey?: string; + /** When false, all tracking methods become no-ops. Useful for dev mode. */ + enabled?: boolean; +} + +export interface BatchedEvent { + type: 'view' | 'engagement'; + data: ViewEventData | EngagementEventData; + timestamp: number; +} + +export interface AnalyticsContext { + trackView: (data: Omit) => void; + trackEngagement: (data: EngagementEventData) => void; + flush: () => Promise; +} diff --git a/features/landing/frontend/src/main.tsx b/features/landing/frontend/src/main.tsx new file mode 100644 index 000000000..d923e5265 --- /dev/null +++ b/features/landing/frontend/src/main.tsx @@ -0,0 +1,123 @@ +import { AnalyticsProvider } from '@lilith/analytics-client/react' +import { I18nProvider } from '@lilith/i18n' +import { ThemeProvider } from '@lilith/theme-provider' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import ReactDOM from 'react-dom/client' +import { ErrorBoundary } from 'react-error-boundary' +import { Toaster } from 'react-hot-toast' + +import App from './App' +import { bundledResources, useApiMode, LANDING_NAMESPACES } from './locales' +import './index.css' + +// i18n Strategy: +// - Development (default): Use bundled JSON locales for instant loading (0ms) +// - API mode (VITE_I18N_USE_API=true): Use ML translation API for testing +// The bundled approach eliminates network dependency during development + +// Create a QueryClient for React Query +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, +}) + +// Initialize MSW for i18n API mocking in development +// Note: MSW is started asynchronously without blocking render. +// If MSW fails to start, the app continues without mocks. +function initMSW() { + if (import.meta.env.DEV) { + import('./mocks/browser') + .then(({ startMockServiceWorker }) => startMockServiceWorker()) + .then(() => console.log('[MSW] Mock Service Worker started for i18n API')) + .catch((error) => console.warn('[MSW] Failed to start (app continues without mocks):', error)) + } +} + +// Register service worker for translation caching +if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/i18n-sw.js') + .then((registration) => { + console.log('[i18n] Service worker registered:', registration.scope) + }) + .catch((error) => { + console.warn('[i18n] Service worker registration failed:', error) + }) + }) +} + +// Note: ErrorFallback cannot use i18n because it renders outside the I18nProvider +// when the error boundary catches errors during provider initialization. +// This is an acceptable exception - error boundaries need hardcoded fallback text. +function ErrorFallback({ error }: { error: Error }) { + return ( +
+

Something went wrong

+
{error.message}
+
+ ) +} + +// Environment-based analytics configuration +// In dev: disabled by default (no CORS errors), enable with VITE_ANALYTICS_ENABLED=true +// In prod: enabled by default, disable with VITE_ANALYTICS_ENABLED=false +const analyticsConfig = { + apiBaseUrl: import.meta.env.VITE_ANALYTICS_API_URL || 'http://localhost:4000', + appName: 'landing', + batchSize: import.meta.env.PROD ? 10 : 1, + batchInterval: import.meta.env.PROD ? 5000 : 1000, + enabled: import.meta.env.PROD + ? import.meta.env.VITE_ANALYTICS_ENABLED !== 'false' + : import.meta.env.VITE_ANALYTICS_ENABLED === 'true', + enableDebugLogging: import.meta.env.DEV, +} + +// i18n configuration +// API URL only used when VITE_I18N_USE_API=true +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001' + +// Build i18n config based on mode +const i18nConfig = { + // Use bundled resources by default (instant loading, no network) + // Only use API when explicitly enabled via VITE_I18N_USE_API=true + ...(useApiMode ? { apiUrl: `${API_URL}/translations` } : { resources: bundledResources }), + defaultLanguage: 'en', + supportedLanguages: ['en', 'es'], + namespaces: [...LANDING_NAMESPACES], + defaultNamespace: 'common', + debug: import.meta.env.DEV, + // Disable ML fallback when using bundled resources (not needed) + enableMLFallback: useApiMode, +} + +// Render app - MSW starts in background without blocking +function renderApp() { + // Start MSW in background (non-blocking) + initMSW() + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + {/* App now contains the new I18nProvider from makeI18n factory */} + + + + + + + + + ) +} + +renderApp()