2026-06-05 17:29:09 +05:30
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > BizGaze Support — Agent Console</ title >
< style >
: root { --brand : #FFC708 ; --brand-d : #E0AC00 ; --blue : #1F3B73 ; --blue-d : #16294f ; --ink : #1f2430 ; --muted : #6b7280 ; --bg : #f6f8fb ; --card : #fff ; --line : #e6e9ef ; }
* { box-sizing : border-box ;}
body { font-family : 'Segoe UI' , system-ui , sans-serif ; background : var ( -- bg ); color : var ( -- ink ); margin : 0 ;}
. topbar { background : var ( -- blue ); padding : .7 rem 1.2 rem ; display : flex ; align-items : center ; justify-content : space-between ; gap : .6 rem ;}
. brandrow { display : flex ; align-items : center ; gap : .6 rem ;}
. logo { width : 28 px ; height : 28 px ; border-radius : 7 px ; background : var ( -- brand ); display : grid ; place-items : center ; font-weight : 800 ; color : var ( -- blue );}
. brand { font-weight : 700 ; color : #fff ;} . brand span { color : var ( -- brand ); font-weight : 600 ;}
. agentchip { color : #dbe4f5 ; font-size : .85 rem ;}
. agentchip b { color : #fff ;} . agentchip a { color : var ( -- brand ); text-decoration : none ; margin-left : .5 rem ; cursor : pointer ;}
. wrap { min-height : calc ( 100 vh - 50 px ); display : grid ; place-items : center ; padding : 1.5 rem ;}
. card { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 18 px ; padding : 2.2 rem ; max-width : 430 px ; width : 100 % ; box-shadow : 0 10 px 30 px rgba ( 20 , 30 , 60 , .06 );}
h1 { font-size : 1.35 rem ; margin : .1 rem 0 .3 rem ; color : var ( -- blue ); text-align : center ;}
. sub { color : var ( -- muted ); font-size : .92 rem ; margin-bottom : 1.3 rem ; text-align : center ;}
. lbl { display : block ; font-size : .74 rem ; color : var ( -- muted ); text-transform : uppercase ; letter-spacing : .06 em ; margin : .85 rem 0 .25 rem ;}
input { width : 100 % ; padding : .7 rem ; border-radius : 10 px ; border : 2 px solid var ( -- line ); background : #fbfcfe ; color : var ( -- ink ); font-size : 1 rem ;}
input : focus { outline : none ; border-color : var ( -- brand );}
input . code { font-size : 1.7 rem ; letter-spacing : .35 rem ; text-align : center ;}
. btn { margin-top : 1.1 rem ; width : 100 % ; padding : .85 rem ; background : var ( -- brand ); color : var ( -- ink ); border : none ; border-radius : 10 px ; font-weight : 700 ; font-size : 1.02 rem ; cursor : pointer ;}
. btn : hover { background : var ( -- brand - d );}
. status { color : var ( -- muted ); font-size : .88 rem ; margin-top : .9 rem ; text-align : center ; min-height : 1.1 em ;}
. err { color : #b91c1c ;}
. prefill { background : #EAF0FB ; border : 1 px solid #c7d6f0 ; border-radius : 8 px ; padding : .5 rem .7 rem ; font-size : .85 rem ; color : var ( -- blue - d ); margin-top : .25 rem ;}
. topbar2 { background : var ( -- card ); border-bottom : 1 px solid var ( -- line ); padding : .5 rem 1 rem ; display : none ; justify-content : space-between ; align-items : center ;}
. topbar2 . show { display : flex ;} # barStatus { font-weight : 600 ; font-size : .9 rem ; color : var ( -- blue );}
# endBtn { padding : .45 rem 1 rem ; background : #fee2e2 ; color : #b91c1c ; border : none ; border-radius : 8 px ; font-weight : 600 ; cursor : pointer ;}
# video { width : 100 vw ; height : calc ( 100 vh - 46 px ); background : #0b1220 ; object-fit : contain ; display : none ; cursor : crosshair ; outline : none ;}
2026-06-09 16:47:43 +05:30
. profile { position : relative }
. profile . pbtn { display : flex ; align-items : center ; gap : .4 rem ; background : rgba ( 255 , 255 , 255 , .14 ); color : #fff ; border : 1 px solid #46598c ; border-radius : 10 px ; padding : .45 rem .85 rem ; font-weight : 600 ; font-size : .88 rem ; cursor : pointer }
. profile . pbtn : hover { background : rgba ( 255 , 255 , 255 , .24 )}
. profile . pmenu { position : absolute ; right : 0 ; top : calc ( 100 % + 6 px ); background : #fff ; border : 1 px solid #e6e9ef ; border-radius : 10 px ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .18 ); min-width : 190 px ; overflow : hidden ; z-index : 5000 ; display : none }
. profile . pmenu . open { display : block }
. profile . pmenu a { display : block ; padding : .6 rem .9 rem ; color : #1f2430 ; text-decoration : none ; font-size : .9 rem ; cursor : pointer }
. profile . pmenu a : hover { background : #f1f5f9 }
. profile . pmenu a . danger { color : #b91c1c ; border-top : 1 px solid #eef1f6 }
2026-06-10 16:46:03 +05:30
. pwwrap { position : relative ;}
. pwwrap input { padding-right : 2.7 rem ;}
. eye { position : absolute ; right : .5 rem ; top : 50 % ; transform : translateY ( -50 % ); background : none ; border : none ; padding : .3 rem ; width : auto ; color : var ( -- muted ); display : inline-flex ; align-items : center ; cursor : pointer ; margin : 0 ;}
. eye : hover { color : var ( -- blue );}
2026-06-05 17:29:09 +05:30
</ style >
</ head >
< body >
< 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 >
</ div >
< div class = "topbar2" id = "bar" >< span id = "barStatus" ></ span >< button id = "endBtn" > End session</ button ></ div >
< div class = "wrap" id = "wrap" >< div class = "card" id = "card" ></ div ></ div >
< video id = "video" autoplay playsinline muted tabindex = "0" ></ video >
< script >
2026-06-09 16:47:43 +05:30
let ICE = { iceServers : [{ urls : 'stun:stun.l.google.com:19302' }]};
2026-06-10 16:46:03 +05:30
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 ( _ ){}
2026-06-10 15:47:02 +05:30
async function ensureIce (){ try { await __icePromise ;} catch ( _ ){} return ICE ;}
2026-06-09 16:47:43 +05:30
function pEsc ( s ){ return String ( s == null ? '' : s ). replace ( /[&<>"]/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ]));}
function profileHTML ( name ){ return '<div class="profile"><button class="pbtn" id="pbtn">' + pEsc ( name ) + ' <span style="font-size:.65rem">▾</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 ();
2026-06-05 17:29:09 +05:30
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' );
2026-06-09 16:47:43 +05:30
let ws , pc , inputChannel , chatChannel , sessionId , me = null ;
2026-06-05 17:29:09 +05:30
async function api ( path , body , method = 'POST' ){
const opt = { method , headers : { 'Content-Type' : 'application/json' }};
if ( body ) opt . body = JSON . stringify ( body );
const r = await fetch ( path , opt ); const data = await r . json (). catch (()=>({}));
if ( ! r . ok ) throw new Error ( data . error || 'request failed' ); return data ;
}
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 (); } }); }); }
// ---- boot: are we a logged-in agent? ----
( async function boot (){
try { me = await api ( '/api/me' , null , 'GET' ); renderAgent (); }
catch { renderLogin (); }
})();
// ---- LOGIN ----
2026-06-10 16:46:03 +05:30
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>' ;
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>' ;
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 ;};});}
2026-06-05 17:29:09 +05:30
function renderLogin (){
agentChip . textContent = '' ;
card . innerHTML = `
2026-06-10 16:46:03 +05:30
<h1>Sign in</h1>
2026-06-05 17:29:09 +05:30
<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">
2026-06-10 16:46:03 +05:30
<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>
2026-06-09 16:47:43 +05:30
<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>
2026-06-05 17:29:09 +05:30
<div class="status err" id="err"></div>` ;
{
const doSignIn = async ()=>{
try {
2026-06-09 16:47:43 +05:30
await api ( '/api/login' ,{ email : document . getElementById ( 'email' ). value , password : document . getElementById ( 'pw' ). value , remember : ( document . getElementById ( 'rememberMe' ) || {}). checked || false });
2026-06-05 17:29:09 +05:30
me = await api ( '/api/me' , null , 'GET' ); renderAgent ();
} catch ( e ){ document . getElementById ( 'err' ). textContent = e . message ; }
};
document . getElementById ( 'loginBtn' ). onclick = doSignIn ;
onEnter ([ 'email' , 'pw' ], doSignIn );
2026-06-10 16:46:03 +05:30
wireEyes ();
2026-06-05 17:29:09 +05:30
}
}
// ---- AGENT CONNECT ----
function renderAgent (){
const displayName = me . name || me . email ;
2026-06-09 16:47:43 +05:30
agentChip . innerHTML = profileHTML ( displayName ); wireProfile ();
2026-06-05 17:29:09 +05:30
card . innerHTML = `
<h1>Start a support session</h1>
<div class="sub">Ask the customer to open the share page and read you their code.</div>
<span class="lbl">Ticket number (optional)</span>
<input id="ticketInput" maxlength="40" placeholder="e.g. TKT-1042 — leave blank for a direct session" value=" ${ esc ( presetTicket ) } " ${ presetTicket ? 'readonly' : '' } >
${ presetTicket ? '<div class="prefill">Linked from service request ' + esc ( presetTicket ) + '</div>' : '' }
<span class="lbl">Session code from customer</span>
<input id="codeInput" class="code" maxlength="6" inputmode="numeric" placeholder="000000" value=" ${ esc ( presetCode ) } ">
<button class="btn" id="connectBtn">Connect</button>
<div class="status" id="status">Enter the customer's code to begin.</div>` ;
connectWS ();
document . getElementById ( 'connectBtn' ). onclick = startConnect ;
onEnter ([ 'ticketInput' , 'codeInput' ], startConnect );
}
async function startConnect (){
const statusEl = document . getElementById ( 'status' ); statusEl . className = 'status' ;
const ticket = document . getElementById ( 'ticketInput' ). value . trim ();
const code = document . getElementById ( 'codeInput' ). value . trim ();
if ( ! /^\d{6}$/ . test ( code )){ statusEl . textContent = 'Please enter the 6-digit code.' ; return ; }
statusEl . textContent = 'Connecting…' ;
ws . send ( JSON . stringify ({ type : 'code-connect' , code , ticket }));
}
function connectWS (){
ws = new WebSocket (( location . protocol === 'https:' ? 'wss://' : 'ws://' ) + location . host + '/ws' );
ws . onmessage = async ( e )=>{ const m = JSON . parse ( e . data ); const statusEl = document . getElementById ( 'status' ); switch ( m . type ){
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 ));
2026-06-09 16:47:43 +05:30
try { const mic = await navigator . mediaDevices . getUserMedia ({ audio : true }); window . __mic = mic ; mic . getAudioTracks (). forEach ( t => pc . addTrack ( t , mic )); } catch ( e ){}
2026-06-05 17:29:09 +05:30
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 ;
2026-06-10 16:46:03 +05:30
case 'transcript' : if ( recogActive && m . text ) addLine ( 'customer' , m . name || 'Customer' , m . text , !! m . chat ); break ;
2026-06-05 17:29:09 +05:30
case 'session-denied' : renderEnded ( 'The customer declined the request.' ); break ;
case 'session-ended' : {
const msgs = { 'share-cancelled' : 'The customer cancelled the screen selection. Ask them to refresh their page for a new code.' ,
'customer-ended' : 'The customer stopped sharing their screen. Ask them to refresh their page for a new code.' ,
'agent-ended' : 'You ended the session.' };
renderEnded ( msgs [ m . reason ] || 'The session has ended.' ); break ;
}
case 'error' : if ( statusEl ){ statusEl . className = 'status err' ; statusEl . textContent = m . message ;} break ;
}};
}
function renderWaiting (){
card . innerHTML = `
<h1>Waiting for the customer…</h1>
<div class="sub">Code accepted. The customer has been asked to tap <b>Allow</b> on their screen.</div>
<div class="status" id="status">Waiting for the customer to tap Allow…</div>
<button class="btn" id="cancelBtn" style="background:#eef1f6;color:#1F3B73">Cancel</button>` ;
document . getElementById ( 'cancelBtn' ). onclick = ()=>{ try { ws . send ( JSON . stringify ({ type : 'end-session' , sessionId }));} catch ( e ){} location . reload (); };
}
function renderEnded ( msg ){
2026-06-10 16:46:03 +05:30
try { stopRecording (); } catch ( _ ){}
2026-06-09 16:47:43 +05:30
removeSessionUI ();
2026-06-05 17:29:09 +05:30
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' ;
card . innerHTML = `
<h1>Session ended</h1>
<div class="sub"> ${ esc ( msg ) } </div>
<button class="btn" id="againBtn">Start a new session</button>` ;
document . getElementById ( 'againBtn' ). onclick = ()=> location . reload ();
}
2026-06-09 16:47:43 +05:30
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>' ;
2026-06-10 16:46:03 +05:30
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 ;
const SR = window . SpeechRecognition || window . webkitSpeechRecognition ;
let recog = null , recogActive = false , transcriptLines = [];
let recTimerInt = null , recStartTs = 0 ;
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' );}
function showRecTimer ( on ){
let c = document . getElementById ( 'recTimer' );
if ( on ){
if ( ! c ){ c = document . createElement ( 'div' ); c . id = 'recTimer' ;
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' ;
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>' ;
const bar = document . getElementById ( 'sessionBar' ), rb = document . getElementById ( 'recBtn' );
if ( bar && rb ){ bar . insertBefore ( c , rb ); } else if ( bar ){ bar . appendChild ( c ); }
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 );}
}
recStartTs = Date . now (); clearInterval ( recTimerInt );
const upd = ()=>{ const v = document . getElementById ( 'recTimerVal' ); if ( v ) v . textContent = fmtElapsedA ( Date . now () - recStartTs ); };
upd (); recTimerInt = setInterval ( upd , 1000 );
} else { clearInterval ( recTimerInt ); recTimerInt = null ; if ( c ) c . remove (); }
}
function addLine ( role , name , text , isChat ){ transcriptLines . push ({ t : Date . now (), role : role , name : name || '' , text : text , chat : !! isChat }); }
function startTranscription (){
transcriptLines = [];
if ( ! SR ) return ;
try {
recog = new SR (); recog . continuous = true ; recog . interimResults = false ; recog . lang = 'en-US' ;
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 ); } } };
recog . onerror = ()=>{};
recog . onend = ()=>{ if ( recogActive ){ try { recog . start ();} catch ( _ ){} } };
recogActive = true ; recog . start ();
} catch ( e ){}
}
function stopTranscription (){ recogActive = false ; if ( recog ){ try { recog . stop ();} catch ( _ ){} recog = null ; } }
function buildTranscriptText (){
const lines = transcriptLines . slice (). sort (( a , b )=> a . t - b . t );
const pad = ( n )=> String ( n ). padStart ( 2 , '0' );
const head = 'BizGaze Support — Session transcript\nSession: ' + sessionId + '\nGenerated: ' + new Date (). toLocaleString () + '\n' + ( '-' . repeat ( 48 )) + '\n' ;
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' );
return head + ( body || '(no speech captured)' ) + '\n' ;
}
async function uploadTranscript (){
if ( ! transcriptLines . length ) return ;
try { await fetch ( '/api/transcript?sessionId=' + encodeURIComponent ( sessionId ),{ method : 'POST' , headers : { 'Content-Type' : 'text/plain' }, body : buildTranscriptText ()}); } catch ( _ ){}
}
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' ;}
function startRecording (){
const remote = video . srcObject ;
const _vt = remote && remote . getVideoTracks && remote . getVideoTracks ()[ 0 ];
if ( ! _vt || _vt . readyState !== 'live' ){ alert ( 'No live screen to record. The customer may have disconnected.' ); return ; }
if ( pc && pc . connectionState && pc . connectionState !== 'connected' ){ alert ( 'Not connected to the customer right now.' ); return ; }
try {
recCtx = new ( window . AudioContext || window . webkitAudioContext )();
const dest = recCtx . createMediaStreamDestination ();
if ( remote . getAudioTracks (). length ){ try { recCtx . createMediaStreamSource ( new MediaStream ( remote . getAudioTracks ())). connect ( dest );} catch ( _ ){} }
if ( window . __mic && window . __mic . getAudioTracks (). length ){ try { recCtx . createMediaStreamSource ( new MediaStream ( window . __mic . getAudioTracks ())). connect ( dest );} catch ( _ ){} }
const mixed = new MediaStream ();
mixed . addTrack ( remote . getVideoTracks ()[ 0 ]);
dest . stream . getAudioTracks (). forEach ( t => mixed . addTrack ( t ));
let mime = 'video/webm;codecs=vp8,opus' ; if ( ! ( window . MediaRecorder && MediaRecorder . isTypeSupported ( mime ))) mime = 'video/webm' ;
recChunks = [];
mediaRecorder = new MediaRecorder ( mixed ,{ mimeType : mime });
mediaRecorder . ondataavailable = ( e )=>{ if ( e . data && e . data . size ) recChunks . push ( e . data ); };
mediaRecorder . onstop = async ()=>{
try { const blob = new Blob ( recChunks ,{ type : 'video/webm' }); if ( blob . size ) await fetch ( '/api/recording?sessionId=' + encodeURIComponent ( sessionId ),{ method : 'POST' , headers : { 'Content-Type' : 'video/webm' }, body : blob }); } catch ( _ ){}
try { recCtx && recCtx . close ();} catch ( _ ){} recCtx = null ;
};
mediaRecorder . start ( 1000 );
startTranscription ();
recBtnUpdate ( true );
showRecTimer ( true );
try { ws . send ( JSON . stringify ({ type : 'recording' , sessionId , on : true }));} catch ( _ ){}
} catch ( e ){ alert ( 'Recording could not start on this browser.' ); }
}
function stopRecording (){
if ( mediaRecorder && mediaRecorder . state !== 'inactive' ){ try { mediaRecorder . stop ();} catch ( _ ){} }
stopTranscription ();
uploadTranscript ();
recBtnUpdate ( false );
showRecTimer ( false );
try { ws . send ( JSON . stringify ({ type : 'recording' , sessionId , on : false }));} catch ( _ ){}
}
2026-06-09 16:47:43 +05:30
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' );
2026-06-10 16:46:03 +05:30
const rec = _btn ( 'recBtn' , SVG_REC , '' , '#0ea5e9' ); rec . title = 'Record' ; rec . querySelectorAll ( 'span' ). forEach (( s , i )=>{ if ( i > 0 ) s . remove (); });
2026-06-09 16:47:43 +05:30
const end = _btn ( 'endBtn2' , SVG_END , 'End' , '#dc2626' );
2026-06-10 16:46:03 +05:30
bar . appendChild ( mic ); bar . appendChild ( chat ); bar . appendChild ( rec ); bar . appendChild ( end );
2026-06-09 16:47:43 +05:30
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 ;
2026-06-10 16:46:03 +05:30
rec . onclick = ()=>{ if ( mediaRecorder && mediaRecorder . state === 'recording' ) stopRecording (); else startRecording (); };
2026-06-09 16:47:43 +05:30
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">✕</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 ( _ ){}
2026-06-10 16:46:03 +05:30
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 = '' ;}
2026-06-09 16:47:43 +05:30
function removeSessionUI (){[ 'sessionBar' , 'chatPanel' , 'remoteAudio' , 'muteBtn' , 'msgToast' ]. forEach ( id =>{ const e = document . getElementById ( id ); if ( e ) e . remove ();});}
2026-06-10 15:47:02 +05:30
async function setupPeer (){
await ensureIce ();
2026-06-09 16:47:43 +05:30
pc = new RTCPeerConnection ( ICE );
2026-06-05 17:29:09 +05:30
inputChannel = pc . createDataChannel ( 'input' ,{ ordered : true });
2026-06-10 16:46:03 +05:30
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 ( _ ){}}; } };
2026-06-05 17:29:09 +05:30
pc . ontrack = ( ev )=>{
2026-06-09 16:47:43 +05:30
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 ();
2026-06-05 17:29:09 +05:30
};
pc . onicecandidate = ( ev )=>{ if ( ev . candidate ) ws . send ( JSON . stringify ({ type : 'ice-candidate' , sessionId , candidate : ev . candidate }));};
2026-06-10 16:46:03 +05:30
pc . onconnectionstatechange = ()=>{ if ( ! pc ) return ; const s = pc . connectionState ;
if ( s === 'failed' ){ renderEnded ( 'The connection was lost. Ask the customer to refresh their page for a new code.' ); }
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 ); }
else if ( s === 'connected' ){ clearTimeout ( pc . _dt ); } };
2026-06-05 17:29:09 +05:30
}
const send = ( o )=>{ if ( inputChannel && inputChannel . readyState === 'open' ) inputChannel . send ( JSON . stringify ( o ));};
const rel = ( e )=>{ const r = video . getBoundingClientRect (); return { x : ( e . clientX - r . left ) / r . width , y : ( e . clientY - r . top ) / r . height };};
let lm = 0 ;
video . addEventListener ( 'mousemove' , e =>{ const t = performance . now (); if ( t - lm < 30 ) return ; lm = t ; send ({ kind : 'mousemove' ,... rel ( e )});});
video . addEventListener ( 'mousedown' , e =>{ video . focus (); send ({ kind : 'mousedown' , button : e . button ,... rel ( e )});});
video . addEventListener ( 'mouseup' , e => send ({ kind : 'mouseup' , button : e . button ,... rel ( e )}));
video . addEventListener ( 'dblclick' , e => send ({ kind : 'dblclick' ,... rel ( e )}));
video . addEventListener ( 'wheel' , e =>{ e . preventDefault (); send ({ kind : 'scroll' , dx : e . deltaX , dy : e . deltaY });},{ passive : false });
video . addEventListener ( 'contextmenu' , e => e . preventDefault ());
video . addEventListener ( 'keydown' , e =>{ e . preventDefault (); send ({ kind : 'keydown' , key : e . key , code : e . code });});
video . addEventListener ( 'keyup' , e =>{ e . preventDefault (); send ({ kind : 'keyup' , key : e . key , code : e . code });});
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 >
</ body >
</ html >