暂无描述
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

connect.html 32KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>BizGaze Support — Agent Console</title>
  7. <style>
  8. :root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
  9. *{box-sizing:border-box;}
  10. body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
  11. .topbar{background:var(--blue);padding:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
  12. .brandrow{display:flex;align-items:center;gap:.6rem;}
  13. .logo{width:28px;height:28px;border-radius:7px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
  14. .brand{font-weight:700;color:#fff;} .brand span{color:var(--brand);font-weight:600;}
  15. .agentchip{color:#dbe4f5;font-size:.85rem;}
  16. .agentchip b{color:#fff;} .agentchip a{color:var(--brand);text-decoration:none;margin-left:.5rem;cursor:pointer;}
  17. .wrap{min-height:calc(100vh - 50px);display:grid;place-items:center;padding:1.5rem;}
  18. .card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.2rem;max-width:430px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.06);}
  19. h1{font-size:1.35rem;margin:.1rem 0 .3rem;color:var(--blue);text-align:center;}
  20. .sub{color:var(--muted);font-size:.92rem;margin-bottom:1.3rem;text-align:center;}
  21. .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.85rem 0 .25rem;}
  22. input{width:100%;padding:.7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:1rem;}
  23. input:focus{outline:none;border-color:var(--brand);}
  24. input.code{font-size:1.7rem;letter-spacing:.35rem;text-align:center;}
  25. .btn{margin-top:1.1rem;width:100%;padding:.85rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;font-size:1.02rem;cursor:pointer;}
  26. .btn:hover{background:var(--brand-d);}
  27. .status{color:var(--muted);font-size:.88rem;margin-top:.9rem;text-align:center;min-height:1.1em;}
  28. .err{color:#b91c1c;}
  29. .prefill{background:#EAF0FB;border:1px solid #c7d6f0;border-radius:8px;padding:.5rem .7rem;font-size:.85rem;color:var(--blue-d);margin-top:.25rem;}
  30. .topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
  31. .topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
  32. #endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
  33. #video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
  34. .profile{position:relative}
  35. .profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer}
  36. .profile .pbtn:hover{background:rgba(255,255,255,.24)}
  37. .profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:190px;overflow:hidden;z-index:5000;display:none}
  38. .profile .pmenu.open{display:block}
  39. .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
  40. .profile .pmenu a:hover{background:#f1f5f9}
  41. .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
  42. .pwwrap{position:relative;}
  43. .pwwrap input{padding-right:2.7rem;}
  44. .eye{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;margin:0;}
  45. .eye:hover{color:var(--blue);}
  46. #homeLink{position:fixed;top:14px;left:16px;z-index:50;display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.92);color:#1F3B73;text-decoration:none;font-weight:600;font-size:.86rem;padding:.45rem .8rem;border-radius:10px;box-shadow:0 4px 12px rgba(0,0,0,.15);}
  47. .formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin-top:.9rem;min-height:1.1em;text-align:left;}
  48. .formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
  49. .formerr.show::before{content:"⚠";font-size:1rem;}
  50. @keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
  51. /* Embedded inside the home shell: hide own chrome (the shell provides it). */
  52. html.embed .topbar{display:none!important;}
  53. html.embed #homeLink{display:none!important;}
  54. html.embed #video{height:100vh!important;}
  55. </style>
  56. </head>
  57. <body>
  58. <script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
  59. <a href="/home" id="homeLink">&#8592; Home</a>
  60. <div class="topbar" id="topbar">
  61. <div class="brandrow"><img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))"><div class="brand">BizGaze <span>Support</span></div></div>
  62. <div class="agentchip" id="agentChip"></div>
  63. </div>
  64. <div class="topbar2" id="bar"><span id="barStatus"></span><button id="endBtn">End session</button></div>
  65. <div class="wrap" id="wrap"><div class="card" id="card"></div></div>
  66. <video id="video" autoplay playsinline muted tabindex="0"></video>
  67. <script>
  68. let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
  69. const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
  70. let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
  71. async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
  72. function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
  73. // When embedded in the home shell, tell the parent when a session is live so the
  74. // rail can show a "return here" indicator.
  75. function bzcSession(active){try{if(window.parent&&window.parent!==window)window.parent.postMessage({type:'bzc-session',flow:'connect',active:!!active},location.origin);}catch(_){}}
  76. function profileHTML(name){return '<div class="profile"><button class="pbtn" id="pbtn">'+pEsc(name)+' <span style="font-size:.65rem">&#9662;</span></button><div class="pmenu" id="pmenu"><a href="/home">Home</a><a href="/dashboard">Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
  77. function wireProfile(){const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');if(!btn)return;btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};document.addEventListener('click',()=>menu.classList.remove('open'));const lo=document.getElementById('plogout');if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};}
  78. function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
  79. makeBrandClickable();
  80. const params=new URLSearchParams(location.search);
  81. const presetTicket=params.get('ticket')||'';
  82. const presetCode=params.get('code')||'';
  83. const card=document.getElementById('card'), wrap=document.getElementById('wrap'),
  84. agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
  85. topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
  86. let ws,pc,inputChannel,chatChannel,sessionId,me=null;
  87. async function api(path,body,method='POST'){
  88. const opt={method,headers:{'Content-Type':'application/json'}};
  89. if(body)opt.body=JSON.stringify(body);
  90. const r=await fetch(path,opt); const data=await r.json().catch(()=>({}));
  91. if(!r.ok) throw new Error(data.error||'request failed'); return data;
  92. }
  93. function onEnter(ids, fn){ ids.forEach(id => { const el=document.getElementById(id); if(el) el.addEventListener('keydown', e=>{ if(e.key==='Enter'){ e.preventDefault(); fn(); } }); }); }
  94. // ---- boot: are we a logged-in agent? ----
  95. (async function boot(){
  96. try{ me=await api('/api/me',null,'GET'); renderAgent(); }
  97. catch{ renderLogin(); }
  98. })();
  99. // ---- LOGIN ----
  100. const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
  101. const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
  102. function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
  103. function renderLogin(){
  104. agentChip.textContent='';
  105. card.innerHTML = `
  106. <h1>Sign in</h1>
  107. <div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
  108. <span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
  109. <span class="lbl">Password</span><div class="pwwrap"><input id="pw" type="password" placeholder="password"><button type="button" class="eye" data-for="pw" aria-label="Show password"></button></div>
  110. <label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="rememberMe" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
  111. <button class="btn" id="loginBtn" style="width:100%">Sign in</button>
  112. <div class="formerr" id="err"></div>`;
  113. {
  114. const doSignIn=async()=>{
  115. const errEl=document.getElementById('err'); errEl.textContent=''; errEl.classList.remove('show');
  116. try{
  117. await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
  118. me=await api('/api/me',null,'GET'); renderAgent();
  119. }catch(e){ errEl.textContent=/invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message; errEl.classList.add('show'); }
  120. };
  121. document.getElementById('loginBtn').onclick=doSignIn;
  122. onEnter(['email','pw'], doSignIn);
  123. wireEyes();
  124. }
  125. }
  126. // ---- AGENT CONNECT ----
  127. function renderAgent(){
  128. const displayName = me.name || me.email;
  129. agentChip.innerHTML = profileHTML(displayName); wireProfile();
  130. card.innerHTML = `
  131. <h1>Start a support session</h1>
  132. <div class="sub">Ask the customer to open the share page and read you their code.</div>
  133. <span class="lbl">Ticket number (optional)</span>
  134. <input id="ticketInput" maxlength="40" placeholder="e.g. TKT-1042 — leave blank for a direct session" value="${esc(presetTicket)}" ${presetTicket?'readonly':''}>
  135. ${presetTicket?'<div class="prefill">Linked from service request '+esc(presetTicket)+'</div>':''}
  136. <span class="lbl">Session code from customer</span>
  137. <input id="codeInput" class="code" maxlength="6" inputmode="numeric" placeholder="000000" value="${esc(presetCode)}">
  138. <button class="btn" id="connectBtn">Connect</button>
  139. <div class="status" id="status">Enter the customer's code to begin.</div>`;
  140. connectWS();
  141. document.getElementById('connectBtn').onclick=startConnect;
  142. onEnter(['ticketInput','codeInput'], startConnect);
  143. }
  144. async function startConnect(){
  145. const statusEl=document.getElementById('status'); statusEl.className='status';
  146. const ticket=document.getElementById('ticketInput').value.trim();
  147. const code=document.getElementById('codeInput').value.trim();
  148. if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
  149. statusEl.textContent='Connecting…';
  150. ws.send(JSON.stringify({type:'code-connect',code,ticket}));
  151. }
  152. function connectWS(){
  153. ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
  154. ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
  155. case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
  156. case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
  157. case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
  158. try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
  159. const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
  160. ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
  161. case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
  162. case 'transcript': if(recogActive&&m.text) addLine('customer', m.name||'Customer', m.text, !!m.chat); break;
  163. case 'session-denied': renderEnded('The customer declined the request.'); break;
  164. case 'session-ended': {
  165. const msgs={'share-cancelled':'The customer cancelled the screen selection. Ask them to refresh their page for a new code.',
  166. 'customer-ended':'The customer stopped sharing their screen. Ask them to refresh their page for a new code.',
  167. 'agent-ended':'You ended the session.'};
  168. renderEnded(msgs[m.reason]||'The session has ended.'); break;
  169. }
  170. case 'error': if(statusEl){statusEl.className='status err';statusEl.textContent=m.message;} break;
  171. }};
  172. }
  173. function renderWaiting(){
  174. card.innerHTML=`
  175. <h1>Waiting for the customer…</h1>
  176. <div class="sub">Code accepted. The customer has been asked to tap <b>Allow</b> on their screen.</div>
  177. <div class="status" id="status">Waiting for the customer to tap Allow…</div>
  178. <button class="btn" id="cancelBtn" style="background:#eef1f6;color:#1F3B73">Cancel</button>`;
  179. document.getElementById('cancelBtn').onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId}));}catch(e){} location.reload(); };
  180. }
  181. function renderEnded(msg){
  182. bzcSession(false);
  183. try{ stopRecording(); }catch(_){}
  184. removeSessionUI();
  185. if(pc){ try{pc.close();}catch(e){} pc=null; }
  186. video.style.display='none'; bar.classList.remove('show');
  187. topbar.style.display='flex'; wrap.style.display='grid';
  188. { const hl=document.getElementById('homeLink'); if(hl && !document.documentElement.classList.contains('embed')) hl.style.display=''; }
  189. card.innerHTML=`
  190. <h1>Session ended</h1>
  191. <div class="sub">${esc(msg)}</div>
  192. <button class="btn" id="againBtn">Start a new session</button>`;
  193. document.getElementById('againBtn').onclick=()=>location.reload();
  194. }
  195. let chatOpen=false;
  196. const SVG_MIC='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
  197. const SVG_MICOFF='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="3" x2="21" y2="21"/><path d="M9 9v4a3 3 0 0 0 5 2.2"/><path d="M5 10a7 7 0 0 0 10.9 5.6"/><line x1="12" y1="19" x2="12" y2="22"/></svg>';
  198. const SVG_CHAT='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
  199. const SVG_END='<svg viewBox="0 0 24 24" width="17" height="17" fill="#fff"><rect x="5" y="5" width="14" height="14" rx="2.5"/></svg>';
  200. const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
  201. const SVG_RECSTOP='<svg viewBox="0 0 24 24" width="15" height="15" fill="#fff"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
  202. let mediaRecorder=null, recChunks=[], recCtx=null;
  203. const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  204. let recog=null, recogActive=false, transcriptLines=[];
  205. let recTimerInt=null, recStartTs=0;
  206. function fmtElapsedA(ms){const s=Math.max(0,Math.floor(ms/1000));return String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
  207. function showRecTimer(on){
  208. let c=document.getElementById('recTimer');
  209. if(on){
  210. if(!c){ c=document.createElement('div'); c.id='recTimer';
  211. c.style.cssText='display:inline-flex;align-items:center;gap:6px;color:#fff;font-weight:700;font-size:.85rem;background:#dc2626;padding:.5rem .7rem;border-radius:12px';
  212. c.innerHTML='<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:recpulseA 1.2s infinite"></span><span id="recTimerVal">00:00</span>';
  213. const bar=document.getElementById('sessionBar'), rb=document.getElementById('recBtn');
  214. if(bar&&rb){ bar.insertBefore(c, rb); } else if(bar){ bar.appendChild(c); }
  215. if(!document.getElementById('recPulseStyleA')){const st=document.createElement('style');st.id='recPulseStyleA';st.textContent='@keyframes recpulseA{0%,100%{opacity:1}50%{opacity:.2}}';document.head.appendChild(st);}
  216. }
  217. recStartTs=Date.now(); clearInterval(recTimerInt);
  218. const upd=()=>{ const v=document.getElementById('recTimerVal'); if(v) v.textContent=fmtElapsedA(Date.now()-recStartTs); };
  219. upd(); recTimerInt=setInterval(upd,1000);
  220. } else { clearInterval(recTimerInt); recTimerInt=null; if(c) c.remove(); }
  221. }
  222. function addLine(role, name, text, isChat){ transcriptLines.push({ t: Date.now(), role: role, name: name||'', text: text, chat: !!isChat }); }
  223. function startTranscription(){
  224. transcriptLines=[];
  225. if(!SR) return;
  226. try{
  227. recog=new SR(); recog.continuous=true; recog.interimResults=false; recog.lang='en-US';
  228. recog.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ if(e.results[i].isFinal){ const txt=(e.results[i][0].transcript||'').trim(); if(txt) addLine('agent',(me&&(me.name||me.email))||'Agent',txt,false); } } };
  229. recog.onerror=()=>{};
  230. recog.onend=()=>{ if(recogActive){ try{recog.start();}catch(_){} } };
  231. recogActive=true; recog.start();
  232. }catch(e){}
  233. }
  234. function stopTranscription(){ recogActive=false; if(recog){ try{recog.stop();}catch(_){} recog=null; } }
  235. function buildTranscriptText(){
  236. const lines=transcriptLines.slice().sort((a,b)=>a.t-b.t);
  237. const pad=(n)=>String(n).padStart(2,'0');
  238. const head='BizGaze Support — Session transcript\nSession: '+sessionId+'\nGenerated: '+new Date().toLocaleString()+'\n'+('-'.repeat(48))+'\n';
  239. const body=lines.map(l=>{ const d=new Date(l.t); const ts='['+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+']'; const who=(l.role==='agent'?'Agent':'Customer')+(l.name?' ('+l.name+')':'')+(l.chat?' [chat]':''); return ts+' '+who+': '+l.text; }).join('\n');
  240. return head+(body||'(no speech captured)')+'\n';
  241. }
  242. async function uploadTranscript(){
  243. if(!transcriptLines.length) return;
  244. try{ await fetch('/api/transcript?sessionId='+encodeURIComponent(sessionId),{method:'POST',headers:{'Content-Type':'text/plain'},body:buildTranscriptText()}); }catch(_){}
  245. }
  246. function recBtnUpdate(on){const b=document.getElementById('recBtn');if(!b)return;b.innerHTML='<span style="display:inline-flex">'+(on?SVG_RECSTOP:SVG_REC)+'</span>';b.title=on?'Stop recording':'Record';b.style.background=on?'#dc2626':'#0ea5e9';}
  247. function startRecording(){
  248. const remote=video.srcObject;
  249. const _vt=remote&&remote.getVideoTracks&&remote.getVideoTracks()[0];
  250. if(!_vt||_vt.readyState!=='live'){ alert('No live screen to record. The customer may have disconnected.'); return; }
  251. if(pc&&pc.connectionState&&pc.connectionState!=='connected'){ alert('Not connected to the customer right now.'); return; }
  252. try{
  253. recCtx=new (window.AudioContext||window.webkitAudioContext)();
  254. const dest=recCtx.createMediaStreamDestination();
  255. if(remote.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(remote.getAudioTracks())).connect(dest);}catch(_){} }
  256. if(window.__mic&&window.__mic.getAudioTracks().length){ try{recCtx.createMediaStreamSource(new MediaStream(window.__mic.getAudioTracks())).connect(dest);}catch(_){} }
  257. const mixed=new MediaStream();
  258. mixed.addTrack(remote.getVideoTracks()[0]);
  259. dest.stream.getAudioTracks().forEach(t=>mixed.addTrack(t));
  260. // Prefer MP4 (H.264/AAC) — playable by most tools (Windows Media Player, QuickTime,
  261. // WhatsApp, etc.). Fall back to WebM only if the browser can't record MP4.
  262. const REC_TYPES=['video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4;codecs=h264,aac','video/mp4','video/webm;codecs=vp8,opus','video/webm'];
  263. let mime='video/webm'; for(const t of REC_TYPES){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)){ mime=t; break; } }
  264. const recExt = mime.indexOf('mp4')!==-1 ? 'mp4' : 'webm';
  265. const recBlobType = mime.indexOf('mp4')!==-1 ? 'video/mp4' : 'video/webm';
  266. recChunks=[];
  267. mediaRecorder=new MediaRecorder(mixed,{mimeType:mime});
  268. mediaRecorder.ondataavailable=(e)=>{ if(e.data&&e.data.size) recChunks.push(e.data); };
  269. mediaRecorder.onstop=async()=>{
  270. try{ const blob=new Blob(recChunks,{type:recBlobType}); if(blob.size) await fetch('/api/recording?sessionId='+encodeURIComponent(sessionId)+'&ext='+recExt,{method:'POST',headers:{'Content-Type':recBlobType},body:blob}); }catch(_){}
  271. try{recCtx&&recCtx.close();}catch(_){} recCtx=null;
  272. };
  273. mediaRecorder.start(1000);
  274. startTranscription();
  275. recBtnUpdate(true);
  276. showRecTimer(true);
  277. try{ws.send(JSON.stringify({type:'recording',sessionId,on:true}));}catch(_){}
  278. }catch(e){ alert('Recording could not start on this browser.'); }
  279. }
  280. function stopRecording(){
  281. if(mediaRecorder&&mediaRecorder.state!=='inactive'){ try{mediaRecorder.stop();}catch(_){} }
  282. stopTranscription();
  283. uploadTranscript();
  284. recBtnUpdate(false);
  285. showRecTimer(false);
  286. try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
  287. }
  288. function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML='<span style="display:inline-flex">'+svg+'</span><span>'+label+'</span>';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;}
  289. function buildBar(){
  290. if(document.getElementById('sessionBar'))return;
  291. { const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
  292. bzcSession(true);
  293. const bar=document.createElement('div'); bar.id='sessionBar';
  294. bar.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)';
  295. const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
  296. const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
  297. const rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
  298. const end=_btn('endBtn2',SVG_END,'End','#dc2626');
  299. bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);bar.appendChild(end);
  300. document.body.appendChild(bar);
  301. mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span><span>'+(t.enabled?'Mic':'Muted')+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
  302. chat.onclick=toggleChat;
  303. rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
  304. end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
  305. buildChatPanel();
  306. document.addEventListener('pointerdown',ensureAudio,{once:true});
  307. document.addEventListener('keydown',ensureAudio,{once:true});
  308. if('Notification' in window && Notification.permission==='default'){ try{Notification.requestPermission();}catch(_){}}
  309. }
  310. function buildChatPanel(){
  311. if(document.getElementById('chatPanel'))return;
  312. const p=document.createElement('div'); p.id='chatPanel';
  313. p.style.cssText='position:fixed;right:18px;bottom:84px;width:300px;max-width:92vw;height:360px;max-height:62vh;z-index:2147483001;background:#fff;border:1px solid #e6e9ef;border-radius:16px;box-shadow:0 14px 34px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden';
  314. p.innerHTML='<div style="background:#1F3B73;color:#fff;padding:.6rem .85rem;font-weight:600;font-size:.92rem;display:flex;justify-content:space-between;align-items:center">Chat <span id="chatClose" style="cursor:pointer;opacity:.85">&#10005;</span></div><div id="chatMsgs" style="flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem;font-size:.85rem"></div><div style="display:flex;gap:.4rem;padding:.5rem;border-top:1px solid #e6e9ef"><input id="chatInput" placeholder="Type a message..." style="flex:1;padding:.5rem;border:1px solid #e6e9ef;border-radius:8px;font-size:.85rem;outline:none"><button id="chatSend" style="background:#FFC708;border:none;border-radius:8px;padding:.5rem .85rem;font-weight:700;cursor:pointer">Send</button></div>';
  315. document.body.appendChild(p);
  316. document.getElementById('chatSend').onclick=sendChat;
  317. document.getElementById('chatClose').onclick=toggleChat;
  318. document.getElementById('chatInput').addEventListener('keydown',e=>{if(e.key==='Enter')sendChat();});
  319. }
  320. function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
  321. function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
  322. function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
  323. function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
  324. let __ac=null;
  325. function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
  326. function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
  327. try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
  328. function sendChat(){const i=document.getElementById('chatInput');if(!i)return;const t=i.value.trim();if(!t)return;if(chatChannel&&chatChannel.readyState==='open'){chatChannel.send(JSON.stringify({name:(me&&(me.name||me.email))||'Support agent',text:t}));}addChat({from:'__self',name:'You',text:t});if(recogActive)addLine('agent',(me&&(me.name||me.email))||'Agent',t,true);i.value='';}
  329. function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
  330. async function setupPeer(){
  331. await ensureIce();
  332. pc=new RTCPeerConnection(ICE);
  333. inputChannel=pc.createDataChannel('input',{ordered:true});
  334. pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{const mm=JSON.parse(e.data);addChat(mm);if(recogActive)addLine('customer',mm.name||'Customer',mm.text,true);}catch(_){}}; } };
  335. pc.ontrack=(ev)=>{
  336. if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; return; }
  337. video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
  338. };
  339. pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
  340. pc.onconnectionstatechange=()=>{ if(!pc)return; const s=pc.connectionState;
  341. if(s==='failed'){ renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); }
  342. else if(s==='disconnected'){ clearTimeout(pc._dt); pc._dt=setTimeout(()=>{ if(pc&&(pc.connectionState==='disconnected'||pc.connectionState==='failed')) renderEnded('The connection was lost. Ask the customer to refresh their page for a new code.'); },8000); }
  343. else if(s==='connected'){ clearTimeout(pc._dt); } };
  344. }
  345. const send=(o)=>{if(inputChannel&&inputChannel.readyState==='open')inputChannel.send(JSON.stringify(o));};
  346. const rel=(e)=>{const r=video.getBoundingClientRect();return{x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height};};
  347. let lm=0;
  348. video.addEventListener('mousemove',e=>{const t=performance.now();if(t-lm<30)return;lm=t;send({kind:'mousemove',...rel(e)});});
  349. video.addEventListener('mousedown',e=>{video.focus();send({kind:'mousedown',button:e.button,...rel(e)});});
  350. video.addEventListener('mouseup',e=>send({kind:'mouseup',button:e.button,...rel(e)}));
  351. video.addEventListener('dblclick',e=>send({kind:'dblclick',...rel(e)}));
  352. video.addEventListener('wheel',e=>{e.preventDefault();send({kind:'scroll',dx:e.deltaX,dy:e.deltaY});},{passive:false});
  353. video.addEventListener('contextmenu',e=>e.preventDefault());
  354. video.addEventListener('keydown',e=>{e.preventDefault();send({kind:'keydown',key:e.key,code:e.code});});
  355. video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.key,code:e.code});});
  356. document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
  357. function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
  358. </script>
  359. </body>
  360. </html>