feat: live presence, delivery-tick sync, brand rollout, call fixes, desktop 0.1.3

Live presence (fixes stale in-call/status until refresh — impossible in apps):
- server broadcasts a user's status over the chat socket on connect/disconnect, call
  join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks).
- client onPresence() updates the contact dot + open-chat header live.

Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey,
read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered.

Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced);
branded call-connecting + chat-thread loaders; header subtitle tracks live call state.

Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark);
desktop shell drops Web Push so only the single native toast fires (#5).

Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico),
branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash.

Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size
icon; dropped unused node-notifier; removed home-mockup.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:43:02 +05:30
parent a152005b71
commit dc1915bb43
26 changed files with 411 additions and 338 deletions
+122
View File
@@ -0,0 +1,122 @@
# TASK FOR CLAUDE (VS Code): Apply the Biz Connect brand — icons, splash, loaders, toasts
Read this whole file, then execute it. Work CONSERVATIVELY — only ADD / restyle, do NOT
refactor unrelated code. The app is live in production. Find the right spots in the CURRENT
code yourself (don't assume old names). Summarise all changes and STOP for my review before
committing anything.
Repo root: C:\BizGaze_Support\remote-access-app
Master brand assets are already placed in: brand-assets\
- app-icon-1024.png (1024 full-bleed app icon master)
- splash-2732-dark.png (2732 splash, brand blue — primary)
- splash-2732-light.png (2732 splash, white — optional light theme)
- loader-ring.svg (default spinner, light backgrounds)
- loader-orbit.svg (branded spinner, light backgrounds)
- loader-orbit-dark.svg (branded spinner, dark/overlay backgrounds)
Brand colours: blue #1F3B73, blue-dark #16294F, yellow #FFC708, gold #E0AC00.
Note: brand-assets/ is only the SOURCE. Put the GENERATED / used copies where each target
needs them — web assets under server/public/ (served over HTTP); native icons/splash into the
platform build folders (Electron / Android / iOS) as applicable.
## 1. App icons (from brand-assets/app-icon-1024.png)
Generate and place:
- Windows .ico (multi-size 16/32/48/256) — fixes the tiny/blurry taskbar icon.
- Favicon: server/public/favicon.ico + a 32px PNG; add to each page <head>:
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png">
- PWA icons 192, 512, and a maskable 512; reference them in the web manifest, and set the
manifest "background_color" and "theme_color" to "#1F3B73".
- Native mobile icons (Android mipmap set + adaptive, iOS AppIcon set) into their folders.
## 2. Splash (from brand-assets/splash-2732-dark.png)
- Web/PWA loading + Electron splash: brand-blue background #1F3B73 with the logo centred
(use the 2732 image, or reuse the app's splash.html if present).
- Native launch screens: use the 2732 image (Android 12 SplashScreen background #1F3B73 + the
app icon; iOS LaunchScreen background #1F3B73 + centred logo). Keep native launch STATIC.
- Use splash-2732-light.png only if a light theme is supported.
## 3. Loaders (from brand-assets/loader-*.svg)
Copy the three SVGs into server/public/ (e.g. server/public/loaders/). Wire them:
- Everyday "Loading…" and in-app spinners -> loader-ring.svg
- The hero "Connecting…" moment (session connect) -> loader-orbit.svg (light) / loader-orbit-dark.svg (on dark)
Show one centered in the middle of the screen/panel while loading; hide when done. Always pair
show with hide on BOTH the success and error paths so it can never get stuck.
## 4. Branded notification toasts
Create server/public/bizconnect-toast.css with EXACTLY:
```css
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
.bzt.bzt-in{transform:translateX(0);opacity:1}
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
.bzt-badge svg{width:20px;height:20px}
.bzt-body{flex:1;min-width:0;padding-top:1px}
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
.bzt-x:hover{color:#1f2430}
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
```
Create server/public/bizconnect-toast.js with EXACTLY:
```js
/* Biz Connect toast API. Requires bizconnect-toast.css.
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
window.BZToast=(function(){
var wrap=null;
var ICON={
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.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>',
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
};
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
function show(message,opts){
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
var t=document.createElement('div'); t.className='bzt '+type;
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">&times;</button>';
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
var dur=(opts.duration==null?4000:opts.duration), timer;
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
}
return { show:show,
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
})();
```
Then, on every page that shows notifications (connect, share, home, dashboard, console),
include both files near the top BEFORE the page's own inline <script>:
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
(Load the JS via src only — never inline.)
Now REPLACE the existing toast / notification pop-ups with the branded API (keep the same
triggers and text, just route them through BZToast so they look on-brand):
- Incoming chat message -> BZToast.message(text, {title: senderName})
- A success (e.g. recording/transcript saved, agent added) -> BZToast.success('…')
- An error (connection lost, upload failed, invalid code) -> BZToast.error('…')
- Neutral info -> BZToast.info('…')
Remove the old inline toast markup/styles it replaces. Toasts appear top-right and auto-dismiss.
## 5. Verify (do not commit until I review)
- node --check any JS you extract/touch; confirm every page still loads.
- Confirm: taskbar/favicon icon is crisp, splash shows on launch, a loader appears centered
during connect/report, and notifications now use the branded toast.
- List exactly which files changed, then STOP for my review.
+3 -2
View File
@@ -22,8 +22,9 @@ Output in `desktop/dist/`:
- `latest.yml` — the update manifest electron-updater reads
- `*.blockmap` — enables delta downloads
The release build points at production (`SERVER_URL` defaults to `https://remote.bizgaze.com`
in `main.js`).
A PACKAGED build points at production (`main.js` defaults `SERVER_URL` to
`https://remote.bizgaze.com` when `app.isPackaged`); running from source in dev defaults to
`http://localhost:8090`. `SERVER_URL` overrides either.
## Publish a release (self-hosted feed)
1. Bump `version` in `desktop/package.json` (semver — electron-updater compares this).
+6 -2
View File
@@ -3,11 +3,15 @@
Electron shell that loads the live Connect web UI and adds native screen capture. See the
overall plan in [../CLIENTS.md](../CLIENTS.md).
## Run (dev)
## Run (dev = local testing)
```bash
npm install
SERVER_URL=http://localhost:8090 npm start # default is https://remote.bizgaze.com
npm start # dev auto-targets http://localhost:8090 (your local server)
SERVER_URL=https://remote.bizgaze.com npm start # …or point dev at production to compare
```
`npm start` IS the local desktop test — no separate "local" installer needed. In dev (unpackaged)
the shell defaults to the local server; a PACKAGED installer defaults to production. `SERVER_URL`
overrides either. (Bash/Git-Bash syntax above; in PowerShell: `$env:SERVER_URL='…'; npm start`.)
## Build installers
```bash
+27 -2
View File
@@ -97,9 +97,26 @@ ipcMain.on('set-unread', (_e, { count, dataUrl } = {}) => {
} catch (_) {}
});
const SERVER_URL = (process.env.SERVER_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
// Server origin: a PACKAGED build (the installer) points at production; running from source in dev
// (`npm start`, unpackaged) defaults to the local server so you can test the shell against localhost
// with no flags or separate "local" build. SERVER_URL always overrides (e.g. point dev at prod).
const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote.bizgaze.com' : 'http://localhost:8090')).replace(/\/+$/, '');
let win;
let splash;
// A tiny brand-blue splash (splash.html) shown while the web UI loads, so launch feels instant
// and on-brand instead of a blank window. Closed as soon as the main window is ready to show.
function createSplash() {
splash = new BrowserWindow({
width: 440, height: 440, frame: false, resizable: false, center: true,
backgroundColor: '#1F3B73', skipTaskbar: true, alwaysOnTop: true, show: true,
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
splash.loadFile(path.join(__dirname, 'splash.html'));
splash.on('closed', () => { splash = null; });
}
function closeSplash() { if (splash) { try { splash.close(); } catch (_) {} splash = null; } }
function createWindow() {
win = new BrowserWindow({
@@ -108,7 +125,8 @@ function createWindow() {
minWidth: 880,
minHeight: 600,
title: 'Biz Connect',
backgroundColor: '#0f1830',
backgroundColor: '#1F3B73',
show: false, // reveal only once the page is ready — the splash covers the gap
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -118,6 +136,12 @@ function createWindow() {
},
});
// Reveal the main window when its first paint is ready, and retire the splash. A fallback
// timer guarantees we never get stuck on the splash if the load stalls.
const reveal = () => { closeSplash(); if (win && !win.isVisible()) { win.show(); win.focus(); } };
win.once('ready-to-show', reveal);
setTimeout(reveal, 12000);
// Open the landing page (same entry as the website): the "before login" screen with the
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
win.loadURL(SERVER_URL + '/');
@@ -158,6 +182,7 @@ function configureSession() {
app.whenReady().then(() => {
configureSession();
createSplash();
createWindow();
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
+2 -6
View File
@@ -1,6 +1,6 @@
{
"name": "biz-connect-desktop",
"version": "0.1.2",
"version": "0.1.3",
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
"author": {
"name": "BizGaze",
@@ -12,8 +12,7 @@
"dist": "electron-builder"
},
"dependencies": {
"electron-updater": "^6.3.9",
"node-notifier": "^10.0.1"
"electron-updater": "^6.3.9"
},
"devDependencies": {
"electron": "^31.0.0",
@@ -26,9 +25,6 @@
"buildResources": "build",
"output": "dist"
},
"asarUnpack": [
"**/node_modules/node-notifier/**"
],
"publish": [
{
"provider": "generic",
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
html,body{margin:0;height:100%;overflow:hidden;}
body{background:#1F3B73;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;
font-family:'Segoe UI',system-ui,sans-serif;-webkit-user-select:none;user-select:none;}
/* Branded orbit mark (white C + circling yellow dot) — matches the app icon/loader */
.mark{width:96px;height:96px;}
.mark .dot{transform-origin:50px 50px;animation:spin 1.1s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.name{color:#fff;font-size:20px;font-weight:700;letter-spacing:.3px;}
.name b{color:#FFC708;font-weight:700;}
.sub{color:rgba(255,255,255,.66);font-size:12.5px;margin-top:-14px;}
</style>
</head>
<body>
<svg class="mark" viewBox="0 0 100 100" aria-label="Biz Connect">
<circle cx="50" cy="50" r="34" fill="none" stroke="#ffffff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
<g class="dot"><circle cx="84" cy="50" r="7" fill="#FFC708"/></g>
</svg>
<div class="name">Biz <b>Connect</b></div>
<div class="sub">Starting…</div>
</body>
</html>
+26 -2
View File
@@ -1,7 +1,8 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
const { chatClients } = require('./presence');
const { chatClients, meetingRooms } = require('./presence');
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
@@ -28,4 +29,27 @@ function pushToUser(userId, obj) {
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
module.exports = { register, unregister, isOnline, pushToUser };
// --- Live presence -------------------------------------------------------------------------
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip:
// they connect or disconnect a socket, or join/leave a call. Without pushing that change, OTHER
// users only see it after a full page reload — impossible in the desktop/mobile apps. So whenever
// it changes we broadcast the user's fresh status to everyone else's sockets, and the client
// updates that contact's dot/subtitle in place.
function isInCall(userId) {
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
return false;
}
function effectiveStatus(userId) {
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
try { const u = repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
}
function broadcastPresence(userId) {
if (!userId) return;
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId) });
for (const [uid, set] of chatClients) {
if (uid === userId) continue; // no need to tell someone about their own status
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
}
}
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+18
View File
@@ -0,0 +1,18 @@
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
.bzt.bzt-in{transform:translateX(0);opacity:1}
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
.bzt-badge svg{width:20px;height:20px}
.bzt-body{flex:1;min-width:0;padding-top:1px}
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
.bzt-x:hover{color:#1f2430}
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
+29
View File
@@ -0,0 +1,29 @@
/* Biz Connect toast API. Requires bizconnect-toast.css.
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
window.BZToast=(function(){
var wrap=null;
var ICON={
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.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>',
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
};
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
function show(message,opts){
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
var t=document.createElement('div'); t.className='bzt '+type;
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">&times;</button>';
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
var dur=(opts.duration==null?4000:opts.duration), timer;
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
}
return { show:show,
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
})();
+11 -4
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Biz Connect — Agent Console</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
<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;}
@@ -165,7 +171,7 @@ async function startConnect(){
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…';
statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting…';
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
}
@@ -173,7 +179,7 @@ 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 'session-ready': if(statusEl)statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Allowed — connecting…'; break;
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
// 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){} }
@@ -346,8 +352,9 @@ function buildChatPanel(){
}
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);}
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}if(window.BZToast)BZToast.message(msg.text,{title:(msg.name||'Message')});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(_){}}}
// Branded toast (BZToast, /bizconnect-toast.js). Classify by wording so errors show red.
function toast(text){var s=String(text==null?'':text);if(!window.BZToast)return;if(/could ?n.t|cannot|failed|invalid|error|denied|expired|not found|please enter/i.test(s))return BZToast.error(s);return BZToast.message(s);}
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(_){}}
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Biz Connect — Dashboard</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 B

-277
View File
@@ -1,277 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BizGaze Connect — Home</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
html,body{height:100%;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;overflow:hidden;}
/* ---- Top bar (matches console.html) ---- */
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
.brandrow{display:flex;align-items:center;gap:.6rem;cursor:pointer;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
.brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.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}
/* ---- Shell ---- */
.shell{flex:1 1 auto;display:flex;min-height:0;}
/* ---- Sidebar ---- */
.sidebar{width:320px;flex:0 0 320px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
.side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
.side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
.side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
.newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;}
.newchat:hover{background:#dbe6fb;}
.search{position:relative;}
.search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
.search input:focus{outline:none;border-color:var(--brand);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;}
.avatar .dot.on{background:var(--green);}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--blue);color:#fff;font-size:.7rem;font-weight:700;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Main content ---- */
.content{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
.tabs{display:flex;gap:.4rem;padding:1rem 1.5rem 0;border-bottom:1px solid var(--line);background:var(--card);}
.tabs button{background:transparent;color:var(--muted);font-weight:600;font-size:.92rem;border:none;border-bottom:3px solid transparent;padding:.6rem .9rem .8rem;cursor:pointer;display:flex;align-items:center;gap:.45rem;border-radius:8px 8px 0 0;}
.tabs button:hover{color:var(--blue);background:#f6f8fb;}
.tabs button.active{color:var(--blue);border-bottom-color:var(--brand);}
.panel-wrap{flex:1 1 auto;overflow-y:auto;padding:2rem 1.5rem;display:flex;}
.panel{display:none;margin:auto;width:100%;max-width:560px;}
.panel.active{display:block;}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;}
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
.feat-icon.blue{background:var(--blue-soft);color:var(--blue);}
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
.btn:hover{background:var(--brand-d);}
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
@media (max-width:760px){
.sidebar{width:108px;flex:0 0 108px;}
.side-title h2,.search,.chat-main{display:none;}
.chat-row{justify-content:center;}
.side-head{padding:.8rem .5rem;}
}
</style>
</head>
<body>
<header>
<div class="brandrow" id="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 class="y">Connect</span> <span class="tag">· Home</span></div>
</div>
<div id="hdrRight"></div>
</header>
<div class="shell">
<!-- ---------- Sidebar ---------- -->
<aside class="sidebar">
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
<button class="newchat" title="New chat" aria-label="New chat">+</button>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
</div>
</div>
<div class="chatlist" id="chatlist"></div>
</aside>
<!-- ---------- Main ---------- -->
<section class="content">
<div class="tabs">
<button data-tab="meeting" class="active">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
Meeting
</button>
<button data-tab="share">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Share Screen
</button>
<button data-tab="connect">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
Connect Screen
</button>
</div>
<div class="panel-wrap">
<!-- Meeting -->
<div class="panel active" data-panel="meeting">
<div class="card">
<div class="feat-icon yellow">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
</div>
<span class="pill-soon">COMING SOON</span>
<h1>Meetings are on the way</h1>
<p>Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed. We're putting on the finishing touches.</p>
<button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
<div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> to start a session.</div>
</div>
</div>
<!-- Share Screen -->
<div class="panel" data-panel="share">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<h1>Share your screen</h1>
<p>Let a teammate or customer see your screen instantly. You'll get a 6-digit code to share — they enter it to connect. No download, works right in the browser.</p>
<a class="btn" href="/share">Start sharing →</a>
<div class="hint">Desktop browsers only — phones can't share their screen yet.</div>
</div>
</div>
<!-- Connect Screen -->
<div class="panel" data-panel="connect">
<div class="card">
<div class="feat-icon blue">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
</div>
<h1>Connect to a screen</h1>
<p>Helping someone out? Enter the 6-digit code they give you to view their screen and provide live support — with two-way voice and chat built in.</p>
<a class="btn" href="/connect">Open connect page →</a>
<div class="hint">The other person taps <b>Allow</b> before you can see anything.</div>
</div>
</div>
</div>
</section>
</div>
<script>
// ---------- Helpers (reused patterns from console.html) ----------
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){return name.trim().split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();}
// Stable avatar color from a name
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
function avColor(name){let h=0;for(const c of name)h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
// Profile dropdown (mirrors profileHTML()/wireProfile() from console.html)
const SAMPLE_USER={name:'Sravan Mareddy',email:'sravanm@bizgaze.com',role:'admin'};
function profileHTML(u){
return '<div class="profile"><button class="pbtn" id="pbtn">'
+ '<span class="pav">'+pEsc(initials(u.name))+'</span>'
+ pEsc(u.name)+' <span style="font-size:.65rem">&#9662;</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(u.name)+'</div><div class="e">'+pEsc(u.email)+' · '+pEsc(u.role)+'</div></div>'
+ '<a href="/console">Console / Dashboard</a>'
+ '<a href="#">Settings</a>'
+ '<a class="danger" id="plogout">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=(e)=>{e.preventDefault();alert('Mockup — logout would sign you out and return to /.');};
}
document.getElementById('hdrRight').innerHTML=profileHTML(SAMPLE_USER);
wireProfile();
document.getElementById('brandrow').onclick=()=>{location.href='/';};
// ---------- Mock chat data ----------
const CHATS=[
{name:'Anwi Systems', msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true, active:true},
{name:'Priya Sharma', msg:"Can you connect to my screen at 3pm?", time:'9:15 AM', unread:2, online:true},
{name:'GAPL Group', msg:"You: I've shared the 6-digit code with you", time:'Yesterday', unread:0, online:false},
{name:'Battery Doctors', msg:"The invoice module is throwing an error again", time:'Yesterday', unread:5, online:true},
{name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk", time:'Mon', unread:0, online:false},
{name:'STC Support', msg:"Typing…", time:'Mon', unread:1, online:true},
{name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍", time:'Sun', unread:0, online:false},
{name:'DMS 3.0 Team', msg:"You: Closing the ticket, all resolved", time:'Fri', unread:0, online:false},
];
const listEl=document.getElementById('chatlist');
function chatRowHTML(c,i){
const cls=['chat-row'];
if(c.active)cls.push('active');
if(c.unread>0)cls.push('unread');
return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
+ '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
+ '<span class="dot'+(c.online?' on':'')+'"></span></div>'
+ '<div class="chat-main">'
+ '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
+ (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
+ '</div></div>';
}
function renderChats(filter){
const q=(filter||'').trim().toLowerCase();
const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q));
listEl.innerHTML = rows.length
? rows.map(({c,i})=>chatRowHTML(c,i)).join('')
: '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
listEl.querySelectorAll('.chat-row').forEach(row=>{
row.onclick=()=>{
CHATS.forEach(c=>c.active=false);
CHATS[+row.dataset.i].active=true;
CHATS[+row.dataset.i].unread=0;
renderChats(document.getElementById('chatSearch').value);
};
});
}
renderChats('');
document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
// ---------- Tab switching ----------
const tabBtns=document.querySelectorAll('.tabs button');
const panels=document.querySelectorAll('.panel');
tabBtns.forEach(btn=>{
btn.onclick=()=>{
const tab=btn.dataset.tab;
tabBtns.forEach(b=>b.classList.toggle('active',b===btn));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
};
});
// Mockup-only stubs
document.querySelector('.newchat').onclick=()=>alert('Mockup — “New chat” would open the contact picker.');
document.getElementById('notifyBtn').onclick=()=>alert("Thanks! We'll let you know when Meetings launches.");
</script>
</body>
</html>
+95 -31
View File
@@ -7,9 +7,11 @@
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -645,10 +647,18 @@
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
/* ---- Login (shown on /home when logged out) ---- */
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;}
.authcard{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2rem;max-width:400px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.08);}
.authcard h1{font-size:1.3rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;
background:radial-gradient(1200px 600px at 50% -10%, #24437f 0%, #1F3B73 42%, #16294F 100%);}
.authcard{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2rem 2rem 1.7rem;max-width:400px;width:100%;box-shadow:0 24px 60px rgba(9,17,38,.38);}
.authcard .auth-brand{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-bottom:1rem;}
.authcard .auth-brand img{width:66px;height:66px;border-radius:17px;box-shadow:0 10px 24px rgba(31,59,115,.30);}
.authcard .auth-brand .wm{font-size:1.15rem;font-weight:800;color:var(--blue);letter-spacing:.2px;}
.authcard .auth-brand .wm b{color:var(--brand-d);font-weight:800;}
.authcard h1{font-size:1.12rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
.authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
/* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */
.authcard .gobtn{background:var(--brand);color:var(--blue-d);}
.authcard .gobtn:hover{filter:brightness(.95);}
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
.authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
.authtabs button.active{background:var(--blue);color:#fff;}
@@ -667,11 +677,14 @@
.hidden{display:none;}
/* ---- Loading / toast ---- */
.loading{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:9000;color:var(--muted);font-size:.9rem;}
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:.9rem;}
.loading .ld-inner img{width:64px;height:64px;}
.toast{position:fixed;left:50%;bottom:1.6rem;transform:translateX(-50%) translateY(1rem);background:var(--blue);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.88rem;box-shadow:0 10px 28px rgba(0,0,0,.22);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9500;}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
.loading{position:fixed;inset:0;display:grid;place-items:center;background:#1F3B73;z-index:9000;color:rgba(255,255,255,.82);font-size:.9rem;}
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:1rem;}
.loading .ld-inner img{width:72px;height:72px;}
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
.thread-loading{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.7rem;color:var(--muted);font-size:.86rem;}
.call-connecting{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:#16294F;color:rgba(255,255,255,.82);font-size:.95rem;}
.call-connecting .cc-txt{letter-spacing:.2px;}
/* in-app toasts now render via /bizconnect-toast.css (BZToast) */
/* Hamburger menu button (header) */
.navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
@@ -740,11 +753,11 @@
<body>
<script src="/icons.js?v=4"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-02-batch26';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-02-batch32';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loader-orbit.svg" alt="" width="64" height="64"><span>Loading…</span></div></div>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><span>Loading…</span></div></div>
<header>
<div class="brandrow">
@@ -815,7 +828,6 @@ function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, {
<div class="authwrap" id="authwrap"></div>
<div class="toast" id="toast"></div>
<script>
// ---------- Helpers ----------
@@ -832,8 +844,16 @@ function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there'
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
let toastTimer=null;
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2600);}
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
// errors/successes get the right colour+icon; everything else shows as a neutral message.
function toast(msg){
var s=String(msg==null?'':msg);
if(!window.BZToast) return; // loaded synchronously in <head>
if(/could ?n.t|cannot|can.t|failed|too large|invalid|error|denied|lost|blocked|not registered|required|need|no result|already/i.test(s)) return BZToast.error(s);
if(/renamed|updated|enabled|added|saved|created|invited|removed|left the|muted|photo updated|now an admin|no longer|success/i.test(s)) return BZToast.success(s);
return BZToast.message(s);
}
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
function profileHTML(u){
@@ -1180,9 +1200,12 @@ function welcomeHTML(){
+ '</div></div>';
}
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
function dmSubLabel(it){ return (it&&it.callActive)?'In a call':statusLabel(it); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):statusLabel(it));
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):dmSubLabel(it));
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
@@ -1385,7 +1408,11 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m &&
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead(d){ if(!d||!d.by) return; if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); } }
function onChatRead(d){ if(!d||!d.by) return;
if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); }
// #6: keep the chat-list tick in sync with the thread — my last message is now read (blue double).
const it=rowFor('dm', d.by); if(it && it.last_from_me && it.last_status!=='read'){ it.last_status='read'; renderChats(searchVal()); }
}
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs).
async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
@@ -1397,7 +1424,11 @@ function markMsgDeleted(id){
}
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } }
function onChatDelivered(d){ if(!d||!d.id) return;
const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); }
// #6: mirror the delivered state onto the chat-list tick (double grey), unless it's already read.
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status==='sent'){ it.last_status='delivered'; renderChats(searchVal()); }
}
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); } }
// Shared group call: start it (or join the live one — the server returns the existing room).
@@ -1419,9 +1450,20 @@ async function startOrJoinDmCall(otherId){
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); } }
catch(e){ toast(e.message||'Could not start the call'); }
}
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
// open chat header immediately (no refresh, which the desktop/mobile apps can't do). #6
function onPresence(d){
if(!d||!d.userId) return;
const it=rowFor('dm', d.userId);
if(it){ it.online=!!d.online; it.status=d.status||'active'; renderChats(searchVal()); }
if(selected && selected.kind==='dm' && selected.id===d.userId && it){
const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it);
const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it);
}
}
function onDmCall(d){
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; }
if(selected&&selected.kind==='dm'&&selected.id===d.with) updateCallBtn(!!d.active);
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; } // #4: drop the stuck "in call" status
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
renderChats(searchVal());
@@ -1629,6 +1671,7 @@ async function openConvo(kind,id){
// when opened from a notification; an async-only render would defer the paint until a click).
const ckey=kind+':'+id;
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
if(!selected||selected.kind!==kind||selected.id!==id) return;
wireMentions();
@@ -1894,7 +1937,11 @@ async function setupNativePush(){
return true; // handled the native path (skip Web Push regardless of outcome)
}
async function setupPush(){
if(await setupNativePush()) return; // native app → FCM/APNs, not Web Push
if(await setupNativePush()) return; // mobile native app → FCM/APNs, not Web Push
// Desktop shell: the in-page bridge already shows a native toast (notify → replyNotify) while the
// app runs, so ALSO subscribing to Web Push made every message pop TWICE (in-app toast + SW push,
// with different icons). Drop any Web Push subscription here so only the branded native toast fires.
if(nativePlatform()==='desktop'){ try{ await unsubscribePush(); }catch(_){} console.log('[push] desktop shell → native toasts only, skipping Web Push'); return; }
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
@@ -1960,11 +2007,16 @@ function notify(title, body, kind, id){
}).catch(()=>{});
return;
}
// Web / PWA: standard Notification.
// Web / PWA: standard Notification. Icon = the sender's/group's DP (falling back to their
// initials, then the brand app icon) — never the old wordmark. badge = brand mark.
if(!('Notification' in window) || Notification.permission!=='granted') return;
const n=new Notification(title, { body, icon:'/logo.png' });
notifAvatarDataUrl(kind, id, String(title||'').replace(/^[^\p{L}\p{N}]+/u,'')).then(avatar=>{
const n=new Notification(title, { body, icon: avatar || '/icon-192.png', badge:'/icon-192.png' });
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}).catch(()=>{
try{ const n=new Notification(title, { body, icon:'/icon-192.png' }); n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; }catch(_){}
});
}catch(_){}
}
let _audioCtx=null;
@@ -2038,7 +2090,7 @@ function onChatMessage(m){
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
toast((m.fromName||'New message')+': '+(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment'));
if(window.BZToast) BZToast.message(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment', {title:(m.fromName||'New message')});
}
renderChats(searchVal()); updateRailUnread();
}
@@ -2047,7 +2099,7 @@ function connectChatWs(){
try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){}
}
@@ -2379,6 +2431,12 @@ function openScheduleModal(gid, editMtg){
}catch(e){ err.textContent=e.message||'Could not save'; }
};
}
// Branded connecting screen shown from the moment a call is entered until the room is ready —
// so a slow link shows our loader, not a blank/half meeting window.
function renderCallConnecting(){
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
}
function renderCall(){
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
@@ -2571,6 +2629,7 @@ async function enterMeeting(code, audioOnly){
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
meetState='call'; meetRailLive(true);
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
meetWs.onmessage=onMeetMsg;
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } };
@@ -2590,7 +2649,7 @@ async function onMeetMsg(e){
refreshMeetPanel(); updateHostControls();
return;
}
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended
if(m.type==='meeting-peer-joined'){
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
@@ -2619,7 +2678,7 @@ async function onMeetMsg(e){
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
return;
}
if(m.type==='error'){ const msg=m.message; leaveMeeting(); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
if(m.type==='error'){ const msg=m.message; leaveMeeting(true); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
}
function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } }
function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } }
@@ -2658,10 +2717,14 @@ async function toggleCam(){
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
}
let meetLeaving=false;
function leaveMeeting(){
// forced = the call is ending for us (1:1 hangup, host ended, room closed/error) rather than us
// voluntarily stepping out of a group others are still in. Only a VOLUNTARY exit from a GROUP
// call hands the host role on; a 1:1 call (or a forced end) has nobody to hand to — handing off
// there wrongly announced "Host handed to <the person who just left>" (bug).
function leaveMeeting(forced){
if(meetLeaving) return; meetLeaving=true;
// Host leaving voluntarily must hand off so the meeting isn't left host-less.
if(meetIsHost && meetPeers.size>0){
const isDm=!!(meetReturn && meetReturn.kind==='dm');
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
const next=meetPeers.keys().next().value; // first remaining participant
if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
}
@@ -2770,7 +2833,8 @@ async function renderLogin(){
const aw=document.getElementById('authwrap'); aw.style.display='flex';
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
aw.innerHTML=`<div class="authcard">
<h1>Welcome to Biz Connect</h1>
<div class="auth-brand"><img src="/icon-192.png" alt="Biz Connect"><div class="wm">Biz <b>Connect</b></div></div>
<h1>Welcome back</h1>
<div class="sub">Sign in to access chats, screen share and connect.</div>
${regOpen?`<div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browser Host — Remote Access</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<style>
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
+4 -2
View File
@@ -5,9 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Biz Connect</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
*{box-sizing:border-box;}

Before

Width:  |  Height:  |  Size: 471 B

After

Width:  |  Height:  |  Size: 471 B

Before

Width:  |  Height:  |  Size: 471 B

After

Width:  |  Height:  |  Size: 471 B

Before

Width:  |  Height:  |  Size: 473 B

After

Width:  |  Height:  |  Size: 473 B

+9 -2
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Biz Connect — Share your screen</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<link rel="stylesheet" href="/bizconnect-toast.css">
<script src="/bizconnect-toast.js"></script>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
*{box-sizing:border-box;}
@@ -275,8 +281,9 @@ function buildChatPanel(){
}
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);}
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}if(window.BZToast)BZToast.message(msg.text,{title:(msg.name||'Message')});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(_){}}}
// Branded toast (BZToast, /bizconnect-toast.js). Classify by wording so errors show red.
function toast(text){var s=String(text==null?'':text);if(!window.BZToast)return;if(/could ?n.t|cannot|failed|invalid|error|denied|expired|not found|please enter/i.test(s))return BZToast.error(s);return BZToast.message(s);}
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(_){}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+2 -2
View File
@@ -15,8 +15,8 @@ self.addEventListener('push', (event) => {
const title = d.title || 'Biz Connect';
const options = {
body: d.body || '',
icon: '/logo.png',
badge: '/logo.png',
icon: d.icon || '/icon-192.png', // sender/group DP when the server provides one, else brand icon (not the old wordmark)
badge: '/icon-192.png',
tag: d.tag || undefined, // collapse repeats from the same chat
renotify: !!d.tag,
data: { kind: d.kind || '', id: d.id || '' },
+5 -1
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8">
<title>Remote Session</title>
<meta name="theme-color" content="#1F3B73">
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
<style>
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
header { background: #1e293b; padding: 0.6rem 1rem; display: flex; justify-content: space-between; align-items: center; }
@@ -15,7 +19,7 @@
</head>
<body>
<header>
<div id="status">Connecting…</div>
<div id="status"><img src="/loaders/loader-orbit-dark.svg" width="18" height="18" style="vertical-align:-4px;margin-right:6px" alt="">Connecting…</div>
<div>
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
<button id="endBtn">End session</button>
+1
View File
@@ -300,6 +300,7 @@ route('POST', '/api/me/status', async (req, res) => {
const { status } = await readBody(req);
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
try { R.users.setStatus(u.id, status); } catch (_) {}
try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh)
json(res, 200, { ok: true, status });
});
+13 -3
View File
@@ -29,6 +29,7 @@ function handle(ws, m, req) {
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
CHAT.register(u.id, ws);
ws.send(JSON.stringify({ type: 'chat-ready' }));
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
break;
}
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
@@ -37,7 +38,7 @@ function handle(ws, m, req) {
const msg = R.messages.byId(m.id);
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id }); } catch (_) {} }
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
break;
}
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
@@ -72,6 +73,7 @@ function handle(ws, m, req) {
// …and tell existing peers a newcomer arrived.
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
if (ws._meetingUserId) CHAT.broadcastPresence(ws._meetingUserId); // now in a call → update contacts live
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
break;
}
@@ -286,16 +288,20 @@ function leaveMeeting(ws) {
const peers = meetingRooms.get(room);
ws._meetingRoom = null;
const pid = ws._peerId;
if (!peers) return;
const leaverId = ws._meetingUserId;
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
peers.delete(pid);
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
if (roomToDmCall.has(room)) {
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } }
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
meetingRooms.delete(room);
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
if (leaverId) CHAT.broadcastPresence(leaverId);
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
return;
}
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
@@ -305,11 +311,15 @@ function leaveMeeting(ws) {
roomHost.delete(room);
try { require('./calls').endCallByRoom(room); } catch (_) {}
}
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
}
function cleanup(ws) {
const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change
CHAT.unregister(ws);
leaveMeeting(ws);
if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
if (ws.sessionId) {