`; } // ============ ADMIN PANEL ============ // ============ ADMIN COPILOT (AI assistant) ============ // JR-only conversational assistant. The 'admin-copilot' edge function is the // brain (Claude + tool schema). This component is the hands: it runs the // agent loop, executes READ tools immediately, and gates every WRITE behind // a confirm card before reusing the app's own action flows. Mounted inside // AdminPanel so it can reuse generateInvoicePDF + admin state. const COPILOT_WRITE_TOOLS = new Set(['create_reminder','create_booking','reschedule_booking','cancel_booking','send_announcement','send_client_message','edit_invoice_amount','adjust_points','block_date','unblock_date','add_supply','mark_supply_low','post_availability','create_invoice','mark_invoice_paid','create_client','request_review','create_blog_post','create_campaign','start_campaign','send_campaign_test','close_out_session','mark_reviewed']); const COPILOT_TIERS = [{ name:'Gold', min:100000 },{ name:'Silver', min:50000 },{ name:'Bronze', min:20000 }]; function copilotTierOf(total){ for (const t of COPILOT_TIERS){ if ((total||0) >= t.min) return t.name; } return 'No tier'; } function copilotStripMd(s){ return String(s||'').replace(/\*\*(.+?)\*\*/g,'$1').replace(/__(.+?)__/g,'$1').replace(/^#{1,6}\s+/gm,'').replace(/^\s*[-*]\s+/gm,'• '); } function copilotFmtDateTime(iso){ try { const d=new Date(iso); return d.toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric'}) + ' at ' + d.toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit'}); } catch(_){ return String(iso); } } function copilotCleanBlocks(content){ return (content||[]).map(b=>{ if (b.type==='text') return { type:'text', text:b.text }; if (b.type==='tool_use') return { type:'tool_use', id:b.id, name:b.name, input:b.input }; return b; }); } // Quiet-hours helpers (mirror AdminPanel postAvailability — no last-minute // pushes 10pm–9am Toronto; held notifications release next 9am Toronto). function copilotTorontoNowParts(){ const parts=new Intl.DateTimeFormat('en-US',{timeZone:'America/Toronto',hour12:false,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}).formatToParts(new Date()); const o={}; for(const p of parts)o[p.type]=p.value; return {year:+o.year,month:+o.month,day:+o.day,hour:+o.hour,minute:+o.minute}; } function copilotNextNineAmTorontoIso(){ const t=copilotTorontoNowParts(); let y=t.year,m=t.month,d=t.day; if(t.hour>=9){ const tm=new Date(Date.UTC(t.year,t.month-1,t.day+1)); y=tm.getUTCFullYear(); m=tm.getUTCMonth()+1; d=tm.getUTCDate(); } let guess=new Date(Date.UTC(y,m-1,d,13,0,0)); const fmt=new Intl.DateTimeFormat('en-US',{timeZone:'America/Toronto',hour12:false,hour:'2-digit',minute:'2-digit'}); const [gh,gm]=fmt.format(guess).split(':').map(n=>+n); const diff=(9*60)-(gh*60+gm); guess=new Date(guess.getTime()+diff*60000); return guess.toISOString(); } function copilotIsQuietHoursToronto(){ const {hour}=copilotTorontoNowParts(); return hour>=22||hour<9; } // OpenAI's premium voice catalog. Each previews + speaks via onyx-tts. // 'id' is what gets passed to OpenAI's API; '' = use the edge fn's default (coral). // Each OpenAI voice = a persona slot. JR can rename + rewrite the personality // for any slot; the active persona drives the chat name, the brain's voice, // and the TTS delivery instructions. voiceType describes the immutable voice // characteristic (gender/accent/tone) to help JR pick. const OPENAI_VOICES = [ { id: 'onyx', defaultName: 'Onyx', voiceType: 'Deep male voice · grounded, mature' }, { id: 'ash', defaultName: 'Ash', voiceType: 'Warm male voice · conversational, 30s' }, { id: 'verse', defaultName: 'Verse', voiceType: 'Expressive male voice · friendly, animated' }, { id: 'echo', defaultName: 'Echo', voiceType: 'Measured male voice · steady, calm' }, { id: 'ballad', defaultName: 'Ballad', voiceType: 'British male voice · expressive, dramatic' }, { id: 'fable', defaultName: 'Fable', voiceType: 'British male voice · warm, narrator-ish' }, { id: 'alloy', defaultName: 'Alloy', voiceType: 'Neutral voice · balanced, clean' }, { id: 'sage', defaultName: 'Sage', voiceType: 'Neutral voice · grounded, mellow' }, { id: 'coral', defaultName: 'Coral', voiceType: 'Warm female voice · friendly, easy' }, { id: 'nova', defaultName: 'Nova', voiceType: 'Bright female voice · upbeat, lively' }, { id: 'shimmer', defaultName: 'Shimmer', voiceType: 'Smooth female voice · calm, soothing' }, ]; // Default personality (delivery + character) per voice. Used as the starting // point for each persona; JR can edit/override and the overrides save into // admin_settings.onyx_personas. const DEFAULT_PERSONA_INSTRUCTIONS = { onyx: "Voice: a deep, mature, grounded man in his late 30s. Premium, confident, with real gravitas — composed and direct, never soft-spoken. Think the trusted older brother who runs his own shop — solid, sharp, a quiet authority. Subtle tattoo-shop cool, never crude. Steady pacing with deliberate pauses.", ash: "Voice: a warm, easygoing man in his early 30s. Talk like you're chatting with a good friend — relaxed, natural, with gentle pauses and genuine friendly warmth. Confident and grounded, a subtle tattoo-shop cool, but still smooth and premium. Just human and easy.", verse: "Voice: a friendly, expressive man in his 30s. Animated and casual, a little upbeat — like an enthusiastic homie who still keeps it premium. Energy without trying too hard, real warmth.", echo: "Voice: a calm, measured man. Steady pacing, thoughtful, never rushed. Like a wise older brother — grounded and centered, not stiff. Warm under the calm.", ballad: "Voice: British, expressive, slightly dramatic. Theatrical warmth without overdoing it — think a charming actor who happens to know tattoos. Premium polish, playful undertone.", fable: "Voice: British, warm, narrator-style. Smooth, paternal, gentle — like a bedtime-story warmth. Premium, comforting, slow when it matters.", alloy: "Voice: neutral, clean, balanced. Professional but human — like a sharp executive assistant who's still warm. Efficient, friendly, premium.", sage: "Voice: calm, grounded, mellow. Centered, zen-adjacent but not flat. Warmth in the steadiness — like a meditation teacher who runs a tattoo shop.", coral: "Voice: a warm, friendly woman in her early 30s. Easy, casual, like a close friend — natural pauses, genuine warmth. Subtle confidence, premium polish.", nova: "Voice: a bright, upbeat woman. Lively energy, fun, the friend who lights up a room — but kept premium and never over the top.", shimmer: "Voice: a smooth, sultry-calm woman. Slow, soothing pacing — like a confident host of a late-night radio show. Premium warmth.", }; // Onyx Actions menu — a tappable, categorized cheat-sheet of everything Onyx // can do. Tapping a row (after a confirm) drops its prompt into the chat and // runs it; Onyx executes if it has enough to go on, or asks for the missing // details first. Keep in sync with the copilot tool set. const ONYX_ACTIONS = [ { cat: 'Look things up', items: [ { label: "Look up a client", prompt: "Look up a client's info." }, { label: "Upcoming appointments", prompt: "Show me my upcoming appointments." }, { label: "Find invoices", prompt: "Show me recent invoices." }, { label: "How's the month going", prompt: "Give me a business summary for this month." }, { label: "Supplies running low", prompt: "What supplies am I running low on?" }, { label: "Posted openings", prompt: "What last-minute openings do I have posted?" }, { label: "Email campaigns", prompt: "Show me my marketing campaigns." }, ] }, { cat: 'Appointments & calendar', items: [ { label: "Book an appointment", prompt: "Book an appointment for a client." }, { label: "Reschedule an appointment", prompt: "Reschedule an appointment." }, { label: "Cancel an appointment", prompt: "Cancel an appointment." }, { label: "Close out a session", prompt: "Close out a finished session." }, { label: "Post a last-minute opening", prompt: "Post a last-minute opening." }, { label: "Block a date", prompt: "Block a date on my calendar." }, { label: "Unblock a date", prompt: "Unblock a date on my calendar." }, ] }, { cat: 'Clients & points', items: [ { label: "Create a client account", prompt: "Create a new client account." }, { label: "Adjust a client's points", prompt: "Adjust a client's points." }, { label: "Send a review request", prompt: "Send a review request to a client." }, { label: "Mark a Google review (+1,000 pts)", prompt: "Mark a client as having left a Google review." }, ] }, { cat: 'Invoices & money', items: [ { label: "Create an invoice", prompt: "Create an invoice for a client." }, { label: "Mark an invoice paid", prompt: "Mark an invoice as paid." }, { label: "Edit an invoice amount", prompt: "Edit an invoice amount." }, ] }, { cat: 'Messaging', items: [ { label: "Message a client", prompt: "Send a message to a client." }, { label: "Send an announcement", prompt: "Send an announcement to clients." }, ] }, { cat: 'Content & marketing', items: [ { label: "Write a blog post", prompt: "Write a blog post." }, { label: "Create an email campaign", prompt: "Create a marketing email campaign." }, { label: "Start a campaign", prompt: "Start a marketing campaign." }, { label: "Send myself a campaign test", prompt: "Send me a test of a campaign." }, ] }, { cat: 'Supplies & reminders', items: [ { label: "Add a supply", prompt: "Add a supply to my inventory." }, { label: "Mark a supply low", prompt: "Mark a supply as running low." }, { label: "Set a reminder", prompt: "Set a reminder for me." }, ] }, ]; function AdminCopilot({ open, onClose, clients, pushClientIds, newsOptOutEmails, generateInvoicePDF, onDidWrite, seed, onSeedConsumed }) { const GOLDC = '#d4af37'; const GOLDDIM = '#b8943f'; const [convo, setConvo] = useState([]); // Anthropic-format messages const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(null); // { baseConvo, tu, remaining, results, preview } const [showActions, setShowActions] = useState(false); // Actions cheat-sheet sheet const [confirmAction, setConfirmAction] = useState(null); // {label, prompt} awaiting confirm const [listening, setListening] = useState(false); // mic capturing audio const [micStarting, setMicStarting] = useState(false); // tapped, waiting for engine ready const [voiceOn, setVoiceOn] = useState(() => { try { return localStorage.getItem('jr_onyx_voice') === '1'; } catch (_) { return false; } }); // speak replies const [voices, setVoices] = useState([]); // available browser TTS voices const [voiceURI, setVoiceURI] = useState(() => { try { return localStorage.getItem('jr_ava_voice') || ''; } catch (_) { return ''; } }); // JR's chosen voice (empty = auto) const [showVoicePicker, setShowVoicePicker] = useState(false); const [previewingURI, setPreviewingURI] = useState(''); // voiceURI currently being previewed (in picker) // Report-to-dev: when Onyx hits a wiring failure, we capture the context // here so JR can flag it with one tap. Saves to public.dev_feedback for // me (the dev assistant in future sessions) to pick up. const [lastFailure, setLastFailure] = useState(null); // { tool_name, tool_input, error_message } const [showReportModal, setShowReportModal] = useState(false); const [reportNotes, setReportNotes] = useState(''); const [reportSaving, setReportSaving] = useState(false); const [reportSent, setReportSent] = useState(false); // JR's per-voice persona overrides: { voice_id: { name, instructions } }. // Anything not overridden falls back to defaults. const [personas, setPersonas] = useState({}); // Edit-persona sheet state. const [editingPersonaId, setEditingPersonaId] = useState(null); const [personaEditName, setPersonaEditName] = useState(''); const [personaEditInstr, setPersonaEditInstr] = useState(''); const [personaEditSpeed, setPersonaEditSpeed] = useState(1.0); const scrollRef = useRef(null); const recogRef = useRef(null); const spokenRef = useRef(0); // how many convo entries have been voiced const audioRef = useRef(null); //