// ============================================================ // MOORE AI v13 — ROOT APP COMPONENT // Holds all state, routing, nav shell, voice/AI engine // No import/export — all globals loaded in order via index.html // ============================================================ // ========================================================= // AUTH GATE — redirect to /login if not authenticated // ========================================================= (function() { const auth = sessionStorage.getItem('moore_auth'); if (!auth) { window.location.href = '/login'; } })(); const { useState, useEffect, useRef, useCallback, useMemo } = React; // ========================================================= // ERROR BOUNDARY // ========================================================= class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(e) { return { hasError: true, error: e }; } render() { if (this.state.hasError) { return (
Render Error
{this.state.error?.message || 'Unknown error'}
); } return this.props.children; } } // ========================================================= // ========================================================= // BALANCED JSON EXTRACTOR — handles nested objects in ACTION: {...} // Also catches raw JSON blobs the AI emits without the ACTION: prefix. // ========================================================= const parseActionBlocks = (text) => { const actions = []; const ranges = []; const ACTION_TYPES = /^(CREATE_CONTACT|UPDATE_CONTACT|ADD_NOTE|SEND_SMS|SEND_EMAIL|MAKE_CALL|SCHEDULE|UPDATE_OPP|MOVE_STAGE|ADD_TASK|CHAIN)$/; // Walk balanced braces starting at `start` (text[start] must be '{') const extractEnd = (src, start) => { let depth = 0, j = start, inStr = false, esc = false; while (j < src.length) { const c = src[j]; if (esc) { esc = false; } else if (c === '\\' && inStr) { esc = true; } else if (c === '"') { inStr = !inStr; } else if (!inStr) { if (c === '{') depth++; else if (c === '}') { depth--; if (depth === 0) return j; } } j++; } return -1; }; // Pass 1 — explicit "ACTION: {" blocks let i = 0; while (true) { const mi = text.indexOf('ACTION: {', i); if (mi === -1) break; const end = extractEnd(text, mi + 8); if (end === -1) break; try { const jsonStr = text.substring(mi + 8, end + 1); JSON.parse(jsonStr); actions.push(jsonStr); ranges.push([mi, end + 1]); } catch(e) {} i = end + 1; } // Remove pass-1 blocks from a working copy let clean = text; for (let k = ranges.length - 1; k >= 0; k--) { clean = clean.substring(0, ranges[k][0]) + clean.substring(ranges[k][1]); } // Pass 2 — raw JSON blobs the AI embedded without the "ACTION: " prefix const raw2Ranges = []; i = 0; while (i < clean.length) { const brace = clean.indexOf('{', i); if (brace === -1) break; const end = extractEnd(clean, brace); if (end === -1) { i = brace + 1; continue; } const candidate = clean.substring(brace, end + 1); try { const parsed = JSON.parse(candidate); if (parsed && typeof parsed.type === 'string' && ACTION_TYPES.test(parsed.type)) { actions.push(candidate); raw2Ranges.push([brace, end + 1]); } } catch(e) {} i = end + 1; } for (let k = raw2Ranges.length - 1; k >= 0; k--) { clean = clean.substring(0, raw2Ranges[k][0]) + clean.substring(raw2Ranges[k][1]); } // Tidy up leftover punctuation artefacts (trailing colons, double spaces, blank lines) clean = clean.replace(/:\s*$/gm, '').replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); return { actions, clean }; }; const stripActionBlocks = (text) => parseActionBlocks(text).clean; // ========================================================= // ACTION APPROVAL HELPERS // ========================================================= const ACTION_META = { SEND_EMAIL: { icon: 'mail', label: 'Send Email', color: '#06b6d4' }, SEND_SMS: { icon: 'message-circle', label: 'Send SMS', color: '#10b981' }, MAKE_CALL: { icon: 'phone-call', label: 'Call', color: '#3b82f6' }, CREATE_CONTACT: { icon: 'user-plus', label: 'Create Contact', color: '#3b82f6' }, UPDATE_CONTACT: { icon: 'pencil', label: 'Update Contact', color: '#64748b' }, ADD_NOTE: { icon: 'file-text', label: 'Add Note', color: '#ec4899' }, ADD_TASK: { icon: 'check-square', label: 'Create Task', color: '#10b981' }, SCHEDULE: { icon: 'calendar-check', label: 'Schedule', color: '#8b5cf6' }, UPDATE_OPP: { icon: 'trophy', label: 'Update Deal', color: '#f59e0b' }, MOVE_STAGE: { icon: 'git-merge', label: 'Move Stage', color: '#6366f1' }, CHAIN: { icon: 'zap', label: 'Chain', color: '#f59e0b' }, }; const describeAction = (action) => { const name = action.name || (action.data ? `${action.data.firstName||''} ${action.data.lastName||''}`.trim() : '') || ''; switch (action.type) { case 'SEND_EMAIL': return `${name}${action.subject ? ` — "${action.subject.substring(0,45)}"` : ''}`; case 'SEND_SMS': return `${name}${action.message ? ` — "${action.message.substring(0,45)}..."` : ''}`; case 'MAKE_CALL': return `${name}${action.objective ? ` — ${action.objective.substring(0,45)}` : ''}`; case 'CREATE_CONTACT': return name || 'new contact'; case 'ADD_NOTE': return `${name}${action.body ? ` — "${action.body.substring(0,45)}"` : ''}`; case 'ADD_TASK': return action.title ? `"${action.title.substring(0,55)}"` : ''; case 'SCHEDULE': return `${name}${action.title ? ` — ${action.title}` : ''}`; case 'UPDATE_OPP': return `mark ${action.status}`; case 'MOVE_STAGE': return 'move to new stage'; default: return ''; } }; const ActionApprovalCard = ({ m, approveActions, cancelActions }) => (
{m.actions.length} Action{m.actions.length !== 1 ? 's' : ''} Queued — Approve to Execute
{m.actions.map((action, ai) => { const meta = ACTION_META[action.type] || { icon: 'zap', label: action.type, color: '#94a3b8' }; const desc = describeAction(action); return (
{action.type} {desc && {desc}}
); })}
); // MAIN APP // ========================================================= const App = () => { // ── Theme ── const [theme, setTheme] = useState('dark'); // ── Core state ── const [activeTab, setActiveTab] = useState('dashboard'); const [loading, setLoading] = useState(true); const [crmData, setCrmData] = useState({ stats: { pipelineValue: 0, totalLeads: 0, winRate: 0, aiActions: 0 }, opportunities: [] }); const [pipelines, setPipelines] = useState([]); const [contacts, setContacts] = useState([]); const [calEvents, setCalEvents] = useState([]); const [conversations, setConversations] = useState([]); const [analytics, setAnalytics] = useState(null); const [selectedLead, setSelectedLead] = useState(null); const [selectedContact, setSelectedContact] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [notifications, setNotifications] = useState([]); const [loadingTabs, setLoadingTabs] = useState({}); // ── Tasks (localStorage) ── const [tasks, setTasks] = useState(() => { try { return JSON.parse(localStorage.getItem('moore_tasks') || '[]'); } catch(e) { return []; } }); // ── AI Chat state ── const [chatOpen, setChatOpen] = useState(false); const [chatInput, setChatInput] = useState(''); const [messages, setMessages] = useState([ { role: 'ai', text: "Systems online. Registry synced. I'm ready to architect your pipeline — type a command or use voice." } ]); const [isTyping, setIsTyping] = useState(false); const [isListening, setIsListening] = useState(false); const [chatMode, setChatMode] = useState('minimized'); // ── Pipeline view ── const [pipelineView, setPipelineView] = useState('kanban'); const [pipelineFilter, setPipelineFilter] = useState('all'); const [selectedPipelineId, setSelectedPipelineId] = useState(''); // ── Calendar state ── const [calView, setCalView] = useState('month'); const [calDate, setCalDate] = useState(new Date()); const [calendars, setCalendars] = useState([]); const [selectedCalendarId, setSelectedCalendarId] = useState(''); // ── Contact form ── const [showContactForm, setShowContactForm] = useState(false); const [contactForm, setContactForm] = useState({ firstName: '', lastName: '', email: '', phone: '' }); // ── Notes ── const [selectedLeadNotes, setSelectedLeadNotes] = useState([]); const [newNote, setNewNote] = useState(''); // ── Refs ── const audioRef = useRef(null); const recognitionRef = useRef(null); const chatEndRef = useRef(null); const dragItemRef = useRef(null); const handleSendMsgRef = useRef(null); const pendingActionsRef = useRef({}); const transcriptRef = useRef(''); const silenceTimerRef = useRef(null); const isVoiceEnabledRef = useRef(true); const [isVoiceEnabled, setIsVoiceEnabled] = useState(true); const [geminiKey, setGeminiKey] = useState(''); const [blandKey, setBlandKey] = useState(''); const [locationName, setLocationName] = useState('BigBlueCollar'); const [liveTranscript, setLiveTranscript] = useState(''); const [actionItems, setActionItems] = useState([]); const [activeCalls, setActiveCalls] = useState([]); // ── Theme persistence ── useEffect(() => { localStorage.setItem('moore_theme', theme); }, [theme]); // ── AUDIO: Gemini TTS + browser Speech Synthesis fallback ── const stopAudio = () => { if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; } window.speechSynthesis?.cancel(); }; const speakBrowser = (text) => { if (!isVoiceEnabledRef.current) return; const clean = text.replace(/[*_#`~]/g, '').trim(); const utterance = new SpeechSynthesisUtterance(clean); const voices = window.speechSynthesis?.getVoices?.() || []; const maleVoice = voices.find(v => v.name.includes('Google US English Male') || v.name.includes('Google UK English Male') || v.name.includes('Daniel') || v.name.includes('Arthur') || v.name.includes('Alex') || (v.name.toLowerCase().includes('male') && v.lang.startsWith('en')) ); if (maleVoice) utterance.voice = maleVoice; utterance.rate = 1.05; window.speechSynthesis.speak(utterance); }; const speak = async (text) => { if (!isVoiceEnabledRef.current || !text || text.length < 5) return; stopAudio(); const clean = stripActionBlocks(text).replace(/[*_#`~]/g, '').trim().substring(0, 1000); if (!clean) return; if (geminiKey) { try { const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent?key=${geminiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: 'Say professionally and conversationally: ' + clean }] }], generationConfig: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Algieba' } } } }, model: 'gemini-2.5-flash-preview-tts' }) } ); const data = await res.json(); if (data.error) throw new Error(data.error.message); const audioPart = data.candidates?.[0]?.content?.parts?.[0]?.inlineData; if (audioPart?.data) { const sampleRateMatch = (audioPart.mimeType || '').match(/rate=(\d+)/); const sampleRate = sampleRateMatch ? parseInt(sampleRateMatch[1]) : 24000; const binaryStr = window.atob(audioPart.data); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); const wavBlob = pcmToWav(bytes.buffer, sampleRate); const url = URL.createObjectURL(wavBlob); const audio = new Audio(url); audioRef.current = audio; audio.play(); return; } } catch (e) { console.warn('Gemini TTS failed, using browser voice:', e.message); } } speakBrowser(clean); }; // ── VOICE RECOGNITION — continuous with silence timer ── useEffect(() => { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (SR) { const rec = new SR(); rec.continuous = true; rec.interimResults = true; rec.lang = 'en-US'; rec.onresult = (event) => { let finalTranscript = ''; let interimTranscript = ''; for (let i = 0; i < event.results.length; ++i) { if (event.results[i].isFinal) finalTranscript += event.results[i][0].transcript; else interimTranscript += event.results[i][0].transcript; } const currentText = finalTranscript + interimTranscript; transcriptRef.current = currentText; setLiveTranscript(currentText); setChatInput(currentText); clearTimeout(silenceTimerRef.current); silenceTimerRef.current = setTimeout(() => { if (transcriptRef.current.trim()) { rec.stop(); setIsListening(false); if (handleSendMsgRef.current) { handleSendMsgRef.current(null, transcriptRef.current); } transcriptRef.current = ''; setLiveTranscript(''); setChatInput(''); } }, 2000); }; rec.onerror = (e) => { console.warn('Speech error:', e.error); setIsListening(false); clearTimeout(silenceTimerRef.current); }; rec.onend = () => { setIsListening(false); clearTimeout(silenceTimerRef.current); }; recognitionRef.current = rec; } }, []); useEffect(() => { isVoiceEnabledRef.current = isVoiceEnabled; }, [isVoiceEnabled]); const toggleListening = () => { if (!recognitionRef.current) { alert('Voice input requires Chrome or Edge browser.'); return; } if (isListening) { recognitionRef.current.stop(); setIsListening(false); clearTimeout(silenceTimerRef.current); if (transcriptRef.current.trim() && handleSendMsgRef.current) { handleSendMsgRef.current(null, transcriptRef.current); transcriptRef.current = ''; setLiveTranscript(''); setChatInput(''); } } else { try { setChatInput(''); transcriptRef.current = ''; setLiveTranscript(''); if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; } window.speechSynthesis?.cancel(); recognitionRef.current.start(); setIsListening(true); } catch(e) { console.error('Mic start error:', e); } } }; // ── DATA FETCHING ── const syncOpportunities = async (pipelineId = '') => { try { const data = await API.syncOpportunities(pipelineId); if (data) setCrmData(data); } catch (e) { console.warn('Sync failed', e); } }; const fetchPipelines = async () => { try { const data = await API.fetchPipelines(); if (data.success) setPipelines(data.pipelines); } catch (e) {} }; const fetchContacts = async () => { setLoadingTabs(p => ({ ...p, contacts: true })); try { const data = await API.fetchContacts(); if (data.success) setContacts(data.contacts); } catch (e) {} setLoadingTabs(p => ({ ...p, contacts: false })); }; const fetchCalendar = async (calId) => { setLoadingTabs(p => ({ ...p, calendar: true })); try { const data = await API.fetchCalendar(calId || selectedCalendarId); if (data.success) setCalEvents(data.events); } catch (e) {} setLoadingTabs(p => ({ ...p, calendar: false })); }; const fetchCalendars = async () => { try { const [calsData, settingsData] = await Promise.all([API.fetchCalendars(), API.fetchSettings()]); if (calsData.success && calsData.calendars.length > 0) { setCalendars(calsData.calendars); const saved = settingsData.calendarId || calsData.calendars[0].id; setSelectedCalendarId(saved); return saved; } } catch (e) {} return null; }; const fetchConversations = async () => { setLoadingTabs(p => ({ ...p, inbox: true })); try { const data = await API.fetchConversations(); if (data.success) setConversations(data.conversations); } catch (e) {} setLoadingTabs(p => ({ ...p, inbox: false })); }; const fetchAnalytics = async () => { setLoadingTabs(p => ({ ...p, analytics: true })); try { const data = await API.fetchAnalytics(); if (data.success) setAnalytics(data); } catch (e) {} setLoadingTabs(p => ({ ...p, analytics: false })); }; const fetchLeadNotes = async (contactId) => { if (!contactId) return; try { const data = await API.fetchContactNotes(contactId); if (data.success) setSelectedLeadNotes(data.notes); } catch (e) {} }; useEffect(() => { (async () => { try { const cfg = await API.fetchConfig(); if (cfg.geminiKey) setGeminiKey(cfg.geminiKey); if (cfg.blandKey) setBlandKey(cfg.blandKey); if (cfg.locationName) setLocationName(cfg.locationName); } catch(e) {} await Promise.all([syncOpportunities(), fetchPipelines()]); setLoading(false); })(); }, []); useEffect(() => { if (activeTab === 'contacts' && contacts.length === 0) fetchContacts(); if (activeTab === 'calendar') { fetchCalendars().then(calId => fetchCalendar(calId)); } if (activeTab === 'inbox' && conversations.length === 0) fetchConversations(); if (activeTab === 'analytics' && !analytics) fetchAnalytics(); }, [activeTab]); useEffect(() => { if (window.lucide) window.lucide.createIcons(); }); useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isTyping]); useEffect(() => { if (selectedLead?.contactId) fetchLeadNotes(selectedLead.contactId); else setSelectedLeadNotes([]); }, [selectedLead]); // ── AI CHAT — calls Gemini directly from browser ── const searchContactByName = async (name) => { try { const data = await API.searchContacts(name); return (data.contacts || [])[0] || null; } catch(e) { return null; } }; const pollBlandCall = async (callId, contactId, contactName) => { const done = ['completed','no-answer','failed','busy','canceled']; for (let i = 0; i < 60; i++) { await new Promise(r => setTimeout(r, 5000)); try { const data = await API.pollCall(callId); if (done.includes(data.status)) { if (data.status === 'completed' && (data.transcript || data.summary)) { await API.addNote(contactId, `[AI CALL TRANSCRIPT]\n${data.transcript}\n\n[SUMMARY]: ${data.summary}`); const aiFollowUp = await callGeminiDirect( `[SYSTEM] Call with ${contactName} completed.\nSUMMARY: ${data.summary}\nTRANSCRIPT: ${data.transcript}\n\nExtract 1-3 action items as a JSON array: [{"title":"...","priority":"high|medium|low","contact":"${contactName}"}]. Respond ONLY with the JSON array.`, geminiKey ); try { const items = JSON.parse(aiFollowUp.replace(/```json|```/g,'').trim()); setActionItems(prev => [...items.map(it => ({ ...it, id: Date.now() + Math.random(), done: false, createdAt: new Date().toISOString() })), ...prev]); } catch(e) {} } return data; } setActiveCalls(prev => prev.map(c => c.callId === callId ? { ...c, status: data.status } : c)); } catch(e) { break; } } return null; }; const callGeminiDirect = async (prompt, key) => { const models = ['gemini-2.5-flash', 'gemini-2.0-flash-001', 'gemini-2.0-flash', 'gemini-1.5-flash']; for (const model of models) { try { const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { maxOutputTokens: 4096, temperature: 0.85 } }) } ); const data = await res.json(); if (data.error) throw new Error(data.error.message); const text = data.candidates?.[0]?.content?.parts?.[0]?.text; if (text) { console.log(`[Gemini] ${model} OK`); return text; } } catch (e) { console.warn(`[Gemini] ${model}:`, e.message); } } throw new Error('All Gemini models failed — check API key'); }; const executeCrmAction = async (action, allContacts) => { const type = action.type; if (type === 'ADD_TASK') { const newTask = { id: Date.now(), title: action.title, priority: action.priority || 'medium', contact: action.contact || '', status: 'todo', source: 'AI Generated', createdAt: new Date().toISOString() }; setTasks(prev => { const updated = [newTask, ...prev]; localStorage.setItem('moore_tasks', JSON.stringify(updated)); return updated; }); return { ok: true, msg: `✓ Task added to Tasks board: **${action.title}**` }; } if (type === 'CREATE_CONTACT') { const data = await API.createContact(action.data); if (!data.success) throw new Error(data.error); setTimeout(fetchContacts, 1500); return { ok: true, msg: `✓ Contact **${action.data.firstName} ${action.data.lastName||''}** added to CRM (ID: ${data.id})` }; } if (type === 'UPDATE_CONTACT') { let contactId = action.contactId; if (!contactId && action.name) { const found = await searchContactByName(action.name); contactId = found?.id; if (!contactId) throw new Error(`Contact "${action.name}" not found`); } if (action.data && Object.keys(action.data).length) { await API.updateContact(contactId, action.data); } if (action.note) { await API.addNote(contactId, action.note); } setTimeout(fetchContacts, 1500); return { ok: true, msg: `✓ Contact updated` }; } if (type === 'ADD_NOTE') { let contactId = action.contactId; if (!contactId && action.name) { const found = await searchContactByName(action.name); contactId = found?.id; if (!contactId) throw new Error(`Contact "${action.name}" not found`); } await API.addNote(contactId, action.body); return { ok: true, msg: `✓ Note added to contact` }; } if (type === 'SEND_SMS' || type === 'SEND_EMAIL') { let contactId = action.contactId; let contactName = action.name || 'Contact'; if (!contactId && action.name) { const found = await searchContactByName(action.name); contactId = found?.id; if (!contactId) throw new Error(`Contact "${action.name}" not found`); } const msgType = type === 'SEND_EMAIL' ? 'Email' : 'SMS'; const data = await API.sendMessage(contactId, msgType, action.message, action.subject); if (!data.success) throw new Error(data.error); return { ok: true, msg: `✓ ${msgType} sent to **${contactName}**` }; } if (type === 'MAKE_CALL') { let contactId = action.contactId; let phone = action.phone; let contactName = action.name || 'Contact'; if (!contactId && action.name) { const found = await searchContactByName(action.name); contactId = found?.id; phone = phone || found?.phone; contactName = found?.name || contactName; } if (!phone) throw new Error(`No phone number for "${action.name}". Add one in Contacts.`); if (!blandKey) throw new Error('BLAND_API_KEY not set in .env'); const data = await API.makeCall(contactId, phone, contactName, action.objective || 'follow up'); if (!data.success) throw new Error(data.error); setActiveCalls(prev => [...prev, { callId: data.callId, contactName, status: 'ringing', startedAt: new Date() }]); pollBlandCall(data.callId, contactId, contactName).then(result => { if (result) { setMessages(prev => [...prev, { role: 'ai', text: `📞 Call with **${contactName}** ${result.status}. ${result.summary ? 'Summary: ' + result.summary : ''} Transcript saved to contact notes.`, type: 'call-result' }]); setActiveCalls(prev => prev.filter(c => c.callId !== data.callId)); } }); return { ok: true, msg: `📞 Calling **${contactName}** now — transcript will be saved to their notes when done` }; } if (type === 'SCHEDULE') { let contactId = action.contactId; if (!contactId && action.name) { const found = await searchContactByName(action.name); contactId = found?.id; if (!contactId) throw new Error(`Contact "${action.name}" not found`); } const data = await API.scheduleAppointment({ contactId, startTime: action.startTime, endTime: action.endTime, title: action.title, notes: action.notes }); if (!data.success) throw new Error(data.error); setTimeout(fetchCalendar, 1500); return { ok: true, msg: `✓ Appointment scheduled: **${action.title}**` }; } if (type === 'UPDATE_OPP') { await API.updateOpportunity(action.id, { status: action.status }); setTimeout(() => syncOpportunities(selectedPipelineId), 1200); return { ok: true, msg: `✓ Deal status → **${action.status}**` }; } if (type === 'MOVE_STAGE') { await API.moveStage(action.id, action.stageId); setTimeout(() => syncOpportunities(selectedPipelineId), 1200); return { ok: true, msg: `✓ Deal moved to new stage` }; } return { ok: false, msg: `Unknown action: ${type}` }; }; const approveActions = async (approvalId) => { const actionStrs = pendingActionsRef.current[approvalId]; if (!actionStrs) return; delete pendingActionsRef.current[approvalId]; setMessages(prev => prev.filter(m => !(m.type === 'action-approval' && m.id === approvalId))); for (const actionStr of actionStrs) { try { const action = JSON.parse(actionStr); const pendingId = Date.now() + Math.random(); setMessages(prev => [...prev, { role: 'ai', text: `⏳ Executing: ${action.type.replace(/_/g,' ')}...`, id: pendingId, type: 'action-pending' }]); const result = await executeCrmAction(action, contacts); setMessages(prev => prev.map(m => m.id === pendingId ? { ...m, text: result.msg, type: result.ok ? 'action-success' : 'action-error' } : m)); } catch (ae) { setMessages(prev => [...prev, { role: 'ai', text: `✗ Action failed: ${ae.message}`, type: 'action-error' }]); } } }; const cancelActions = (approvalId) => { delete pendingActionsRef.current[approvalId]; setMessages(prev => prev.filter(m => !(m.type === 'action-approval' && m.id === approvalId))); setMessages(prev => [...prev, { role: 'ai', text: 'Got it — actions cancelled.' }]); }; const handleSendMessage = async (e, voiceText = null) => { if (e) e.preventDefault(); const userMsg = voiceText || chatInput; if (!userMsg.trim()) return; setChatInput(''); setLiveTranscript(''); setMessages(prev => [...prev, { role: 'user', text: userMsg }]); setIsTyping(true); stopAudio(); const key = geminiKey; if (!key) { setMessages(prev => [...prev, { role: 'ai', text: 'No Gemini API key loaded. Check Settings → Test Gemini AI.' }]); setIsTyping(false); return; } try { const leadCtx = selectedLead ? `\nFOCUSED LEAD: ${selectedLead.name} | ${selectedLead.status} | $${selectedLead.value} | Phone: ${selectedLead.phone} | Contact ID: ${selectedLead.contactId}` : ''; const now = new Date(); const systemPrompt = `You are Moore AI (Jake Moore), sharp Pipeline Architect for BigBlueCollar. CURRENT TIME: ${now.toLocaleString()} PIPELINE: ${crmData.stats.totalLeads} leads · $${crmData.stats.pipelineValue} · ${crmData.stats.winRate}% win rate${leadCtx} TOP OPPORTUNITIES: ${JSON.stringify((crmData.opportunities||[]).slice(0,25).map(o=>({id:o.id,name:o.name,status:o.status,stage:o.stageName,value:o.value,contact:o.contact,contactId:o.contactId,email:o.email,phone:o.phone})))} CAPABILITIES — output ACTION blocks at the END of your response, each on its OWN LINE: ACTION: {"type":"CREATE_CONTACT","data":{"firstName":"","lastName":"","email":"","phone":""}} ACTION: {"type":"UPDATE_CONTACT","name":"Contact Name","contactId":"","data":{"email":"","phone":"","tags":[]},"note":"note text"} ACTION: {"type":"ADD_NOTE","name":"Contact Name","contactId":"","body":"note text"} ACTION: {"type":"SEND_SMS","name":"Contact Name","contactId":"","message":"..."} ACTION: {"type":"SEND_EMAIL","name":"Contact Name","contactId":"","subject":"...","message":"..."} ACTION: {"type":"MAKE_CALL","name":"Contact Name","contactId":"","phone":"","objective":"what to accomplish on the call"} ACTION: {"type":"SCHEDULE","name":"Contact Name","contactId":"","title":"Appointment title","startTime":"ISO datetime","endTime":"ISO datetime","notes":""} ACTION: {"type":"UPDATE_OPP","id":"EXACT_ID","status":"won|lost|open"} ACTION: {"type":"MOVE_STAGE","id":"EXACT_ID","stageId":"STAGE_ID"} ACTION: {"type":"ADD_TASK","title":"Task title","priority":"high|medium|low","contact":"Contact Name"} RULE: ACTION lines must start with exactly "ACTION: {" — never inline JSON inside sentences. Write your human-readable summary first, then list all ACTION lines after. PERSONA: Sharp, decisive. Say "Yo", "Lock it in", "Done." Keep it tight. State what action you took. If you can't find a contact ID, use "name" field — system will search.`; let responseText = await callGeminiDirect(`${systemPrompt}\n\nUser: ${userMsg}`, key); const { actions: matches, clean: displayText } = parseActionBlocks(responseText); if (displayText) { setMessages(prev => [...prev, { role: 'ai', text: displayText }]); speak(displayText); } if (matches.length > 0) { const approvalId = Date.now() + Math.random(); const parsedActions = matches.map(s => { try { return JSON.parse(s); } catch(e) { return null; } }).filter(Boolean); pendingActionsRef.current[approvalId] = matches; setMessages(prev => [...prev, { role: 'ai', type: 'action-approval', id: approvalId, actions: parsedActions }]); } else if (!displayText) { setMessages(prev => [...prev, { role: 'ai', text: 'Done.' }]); } } catch (err) { const errMsg = `Neural link error: ${err.message}`; setMessages(prev => [...prev, { role: 'ai', text: errMsg }]); speakBrowser('Error connecting to AI.'); } finally { setIsTyping(false); } }; // Keep ref updated for voice recognition stale closure fix useEffect(() => { handleSendMsgRef.current = handleSendMessage; }); // ── CRM ACTIONS ── const updateOppStatus = async (id, status) => { await API.updateOpportunity(id, { status }); syncOpportunities(); addNotification(`Deal status updated to ${status}`, 'success'); }; const addNote = async () => { if (!newNote.trim() || !selectedLead?.contactId) return; await API.addNote(selectedLead.contactId, newNote); setSelectedLeadNotes(prev => [{ body: newNote, dateAdded: new Date().toISOString() }, ...prev]); setNewNote(''); addNotification('Note added', 'success'); }; const createContact = async () => { if (!contactForm.firstName) return; await API.createContact(contactForm); setShowContactForm(false); setContactForm({ firstName: '', lastName: '', email: '', phone: '' }); fetchContacts(); addNotification('Contact created', 'success'); }; const addNotification = (msg, type = 'info') => { const id = Date.now(); setNotifications(prev => [...prev, { id, msg, type }]); setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 4000); }; const filteredOpps = useMemo(() => { let opps = crmData.opportunities; if (searchQuery && activeTab === 'pipeline') { opps = opps.filter(o => (o.name || '').toLowerCase().includes(searchQuery.toLowerCase()) || (o.contact || '').toLowerCase().includes(searchQuery.toLowerCase()) ); } return opps; }, [crmData.opportunities, searchQuery, activeTab]); const openTaskCount = tasks.filter(t => t.status !== 'done').length; // ── NAV ITEMS ── const navItems = [ { group: 'CORE', items: [ { id: 'dashboard', icon: 'layout-dashboard', label: 'Dashboard' }, { id: 'tasks', icon: 'kanban', label: 'Tasks', badge: openTaskCount || null }, { id: 'contacts', icon: 'users', label: 'Contacts' }, { id: 'pipeline', icon: 'git-merge', label: 'Pipeline', badge: crmData.stats.totalLeads || null }, { id: 'calendar', icon: 'calendar-days', label: 'Calendar' }, { id: 'inbox', icon: 'inbox', label: 'Inbox', badge: conversations.filter(c => c.unreadCount > 0).length || null }, ]}, { group: 'INTELLIGENCE', items: [ { id: 'ai-command', icon: 'brain-circuit', label: 'AI Strategy', accent: true }, { id: 'action-items', icon: 'bell', label: 'Reminders', badge: actionItems.filter(a=>!a.done).length || null }, { id: 'action-reference', icon: 'zap', label: 'Action Reference' }, { id: 'analytics', icon: 'bar-chart-2', label: 'Analytics' }, ]}, { group: 'SYSTEM', items: [ { id: 'settings', icon: 'settings', label: 'Settings' }, ]} ]; // ── LOADING SCREEN ── if (loading) return (
Moore AI
INITIALIZING NEURAL CORE...
); // ── RENDER ── return (