first commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<!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:.7rem 1.2rem;display:flex;align-items:center;justify-content:space-between;gap:.6rem;}
|
||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||
.logo{width:28px;height:28px;border-radius:7px;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:.85rem;}
|
||||
.agentchip b{color:#fff;} .agentchip a{color:var(--brand);text-decoration:none;margin-left:.5rem;cursor:pointer;}
|
||||
.wrap{min-height:calc(100vh - 50px);display:grid;place-items:center;padding:1.5rem;}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2.2rem;max-width:430px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.06);}
|
||||
h1{font-size:1.35rem;margin:.1rem 0 .3rem;color:var(--blue);text-align:center;}
|
||||
.sub{color:var(--muted);font-size:.92rem;margin-bottom:1.3rem;text-align:center;}
|
||||
.lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.85rem 0 .25rem;}
|
||||
input{width:100%;padding:.7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:1rem;}
|
||||
input:focus{outline:none;border-color:var(--brand);}
|
||||
input.code{font-size:1.7rem;letter-spacing:.35rem;text-align:center;}
|
||||
.btn{margin-top:1.1rem;width:100%;padding:.85rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;font-size:1.02rem;cursor:pointer;}
|
||||
.btn:hover{background:var(--brand-d);}
|
||||
.status{color:var(--muted);font-size:.88rem;margin-top:.9rem;text-align:center;min-height:1.1em;}
|
||||
.err{color:#b91c1c;}
|
||||
.prefill{background:#EAF0FB;border:1px solid #c7d6f0;border-radius:8px;padding:.5rem .7rem;font-size:.85rem;color:var(--blue-d);margin-top:.25rem;}
|
||||
.topbar2{background:var(--card);border-bottom:1px solid var(--line);padding:.5rem 1rem;display:none;justify-content:space-between;align-items:center;}
|
||||
.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;}
|
||||
</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>
|
||||
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;
|
||||
|
||||
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 ----
|
||||
function renderLogin(){
|
||||
agentChip.textContent='';
|
||||
card.innerHTML = `
|
||||
<h1>Agent sign in</h1>
|
||||
<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>
|
||||
<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});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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(); };
|
||||
|
||||
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));
|
||||
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;
|
||||
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){
|
||||
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();
|
||||
}
|
||||
|
||||
function setupPeer(){
|
||||
pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
||||
inputChannel=pc.createDataChannel('input',{ordered:true});
|
||||
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 customer’s screen'; video.focus();
|
||||
};
|
||||
pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));};
|
||||
}
|
||||
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>
|
||||
Reference in New Issue
Block a user