managed TURN (mobile/cellular fix) + UI bug fixes

- server: /api/ice endpoint reads TURN creds from env (TURN_URLS/USERNAME/CREDENTIAL)
- share/connect: load ICE config at page open
- fixes: stop icon, bright chat notification, beep audio-unlock,
  customer screen cleanup on session end, Home link, Remember-me on agent login, Time spent fixed from 90 seconds to actual time spent
This commit is contained in:
2026-06-09 16:47:43 +05:30
parent 0fef3275bf
commit 3560e1756e
6 changed files with 659 additions and 350 deletions
+71 -8
View File
@@ -31,6 +31,14 @@
.topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);}
#endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;}
#video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;}
.profile{position:relative}
.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}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.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}
.profile .pmenu.open{display:block}
.profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
</style>
</head>
<body>
@@ -43,13 +51,20 @@
<video id="video" autoplay playsinline muted tabindex="0"></video>
<script>
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
try{fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){}
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
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="/console">Console / Dashboard</a><a id="plogout" class="danger">Logout</a></div></div>';}
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='/';};}
function makeBrandClickable(){document.querySelectorAll('.brandrow,.wordmark').forEach(el=>{el.style.cursor='pointer';el.addEventListener('click',()=>{location.href='/';});});}
makeBrandClickable();
const params=new URLSearchParams(location.search);
const presetTicket=params.get('ticket')||'';
const presetCode=params.get('code')||'';
const card=document.getElementById('card'), wrap=document.getElementById('wrap'),
agentChip=document.getElementById('agentChip'), bar=document.getElementById('bar'),
topbar=document.getElementById('topbar'), video=document.getElementById('video'), barStatus=document.getElementById('barStatus');
let ws,pc,inputChannel,sessionId,me=null;
let ws,pc,inputChannel,chatChannel,sessionId,me=null;
async function api(path,body,method='POST'){
const opt={method,headers:{'Content-Type':'application/json'}};
@@ -73,12 +88,13 @@ function renderLogin(){
<div class="sub">Sign in to start a support session. Your name is shown to the customer.</div>
<span class="lbl">Email</span><input id="email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span><input id="pw" type="password" placeholder="password">
<button class="btn" id="loginBtn">Sign in</button>
<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>
<button class="btn" id="loginBtn" style="width:100%">Sign in</button>
<div class="status err" id="err"></div>`;
{
const doSignIn=async()=>{
try{
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value});
await api('/api/login',{email:document.getElementById('email').value,password:document.getElementById('pw').value,remember:(document.getElementById('rememberMe')||{}).checked||false});
me=await api('/api/me',null,'GET'); renderAgent();
}catch(e){ document.getElementById('err').textContent=e.message; }
};
@@ -90,8 +106,7 @@ function renderLogin(){
// ---- AGENT CONNECT ----
function renderAgent(){
const displayName = me.name || me.email;
agentChip.innerHTML = `Signed in as <b>${esc(displayName)}</b><a id="logout">Log out</a>`;
document.getElementById('logout').onclick=async()=>{ await api('/api/logout'); location.reload(); };
agentChip.innerHTML = profileHTML(displayName); wireProfile();
card.innerHTML = `
<h1>Start a support session</h1>
@@ -123,6 +138,7 @@ function connectWS(){
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){}
const ans=await pc.createAnswer(); await pc.setLocalDescription(ans);
ws.send(JSON.stringify({type:'answer',sessionId,sdp:pc.localDescription})); break;
case 'ice-candidate': if(m.candidate&&pc) await pc.addIceCandidate(new RTCIceCandidate(m.candidate)); break;
@@ -146,6 +162,7 @@ function renderWaiting(){
}
function renderEnded(msg){
removeSessionUI();
if(pc){ try{pc.close();}catch(e){} pc=null; }
video.style.display='none'; bar.classList.remove('show');
topbar.style.display='flex'; wrap.style.display='grid';
@@ -156,12 +173,58 @@ function renderEnded(msg){
document.getElementById('againBtn').onclick=()=>location.reload();
}
let chatOpen=false;
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>';
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>';
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>';
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>';
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;}
function buildBar(){
if(document.getElementById('sessionBar'))return;
const bar=document.createElement('div'); bar.id='sessionBar';
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)';
const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb');
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end);
document.body.appendChild(bar);
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';};
chat.onclick=toggleChat;
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
buildChatPanel();
document.addEventListener('pointerdown',ensureAudio,{once:true});
document.addEventListener('keydown',ensureAudio,{once:true});
if('Notification' in window && Notification.permission==='default'){ try{Notification.requestPermission();}catch(_){}}
}
function buildChatPanel(){
if(document.getElementById('chatPanel'))return;
const p=document.createElement('div'); p.id='chatPanel';
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';
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>';
document.body.appendChild(p);
document.getElementById('chatSend').onclick=sendChat;
document.getElementById('chatClose').onclick=toggleChat;
document.getElementById('chatInput').addEventListener('keydown',e=>{if(e.key==='Enter')sendChat();});
}
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);}}
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);}
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(_){}}}
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);}
let __ac=null;
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
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(_){}}
try{['pointerdown','keydown','touchstart','click'].forEach(ev=>document.addEventListener(ev,ensureAudio,{passive:true}));}catch(_){}
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});i.value='';}
function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','msgToast'].forEach(id=>{const e=document.getElementById(id);if(e)e.remove();});}
function setupPeer(){
pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
pc=new RTCPeerConnection(ICE);
inputChannel=pc.createDataChannel('input',{ordered:true});
pc.ondatachannel=(ev)=>{ if(ev.channel.label==='chat'){ chatChannel=ev.channel; chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; } };
pc.ontrack=(ev)=>{
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; bar.classList.add('show'); video.style.display='block';
barStatus.textContent='Connected — viewing the customers screen'; video.focus();
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; }
video.srcObject=ev.streams[0]; wrap.style.display='none'; topbar.style.display='none'; video.style.display='block'; video.focus(); buildBar();
};
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
}