/** * ═══════════════════════════════════════════════════════════════════════════ * ECO COMPUTER — VOICE AGENT WIDGET (Phase C, v1.5.0) * ═══════════════════════════════════════════════════════════════════════════ * Floating "🎤 Talk to us" pill — sits to the LEFT of the Ask AI button. * * Flow: * 1. User taps the button. * 2. We POST to /voice.php → get ephemeral token (value: "ek_..."). * 3. Browser opens WebRTC peer connection directly to OpenAI's * /v1/realtime/calls endpoint using that ephemeral token. * 4. Mic audio streams up; AI audio streams back. * 5. A data channel ('oai-events') carries control events: * - transcripts (user + assistant) * - function/tool calls * 6. When the model calls a tool (lookup_order / amend_order / * list_products), we execute it against the existing PHP endpoints * and send the JSON result back through the data channel. * 7. Session auto-ends after VOICE_MAX_SESSION_SEC (from server, default 300s). * * No real OpenAI API key is ever exposed to the browser. * ═══════════════════════════════════════════════════════════════════════════ */ (function () { const { useState, useRef, useEffect, useCallback } = React; // ────────────────────────────────────────────────────────────────────────── // 1. THEME — matches existing chatbot pill (green, rounded, soft shadow) // ────────────────────────────────────────────────────────────────────────── const GREEN_500 = '#3a7d52'; const GREEN_700 = '#2e6742'; const GREEN_900 = '#1a3d26'; const RED = '#cc1a1a'; // ────────────────────────────────────────────────────────────────────────── // 2. TOOL EXECUTORS — these mirror the chatbot's PHP endpoints. // Called by the model via the data channel. // ────────────────────────────────────────────────────────────────────────── async function execTool(name, args) { try { if (name === 'list_products') { const r = await fetch('products_list.php'); return await r.json(); } if (name === 'lookup_order') { const r = await fetch('order_lookup.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_number: args.order_number || '', phone: args.phone || '', }), }); return await r.json(); } if (name === 'amend_order') { const r = await fetch('order_amend.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args), }); return await r.json(); } // ── Shopping-cart tools — drive the live on-page cart (window.ecoCart) ── const cart = (typeof window !== 'undefined' && window.ecoCart && window.ecoCart.ready) ? window.ecoCart : null; if (name === 'view_cart') { if (!cart) return { error: 'cart_unavailable' }; return { items: cart.list(), total: cart.total(), count: cart.count() }; } if (name === 'add_to_cart') { if (!cart) return { error: 'cart_unavailable' }; const r = cart.add(args.sku, args.quantity || 1); return { ...r, cart: cart.list(), total: cart.total(), count: cart.count() }; } if (name === 'update_cart_item') { if (!cart) return { error: 'cart_unavailable' }; const r = cart.setQty(args.sku, args.quantity); return { ...r, cart: cart.list(), total: cart.total(), count: cart.count() }; } if (name === 'remove_cart_item') { if (!cart) return { error: 'cart_unavailable' }; const r = cart.remove(args.sku); return { ...r, cart: cart.list(), total: cart.total(), count: cart.count() }; } if (name === 'clear_cart') { if (!cart) return { error: 'cart_unavailable' }; cart.clear(); return { ok: true, cart: [], total: 0, count: 0 }; } if (name === 'open_checkout') { if (!cart) return { error: 'cart_unavailable' }; cart.openCheckout(); return { ok: true }; } return { error: 'Unknown tool: ' + name }; } catch (e) { return { error: 'Tool execution failed: ' + (e?.message || String(e)) }; } } // ────────────────────────────────────────────────────────────────────────── // 3. ICONS // ────────────────────────────────────────────────────────────────────────── const MicIcon = () => ( ); const PhoneOffIcon = () => ( ); const MuteIcon = ({ muted }) => muted ? ( ) : ; // ────────────────────────────────────────────────────────────────────────── // 4. MAIN COMPONENT // ────────────────────────────────────────────────────────────────────────── function EcoVoiceAgent() { const [open, setOpen] = useState(false); // panel visibility const [status, setStatus] = useState('idle'); // idle | connecting | listening | speaking | error | ended const [muted, setMuted] = useState(false); const [remaining, setRemaining] = useState(null); // seconds left const [transcript, setTranscript] = useState([]); // [{role:'user'|'assistant', text:''}] const [errorMsg, setErrorMsg] = useState(''); // Refs for things that must persist across renders without re-rendering const pcRef = useRef(null); // RTCPeerConnection const dcRef = useRef(null); // RTCDataChannel const micRef = useRef(null); // MediaStream const audioRef = useRef(null); //