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(); } // ============================================================================ // FIREBASE AUTH SERVICE // ============================================================================ interface AppUser { uid: string; email: string; name: string; status: string; 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, createdAt: data.createdAt }; } return null; }, // 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'); // Create invite record await firebaseDb.collection('invites').doc(email).set({ email, name, invitedAt: firebase.firestore.FieldValue.serverTimestamp(), status: 'pending' }); // Return the invite link (share manually with the user) 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'); // Create the user const userCredential = await firebaseAuth.createUserWithEmailAndPassword(email, password); const uid = userCredential.user.uid; // Create user document await firebaseDb.collection('users').doc(uid).set({ email, name, status: 'active', createdAt: firebase.firestore.FieldValue.serverTimestamp() }); // Update invite status await firebaseDb.collection('invites').doc(email).update({ status: 'completed', completedAt: firebase.firestore.FieldValue.serverTimestamp(), uid }); return userCredential; }, // Get all users (for admin) getAllUsers: async (): Promise => { if (!firebaseDb) return []; const snapshot = await firebaseDb.collection('users').orderBy('createdAt', 'desc').get(); return snapshot.docs.map((doc: any) => ({ uid: doc.id, ...doc.data() })); }, // Get all pending invites getPendingInvites: async (): Promise => { if (!firebaseDb) return []; const snapshot = await firebaseDb.collection('invites').where('status', '==', 'pending').get(); return snapshot.docs.map((doc: any) => ({ email: doc.id, ...doc.data() })); }, // Delete/revoke invite revokeInvite: async (email: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); await firebaseDb.collection('invites').doc(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 // ============================================================================ const api = { // Agents async listAgents(page = 1, pageSize = 20): Promise<{ count: number; results: Agent[] }> { const res = await fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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 fetch(`${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) => (