const {useState,useEffect,useRef,useMemo,useCallback}=React; const iconMap={ArrowLeft:'‹',Mic:'🎙',MoreVertical:'⋮',Send:'➤',Volume2:'🔊',VolumeX:'🔇',MessageCircle:'◯',LoaderCircle:'↻',Eye:'◉',Minimize2:'—',LockKeyhole:'🔐',Bot:'🤖',Database:'🗄',Gauge:'◴',LayoutDashboard:'▦',LogOut:'↪',Settings:'⚙',BookOpen:'📖',ShieldCheck:'✓',ExternalLink:'↗'}; function I({name,size=20,className=''}){return {iconMap[name]||'•'}} async function api(path,options={}){const r=await fetch(`/api/${path.replace(/^\//,'')}`,{credentials:'include',headers:{...(options.body instanceof FormData?{}:{'Content-Type':'application/json'}),...(options.headers||{})},...options});const t=r.headers.get('content-type')||'';const p=t.includes('application/json')?await r.json().catch(()=>({})):await r.text();if(!r.ok)throw new Error(typeof p==='object'&&p.error?p.error:`Request failed (${r.status})`);return p} const postJson=(path,body)=>api(path,{method:'POST',body:JSON.stringify(body)}); function usePath(){const[path,setPath]=useState(location.pathname);useEffect(()=>{const f=()=>setPath(location.pathname);addEventListener('popstate',f);return()=>removeEventListener('popstate',f)},[]);return path} function go(path,replace=false){history[replace?'replaceState':'pushState']({},'',path);dispatchEvent(new PopStateEvent('popstate'))} function A({to,children,className=''}){return {e.preventDefault();go(to)}}>{children}} const now=()=>new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); async function openCoachPreview(coachId,firebaseUid=''){ const popup=window.open('about:blank','upchamp-coach-preview'); if(popup){popup.document.title='Opening coach preview…';popup.document.body.innerHTML='
Opening coach preview…
'} try{ const d=await postJson('admin/preview-coach.php',{coachId,firebaseUid:firebaseUid.trim()}); if(popup)popup.location.href=d.url;else location.href=d.url; }catch(e){if(popup)popup.close();throw e} } function Launch(){const[status,setStatus]=useState('Preparing your coach…');const ticket=new URLSearchParams(location.search).get('ticket');useEffect(()=>{if(!ticket)return;postJson('unity/redeem-ticket.php',{ticket}).then(()=>{setStatus('Player verified. Opening coach chat…');history.replaceState({},'','/');go('/chat',true)}).catch(e=>setStatus(e.message))},[ticket]);return
UC

UPCHAMP AI

{ticket?status:'Open this coach chat from inside the signed-in UpChamp app.'}

Admin portal
} function CoachWindow({coach,speaking,collapsed,onToggle}){if(!coach?.showCharacterWindow)return null;return
Coach View
{!collapsed&&<>
{coach.imageUrl?{coach.name}:
{coach.name?.[0]||'C'}
}
{speaking?`${coach.name} is speaking…`:`${coach.name} is ready`}
}
} function MessageBubble({message,coach}){const u=message.role==='user';return
{!u&&
{coach?.shortName||coach?.name?.[0]||'C'}
}
{!u&&
{coach?.name}
}
{message.text}
{message.time||''}{u?' ✓✓':''}
{message.stats&&
{message.stats.map(x=>
{x.label}{x.value}
)}
}
} function CoachChat(){const[boot,setBoot]=useState({loading:true,error:''});const[user,setUser]=useState(null);const[coach,setCoach]=useState(null);const[isPreview,setIsPreview]=useState(false);const[messages,setMessages]=useState([]);const[input,setInput]=useState('');const[sending,setSending]=useState(false);const[listening,setListening]=useState(false);const[speaking,setSpeaking]=useState(false);const[voiceOn,setVoiceOn]=useState(true);const[collapsed,setCollapsed]=useState(false);const scrollRef=useRef(null);const recognitionRef=useRef(null); useEffect(()=>{api('user/context.php').then(d=>{setUser(d.user);setCoach(d.coach);setIsPreview(!!d.preview);setVoiceOn(d.coach?.voiceDefaultOn!==false&&d.coach?.voiceMode!=='off');setMessages([{role:'assistant',text:d.greeting,time:now(),stats:d.highlightStats||null}]);setBoot({loading:false,error:''})}).catch(e=>setBoot({loading:false,error:e.message}))},[]);useEffect(()=>{scrollRef.current?.scrollTo({top:scrollRef.current.scrollHeight,behavior:'smooth'})},[messages,sending]); const speakBrowser=useCallback(text=>{if(!('speechSynthesis'in window)||!voiceOn)return;speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(text);u.lang=coach?.browserVoiceLanguage||'en-US';const vs=speechSynthesis.getVoices();if(coach?.browserVoiceName){const m=vs.find(v=>v.name.includes(coach.browserVoiceName));if(m)u.voice=m}u.onstart=()=>setSpeaking(true);u.onend=u.onerror=()=>setSpeaking(false);speechSynthesis.speak(u)},[coach,voiceOn]); const speakChirp=useCallback(async text=>{if(!voiceOn)return;setSpeaking(true);try{const d=await postJson('chat/tts.php',{text});const a=new Audio(`data:${d.mime};base64,${d.audio}`);a.onended=a.onerror=()=>setSpeaking(false);await a.play()}catch{setSpeaking(false);speakBrowser(text)}},[voiceOn,speakBrowser]); const playReply=useCallback(text=>{if(!voiceOn||!coach)return;if(coach.voiceMode==='chirp')speakChirp(text);else if(coach.voiceMode==='browser')speakBrowser(text)},[coach,voiceOn,speakBrowser,speakChirp]); const submit=useCallback(async text=>{const clean=text.trim();if(!clean||sending)return;const m={role:'user',text:clean,time:now()};const next=[...messages,m];setMessages(next);setInput('');setSending(true);try{const d=await postJson('chat/message.php',{message:clean,history:next.slice(-12).map(({role,text})=>({role,text}))});setMessages(c=>[...c,{role:'assistant',text:d.text,time:now(),stats:d.stats||null}]);playReply(d.text)}catch(e){setMessages(c=>[...c,{role:'assistant',text:`I couldn't answer that right now: ${e.message}`,time:now()}])}finally{setSending(false)}},[messages,sending,playReply]); const SR=useMemo(()=>window.SpeechRecognition||window.webkitSpeechRecognition,[]);const startListening=useCallback(()=>{if(sending||listening)return;if(!SR){alert('Speech recognition is not available in this embedded browser. You can still type.');return}window.speechSynthesis?.cancel();const r=new SR();r.lang=coach?.speechLanguage||'en-US';r.continuous=false;r.interimResults=true;let finalText='';r.onresult=e=>{let interim='';for(let i=e.resultIndex;i{setListening(false);recognitionRef.current=null;if(finalText.trim())submit(finalText)};r.onerror=()=>{setListening(false);recognitionRef.current=null};recognitionRef.current=r;r.start();setListening(true)},[SR,coach,listening,sending,submit]);const stopListening=useCallback(()=>{try{recognitionRef.current?.stop()}catch{}},[]); if(boot.loading)return
Loading your personal coach…
;if(boot.error)return
{boot.error}Return
; return
UC
{coach.name}{isPreview?'Admin preview':`Coaching ${user.displayName} now`}
setCollapsed(v=>!v)}/>
{messages.map((m,i)=>)}{sending&&
{coach.shortName||coach.name[0]}
}
Voice responses{voiceOn?'ON':'OFF'}
{e.preventDefault();submit(input)}}>setInput(e.target.value)} placeholder="Type your message…"/>Push to talk • Release to send
} function AdminLogin(){const[email,setEmail]=useState('');const[password,setPassword]=useState('');const[error,setError]=useState('');const[busy,setBusy]=useState(false);async function submit(e){e.preventDefault();setBusy(true);setError('');try{await postJson('auth/admin-login.php',{email,password});go('/admin')}catch(x){setError(x.message)}finally{setBusy(false)}}return
UC

UPCHAMP AI ADMIN

Manage coaches, Firebase, Gemini, voices and knowledge.

{error&&
{error}
}
} function AdminShell({path}){const[ready,setReady]=useState(false);useEffect(()=>{api('auth/admin-session.php').then(()=>setReady(true)).catch(()=>go('/admin/login',true))},[]);async function logout(){await postJson('auth/admin-logout.php',{});go('/admin/login')}if(!ready)return
Checking admin session…
;let Page=AdminDashboard;if(path==='/admin/coaches')Page=Coaches;else if(path.startsWith('/admin/coach/'))Page=CoachEditor;else if(path==='/admin/firebase')Page=FirebaseSettings;else if(path==='/admin/knowledge')Page=Knowledge;else if(path==='/admin/usage')Page=Usage;else if(path==='/admin/settings')Page=AdminSettings;const nav=[['/admin','LayoutDashboard','Overview'],['/admin/coaches','Bot','Coaches'],['/admin/firebase','Database','Firebase'],['/admin/knowledge','BookOpen','Knowledge'],['/admin/usage','Gauge','Usage'],['/admin/settings','Settings','Settings']];return
} function Metric({name,label,value}){return
{label}{value}
} function AdminDashboard(){const[data,setData]=useState(null);useEffect(()=>{api('admin/dashboard.php').then(setData)},[]);if(!data)return

Dashboard

Loading…

;return
UPCHAMP AI

Coach control center

Manage the embedded Unity coach experience from one place.

Manage coaches

Unity handoff

Unity sends a Firebase ID token, receives a one-time launch ticket, then opens this web chat. The permanent token never appears in the URL.

POST /api/unity/create-ticket.php

Current AI route

Text questions use {data.model}. Voice replies use {data.voiceMode}.

Data context

{data.firebaseSources} Firebase source paths are configured. A server cache reduces repeated Firebase reads.

} function Coaches(){const[items,setItems]=useState(null);const[msg,setMsg]=useState('');const load=()=>api('admin/coaches.php').then(d=>setItems(d.coaches));useEffect(()=>{let active=true;api('admin/coaches.php').then(d=>{if(active)setItems(d.coaches)}).catch(e=>{if(active){setMsg(e.message);setItems([])}});return()=>{active=false}},[]);async function remove(id){if(!confirm('Delete this coach?'))return;try{await api('admin/coaches.php',{method:'DELETE',body:JSON.stringify({id})});setMsg('Coach deleted.');load()}catch(e){setMsg(e.message)}}if(!items)return

Coaches

Loading…

;return
COACH LIBRARY

AI coach templates

Create Zyne, Lina and other coach personalities. Firebase selects the matching coach ID for each player.

Create coach
{msg&&
{msg}
}
{items.map(c=>
{c.imageUrl?:
{c.name?.[0]}
}

{c.name}

{c.subtitle}

{c.model}
{c.voiceMode}

Edit{c.id!=='default'&&}
)}
} const modelOptions=[['gemini-2.5-flash-lite','Gemini 2.5 Flash-Lite — default, free tier available'],['gemini-2.5-flash','Gemini 2.5 Flash — stronger, free tier available'],['gemini-3-flash-preview','Gemini 3 Flash Preview — project availability varies']];const voiceNames=['Achernar','Achird','Algenib','Algieba','Alnilam','Aoede','Autonoe','Callirrhoe','Charon','Despina','Enceladus','Erinome','Fenrir','Gacrux','Iapetus','Kore','Laomedeia','Leda','Orus','Puck','Pulcherrima','Rasalgethi','Sadachbia','Sadaltager','Schedar','Sulafat','Umbriel','Vindemiatrix','Zephyr','Zubenelgenubi']; function Section({title,children}){return

{title}

{children}
}function Field({label,children,full}){return }function Toggle({label,value,onChange}){return } function CoachEditor({path}){const coachId=(path||location.pathname).split('/').filter(Boolean).pop()||'default';const[form,setForm]=useState(null);const[msg,setMsg]=useState('');const[busy,setBusy]=useState(false);const[previewUid,setPreviewUid]=useState('');useEffect(()=>{let active=true;setForm(null);setMsg('');api(`admin/coaches.php?id=${encodeURIComponent(coachId)}`).then(d=>{if(active)setForm(d.coach)}).catch(e=>{if(active)setMsg(e.message)});return()=>{active=false}},[coachId]);const set=(k,v)=>setForm(f=>({...f,[k]:v}));async function save(e){e.preventDefault();if(!form)return;const name=(form.name||'').trim();if(!name){setMsg('Coach name is required.');return}setBusy(true);setMsg('Saving coach…');try{const payload={...form,name,shortName:(form.shortName||'').trim()};const d=await api('admin/coaches.php',{method:'POST',body:JSON.stringify(payload)});setForm(d.coach);setMsg(`Saved successfully: ${d.coach.name}`);if(coachId==='new'||coachId!==d.coach.id)go(`/admin/coach/${d.coach.id}`,true)}catch(x){setMsg(x.message)}finally{setBusy(false)}}async function upload(e){const file=e.target.files?.[0];if(!file)return;const fd=new FormData();fd.append('image',file);try{const d=await api('admin/upload-coach-image.php',{method:'POST',body:fd});set('imageUrl',d.url);setMsg('Character image uploaded. Save the coach to keep this change.')}catch(x){setMsg(x.message)}}async function preview(){if(!form?.id||coachId==='new'){setMsg('Save this coach first, then preview it.');return}try{await openCoachPreview(form.id,previewUid)}catch(x){setMsg(x.message)}}if(!form)return

AI Coach

{msg||'Loading…'}

{msg&&Back to coaches}
;return
COACH TEMPLATE

Coach identity and AI

The coach is personalized with the signed-in player's private Firebase context.

Back to coaches
{msg&&
{msg}
}
set('name',e.target.value)}/>set('shortName',e.target.value)}/>set('subtitle',e.target.value)}/>set('imageUrl',e.target.value)}/>set('showCharacterWindow',v)}/>
{form.imageUrl&&}
set('maxReplyWords',Number(e.target.value))}/>