import React, { useState, useEffect, useCallback } from 'react'; import { createRoot } from 'react-dom/client'; // ============================================================================ // CONFIGURATION // ============================================================================ const CONFIG = { APP_NAME: 'Recex Voice AI', BACKEND_URL: '/api', }; // ============================================================================ // FIREBASE CONFIGURATION // ============================================================================ // TODO: Replace with your Firebase config from Firebase Console const FIREBASE_CONFIG = { apiKey: "AIzaSyD4wcDdFod7TPvTvb8yaK04warWl7qYjpM", authDomain: "recex-voice-ai.firebaseapp.com", projectId: "recex-voice-ai", storageBucket: "recex-voice-ai.firebasestorage.app", messagingSenderId: "406720918394", appId: "1:406720918394:web:5506791946adb81ddedcc3" }; // Check if Firebase is configured const isFirebaseConfigured = () => FIREBASE_CONFIG.apiKey !== "YOUR_API_KEY"; // Helper to access Firebase from window (avoids TypeScript 'as any' syntax that Babel can't parse) const getFirebase = (): any => { if (typeof window !== 'undefined') { return window["firebase"]; } return null; }; // Initialize Firebase (only if configured) let firebaseApp: any = null; let firebaseAuth: any = null; let firebaseDb: any = null; const firebase = getFirebase(); if (isFirebaseConfigured() && firebase) { firebaseApp = firebase.initializeApp(FIREBASE_CONFIG); firebaseAuth = firebase.auth(); firebaseDb = firebase.firestore(); } // ============================================================================ // RESELLER & BRANDING CONFIGURATION // ============================================================================ // Context types for the multi-tier hierarchy type ContextType = 'platform' | 'reseller' | 'tenant'; interface AppContext { contextType: ContextType; resellerId: string | null; // Reseller ID (e.g., "xpertweb") tenantId: string | null; // Tenant ID (e.g., "client1") domain: string; // Full domain } // Legacy interface for backward compatibility interface ResellerInfo { resellerId: string; // Base domain (e.g., "recex-ai.com") or reseller ID tenant: string; // Subdomain (e.g., "demo", "client1") } interface ColorPalette { 50: string; 100: string; 200: string; 300: string; 400: string; 500: string; 600: string; 700: string; 800: string; 900: string; } interface ResellerBranding { appName: string; logoUrl: string; faviconUrl?: string; apiKey?: string; // Used by backend only colors: { primary: ColorPalette; secondary: ColorPalette; }; } // Default branding (Recex) - used as fallback const DEFAULT_BRANDING: ResellerBranding = { appName: 'Recex Voice AI', logoUrl: '/images/logo.png', faviconUrl: '/images/logo.png', colors: { primary: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#d5ad34', 600: '#b8922a', 700: '#9a7720', 800: '#7c5c16', 900: '#5e410c' }, secondary: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#275a9a', 600: '#1e4a80', 700: '#153a66', 800: '#0c2a4c', 900: '#031a32' } } }; // Platform domain constant const PLATFORM_DOMAIN = 'demo-talk.com'; // Cache for app context (loaded from domainMappings) let cachedAppContext: AppContext | null = null; // Get app context from hostname - supports multi-tier hierarchy const getAppContext = (): AppContext => { if (cachedAppContext) return cachedAppContext; const hostname = window.location.hostname.toLowerCase(); const params = new URLSearchParams(window.location.search); // Allow URL param override for localhost testing const resellerOverride = params.get('reseller'); const tenantOverride = params.get('tenant'); // Handle localhost and IP addresses if (hostname === 'localhost' || hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) { cachedAppContext = { contextType: tenantOverride ? 'tenant' : (resellerOverride ? 'reseller' : 'platform'), resellerId: resellerOverride || null, tenantId: tenantOverride || null, domain: hostname }; return cachedAppContext; } const parts = hostname.replace(/^www\./, '').split('.'); // Check if it's the platform domain // demo-talk.com -> platform if (parts.join('.') === PLATFORM_DOMAIN) { cachedAppContext = { contextType: 'platform', resellerId: null, tenantId: null, domain: hostname }; return cachedAppContext; } // Check for demo-talk.com subdomains // xpertweb.demo-talk.com -> reseller "xpertweb" // client1.xpertweb.demo-talk.com -> tenant "client1" under reseller "xpertweb" if (parts.length >= 3 && parts.slice(-2).join('.') === PLATFORM_DOMAIN) { if (parts.length === 3) { // Single subdomain: reseller cachedAppContext = { contextType: 'reseller', resellerId: parts[0], tenantId: null, domain: hostname }; } else { // Multiple subdomains: tenant.reseller.demo-talk.com cachedAppContext = { contextType: 'tenant', resellerId: parts[1], // Second part is reseller tenantId: parts[0], // First part is tenant domain: hostname }; } return cachedAppContext; } // Handle legacy/other domains (e.g., recex-ai.com, other custom domains) // demo.recex-ai.com -> reseller: "recex-ai.com", tenant: "demo" if (parts.length >= 3) { const tenant = parts[0]; const baseDomain = parts.slice(1).join('.'); cachedAppContext = { contextType: 'tenant', resellerId: baseDomain, // Use domain as reseller ID for legacy tenantId: tenant, domain: hostname }; return cachedAppContext; } // Apex domain (e.g., recex-ai.com) if (parts.length === 2) { cachedAppContext = { contextType: 'reseller', resellerId: parts.join('.'), tenantId: null, domain: hostname }; return cachedAppContext; } // Fallback cachedAppContext = { contextType: 'platform', resellerId: null, tenantId: null, domain: hostname }; return cachedAppContext; }; // Legacy function for backward compatibility const getResellerInfo = (): ResellerInfo => { const context = getAppContext(); // For legacy domains like recex-ai.com, use domain as resellerId // For demo-talk.com subdomains, use resellerId return { resellerId: context.resellerId || PLATFORM_DOMAIN, tenant: context.tenantId || context.resellerId || 'demo' }; }; // Branding service - loads and applies branding from Firestore let cachedBranding: ResellerBranding | null = null; let brandingLoadPromise: Promise | null = null; const brandingService = { // Load branding configuration from Firestore loadBranding: async (): Promise => { if (cachedBranding) return cachedBranding; if (brandingLoadPromise) return brandingLoadPromise; brandingLoadPromise = (async () => { const context = getAppContext(); // For platform context (demo-talk.com apex), use default branding if (context.contextType === 'platform' && !context.resellerId) { cachedBranding = DEFAULT_BRANDING; return cachedBranding; } // Get reseller ID - for demo-talk.com subdomains it's the subdomain, // for legacy domains like recex-ai.com it's the full domain const resellerId = context.resellerId; if (!resellerId) { cachedBranding = DEFAULT_BRANDING; return cachedBranding; } try { if (firebaseDb) { // Try to load from resellers collection const doc = await firebaseDb.collection('resellers').doc(resellerId).get(); if (doc.exists) { const data = doc.data(); // Check for nested branding object or flat structure const branding = data.branding || data; cachedBranding = { appName: branding.appName || DEFAULT_BRANDING.appName, logoUrl: branding.logoUrl || DEFAULT_BRANDING.logoUrl, faviconUrl: branding.faviconUrl || DEFAULT_BRANDING.faviconUrl, colors: branding.colors || DEFAULT_BRANDING.colors }; return cachedBranding; } } } catch (e) { console.warn('Failed to load branding from Firestore:', e); } // Fallback to default branding cachedBranding = DEFAULT_BRANDING; return cachedBranding; })(); return brandingLoadPromise; }, // Get current branding (sync, returns cached or default) getBranding: (): ResellerBranding => { return cachedBranding || DEFAULT_BRANDING; }, // Apply branding to DOM (colors, title, favicon) applyBranding: (branding: ResellerBranding): void => { // Update document title document.title = branding.appName; // Update favicon if (branding.faviconUrl) { let link = document.querySelector("link[rel*='icon']"); if (link) { link.href = branding.faviconUrl; } } // Apply CSS custom properties for colors const root = document.documentElement; Object.entries(branding.colors.primary).forEach(([shade, color]) => { root.style.setProperty(`--color-primary-${shade}`, color); }); Object.entries(branding.colors.secondary).forEach(([shade, color]) => { root.style.setProperty(`--color-secondary-${shade}`, color); }); }, // Get reseller ID for API calls getResellerId: (): string => { const context = getAppContext(); return context.resellerId || PLATFORM_DOMAIN; }, // Get full app context getAppContext: (): AppContext => { return getAppContext(); } }; // Branding Context for React components interface BrandingContextType { branding: ResellerBranding; isLoading: boolean; } const BrandingContext = React.createContext({ branding: DEFAULT_BRANDING, isLoading: true }); const useBranding = () => React.useContext(BrandingContext); // ============================================================================ // FIREBASE AUTH SERVICE // ============================================================================ // Helper to get current tenant (subdomain) - uses getResellerInfo const getTenant = (): string => { const context = getAppContext(); // Return tenantId if available, otherwise resellerId (for reseller-level access) return context.tenantId || context.resellerId || 'demo'; }; // User roles for the multi-tier hierarchy type UserRole = 'platform_admin' | 'reseller_admin' | 'tenant_admin' | 'user'; interface AppUser { uid: string; email: string; name: string; status: string; role: UserRole; resellerId: string | null; // Which reseller this user belongs to tenantId: string | null; // Which tenant (null for reseller_admin) tenant: string; // Legacy field for backward compatibility createdAt: any; } const authService = { // Check if user is signed in getCurrentUser: (): any => { return firebaseAuth?.currentUser; }, // Sign in with email and password signIn: async (email: string, password: string): Promise => { if (!firebaseAuth) throw new Error('Firebase not configured'); return firebaseAuth.signInWithEmailAndPassword(email, password); }, // Sign out signOut: async (): Promise => { if (!firebaseAuth) { localStorage.removeItem('recex_authenticated'); return; } return firebaseAuth.signOut(); }, // Send password reset email sendPasswordReset: async (email: string): Promise => { if (!firebaseAuth) throw new Error('Firebase not configured'); return firebaseAuth.sendPasswordResetEmail(email); }, // Listen to auth state changes onAuthStateChanged: (callback: (user: any) => void): (() => void) => { if (!firebaseAuth) { // Fallback for non-Firebase mode const isAuth = localStorage.getItem('recex_authenticated') === 'true'; callback(isAuth ? { uid: 'local', email: 'admin@local' } : null); return () => {}; } return firebaseAuth.onAuthStateChanged(callback); }, // Get user data from Firestore getUserData: async (uid: string): Promise => { if (!firebaseDb) return null; const doc = await firebaseDb.collection('users').doc(uid).get(); if (doc.exists) { const data = doc.data(); return { uid, email: data.email, name: data.name, status: data.status, role: data.role || 'user', resellerId: data.resellerId || null, tenantId: data.tenantId || null, tenant: data.tenant || data.tenantId || data.resellerId, // Legacy compatibility createdAt: data.createdAt }; } return null; }, // Verify user has access to current context (reseller/tenant) and is active verifyTenant: async (uid: string): Promise<{ valid: boolean; reason?: string; user?: AppUser }> => { if (!firebaseDb) return { valid: true }; // Allow if no Firebase const doc = await firebaseDb.collection('users').doc(uid).get(); const context = getAppContext(); console.log('verifyTenant debug:', { uid, docExists: doc.exists, context }); if (!doc.exists) return { valid: false, reason: 'not_registered' }; const data = doc.data(); const user: AppUser = { uid, email: data.email, name: data.name, status: data.status, role: data.role || 'user', resellerId: data.resellerId || null, tenantId: data.tenantId || null, tenant: data.tenant || data.tenantId || data.resellerId, createdAt: data.createdAt }; console.log('verifyTenant user data:', { role: user.role, resellerId: user.resellerId, tenantId: user.tenantId, status: user.status }); // Check if user is inactive if (user.status === 'inactive') { return { valid: false, reason: 'inactive' }; } // Platform admin can access anything if (user.role === 'platform_admin') { return { valid: true, user }; } // For reseller context (e.g., xpertweb.demo-talk.com) if (context.contextType === 'reseller') { // Reseller admin can access their reseller if (user.role === 'reseller_admin' && user.resellerId === context.resellerId) { return { valid: true, user }; } // Legacy support: check tenant field matches resellerId if (user.tenant === context.resellerId) { return { valid: true, user }; } return { valid: false, reason: 'wrong_tenant' }; } // For tenant context (e.g., client1.xpertweb.demo-talk.com or demo.recex-ai.com) if (context.contextType === 'tenant') { // Reseller admin can access any tenant under their reseller if (user.role === 'reseller_admin' && user.resellerId === context.resellerId) { return { valid: true, user }; } // Tenant admin or user must match both reseller and tenant if ((user.role === 'tenant_admin' || user.role === 'user') && user.resellerId === context.resellerId && user.tenantId === context.tenantId) { return { valid: true, user }; } // Legacy support: check tenant field if (user.tenant === context.tenantId || user.tenant === context.resellerId) { return { valid: true, user }; } return { valid: false, reason: 'wrong_tenant' }; } // Platform context - only platform admins return { valid: false, reason: 'wrong_tenant' }; }, // Invite a new user (creates invite record, returns invite link) inviteUser: async (email: string, name: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); const tenant = getTenant(); // Create invite record with tenant await firebaseDb.collection('invites').doc(`${tenant}_${email}`).set({ email, name, tenant, invitedAt: firebase.firestore.FieldValue.serverTimestamp(), status: 'pending' }); // Return the invite link (must be opened on same subdomain) return `${window.location.origin}?invite=${encodeURIComponent(email)}&name=${encodeURIComponent(name)}`; }, // Complete signup from invite completeSignup: async (email: string, password: string, name: string): Promise => { if (!firebaseAuth || !firebaseDb) throw new Error('Firebase not configured'); const tenant = getTenant(); const inviteId = `${tenant}_${email}`; // Verify invite exists for this tenant const inviteDoc = await firebaseDb.collection('invites').doc(inviteId).get(); if (!inviteDoc.exists) { throw new Error('Invalid or expired invitation for this domain'); } const inviteData = inviteDoc.data(); if (inviteData.tenant !== tenant) { throw new Error('This invitation is not valid for this domain'); } if (inviteData.status !== 'pending') { throw new Error('This invitation has already been used'); } // Create the user const userCredential = await firebaseAuth.createUserWithEmailAndPassword(email, password); const uid = userCredential.user.uid; // Create user document with tenant await firebaseDb.collection('users').doc(uid).set({ email, name, tenant, status: 'active', createdAt: firebase.firestore.FieldValue.serverTimestamp() }); // Update invite status await firebaseDb.collection('invites').doc(inviteId).update({ status: 'completed', completedAt: firebase.firestore.FieldValue.serverTimestamp(), uid }); return userCredential; }, // Get all users (for admin) getAllUsers: async (): Promise => { if (!firebaseDb) return []; const tenant = getTenant(); const snapshot = await firebaseDb.collection('users') .where('tenant', '==', tenant) .orderBy('createdAt', 'desc') .get(); return snapshot.docs.map((doc: any) => ({ uid: doc.id, ...doc.data() })); }, // Get all pending invites for current tenant getPendingInvites: async (): Promise => { if (!firebaseDb) return []; const tenant = getTenant(); const snapshot = await firebaseDb.collection('invites') .where('tenant', '==', tenant) .where('status', '==', 'pending') .get(); return snapshot.docs.map((doc: any) => ({ email: doc.data().email, ...doc.data() })); }, // Delete/revoke invite revokeInvite: async (email: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); const tenant = getTenant(); await firebaseDb.collection('invites').doc(`${tenant}_${email}`).delete(); }, // Deactivate user deactivateUser: async (uid: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); await firebaseDb.collection('users').doc(uid).update({ status: 'inactive' }); }, // Reactivate user reactivateUser: async (uid: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); await firebaseDb.collection('users').doc(uid).update({ status: 'active' }); } }; // ============================================================================ // TYPES // ============================================================================ interface Agent { id: string; name: string; language: string; voice_persona: string; persona_name: string; status: string; agent_prompt?: string; introduction?: string; objective?: string; result_prompt?: string; result_schema?: Record; custom_variables?: string[]; created_at?: string; } interface Call { id: string; callee_name: string; mobile_number: string; agent_id: string; status: string; lifecycle_status: string; duration_minutes?: number; duration_seconds?: number; recording_url?: string; result?: Record; created_at: string; started_at?: string; ended_at?: string; engagement_status?: string; answered_by?: string; custom_data?: Record; agent?: { id: string; name: string }; } // Fixed values from API for filters const CALL_STATUSES = ['COMPLETED', 'IN_PROGRESS', 'NOT_CONNECTED', 'FAILED', 'SCHEDULED', 'CANCELLED']; const ENGAGEMENT_STATUSES = ['ENGAGED', 'NOT_ENGAGED']; const ANSWERED_BY_OPTIONS = ['HUMAN', 'VOICEMAIL', 'UNKNOWN']; interface Campaign { id: string; name: string; status: string; description?: string; agent: { id: string; name: string; voice_persona: string; }; total_call_count: number; connected_call_count: number; not_connected_call_count: number; failed_call_count: number; engaged_call_count: number; not_engaged_call_count: number; created_at: string; started_at?: string; ended_at?: string; } type TabType = 'agents' | 'calls' | 'campaigns'; type AgentViewType = 'list' | 'create' | 'edit' | 'view'; type CallViewType = 'history' | 'quick' | 'bulk'; type CampaignViewType = 'list' | 'create' | 'view'; // ============================================================================ // API FUNCTIONS // ============================================================================ // Helper function that adds reseller domain header to all API calls const apiFetch = (url: string, options?: RequestInit): Promise => { const resellerId = brandingService.getResellerId(); const headers = new Headers(options?.headers); headers.set('X-Reseller-Domain', resellerId); return fetch(url, { ...options, headers }); }; const api = { // Agents async listAgents(page = 1, pageSize = 20): Promise<{ count: number; results: Agent[] }> { const res = await apiFetch(`${CONFIG.BACKEND_URL}/agents?page=${page}&page_size=${pageSize}`); if (!res.ok) throw new Error('Failed to fetch agents'); return res.json(); }, async getAgent(id: string): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/agents/${id}`); if (!res.ok) throw new Error('Failed to fetch agent'); return res.json(); }, async createAgent(data: Partial): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || err.message || 'Failed to create agent'); } return res.json(); }, async updateAgent(id: string, data: Partial): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/agents/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || err.message || 'Failed to update agent'); } return res.json(); }, // Calls async listCalls(params: { page?: number; pageSize?: number; status?: string[]; engagementStatus?: string[]; answeredBy?: string[]; agentId?: string; campaignId?: string; createdAfter?: string; createdBefore?: string; }): Promise<{ count: number; results: Call[] }> { const searchParams = new URLSearchParams(); searchParams.set('page', String(params.page || 1)); searchParams.set('page_size', String(params.pageSize || 100)); if (params.status?.length) params.status.forEach(s => searchParams.append('status', s)); if (params.engagementStatus?.length) params.engagementStatus.forEach(s => searchParams.append('engagement_status', s)); if (params.answeredBy?.length) params.answeredBy.forEach(s => searchParams.append('answered_by', s)); if (params.agentId) searchParams.set('agent_id', params.agentId); if (params.campaignId) searchParams.set('campaign_id', params.campaignId); if (params.createdAfter) searchParams.set('created_after', params.createdAfter); if (params.createdBefore) searchParams.set('created_before', params.createdBefore); const res = await apiFetch(`${CONFIG.BACKEND_URL}/calls?${searchParams}`); if (!res.ok) throw new Error('Failed to fetch calls'); return res.json(); }, // Fetch all calls (for export) - fetches all pages async listAllCalls(params: { status?: string[]; engagementStatus?: string[]; answeredBy?: string[]; agentId?: string; campaignId?: string; createdAfter?: string; createdBefore?: string; }): Promise { const allCalls: Call[] = []; let page = 1; const pageSize = 100; let hasMore = true; while (hasMore) { const data = await this.listCalls({ ...params, page, pageSize }); allCalls.push(...data.results); hasMore = allCalls.length < data.count; page++; // Safety limit to prevent infinite loops if (page > 100) break; } return allCalls; }, async getCall(id: string): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/calls/${id}`); if (!res.ok) throw new Error('Failed to fetch call'); return res.json(); }, async createCall(data: { agent_id: string; callee_name: string; mobile_number: string; custom_data?: Record }): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/calls`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || err.message || 'Failed to create call'); } return res.json(); }, async createBulkCalls(data: { agent_id: string; data: Array<{ callee_name: string; mobile_number: string; custom_data?: Record }> }): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/calls/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || err.message || 'Failed to create bulk calls'); } return res.json(); }, // Campaigns async listCampaigns(page = 1, pageSize = 20): Promise<{ count: number; results: Campaign[] }> { const res = await apiFetch(`${CONFIG.BACKEND_URL}/campaigns?page=${page}&page_size=${pageSize}`); if (!res.ok) throw new Error('Failed to fetch campaigns'); return res.json(); }, async getCampaign(id: string): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/campaigns/${id}`); if (!res.ok) throw new Error('Failed to fetch campaign'); return res.json(); }, async createCampaign(formData: FormData): Promise { const res = await apiFetch(`${CONFIG.BACKEND_URL}/campaigns`, { method: 'POST', body: formData, }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || err.message || 'Failed to create campaign'); } return res.json(); }, }; // ============================================================================ // DATE & EXPORT UTILITIES // ============================================================================ const getDateRange = (range: string): { start: string; end: string } | null => { const now = new Date(); const end = now.toISOString(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let start: Date; switch (range) { case 'today': start = today; break; case 'yesterday': start = new Date(today.getTime() - 24 * 60 * 60 * 1000); break; case '7days': start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; case '30days': start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; default: return null; // No filter } return { start: start.toISOString(), end }; }; const exportCallsToCSV = (calls: Call[], filename: string, agentsMap?: Map) => { if (calls.length === 0) { alert('No calls to export'); return; } const headers = [ 'Call ID', 'Callee Name', 'Mobile Number', 'Agent Name', 'Status', 'Lifecycle Status', 'Duration (minutes)', 'Duration (seconds)', 'Engagement Status', 'Answered By', 'Created At', 'Started At', 'Ended At', 'Recording URL', 'Custom Data', 'Result' ]; const rows = calls.map(call => [ call.id, call.callee_name, call.mobile_number, call.agent?.name || agentsMap?.get(call.agent_id) || call.agent_id, call.status, call.lifecycle_status, call.duration_minutes?.toFixed(2) || '', call.duration_seconds?.toString() || '', call.engagement_status || '', call.answered_by || '', call.created_at, call.started_at || '', call.ended_at || '', call.recording_url || '', call.custom_data ? JSON.stringify(call.custom_data) : '', call.result ? JSON.stringify(call.result) : '' ]); const csvContent = [ headers.join(','), ...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')) ].join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `${filename}_${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(link.href); }; // ============================================================================ // UTILITY COMPONENTS // ============================================================================ const LoadingSpinner = () => (
); // Skeleton loader components for smoother loading states const Skeleton = ({ className = '' }: { className?: string }) => (
); const SkeletonCard = () => (
); const SkeletonTable = ({ rows = 5 }: { rows?: number }) => (
{[1, 2, 3, 4, 5].map(i => ( ))} {Array.from({ length: rows }).map((_, i) => ( {[1, 2, 3, 4, 5].map(j => ( ))} ))}
); const SkeletonStats = () => (
{[1, 2, 3, 4].map(i => ( ))}
); // Animated page wrapper for smooth transitions const PageTransition = ({ children }: { children: React.ReactNode }) => (
{children}
); const Alert = ({ type, message, onClose }: { type: 'success' | 'error'; message: string; onClose?: () => void }) => (
{message} {onClose && }
); const Button = ({ children, onClick, variant = 'primary', disabled = false, className = '', type = 'button' }: { children: React.ReactNode; onClick?: () => void; variant?: 'primary' | 'secondary' | 'outline' | 'danger'; disabled?: boolean; className?: string; type?: 'button' | 'submit'; }) => { const baseStyles = 'px-4 py-2 rounded-lg font-medium transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98] shadow-sm hover:shadow-md'; const variants = { primary: 'bg-secondary-500 text-white hover:bg-secondary-600 shadow-secondary-500/25', secondary: 'bg-primary-500 text-white hover:bg-primary-600 shadow-primary-500/25', outline: 'border-2 border-secondary-500 text-secondary-500 hover:bg-secondary-50 shadow-none hover:shadow-sm', danger: 'bg-red-500 text-white hover:bg-red-600 shadow-red-500/25', }; return ( ); }; const Input = ({ label, ...props }: { label: string } & React.InputHTMLAttributes) => (
); const Select = ({ label, options, ...props }: { label: string; options: { value: string; label: string }[] } & React.SelectHTMLAttributes) => (
); const TextArea = ({ label, ...props }: { label: string } & React.TextareaHTMLAttributes) => (