BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,10 @@
|
||||
.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;}
|
||||
/* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full
|
||||
height/width otherwise (maximised view) and the icons never overlay the shared content. */
|
||||
body.has-bar #video{width:calc(100vw - 76px);}
|
||||
body.has-bar{background:#0b1220;}
|
||||
.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)}
|
||||
@@ -53,10 +57,11 @@
|
||||
html.embed #homeLink{display:none!important;}
|
||||
html.embed #video{height:100vh!important;}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||
<a href="/home" id="homeLink">← Home</a>
|
||||
<a href="/home" id="homeLink"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||
<div class="topbar" id="topbar">
|
||||
<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>
|
||||
<div class="agentchip" id="agentChip"></div>
|
||||
@@ -68,7 +73,7 @@
|
||||
<script>
|
||||
let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
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(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
@@ -163,7 +168,8 @@ 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){}
|
||||
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
|
||||
if(!window.__mic){ 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;
|
||||
@@ -191,6 +197,8 @@ function renderEnded(msg){
|
||||
bzcSession(false);
|
||||
try{ stopRecording(); }catch(_){}
|
||||
removeSessionUI();
|
||||
document.body.classList.remove('has-bar');
|
||||
if(window.__mic){ try{ window.__mic.getTracks().forEach(t=>t.stop()); }catch(_){} window.__mic=null; } // release mic so the tab's recording dot clears
|
||||
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';
|
||||
@@ -207,7 +215,7 @@ 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>';
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
|
||||
const SVG_REC='<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="7" fill="#ef4444"/></svg>';
|
||||
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>';
|
||||
let mediaRecorder=null, recChunks=[], recCtx=null;
|
||||
@@ -296,20 +304,21 @@ function stopRecording(){
|
||||
showRecTimer(false);
|
||||
try{ws.send(JSON.stringify({type:'recording',sessionId,on:false}));}catch(_){}
|
||||
}
|
||||
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 _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;}
|
||||
function buildBar(){
|
||||
if(document.getElementById('sessionBar'))return;
|
||||
{ const hl=document.getElementById('homeLink'); if(hl) hl.style.display='none'; }
|
||||
bzcSession(true);
|
||||
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)';
|
||||
bar.style.cssText='position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:2147483000;display:flex;flex-direction:column;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:12px 8px;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 rec=_btn('recBtn',SVG_REC,'','#0ea5e9'); rec.title='Record'; rec.querySelectorAll('span').forEach((s,i)=>{ if(i>0) s.remove(); });
|
||||
const end=_btn('endBtn2',SVG_END,'End','#dc2626');
|
||||
bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(rec);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';};
|
||||
document.body.classList.add('has-bar'); // reserve space so the bar never overlays the shared screen
|
||||
mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.title=t.enabled?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(t.enabled?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=t.enabled?'#2563eb':'#6b7280';};
|
||||
chat.onclick=toggleChat;
|
||||
rec.onclick=()=>{ if(mediaRecorder&&mediaRecorder.state==='recording') stopRecording(); else startRecording(); };
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));}catch(_){} };
|
||||
@@ -321,7 +330,7 @@ function buildBar(){
|
||||
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.style.cssText='position:fixed;right:88px;bottom:18px;width:300px;max-width:80vw;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">✕</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;
|
||||
@@ -368,5 +377,6 @@ video.addEventListener('keyup',e=>{e.preventDefault();send({kind:'keyup',key:e.k
|
||||
document.getElementById('endBtn').onclick=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'agent-ended'}));};
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);}
|
||||
.pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;}
|
||||
.pill.on{background:#ecfdf3;color:#15803d;}
|
||||
.pill.off{background:#fee2e2;color:var(--red);}
|
||||
.reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;}
|
||||
.reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;}
|
||||
.chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;}
|
||||
.chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);}
|
||||
.hidden{display:none;}
|
||||
.tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;}
|
||||
.tabs button{background:#eef1f6;color:var(--muted);font-weight:600;}
|
||||
@@ -64,7 +69,9 @@
|
||||
.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}
|
||||
.ic{display:inline-block;vertical-align:middle}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -188,21 +195,109 @@ async function dashboard(me) {
|
||||
<div class="f"><span class="lbl">From</span><input id="fFrom" type="date"></div>
|
||||
<div class="f"><span class="lbl">To</span><input id="fTo" type="date"></div>
|
||||
<button id="fApply">Apply</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">⬇ Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">⬇ PDF</button>
|
||||
<button id="fExcel" class="mini" style="padding:.6rem .9rem">${ic('download',15)} Excel</button>
|
||||
<button id="fPdf" class="mini" style="padding:.6rem .9rem">${ic('download',15)} PDF</button>
|
||||
</div>
|
||||
${IS_ADMIN ? '<input id="repSearch" class="srch" placeholder="Search by agent or ticket">' : ''}
|
||||
<table id="report"><thead><tr><th>Date</th><th>Start time</th>${IS_ADMIN ? '<th>Agent</th>' : ''}<th>Ticket</th><th>Time spent</th><th>Recording / Transcript</th></tr></thead><tbody></tbody></table>
|
||||
<div id="repPager" class="pager"></div>
|
||||
<p id="repSummary" class="muted" style="margin-top:.6rem"></p>
|
||||
</div>`);
|
||||
</div>
|
||||
${IS_ADMIN ? `
|
||||
<div class="card" id="keysCard">
|
||||
<h2>API keys <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— let other systems read your data programmatically</span></h2>
|
||||
<table id="keys"><thead><tr><th>Name</th><th>Scopes</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||
<div><span class="lbl">Name</span><input id="kName" placeholder="e.g. Partner X" style="max-width:200px"></div>
|
||||
<label class="chk"><input type="checkbox" id="kReport" checked> report:read</label>
|
||||
<label class="chk"><input type="checkbox" id="kAudit"> audit:read</label>
|
||||
<button id="kAdd">Generate key</button>
|
||||
</div>
|
||||
<div id="kOut"></div>
|
||||
</div>
|
||||
<div class="card" id="hooksCard">
|
||||
<h2>Webhooks <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— signed event callbacks to your systems</span></h2>
|
||||
<table id="hooks"><thead><tr><th>Endpoint</th><th>Events</th><th>Status</th><th>Last delivery</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<div class="row" style="margin-top:1rem;flex-wrap:wrap;align-items:flex-end;gap:1rem">
|
||||
<div style="flex:1;min-width:240px"><span class="lbl">Endpoint URL</span><input id="hUrl" placeholder="https://your-system.example.com/webhook"></div>
|
||||
<label class="chk"><input type="checkbox" id="hStarted" checked> session.started</label>
|
||||
<label class="chk"><input type="checkbox" id="hEnded" checked> session.ended</label>
|
||||
<button id="hAdd">Add webhook</button>
|
||||
</div>
|
||||
<div id="hOut"></div>
|
||||
</div>` : ''}`);
|
||||
document.getElementById('fApply').onclick = loadReport;
|
||||
document.getElementById('fExcel').onclick = exportExcel;
|
||||
document.getElementById('fPdf').onclick = exportPdf;
|
||||
if (IS_ADMIN) await populateAgentFilter();
|
||||
await loadReport();
|
||||
if (IS_ADMIN) {
|
||||
document.getElementById('kAdd').onclick = createKey;
|
||||
document.getElementById('hAdd').onclick = createHook;
|
||||
await loadKeys();
|
||||
await loadHooks();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Integrations: API keys + webhooks (admin) ----------
|
||||
function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; }
|
||||
function revealBox(label, value, note){
|
||||
return '<div class="reveal"><div class="lbl" style="margin:0 0 .3rem">'+esc(label)+' — copy now</div>'
|
||||
+ '<div style="display:flex;gap:.5rem;align-items:center"><code id="revealVal">'+esc(value)+'</code>'
|
||||
+ '<button class="mini" id="copyReveal">Copy</button></div>'
|
||||
+ '<div class="muted" style="margin-top:.4rem;font-size:.78rem">'+esc(note)+'</div></div>';
|
||||
}
|
||||
function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; }
|
||||
|
||||
async function loadKeys(){
|
||||
let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; }
|
||||
document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>`
|
||||
<tr style="${k.revoked?'opacity:.5':''}">
|
||||
<td>${esc(k.name||'—')}</td>
|
||||
<td class="muted">${esc(k.scopes||'')}</td>
|
||||
<td>${fmtTs(k.created_at)}</td>
|
||||
<td>${fmtTs(k.last_used_at)}</td>
|
||||
<td>${k.revoked?'<span class="pill off">revoked</span>':'<span class="pill on">active</span>'}</td>
|
||||
<td>${k.revoked?'':`<button class="mini danger" onclick="revokeKey('${k.id}')">Revoke</button>`}</td>
|
||||
</tr>`).join('') : '<tr><td colspan=6 class="muted">No API keys yet.</td></tr>';
|
||||
}
|
||||
async function createKey(){
|
||||
const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read');
|
||||
if(!scopes.length){ document.getElementById('kOut').innerHTML='<p class="muted">Select at least one scope.</p>'; return; }
|
||||
try{
|
||||
const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST');
|
||||
document.getElementById('kName').value='';
|
||||
document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost.");
|
||||
wireCopy(); loadKeys();
|
||||
}catch(e){ document.getElementById('kOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||
}
|
||||
window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} };
|
||||
|
||||
async function loadHooks(){
|
||||
let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; }
|
||||
document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>`
|
||||
<tr style="${h.active?'':'opacity:.5'}">
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis" class="muted">${esc(h.url)}</td>
|
||||
<td class="muted">${esc(h.events||'')}</td>
|
||||
<td>${h.last_status==null?'<span class="muted">—</span>':(h.last_status?'<span class="pill on">ok</span>':'<span class="pill off">failing</span>')}</td>
|
||||
<td>${fmtTs(h.last_at)}${h.last_error?' <span class="muted" title="'+esc(h.last_error)+'">'+ic('alertTriangle',13)+'</span>':''}</td>
|
||||
<td><button class="mini danger" onclick="deleteHook('${h.id}')">Delete</button></td>
|
||||
</tr>`).join('') : '<tr><td colspan=5 class="muted">No webhooks yet.</td></tr>';
|
||||
}
|
||||
async function createHook(){
|
||||
const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended');
|
||||
const url=document.getElementById('hUrl').value.trim();
|
||||
if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='<p class="muted">Enter a valid http(s) URL.</p>'; return; }
|
||||
if(!events.length){ document.getElementById('hOut').innerHTML='<p class="muted">Select at least one event.</p>'; return; }
|
||||
try{
|
||||
const r = await api('/api/webhooks', { url, events }, 'POST');
|
||||
document.getElementById('hUrl').value='';
|
||||
document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.');
|
||||
wireCopy(); loadHooks();
|
||||
}catch(e){ document.getElementById('hOut').innerHTML='<p class="muted">'+esc(e.message)+'</p>'; }
|
||||
}
|
||||
window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} };
|
||||
|
||||
const PER_PAGE = 5;
|
||||
function pagerHTML(page, pages, total, fn){
|
||||
if (total <= PER_PAGE) return total ? `<span>${total} total</span>` : '';
|
||||
@@ -241,8 +336,8 @@ function reportRowHTML(r){
|
||||
<td>${esc(r.ticket || 'Direct session')}</td>
|
||||
<td>${r.ended_at ? fmtDuration(dur) : '<span class="pill on">in progress</span>'}</td>
|
||||
<td>${[
|
||||
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>⬇ Video</a>` : '',
|
||||
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>⬇ Text</a>` : ''
|
||||
r.recording ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/recordings/${esc(r.recording)}" download>${ic('download',14)} Video</a>` : '',
|
||||
r.transcript ? `<a class="mini" style="text-decoration:none;display:inline-block;padding:.32rem .6rem;margin:1px" href="/transcripts/${esc(r.transcript)}" download>${ic('download',14)} Text</a>` : ''
|
||||
].join('') || '<span class="muted">—</span>'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
+1920
-100
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,11 @@
|
||||
.indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; }
|
||||
.indicator.show { display:block; }
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🖥️ Browser Host (no install)</h1>
|
||||
<h1><span data-ic="monitor" data-sz="22"></span> Browser Host (no install)</h1>
|
||||
<p class="muted">Shares this screen with a technician. Paste the enroll token from the console and click Go online.</p>
|
||||
<input id="token" placeholder="enroll token">
|
||||
<button id="goBtn">Go online</button>
|
||||
@@ -98,5 +99,6 @@ function teardown() {
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
.inner{max-width:780px;width:100%;text-align:center;}
|
||||
h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;}
|
||||
.sub{color:var(--muted);margin-bottom:2.2rem;}
|
||||
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;}
|
||||
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);}
|
||||
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
|
||||
.ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;}
|
||||
.ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);}
|
||||
.ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;}
|
||||
.divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;}
|
||||
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);}
|
||||
.choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;}
|
||||
@@ -41,6 +41,7 @@
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -63,7 +64,7 @@
|
||||
<div><h3>Share my screen</h3><p>Get a one-time code and show your screen to a BizGaze support agent — no login, no download.</p></div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="foot">🔒 Screen sharing only starts after you approve it, and can be stopped anytime.</div>
|
||||
<div class="foot"><span data-ic="lock" data-sz="14"></span> Screen sharing only starts after you approve it, and can be stopped anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer>© BizGaze · Remote Support</footer>
|
||||
@@ -81,5 +82,6 @@ makeBrandClickable();
|
||||
const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen';
|
||||
}}catch(_){}})();
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+32
-16
@@ -20,7 +20,7 @@
|
||||
.sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;}
|
||||
.codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;}
|
||||
.codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;}
|
||||
.code{font-size:clamp(1.9rem,12vw,3rem);letter-spacing:clamp(.18rem,3vw,.5rem);font-weight:800;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:clip;}
|
||||
.code{font-size:clamp(1.6rem,9vw,2.6rem);letter-spacing:clamp(.1rem,2vw,.4rem);padding-left:clamp(.1rem,2vw,.4rem);font-weight:800;color:var(--ink);white-space:nowrap;}
|
||||
.status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;}
|
||||
.status.on{background:#ecfdf3;color:#15803d;}
|
||||
.consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);}
|
||||
@@ -46,11 +46,12 @@
|
||||
.profile .pmenu a:hover{background:#f1f5f9}
|
||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>if(new URLSearchParams(location.search).get('embed')==='1')document.documentElement.classList.add('embed');</script>
|
||||
<div class="indicator" id="indicator">● Your screen is being shared — close this tab anytime to stop</div>
|
||||
<a href="/" id="homeLink" style="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)">← Home</a>
|
||||
<a href="/" id="homeLink" style="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)"><span data-ic="arrowLeft" data-sz="16"></span> Home</a>
|
||||
<div class="stage">
|
||||
<div class="brandpanel">
|
||||
<img src="/logo.png" alt="" style="width:88px;height:88px;border-radius:22px;object-fit:contain;margin-bottom:1.2rem;background:#fff;padding:8px;box-shadow:0 12px 30px rgba(0,0,0,.25)" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'mark',textContent:'B'}))">
|
||||
@@ -65,12 +66,12 @@
|
||||
<div class="codelabel">Your session code</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:.7rem">
|
||||
<div class="code" id="code">······</div>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:38px;height:38px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button id="copyBtn" title="Click to copy" aria-label="Click to copy" style="flex:0 0 auto;width:28px;height:28px;padding:0;background:#fff;border:1px solid var(--brand);color:var(--blue);border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center"><svg id="copyIcon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status" class="status">Preparing your code…</div>
|
||||
<div id="consentBox"></div>
|
||||
<div class="foot">🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
<div class="foot"><span data-ic="lock" data-sz="14"></span> You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +80,7 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]};
|
||||
let SHARER_NAME='Customer';
|
||||
try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){}
|
||||
const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||'');
|
||||
let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){}
|
||||
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(_){}
|
||||
async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;}
|
||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
// When embedded in the home shell, tell the parent when a session is live so the
|
||||
@@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{
|
||||
try{ await navigator.clipboard.writeText(code); }
|
||||
catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); }
|
||||
const b=document.getElementById('copyBtn'); const old=b.innerHTML;
|
||||
b.innerHTML='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
b.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
setTimeout(()=>{b.innerHTML=old;},1500);
|
||||
};
|
||||
let ws,pc,localStream,chatChannel,sessionId;
|
||||
@@ -144,7 +145,9 @@ function showConsent(m){
|
||||
async function beginCapture(){
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ return false; }
|
||||
try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); }catch(e){}
|
||||
// Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the
|
||||
// mic at once confused customers and silently cancelled the share. The mic permission
|
||||
// is requested only when the customer taps Unmute (see the bar's mic button).
|
||||
try{ ensureIce(); }catch(_){}
|
||||
return true;
|
||||
}
|
||||
@@ -152,11 +155,10 @@ async function startStreaming(){
|
||||
// If the Allow tap already captured the screen (mobile path), reuse it.
|
||||
if(!localStream){
|
||||
await ensureIce();
|
||||
let mic=null; try{ mic=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ mic=null; }
|
||||
setStatus('In the popup: choose your screen, then tap Share / Start.','on');
|
||||
// Screen only — mic stays off until the customer taps Unmute (avoids the dual prompt).
|
||||
try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); }
|
||||
catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
|
||||
if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} }
|
||||
catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; }
|
||||
}
|
||||
await ensureIce();
|
||||
indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true);
|
||||
@@ -168,11 +170,14 @@ async function startStreaming(){
|
||||
pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};};
|
||||
pc.ontrack=(ev)=>{ 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]; } };
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } };
|
||||
pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } };
|
||||
chatChannel=pc.createDataChannel('chat',{ordered:true});
|
||||
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
|
||||
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
|
||||
// Watchdog: clear failure message instead of a blank screen if no path establishes.
|
||||
clearTimeout(window.__connWatch);
|
||||
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
|
||||
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
|
||||
}
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@@ -221,18 +226,28 @@ 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;}
|
||||
const SVG_END='<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g></svg>';
|
||||
function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML='<span style="display:inline-flex">'+svg+'</span>';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';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;right:18px;bottom:18px;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 mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280');
|
||||
const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569');
|
||||
const end=_btn('endBtn2',SVG_END,'Stop','#dc2626');
|
||||
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';};
|
||||
const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML='<span style="display:inline-flex">'+(on?SVG_MIC:SVG_MICOFF)+'</span>';mic.style.background=on?'#2563eb':'#6b7280';};
|
||||
mic.onclick=async()=>{
|
||||
if(!window.__mic){
|
||||
// First unmute: NOW request mic permission, add the track, and renegotiate.
|
||||
let m; try{ m=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ setStatus('Microphone permission was blocked. Allow it in the browser to talk.'); return; }
|
||||
window.__mic=m; const t=m.getAudioTracks()[0];
|
||||
try{ localStream.addTrack(t); if(pc){ pc.addTrack(t,localStream); const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); } }catch(_){}
|
||||
setMic(true); return;
|
||||
}
|
||||
const t=window.__mic.getAudioTracks()[0]; if(!t) return; t.enabled=!t.enabled; setMic(t.enabled);
|
||||
};
|
||||
chat.onclick=toggleChat;
|
||||
end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); };
|
||||
buildChatPanel();
|
||||
@@ -263,5 +278,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
|
||||
a { color: #3b82f6; }
|
||||
</style>
|
||||
<script src="/icons.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div id="status">Connecting…</div>
|
||||
<div>
|
||||
<a href="/">← Console</a>
|
||||
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
|
||||
<button id="endBtn">End session</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => {
|
||||
setTimeout(() => (location.href = '/'), 300);
|
||||
};
|
||||
</script>
|
||||
<script>(function(){var s=document.createElement('style');s.textContent='.ic{display:inline-block;vertical-align:middle}';document.head.appendChild(s);document.querySelectorAll('[data-ic]').forEach(function(e){e.insertAdjacentHTML('afterbegin',window.ic(e.getAttribute('data-ic'),+e.getAttribute('data-sz')||16));});})();</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user