import React, { useState, useEffect, useCallback } from 'react'; import { createRoot } from 'react-dom/client'; // ============================================================================ // CONFIGURATION - Build v46 // ============================================================================ const CONFIG = { APP_NAME: 'Recex Voice AI', BACKEND_URL: '/api', }; // LocalStorage keys - centralized to avoid typos and enable easy changes const STORAGE_KEYS = { AUTHENTICATED: 'recex_authenticated', USER_ROLE: 'recex_user_role', USER_NAME: 'recex_user_name', }; // Utility function to copy text to clipboard with fallback for older browsers const copyToClipboard = async (text) => { try { await navigator.clipboard.writeText(text); return true; } catch (err) { // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-9999px'; document.body.appendChild(textArea); textArea.select(); try { document.execCommand('copy'); return true; } finally { document.body.removeChild(textArea); } } }; // ============================================================================ // TIMEZONE SUPPORT // ============================================================================ const SUPPORTED_TIMEZONES = [ { value: 'Asia/Kolkata', label: 'India (Kolkata, Mumbai, Delhi)', offset: 5.5 }, { value: 'America/New_York', label: 'US Eastern (New York, Miami)', offset: -5 }, { value: 'America/Chicago', label: 'US Central (Chicago, Dallas)', offset: -6 }, { value: 'America/Denver', label: 'US Mountain (Denver, Phoenix)', offset: -7 }, { value: 'America/Los_Angeles', label: 'US Pacific (Los Angeles, Seattle)', offset: -8 }, ]; // Module-level tenant timezone (set when tenant is validated from Firestore) let _tenantTimezone: string | null = null; const setTenantTimezone = (tz: string | null) => { _tenantTimezone = tz; }; // Get the default timezone for pre-selecting in forms: tenant setting > Asia/Kolkata const getDefaultTimezone = (): string => { if (_tenantTimezone) return _tenantTimezone; return 'Asia/Kolkata'; }; // Generate invite link for a tenant user const generateInviteLink = (email, name) => { return `${window.location.origin}?invite=${encodeURIComponent(email)}&name=${encodeURIComponent(name)}`; }; // ============================================================================ // 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(); } // Promise that resolves when Firebase auth state is first determined // This prevents race conditions where API calls are made before auth is ready let authReadyResolve: (value: any) => void; const authReadyPromise = new Promise((resolve) => { authReadyResolve = resolve; }); // Set up auth state listener immediately to know when auth is ready if (firebaseAuth) { firebaseAuth.onAuthStateChanged((user: any) => { authReadyResolve(user); }); } else { // No Firebase - resolve immediately authReadyResolve(null); } // ============================================================================ // TENANT DETECTION (Simplified for recex-ai.com) // ============================================================================ // Get current tenant from subdomain (e.g., "demo" from "demo.recex-ai.com") const getTenant = (): string => { const hostname = window.location.hostname.toLowerCase(); // Handle localhost and IP addresses (allow ?tenant= override only for local dev) if (hostname === 'localhost' || hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) { const params = new URLSearchParams(window.location.search); const tenantOverride = params.get('tenant'); if (tenantOverride) return tenantOverride; return 'demo'; } // For *.recex-ai.com, extract subdomain as tenant const parts = hostname.replace(/^www\./, '').split('.'); if (parts.length >= 3) { return parts[0]; // e.g., "demo" from "demo.recex-ai.com" } // Apex domain (recex-ai.com) defaults to "demo" return 'demo'; }; // Check if we're on the main domain (apex) vs a tenant subdomain const isMainDomain = (): boolean => { const hostname = window.location.hostname.toLowerCase(); // Localhost is considered main domain for testing if (hostname === 'localhost' || hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) { return true; } // Check if it's the apex domain (recex-ai.com) without subdomain const parts = hostname.replace(/^www\./, '').split('.'); // Apex domain has 2 parts (recex-ai.com), subdomain has 3+ (client1.recex-ai.com) return parts.length <= 2; }; 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' } } }; // Branding service - simplified for Recex (hardcoded branding) const brandingService = { // Load branding (always returns default for Recex) loadBranding: async (): Promise => { return DEFAULT_BRANDING; }, // Get current branding getBranding: (): ResellerBranding => { return DEFAULT_BRANDING; }, // Apply branding to DOM (colors, title, favicon) applyBranding: (branding: ResellerBranding): void => { document.title = branding.appName; if (branding.faviconUrl) { const link = document.querySelector("link[rel*='icon']"); if (link) { link["href"] = branding.faviconUrl; } } 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); }); } }; // 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 // ============================================================================ // User roles (simplified 3-tier system) // - superadmin: can access all tenants // - admin: can manage users within their own tenant // - user: regular user within a tenant type UserRole = 'superadmin' | 'admin' | 'user'; interface AppUser { uid: string; email: string; name: string; status: string; role: UserRole; tenant: string; // Which tenant subdomain this user belongs to 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(STORAGE_KEYS.AUTHENTICATED); return; } return firebaseAuth.signOut(); }, // Send password reset email with redirect back to current subdomain sendPasswordReset: async (email: string): Promise => { if (!firebaseAuth) throw new Error('Firebase not configured'); const actionCodeSettings = { url: window.location.origin, handleCodeInApp: false }; return firebaseAuth.sendPasswordResetEmail(email, actionCodeSettings); }, // Listen to auth state changes onAuthStateChanged: (callback: (user: any) => void): (() => void) => { if (!firebaseAuth) { // Fallback for non-Firebase mode const isAuth = localStorage.getItem(STORAGE_KEYS.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(); // Map old roles to new roles for backward compatibility let role: UserRole = data.role || 'user'; if (role === 'platform_admin' || role === 'reseller_admin') { role = 'superadmin'; } else if (role === 'tenant_admin') { role = 'admin'; } return { uid, email: data.email, name: data.name, status: data.status, role, tenant: data.tenant || 'demo', createdAt: data.createdAt }; } return null; }, // Verify user has access to current 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 currentTenant = getTenant(); if (!doc.exists) return { valid: false, reason: 'not_registered' }; const data = doc.data(); // Map old roles to new roles for backward compatibility let role: UserRole = data.role || 'user'; if (role === 'platform_admin' || role === 'reseller_admin') { role = 'superadmin'; } else if (role === 'tenant_admin') { role = 'admin'; } const user: AppUser = { uid, email: data.email, name: data.name, status: data.status, role, tenant: data.tenant || 'demo', createdAt: data.createdAt }; // Check if user is inactive if (user.status === 'inactive') { return { valid: false, reason: 'inactive' }; } // Superadmin can access the main domain (recex-ai.com) and demo.recex-ai.com (demo playground) // but not other tenant subdomains like hdfc.recex-ai.com if (user.role === 'superadmin') { if (!isMainDomain() && currentTenant !== 'demo') { return { valid: false, reason: 'superadmin_wrong_domain' }; } return { valid: true, user }; } // Admin and user must belong to the current tenant if (user.tenant !== currentTenant) { return { valid: false, reason: 'wrong_tenant' }; } // Also verify the tenant itself exists and is active in the tenants collection // This prevents orphaned users (whose tenant was deleted) from logging in if (!isMainDomain() && currentTenant !== 'demo') { try { const tenantSnapshot = await firebaseDb .collection('tenants') .where('slug', '==', currentTenant) .limit(1) .get(); if (tenantSnapshot.empty) { return { valid: false, reason: 'tenant_not_found' }; } const tenantData = tenantSnapshot.docs[0].data(); if (tenantData.status === 'inactive') { return { valid: false, reason: 'tenant_inactive' }; } } catch (err) { console.error('Tenant existence check in verifyTenant failed:', err); // On Firestore error, deny access (fail-closed) return { valid: false, reason: 'tenant_not_found' }; } } return { valid: true, user }; }, // 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(); // Verify the tenant exists and is active before creating an invite if (!isMainDomain() && tenant !== 'demo') { const tenantSnapshot = await firebaseDb .collection('tenants') .where('slug', '==', tenant) .limit(1) .get(); if (tenantSnapshot.empty) { throw new Error('Cannot create invite: this organization does not exist.'); } const tenantData = tenantSnapshot.docs[0].data(); if (tenantData.status === 'inactive') { throw new Error('Cannot create invite: this organization has been deactivated.'); } } const inviteDocRef = firebaseDb.collection('invites').doc(`${tenant}_${email}`); // Delete any existing invite (in case of re-invite) await inviteDocRef.delete(); // Create invite record with tenant await inviteDocRef.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(); // Verify the tenant subdomain actually exists and is active before allowing signup if (!isMainDomain() && tenant !== 'demo') { const tenantSnapshot = await firebaseDb .collection('tenants') .where('slug', '==', tenant) .limit(1) .get(); if (tenantSnapshot.empty) { throw new Error('This organization does not exist.'); } const tenantData = tenantSnapshot.docs[0].data(); if (tenantData.status === 'inactive') { throw new Error('This organization has been deactivated. Please contact your administrator.'); } } 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'); } // Check invite expiration (7 days) const INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days if (inviteData.invitedAt) { const invitedAtMs = inviteData.invitedAt.toMillis ? inviteData.invitedAt.toMillis() : inviteData.invitedAt; if (Date.now() - invitedAtMs > INVITE_EXPIRY_MS) { throw new Error('This invitation has expired. Please request a new one.'); } } // Try to create the user, or sign in if they already exist in Firebase Auth let userCredential; try { userCredential = await firebaseAuth.createUserWithEmailAndPassword(email, password); } catch (err) { if (err.code === 'auth/email-already-in-use') { // User exists in Firebase Auth (was previously deleted from Firestore but not Auth) // Sign them in with the new password they provided try { userCredential = await firebaseAuth.signInWithEmailAndPassword(email, password); } catch (signInErr) { // If sign in fails, the password doesn't match the existing account throw new Error('An account with this email already exists. Please use your previous password or contact your administrator.'); } } else { throw err; } } const uid = userCredential.user.uid; // Create user document with tenant and role from invite await firebaseDb.collection('users').doc(uid).set({ email, name, tenant, role: inviteData.role || 'user', 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/superadmin) // If isSuperadmin is true, returns all users across all tenants getAllUsers: async (isSuperadmin = false): Promise => { if (!firebaseDb) return []; let query = firebaseDb.collection('users').orderBy('createdAt', 'desc'); // If not superadmin, filter by current tenant if (!isSuperadmin) { const tenant = getTenant(); query = firebaseDb.collection('users') .where('tenant', '==', tenant) .orderBy('createdAt', 'desc'); } const snapshot = await query.get(); return snapshot.docs.map((doc: any) => ({ uid: doc.id, ...doc.data() })); }, // Get all pending invites (for admin/superadmin) // If isSuperadmin is true, returns all invites across all tenants getPendingInvites: async (isSuperadmin = false): Promise => { if (!firebaseDb) return []; let snapshot; if (isSuperadmin) { snapshot = await firebaseDb.collection('invites') .where('status', '==', 'pending') .get(); } else { const tenant = getTenant(); 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 (optionally specify tenant for superadmin cross-tenant deletion) revokeInvite: async (email: string, tenantOverride?: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); const tenant = tenantOverride || 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' }); }, // Delete user (removes from Firestore - Firebase Auth user remains but can't access app) deleteUser: async (uid: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); await firebaseDb.collection('users').doc(uid).delete(); }, // Delete tenant and all associated data (users, invites) deleteTenant: async (tenantId: string, tenantSlug: string): Promise => { if (!firebaseDb) throw new Error('Firebase not configured'); // Delete all users belonging to this tenant const usersSnapshot = await firebaseDb.collection('users') .where('tenant', '==', tenantSlug) .get(); const userDeletes = usersSnapshot.docs.map((doc: any) => doc.ref.delete()); // Delete all invites belonging to this tenant const invitesSnapshot = await firebaseDb.collection('invites') .where('tenant', '==', tenantSlug) .get(); const inviteDeletes = invitesSnapshot.docs.map((doc: any) => doc.ref.delete()); // Delete all tenant resources const resourcesSnapshot = await firebaseDb.collection('tenantResources') .where('tenant', '==', tenantSlug) .get(); const resourceDeletes = resourcesSnapshot.docs.map((doc: any) => doc.ref.delete()); // Delete the tenant document const tenantDelete = firebaseDb.collection('tenants').doc(tenantId).delete(); // Execute all deletes await Promise.all([...userDeletes, ...inviteDeletes, ...resourceDeletes, tenantDelete]); } }; // ============================================================================ // TENANT RESOURCE SERVICE - Track which campaigns/agents belong to which tenant // ============================================================================ const tenantResourceService = { // Register a resource (campaign/agent) to the current tenant registerResource: async (resourceType: 'campaign' | 'agent', resourceId: string): Promise => { if (!firebaseDb) return; const tenant = getTenant(); await firebaseDb.collection('tenantResources').doc(`${resourceType}_${resourceId}`).set({ type: resourceType, resourceId: resourceId, tenant: tenant, createdAt: firebase.firestore.FieldValue.serverTimestamp() }); }, // Get all resource IDs of a type for the current tenant getResourceIds: async (resourceType: 'campaign' | 'agent'): Promise> => { if (!firebaseDb) return new Set(); const tenant = getTenant(); const snapshot = await firebaseDb.collection('tenantResources') .where('type', '==', resourceType) .where('tenant', '==', tenant) .get(); const ids = new Set(); snapshot.docs.forEach(doc => { const data = doc.data(); if (data.resourceId) ids.add(data.resourceId); }); return ids; }, // Get ALL assigned resource IDs of a type (across all tenants) getAllAssignedIds: async (resourceType: 'campaign' | 'agent'): Promise> => { if (!firebaseDb) return new Set(); const snapshot = await firebaseDb.collection('tenantResources') .where('type', '==', resourceType) .get(); const ids = new Set(); snapshot.docs.forEach(doc => { const data = doc.data(); if (data.resourceId) ids.add(data.resourceId); }); return ids; }, // Delete a resource registration unregisterResource: async (resourceType: 'campaign' | 'agent', resourceId: string): Promise => { if (!firebaseDb) return; await firebaseDb.collection('tenantResources').doc(`${resourceType}_${resourceId}`).delete(); } }; const COUNTRY_NAMES: Record = { IN: 'India', US: 'United States', CA: 'Canada', GB: 'United Kingdom', AU: 'Australia', DE: 'Germany', FR: 'France', SG: 'Singapore', AE: 'UAE', SA: 'Saudi Arabia', JP: 'Japan', KR: 'South Korea', BR: 'Brazil', MX: 'Mexico', ZA: 'South Africa', NG: 'Nigeria', KE: 'Kenya', PH: 'Philippines', ID: 'Indonesia', MY: 'Malaysia', TH: 'Thailand', VN: 'Vietnam', NZ: 'New Zealand', IE: 'Ireland', NL: 'Netherlands', IT: 'Italy', ES: 'Spain', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland', PT: 'Portugal', CH: 'Switzerland', AT: 'Austria', BE: 'Belgium', IL: 'Israel', HK: 'Hong Kong', TW: 'Taiwan', CL: 'Chile', CO: 'Colombia', AR: 'Argentina' }; const countryName = (code: string) => COUNTRY_NAMES[code] || code; // Get current tenant's assigned phone numbers from Firestore const getTenantPhoneNumbers = async (): Promise> => { if (!firebaseDb) return []; try { const tenant = getTenant(); const snapshot = await firebaseDb.collection('tenants') .where('slug', '==', tenant) .limit(1) .get(); if (!snapshot.empty) { const data = snapshot.docs[0].data(); // New format: array of phone number objects if (data.phoneNumbers && Array.isArray(data.phoneNumbers)) { return data.phoneNumbers; } // Backward compat: old single phoneNumber field if (data.phoneNumber) { return [{ phone_number: data.phoneNumber, country_code: data.phoneCountryCode || '', allowed_countries: [] }]; } } } catch (err) { console.error('Error fetching tenant phone numbers:', err); } return []; }; // ============================================================================ // 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; } interface PhoneNumber { id: string; phone_number: string; country_code: string; allowed_countries: string[]; provider: string; is_default: boolean; is_validated: boolean; created_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 for API calls with Firebase auth token const apiFetch = async (url, options) => { const headers = new Headers(options?.headers); // Wait for Firebase auth to be ready before checking currentUser // This fixes race condition on page refresh where API calls happen before auth initializes await authReadyPromise; // Add Firebase ID token if user is authenticated const currentUser = firebaseAuth?.currentUser; if (currentUser) { try { const idToken = await currentUser.getIdToken(); headers.set('Authorization', `Bearer ${idToken}`); } catch (e) { console.error('Failed to get ID token:', e); } } 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'); const data = await res.json(); // Filter by tenant ownership const currentTenant = getTenant(); const tenantAgentIds = await tenantResourceService.getResourceIds('agent'); const allAssignedIds = await tenantResourceService.getAllAssignedIds('agent'); const filtered = data.results.filter((a: Agent) => { // If resource is assigned to current tenant, show it if (tenantAgentIds.has(a.id)) return true; // If resource is unassigned AND current tenant is "demo", show it (legacy resources) if (currentTenant === 'demo' && !allAssignedIds.has(a.id)) return true; return false; }); return { count: filtered.length, results: filtered }; }, 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'); } const agent = await res.json(); // Register agent to current tenant await tenantResourceService.registerResource('agent', agent.id); return agent; }, 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'); const data = await res.json(); // Filter by tenant's agents const currentTenant = getTenant(); const tenantAgentIds = await tenantResourceService.getResourceIds('agent'); const allAssignedAgentIds = await tenantResourceService.getAllAssignedIds('agent'); const filtered = data.results.filter((c: Call) => { // If agent is assigned to current tenant, show the call if (tenantAgentIds.has(c.agent_id)) return true; // If agent is unassigned AND current tenant is "demo", show the call (legacy) if (currentTenant === 'demo' && !allAssignedAgentIds.has(c.agent_id)) return true; return false; }); return { count: filtered.length, results: filtered }; }, // 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 = data.results.length === pageSize; 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; timezone: string; from_phone_number?: string }): 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 }>; timezone: string; from_phone_number?: string }): 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'); const data = await res.json(); // Filter by tenant ownership const currentTenant = getTenant(); const tenantCampaignIds = await tenantResourceService.getResourceIds('campaign'); const allAssignedIds = await tenantResourceService.getAllAssignedIds('campaign'); const filtered = data.results.filter((c: Campaign) => { // If resource is assigned to current tenant, show it if (tenantCampaignIds.has(c.id)) return true; // If resource is unassigned AND current tenant is "demo", show it (legacy resources) if (currentTenant === 'demo' && !allAssignedIds.has(c.id)) return true; return false; }); return { count: filtered.length, results: filtered }; }, 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'); } const campaign = await res.json(); // Register campaign to current tenant await tenantResourceService.registerResource('campaign', campaign.id); return campaign; }, // Phone Numbers async listNumbers(): Promise { try { const res = await apiFetch(`${CONFIG.BACKEND_URL}/numbers/?page=1&page_size=100`); if (!res.ok) { console.warn('listNumbers failed:', res.status, res.statusText); return []; } const data = await res.json(); console.log('listNumbers response:', data); const results = data.results || (Array.isArray(data) ? data : []); return results; } catch (err) { console.warn('listNumbers error:', err); return []; } }, }; // ============================================================================ // 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) { return { success: false, error: 'No calls to export' }; } // Collect all unique result keys across all calls for flat columns const resultKeysSet = new Set(); calls.forEach(call => { if (call.result && typeof call.result === 'object') { Object.keys(call.result).forEach(key => resultKeysSet.add(key)); } }); const resultKeys = Array.from(resultKeysSet).sort(); const baseHeaders = [ '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', ]; // Append each result key as its own column (prefixed with "Result: ") const headers = [ ...baseHeaders, ...resultKeys.map(key => `Result: ${key}`), ]; const rows = calls.map(call => { const agentName = call.agent?.name || agentsMap?.get(call.agent_id) || call.agent_id; const baseRow = [ call.id, call.callee_name, call.mobile_number, agentName, 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) : '', ]; // Flatten each result key into its own column const resultCells = resultKeys.map(key => { const val = call.result?.[key]; if (val === undefined || val === null) return ''; if (typeof val === 'object') return JSON.stringify(val); return String(val); }); return [...baseRow, ...resultCells]; }); 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); return { success: true }; }; // ============================================================================ // 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) => (