Behavision: face recognition for retail, edge to head office

Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
desktop/frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1738
desktop/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "behavision-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from 'react'
import { api, isDesktop, message } from './bridge.js'
import { usePolled } from './hooks.js'
import Login from './views/Login.jsx'
import Setup from './views/Setup.jsx'
import Live from './views/Live.jsx'
import Customers from './views/Customers.jsx'
import Cameras from './views/Cameras.jsx'
// Three screens, and the trim is by AUDIENCE rather than by taste.
//
// This window runs on a PC behind a counter, and the person in front of it can
// act on exactly three things: is it working, who is this customer, and is the
// camera set up. Footfall and Sales answer a different person's questions - an
// owner comparing shops, who is not standing in one - and they now live on the
// head-office platform where a comparison across sites is even possible. A
// month-on-month chart on a shop PC was a report nobody there could act on,
// competing for the attention of somebody with a customer waiting.
//
// `cloud` marks a screen that cannot work without head office. A PC set up on
// its own hides those rather than showing a screen that can only ever fail:
// the customer record lives on the server, the cameras and what this PC is
// seeing do not.
const VIEWS = [
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
]
export default function App() {
const [session, setSession] = useState(null)
const [view, setView] = useState('live')
const [booting, setBooting] = useState(true)
// A standalone PC can join head office later. That is the same Setup screen,
// reached deliberately rather than because the app will not open otherwise.
const [linking, setLinking] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
}, [])
if (!isDesktop()) {
// The frontend can be served by `npm run dev` for styling work, where the
// Go bindings do not exist. Saying so beats a blank screen and a console
// error nobody will read.
return (
<div className="login"><div className="box">
<h1>Behavision</h1>
<p className="lead">
This is the Behavision window running outside the app, so it has no
connection to the recognition engine. Launch the Behavision
application instead.
</p>
</div></div>
)
}
if (booting) return <div className="login"><p className="note">Starting…</p></div>
// Which shop this PC IS comes before who is standing at it. An installer on a
// brand new counter has a code and often no account yet, and every screen
// behind here is about a shop this PC does not have one of. Unless there is
// no head office at all, which is the other supported answer.
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
const Current = views.find(v => v.id === view)?.View ?? Live
if (linking) {
return <Setup onDone={s => { setLinking(false); setSession(s) }}
onCancel={() => setLinking(false)} />
}
return (
<div className="shell">
<aside className="side">
<div className="brand">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
</div>
<nav className="nav">
{views.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
aria-current={v.id === view ? 'page' : undefined}>
<span className="glyph">{v.glyph}</span>{v.label}
</button>
))}
</nav>
<EngineBox />
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
{session.standalone
? <>
<div className="note" style={{ marginBottom: 8 }}>
Running on its own
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={() => setLinking(true)}>
Link to head office
</button>
</>
: <>
<div className="note" style={{ marginBottom: 8 }}>
{session.user?.email}
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={async () => setSession(await api.logout())}>
Sign out
</button>
</>}
</div>
</aside>
<main className="main"><Current session={session} /></main>
</div>
)
}
// Always visible, because "is recognition actually running" is the question
// behind every other screen — an empty Live page means something completely
// different depending on the answer.
function EngineBox() {
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
const [busy, setBusy] = useState(false)
const s = data ?? { state: 'stopped' }
const act = useCallback(async fn => {
setBusy(true)
try { await fn() } catch (e) { alert(message(e)) }
finally { setBusy(false); reload() }
}, [reload])
const running = s.state === 'running'
const cams = Object.values(s.cameras ?? {})
const up = cams.filter(Boolean).length
let tone = 'idle', text = 'Stopped'
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
return (
<div className="enginebox">
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
{s.recognition_model && (
<span className="label">Model: {s.recognition_model}</span>
)}
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
<div className="actions">
<button className="btn sm" disabled={busy || running}
onClick={() => act(api.startEngine)}>Start</button>
<button className="btn sm" disabled={busy || !running}
onClick={() => act(api.stopEngine)}>Stop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
// The single seam between React and Go.
//
// Wails injects bound methods at window.go.main.App.*. Calling them through
// here rather than importing generated bindings means `npm run build` works
// without running `wails generate`, and it gives one place to handle the
// "engine not running yet" case that every screen has to survive.
const app = () => window?.go?.main?.App
export const isDesktop = () => Boolean(app())
async function call(name, ...args) {
const a = app()
if (!a || typeof a[name] !== 'function') {
throw new Error(`${name} is unavailable — run this inside the Behavision app`)
}
return a[name](...args)
}
// Every binding the UI uses, named as the UI thinks of them.
export const api = {
session: () => call('Session'),
login: (email, password) => call('Login', email, password),
logout: () => call('Logout'),
// The one-shot installation code that links this PC to a shop.
claim: (code) => call('Claim', code),
// Set this PC up on its own, with no head office at all.
runStandalone: () => call('RunStandalone'),
engineStatus: () => call('EngineStatus'),
startEngine: () => call('StartEngine'),
stopEngine: () => call('StopEngine'),
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
placementResult: (id) => call('PlacementResult', id),
streamURL: (id) => call('StreamURL', id),
live: () => call('Live'),
pipelineStatus: () => call('PipelineStatus'),
localIdentities: (n) => call('LocalIdentities', n),
localSightings: (n) => call('LocalSightings', n),
footfall: (from, to, bucket) => call('Footfall', from, to, bucket),
sites: () => call('Sites'),
visitorHistory: (id, limit) => call('VisitorHistory', id, limit),
visitorPhoto: (id) => call('VisitorPhoto', id),
forgetCustomer: (id) => call('ForgetCustomer', id),
sales: (from, to) => call('Sales', from, to),
customers: (q, limit) => call('Customers', q, limit),
saveProfile: (p) => call('SaveProfile', p),
recordPurchase: (id, amount, items, notes) =>
call('RecordPurchase', id, amount, items, notes),
}
// Errors from Go arrive as strings or Error objects depending on the path.
// Normalising here keeps every catch block in the UI to one line.
export function message(err) {
if (!err) return 'Something went wrong.'
if (typeof err === 'string') return err
return err.message || String(err)
}

View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { message } from './bridge.js'
// One hook for every screen that loads and refreshes.
//
// It exists because the naive version has two bugs every screen would repeat:
// a slow response arriving after the user navigated away sets state on an
// unmounted component, and a poll that fires while the previous request is
// still running stacks up requests against an engine that is already slow.
export function usePolled(fn, intervalMs, deps = []) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const alive = useRef(true)
const busy = useRef(false)
const run = useCallback(async () => {
if (busy.current) return
busy.current = true
try {
const result = await fn()
if (!alive.current) return
setData(result)
setError(null)
} catch (err) {
if (alive.current) setError(message(err))
} finally {
busy.current = false
if (alive.current) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
alive.current = true
run()
if (!intervalMs) return () => { alive.current = false }
const id = setInterval(run, intervalMs)
return () => { alive.current = false; clearInterval(id) }
}, [run, intervalMs])
return { data, error, loading, reload: run }
}
export function fmtTime(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function fmtDate(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleDateString([], { day: 'numeric', month: 'short' })
}
export function daysAgo(n) {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString().slice(0, 10)
}
export function today() { return new Date().toISOString().slice(0, 10) }

View File

@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,252 @@
/* Behavision desktop — an instrument panel, not a website.
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
but not cramped, and state readable at a glance from across a counter. */
:root {
--ground: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--warn: #E0A33A;
--bad: #E0655A;
--radius: 8px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
html, body, #root { height: 100%; }
body {
background: var(--ground);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------- shell -- */
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
.side {
background: var(--surface); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand {
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
}
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav button {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
}
.nav button:hover { background: var(--surface-2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
display: block; line-height: 1.4; }
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
.main { min-width: 0; min-height: 0; overflow-y: auto; }
.page { padding: 22px 26px 40px; max-width: 1180px; }
.page > header { margin-bottom: 18px; }
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
/* --------------------------------------------------------------- pieces -- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px;
}
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 14px; }
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.idle { background: var(--muted); }
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
.btn {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
color: var(--ink); white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #26323a; }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
.btn.danger { color: var(--bad); border-color: #4a2823; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.field { display: block; margin-bottom: 12px; }
.field span { display: block; font-size: 11.5px; color: var(--muted);
margin-bottom: 4px; letter-spacing: .01em; }
.field input, .field select, .field textarea {
width: 100%; background: var(--ground); border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
user-select: text;
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent); outline: none;
}
.field textarea { resize: vertical; min-height: 66px; }
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); font-weight: 600;
padding: 8px 10px; border-bottom: 1px solid var(--line); }
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
tbody tr.click { cursor: pointer; }
tbody tr.click:hover { background: var(--surface-2); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.tablewrap { overflow-x: auto; }
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
.err {
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
}
.note { color: var(--muted); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
/* --------------------------------------------------------------- login --- */
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
.login .box { width: 100%; max-width: 380px; }
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
.login form { background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; padding: 20px; }
.login .btn { width: 100%; margin-top: 6px; }
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
text-align: center; line-height: 1.5; }
/* The second way out of the setup screen: a shop with no head office. Styled
quieter than the form above it because linking is still the common case,
but present, because for a single-till shop it is the only one that works. */
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
border-top: 1px solid var(--line-soft); }
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
.login .alt .btn { margin-top: 0; }
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--accent);
text-decoration: underline; text-underline-offset: 3px; }
.linkbtn:hover { color: var(--ink); }
/* ---------------------------------------------------------------- live --- */
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; }
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
.feed .cap { display: flex; justify-content: space-between; align-items: center;
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
.events { list-style: none; max-height: 420px; overflow-y: auto; }
.events li { display: flex; gap: 9px; align-items: baseline;
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.events li:last-child { border-bottom: 0; }
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
flex: none; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
background: var(--surface-2); color: var(--muted); }
.tag.new { background: #17364f; color: #86c2ec; }
.tag.seen { background: #14301f; color: #7fcb9c; }
.tag.miss { background: #3a1c1a; color: #eb9a92; }
/* -------------------------------------------------------------- charts --- */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
gap: 2px; min-width: 0; }
.bars .seg { border-radius: 2px 2px 0 0; }
.bars .seg.ret { background: var(--accent); }
.bars .seg.new { background: #2f6f81; }
.axis { display: flex; justify-content: space-between; color: var(--muted);
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
margin-right: 5px; vertical-align: -1px; }
/* --------------------------------------------------------------- drawer -- */
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
display: flex; justify-content: flex-end; z-index: 30; }
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
/* Close lives in the sticky header (.who) now. Positioned against the fixed
overlay it stayed put while the sheet scrolled underneath it, printing the
button on top of whatever happened to be at the top of the viewport. */
/* -- customer record ---------------------------------------------------- */
/* Full-bleed sticky header: a customer record is long enough to scroll, and
both the name and the way out have to stay reachable. The negative margins
cancel the panel's padding so the background covers the full width. */
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
align-items: flex-start; background: var(--surface);
margin: -20px -22px 18px; padding: 20px 22px 14px;
border-bottom: 1px solid var(--line-soft); }
.who .grow { flex: 1; min-width: 0; }
.who h3 { margin-bottom: 2px; }
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
object-fit: cover; background: var(--ground);
border: 1px solid var(--line); }
.avatar.none { display: grid; place-items: center; color: var(--muted);
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.timeline li:last-child { border-bottom: 0; }
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
flex: none; min-width: 108px; }
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* Visually separated from Save: this is the one control in the sheet that
cannot be undone, and it must not read as just another button in a row. */
.danger-zone { margin-top: 22px; border-color: #4a2823; }
.danger-zone > h3 { color: var(--bad); }
.danger-zone .note { margin-bottom: 10px; }
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
margin-bottom: 12px; }
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 5px; }
.confirm .lbl.bad { color: var(--bad); }
.confirm ul { list-style: none; font-size: 12.5px; }
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
.confirm li::before { content: '·'; position: absolute; left: 2px;
color: var(--muted); }
.confirm .row { display: flex; gap: 8px; }

View File

@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import { usePolled } from '../hooks.js'
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
max_width: 1280 }
export default function Cameras() {
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
async function remove(id) {
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
}
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Cameras</h2>
<p>Add a camera, check it can see faces properly, then it starts working.</p>
</div>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
Add camera
</button>
</header>
{error && <div className="err">{error}</div>}
<div className="card">
{cams.length === 0
? <div className="empty">No cameras yet.</div>
: <div className="tablewrap">
<table>
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
<tbody>
{cams.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td className="mono">{c.url}</td>
<td>
{c.connected === undefined
? <span className="pill"><i className="dot idle" />stopped</span>
: c.connected
? <span className="pill ok"><i className="dot ok" />live</span>
: <span className="pill bad"><i className="dot bad" />offline</span>}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn sm" onClick={() => setCheck(c.id)}>
Check placement
</button>{' '}
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
<button className="btn sm danger" onClick={() => remove(c.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); reload() }} />}
{check && <PlacementSheet id={check} onClose={() => setCheck(null)} />}
</div>
)
}
function CameraSheet({ cam, onClose, onSaved }) {
const isNew = !cam.id
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
path: cam.path || (isNew ? MAKES[0].path : '') })
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
const [test, setTest] = useState(null)
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const set = k => e => setF({ ...f, [k]: e.target.value })
// Only overwrite the path when the preset has one, so choosing "I know the
// path" does not wipe what the installer already typed.
function chooseMake(e) {
const m = makeById(e.target.value)
setMake(m.id)
setF(prev => ({ ...prev, path: m.path || prev.path }))
}
// Blank means "leave alone", never "clear". The engine never returns a
// stored password, so sending an empty one would wipe it on every edit.
function payload() {
const out = {}
for (const [k, v] of Object.entries(f)) {
if (v === '' || v === null || v === undefined) continue
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
}
return out
}
async function runTest() {
setBusy('test'); setError(null); setTest(null)
try { setTest(await api.testCamera(payload())) }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
async function save(e) {
e.preventDefault()
setBusy('save'); setError(null)
try { await api.saveCamera(isNew ? '' : cam.id, payload()); onSaved() }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>{isNew ? 'Add camera' : cam.id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Test the connection before saving — a wrong address is the most common mistake.
</p>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Name</span>
<input value={f.id} onChange={set('id')} disabled={!isNew}
placeholder="entrance" required autoComplete="off" />
</label>
{/* The highest-value field on this form. The address and the
password are on a label or in the installer's notes; the RTSP
path is not written anywhere a shop owner would look, and getting
it wrong produces "could not open stream", which reads like a
password problem and is not. */}
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{makeById(make).note && (
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
{makeById(make).note}
</p>
)}
<div className="fieldrow">
<label className="field"><span>Camera address</span>
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
autoComplete="off" />
</label>
<label className="field"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric"
autoComplete="off" />
</label>
</div>
<label className="field"><span>Stream path</span>
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
autoComplete="off" />
</label>
<div className="fieldrow">
{/* A text input next to a password input is a sign-in form as far
as the webview is concerned, so without this the browser offers
the operator's own Behavision email as the camera's username -
which fails with a message about credentials that points at the
camera. "off" alone is frequently ignored; a non-login name and
new-password on the secret are what actually work. */}
<label className="field"><span>Username</span>
<input value={f.username} onChange={set('username')}
name="camera-account" autoComplete="off" />
</label>
<label className="field"><span>Password</span>
<input type="password" value={f.password} onChange={set('password')}
name="camera-secret" autoComplete="new-password"
placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
{busy === 'test' ? 'Connecting…' : 'Test connection'}
</button>
<button className="btn primary" disabled={!!busy || !f.id}>
{busy === 'save' ? 'Saving…' : 'Save'}
</button>
</div>
{test && (
<div style={{ marginTop: 14 }}>
{test.ok
? <>
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
Connected — {test.width}×{test.height}
</p>
{test.snapshot && (
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
borderRadius: 6, border: '1px solid var(--line)' }}
src={`data:image/jpeg;base64,${test.snapshot}`} />
)}
</>
: <div className="err">{test.error}</div>}
</div>
)}
</form>
</div>
</div>
)
}
// The commissioning wizard. This is what stops a site being signed off with a
// camera that recognises nobody — the failure that otherwise shows up weeks
// later as a footfall report that was always zero.
function PlacementSheet({ id, onClose }) {
const [state, setState] = useState({ verdict: 'starting', advice: [] })
const [error, setError] = useState(null)
const timer = useRef(null)
useEffect(() => {
let alive = true
;(async () => {
try {
setState(await api.startPlacement(id, 25))
timer.current = setInterval(async () => {
try {
const r = await api.placementResult(id)
if (!alive) return
setState(r)
if (!r.running) clearInterval(timer.current)
} catch (e) { if (alive) setError(message(e)) }
}, 1000)
} catch (e) { if (alive) setError(message(e)) }
})()
return () => { alive = false; clearInterval(timer.current) }
}, [id])
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>Placement check — {id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Walk past the camera the way a customer would, a few times.
</p>
{error && <div className="err">{error}</div>}
<div className="card">
<div style={{ fontSize: 15, fontWeight: 600,
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
{state.headline || 'Starting…'}
</div>
{state.running && (
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
overflow: 'hidden', margin: '12px 0' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
transition: 'width .4s linear' }} />
</div>
)}
{state.advice?.length > 0 && (
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
</ul>
)}
{state.quality?.n > 0 && (
<p className="note" style={{ marginTop: 12 }}>
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
median quality {state.quality.p50} ·
gate {state.gate} ·
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
import VisitHistory from './VisitHistory.jsx'
import EraseCustomer from './EraseCustomer.jsx'
// The in-store form. Two jobs in one sheet: capture who this person is, and
// record what they bought — because staff have the customer in front of them
// once, and asking them to open a second screen means the sale never gets
// recorded.
export default function CustomerForm({ customer, session, onClose, onSaved }) {
const [f, setF] = useState({
full_name: customer.full_name ?? '',
phone: customer.phone ?? '',
email: customer.email ?? '',
gender: '',
date_of_birth: '',
notes: '',
consent: customer.has_consent ?? false,
})
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [erasing, setErasing] = useState(false)
const [photo, setPhoto] = useState(null)
const fetched = useCustomerPhoto(customer.id)
const shown = photo ?? fetched
// Erasure destroys a record permanently, so it is a manager's decision. The
// server enforces this too — this only avoids offering a button that would
// come back 403.
const role = session?.user?.role
const canErase = ['admin', 'owner', 'manager'].includes(role)
const set = k => e => setF({ ...f, [k]: e.target.value })
async function save(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
await api.saveProfile({ visitor_id: customer.id, ...f })
const amount = parseFloat(purchase.amount)
if (!isNaN(amount) && amount > 0) {
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
await api.recordPurchase(customer.id, amount, items, purchase.notes)
}
onSaved()
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<div className="who">
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
onBroken={() => setPhoto({ available: false,
reason: 'The photo could not be loaded.' })} />
<div className="grow">
<h3>{customer.full_name || customer.label}</h3>
<p className="note">
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
</p>
{shown && !shown.available && shown.reason &&
<p className="note">{shown.reason}</p>}
</div>
<button type="button" className="btn sm" onClick={onClose}>Close</button>
</div>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<div className="card" style={{ marginBottom: 14 }}>
<h3>Customer details</h3>
<label className="field">
<span>Full name</span>
<input value={f.full_name} onChange={set('full_name')} autoFocus />
</label>
<div className="fieldrow">
<label className="field">
<span>Phone</span>
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
</label>
<label className="field">
<span>Email</span>
<input value={f.email} onChange={set('email')} type="email" />
</label>
</div>
<div className="fieldrow">
<label className="field">
<span>Gender</span>
<select value={f.gender} onChange={set('gender')}>
<option value="">Not recorded</option>
<option>Female</option><option>Male</option><option>Other</option>
</select>
</label>
<label className="field">
<span>Date of birth</span>
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
</label>
</div>
<label className="field">
<span>Notes</span>
<textarea value={f.notes} onChange={set('notes')}
placeholder="Preferences, sizes, anything worth remembering" />
</label>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Purchase (optional)</h3>
<div className="fieldrow">
<label className="field">
<span>Amount</span>
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
</label>
<label className="field">
<span>Items</span>
<input value={purchase.items} placeholder="shirt, belt"
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
</label>
</div>
<p className="note">Leave the amount blank if they did not buy anything —
a visit without a sale is still worth recording.</p>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<h3>Consent</h3>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
onChange={e => setF({ ...f, consent: e.target.checked })} />
<span>This customer agreed to us keeping their details and
recognising them on future visits.</span>
</label>
<p className="note" style={{ marginTop: 10 }}>
Recorded with the date and who collected it. They can withdraw it
at any time, which erases their face data.
</p>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Visits</h3>
<VisitHistory customer={customer} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
{canErase && (
<div className="card danger-zone">
<h3>At the customer's request</h3>
{erasing
? <EraseCustomer customer={customer}
onCancel={() => setErasing(false)}
onDone={onSaved} />
: <>
<p className="note">
Erase this person's face data, photo and details. Their
past visits stay in your footfall figures, without their
name.
</p>
<button type="button" className="btn danger"
onClick={() => setErasing(true)}>
Erase this customer…
</button>
</>}
</div>
)}
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
// The customer's face, when there is one.
//
// Fetched when the sheet opens rather than stored with the customer row: the
// server hands out a signed link that expires in minutes, deliberately, so
// that "delete my data" can actually make a picture stop loading. A link kept
// in a list rendered an hour ago is a broken image.
//
// The fetch lives in a hook and happens ONCE per sheet, because the server
// writes an audit_log row for every read of a face image — "who looked at my
// customers" has to be answerable — and a component that fetched its own copy
// for the picture and again for the caption would put two rows in that log for
// one glance at one person.
export function useCustomerPhoto(id) {
const [photo, setPhoto] = useState(null)
useEffect(() => {
let alive = true
setPhoto(null)
api.visitorPhoto(id)
.then(p => { if (alive) setPhoto(p) })
// A failure to load a photo must never take the customer record with
// it: the name and phone number are what staff opened this for.
.catch(() => { if (alive) setPhoto({ available: false, reason: '' }) })
return () => { alive = false }
}, [id])
return photo
}
// No photo is the normal case — images are off by default — so this renders
// initials, not an error.
export default function CustomerPhoto({ photo, name, onBroken }) {
if (photo?.available) {
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
onError={onBroken} />
}
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
.map(w => w[0].toUpperCase()).join('') || '?'
return (
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
<span>{initials}</span>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtDate } from '../hooks.js'
import CustomerForm from './CustomerForm.jsx'
// The customer database, and the form staff fill in when someone walks in.
export default function Customers({ session }) {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(null)
const { data, error, reload } = usePolled(
() => api.customers(query, 200), 30000, [query])
const rows = data ?? []
const named = rows.filter(r => r.has_profile).length
return (
<div className="page">
<header>
<h2>Customers</h2>
<p>Everyone this business has recognised. Fill in details once and they
are known at every store.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Known people" value={rows.length || '—'} />
<Stat label="With details" value={named || '—'}
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
</div>
<div className="card">
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
placeholder="Search by name or phone"
value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn" onClick={reload}>Refresh</button>
</div>
{rows.length === 0
? <div className="empty">
No customers yet. They appear here the first time a camera sees them.
</div>
: <div className="tablewrap">
<table>
<thead>
<tr>
<th>Customer</th><th>Phone</th>
<th className="num">Visits</th>
<th>First seen</th><th>Last seen</th><th>Details</th>
</tr>
</thead>
<tbody>
{rows.map(c => (
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
<td className="mono">{c.phone || '—'}</td>
<td className="num">{c.visit_count}</td>
<td>{fmtDate(c.first_seen_at)}</td>
<td>{fmtDate(c.last_seen_at)}</td>
<td>
{c.has_profile
? <span className="pill ok"><i className="dot ok" />captured</span>
: <span className="pill warn"><i className="dot warn" />needed</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{selected && (
<CustomerForm customer={selected} session={session}
onClose={() => setSelected(null)}
onSaved={() => { setSelected(null); reload() }} />
)}
</div>
)
}
function Stat({ label, value, sub }) {
return (
<div className="card stat">
<h3>{label}</h3><div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Erasing a customer, at that customer's request.
//
// This is a legal obligation with a destructive implementation, so the screen
// does two things a plain "are you sure" cannot. It says exactly what is
// destroyed and exactly what is kept — staff are asked "will you delete my
// data?" by a real person standing in front of them and have to be able to
// answer truthfully — and it requires the customer's name to be typed, because
// this sits next to Save in a sheet used all day and a misclick is
// unrecoverable.
//
// What is kept is not an oversight. Visits stay (unlinked): they are the
// shop's own footfall history, and silently rewriting last quarter's numbers
// because one customer exercised a right is both wrong and detectable. Consent
// stays, revoked: deleting it destroys the proof of what we were permitted to
// do and when, which is the first thing an auditor asks for.
export default function EraseCustomer({ customer, onCancel, onDone }) {
const name = customer.full_name || customer.label
const [typed, setTyped] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const confirmed = typed.trim().toLowerCase() === name.trim().toLowerCase()
async function erase() {
setBusy(true); setError(null)
try {
await api.forgetCustomer(customer.id)
onDone()
} catch (err) {
// The server deletes stored photos before it touches the database and
// refuses the whole request if one fails, so a failure here means
// nothing was erased. Say so — the alternative is a shop believing a
// request was honoured when it was not.
setError(message(err))
setBusy(false)
}
}
return (
<div className="confirm">
<h4>Erase {name}?</h4>
<div className="cols">
<div>
<p className="lbl bad">Deleted for good</p>
<ul>
<li>Their face data — they will not be recognised again</li>
<li>Their photo</li>
<li>Their name, phone, email and notes</li>
</ul>
</div>
<div>
<p className="lbl">Kept</p>
<ul>
<li>Past visits, with their name removed — your footfall figures
do not change</li>
<li>The consent record, marked withdrawn, as proof of what was
agreed</li>
</ul>
</div>
</div>
<p className="note">This cannot be undone. If they come back they will be
recorded as a new customer.</p>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Type <b>{name}</b> to confirm</span>
<input value={typed} onChange={e => setTyped(e.target.value)}
autoFocus autoComplete="off" spellCheck="false"
aria-label={`Type ${name} to confirm erasure`} />
</label>
<div className="row">
<button type="button" className="btn danger"
disabled={!confirmed || busy} onClick={erase}>
{busy ? 'Erasing…' : 'Erase permanently'}
</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>
Cancel
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [
{ label: '7 days', days: 7, bucket: 'day' },
{ label: '30 days', days: 30, bucket: 'day' },
{ label: '90 days', days: 90, bucket: 'week' },
]
export default function Footfall() {
const [range, setRange] = useState(RANGES[0])
const { data, error, loading } = usePolled(
() => api.footfall(daysAgo(range.days), today(), range.bucket),
60000, [range.days, range.bucket])
const sites = usePolled(() => api.sites(), 60000, [])
const points = data?.points ?? []
const peak = Math.max(1, ...points.map(p => p.visitors))
// Both numbers come from the server, and neither is the sum of the chart.
//
// A customer who came on Monday and Thursday is ONE person and TWO
// bucket-visitors, so adding the bars up gives a headcount that is silently
// too high. Summing new + returning is wrong a second way: a site sending
// counts without face templates produces visits that are real footfall but
// an unknown person, and those are counted in neither.
const unique = data?.total ?? 0
const visits = data?.visits ?? 0
const totalNew = points.reduce((n, p) => n + (p.new ?? 0), 0)
const totalRet = points.reduce((n, p) => n + (p.returning ?? 0), 0)
const identified = totalNew + totalRet
const gate = data?.fraction_below_gate ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Footfall</h2>
<p>Visitors over time, split by whether we had seen them before.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
aria-pressed={r.days === range.days}
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People" value={unique || '—'}
sub={visits ? `${visits} visit${visits === 1 ? '' : 's'} in total` : null} />
<Stat label="New" value={totalNew || '—'} />
<Stat label="Returning" value={totalRet || '—'}
sub={identified ? `${Math.round(100 * totalRet / identified)}% of recognised visits` : null} />
<Stat label="Below quality gate"
value={data ? `${Math.round(gate * 100)}%` : '—'}
tone={gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : undefined}
sub={data?.worst_site ? `worst: ${data.worst_site}` : 'Faces seen but too poor to count'} />
</div>
<div className="card">
<h3>Visitors per {range.bucket}</h3>
{loading && !points.length ? <div className="empty">Loading…</div>
: points.length === 0 ? <div className="empty">No visits recorded in this period.</div>
: <>
<div className="bars">
{points.map((p, i) => (
<div className="col" key={i}
title={`${bucketLabel(p.bucket, range.bucket)}: ${p.visitors} visitor${p.visitors === 1 ? '' : 's'}`}>
<div className="seg new" style={{ height: `${(p.new / peak) * 100}%` }} />
<div className="seg ret" style={{ height: `${(p.returning / peak) * 100}%` }} />
</div>
))}
</div>
<div className="axis">
<span>{bucketLabel(points[0]?.bucket, range.bucket)}</span>
<span>{bucketLabel(points[points.length - 1]?.bucket, range.bucket)}</span>
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />Returning</span>
<span><i style={{ background: '#2f6f81' }} />New</span>
{data?.timezone && <span style={{ marginLeft: 'auto', opacity: 0.6 }}>
times in {data.timezone}</span>}
</div>
</>}
</div>
<Sites sites={sites.data} error={sites.error} />
{gate > 0.5 && (
<div className="err" style={{ marginTop: 16 }}>
More than half the faces {data.worst_site ? `at ${data.worst_site}` : 'this site'} saw
were too poor to count, so this chart understates real footfall. Run a
placement check on that camera before trusting these numbers.
</div>
)}
</div>
)
}
// A site that has stopped reporting looks exactly like a site with no
// customers - the same row of zeroes - and only one of them is something to
// act on. This is the difference, shown next to the chart it explains.
function Sites({ sites, error }) {
if (error || !sites || sites.length === 0) return null
return (
<div className="card" style={{ marginTop: 16 }}>
<h3>Sites</h3>
<table>
<tbody>
{sites.map(s => (
<tr key={s.site_id}>
<td><b>{s.name}</b></td>
<td>
{/* .dot is sized, so it needs a box: a bare span is inline
and would collapse to nothing inside a table cell. */}
<span className={`dot ${s.online ? 'ok' : 'bad'}`}
style={{ display: 'inline-block', marginRight: 6 }} />
{s.online ? 'Reporting' : 'Not reporting'}
</td>
<td>{s.cameras_total
? `${s.cameras_up}/${s.cameras_total} camera${s.cameras_total === 1 ? '' : 's'} connected`
: 'no cameras'}</td>
{/* Dropped events are footfall this site permanently lost, so it
has to be visible rather than inferred from a dip in a graph. */}
<td>{s.dropped > 0
? <span style={{ color: 'var(--bad)' }}>{s.dropped} events lost</span>
: s.queued > 0 ? `${s.queued} queued` : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// Buckets come back as local wall time with no offset, labelled by the
// timezone in the report. Parsing them as a Date would re-interpret them in
// the viewer's zone and shift every label by hours.
function bucketLabel(bucket, size) {
if (!bucket) return ''
const [date, time] = bucket.split('T')
if (size === 'hour') return `${date.slice(5)} ${(time || '').slice(0, 5)}`
return date
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtTime } from '../hooks.js'
// What is happening right now. The first screen a shop manager opens, so it
// answers "is it working" before it answers anything else.
export default function Live() {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
const cams = useCameraFeeds()
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
const events = data?.events ?? []
// fraction_below_gate is the number that decides a site: what share of the
// faces this camera saw were too poor to enrol. Surfaced here rather than
// buried, because a high value looks exactly like "a quiet day".
const worst = cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
return (
<div className="page">
<header>
<h2>Live</h2>
<p>Cameras, recent detections, and whether this site is recognising people.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People known" value={gallery.identities ?? '—'} />
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
<Stat label="Cameras live"
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
<Stat label="Below quality gate"
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
: 'Share of faces too poor to enrol'} />
</div>
<Pipeline pipe={pipe} />
<div className="grid cols-2">
<div>
<div className="card">
<h3>Cameras</h3>
{cameras.length === 0
? <div className="empty">No cameras yet. Add one in Cameras.</div>
: <div className="feeds">
{cameras.map(c => (
<div className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={c.camera_id} />
: <div style={{ aspectRatio: '16/9' }} />}
<div className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</div>
</div>
))}
</div>}
</div>
</div>
<div className="card">
<h3>Recent detections</h3>
{events.length === 0
? <div className="empty">Nothing detected yet.</div>
: <ul className="events">
{events.map((e, i) => <EventRow key={i} e={e} />)}
</ul>}
</div>
</div>
</div>
)
}
// Whether anything is actually reaching head office. Without this the app can
// look perfectly healthy while every detection piles up on disk unsent — which
// is exactly what it did before the bridge existed.
function Pipeline({ pipe }) {
if (!pipe) return null
// A PC set up on its own is not "not linked yet" — nothing is coming, and
// saying so with an idle dot beside a count of zero reads as a fault.
if (pipe.standalone) {
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 10, alignItems: 'center' }}>
<i className="dot ok" />
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
<span className="note">Recognition and customers stay here.</span>
</div>
)
}
const stuck = pipe.claimed && !pipe.broker_up
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
<strong style={{ fontSize: 13 }}>
{!pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
</strong>
</span>
<span className="note">{pipe.accepted} recorded today</span>
{pipe.queued > 0 && (
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
{pipe.queued} waiting to send
</span>
)}
{pipe.dropped > 0 && (
<span className="note" style={{ color: 'var(--bad)' }}>
{pipe.dropped} lost — this PC was offline too long
</span>
)}
</div>
)
}
function EventRow({ e }) {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss' : ''
const age = e.data?.age ?? e.data?.age_range
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
return (
<li>
<span className="when">{fmtTime(e.ts)}</span>
<span className={`tag ${cls}`}>{label(e.type)}</span>
<span style={{ flex: 1, minWidth: 0 }}>
{e.data?.label || e.camera_id}
{extra && <span className="note"> · {extra}</span>}
</span>
</li>
)
}
// The event names are internal; a shop manager should not have to learn them.
function label(type) {
return {
'person.new': 'new',
'person.seen': 'returning',
'person.missed': 'missed',
'camera.up': 'camera up',
'camera.down': 'camera down',
'identity.merged': 'merged',
}[type] ?? type
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
{value}
</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}
// Stream URLs are fetched once per camera and then left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
// make every feed flicker permanently.
function useCameraFeeds() {
const [urls, setUrls] = useState({})
const { data } = usePolled(() => api.cameras(), 10000)
useEffect(() => {
let cancelled = false
;(async () => {
const next = {}
for (const cam of data ?? []) {
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
}
const changed = Object.keys(next).length !== Object.keys(urls).length ||
Object.keys(next).some(k => next[k] !== urls[k])
if (!cancelled && changed) setUrls(next)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
return urls
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// The gate. Nothing else in the app is reachable until this succeeds, because
// the broker credentials and the customer database both live behind it.
export default function Login({ onDone }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function submit(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
onDone(await api.login(email.trim(), password))
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<div className="box">
<h1>Behavision</h1>
<p className="lead">Sign in to connect this PC to your store.</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Email</span>
<input type="email" value={email} autoComplete="username" required
autoFocus onChange={e => setEmail(e.target.value)} />
</label>
<label className="field">
<span>Password</span>
<input type="password" value={password} autoComplete="current-password"
required onChange={e => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="foot">
Signing in downloads this store's recognition models and connects it
to your account. Nothing is sent until a camera is set up.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [{ label: '7 days', days: 7 }, { label: '30 days', days: 30 },
{ label: '90 days', days: 90 }]
// Footfall against sales. The number the business actually judges the product
// by: how many of the people who walked in bought something.
export default function Sales() {
const [range, setRange] = useState(RANGES[1])
const { data, error } = usePolled(
() => api.sales(daysAgo(range.days), today()), 60000, [range.days])
const cur = data?.currency || 'INR'
const money = n => typeof n === 'number'
? new Intl.NumberFormat(undefined, { style: 'currency', currency: cur,
maximumFractionDigits: 0 }).format(n)
: '—'
const conv = data?.conversion ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Sales summary</h2>
<p>How many of the people who came in actually bought something.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Visitors" value={data?.visitors ?? '—'} />
<Stat label="Bought something" value={data?.purchasers ?? '—'} />
<Stat label="Conversion" value={data ? `${Math.round(conv * 100)}%` : '—'}
tone={conv >= 0.3 ? 'ok' : conv > 0 ? 'warn' : undefined} />
<Stat label="Revenue" value={money(data?.revenue)}
sub={data ? `${money(data.average_basket)} average basket` : null} />
</div>
<div className="card">
<h3>Visitors who bought</h3>
{!data ? <div className="empty">Loading…</div>
: data.visitors === 0
? <div className="empty">No visits recorded in this period.</div>
: <>
<div style={{ display: 'flex', height: 34, borderRadius: 6,
overflow: 'hidden', border: '1px solid var(--line)' }}>
<div style={{ width: `${conv * 100}%`, background: 'var(--accent)' }} />
<div style={{ flex: 1, background: 'var(--surface-2)' }} />
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />
Bought — {data.purchasers}</span>
<span><i style={{ background: 'var(--surface-2)' }} />
Left without buying — {data.visitors - data.purchasers}</span>
</div>
</>}
</div>
<p className="note" style={{ marginTop: 14 }}>
Sales come from what staff enter on the customer form. A visit with no
amount counts as a visit that did not convert, which is what makes this
figure meaningful.
</p>
</div>
)
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Linking this PC to a shop — the first thing that happens on a new install,
// and until now the one thing the app could not do.
//
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
// has an installation code and, quite often, no account of their own yet; the
// PC's identity is not a person's identity. The endpoint behind this is
// deliberately unauthenticated for the same reason — requiring a login first
// would mean shipping a password to every shop that installs the software.
export default function Setup({ onDone, onCancel }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [alone, setAlone] = useState(false)
async function submit(e) {
e.preventDefault()
setBusy('claim'); setError(null)
try {
onDone(await api.claim(code))
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
async function standalone() {
setBusy('alone'); setError(null)
try {
onDone(await api.runStandalone())
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
return (
<div className="login">
<div className="box">
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Installation code</span>
{/* Uppercase and letter-spaced because the code arrives read aloud
down a phone or photographed off a screen. Spaces, dashes and
case are stripped on the server, so what is typed here can be
as untidy as it needs to be. */}
<input value={code} autoFocus required
placeholder="ABCDEF-123456-GHIJKL-789012"
autoComplete="off" spellCheck="false"
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
onChange={e => setCode(e.target.value)} />
</label>
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
</button>
</form>
<p className="foot">
The code works once. Ask whoever manages your shops for it — they can
create one from the Behavision platform, under the shop.
</p>
{/* The second way out of this screen, and the reason it exists.
Recognition, the cameras and this shop's own gallery all run on
this PC and need no server, so a shop with one till and no head
office was being blocked from adding a camera until somebody
issued it a code — the software refusing to do the thing it is
for. Linking later is still one click away, and it keeps the
visits already recorded here. */}
<div className="alt">
{onCancel
? <button type="button" className="linkbtn" onClick={onCancel}>
Not now — go back
</button>
: !alone
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
No head office — set this PC up on its own
</button>
: <>
<p className="note">
This PC will watch its cameras and recognise returning
customers on its own. Nothing is sent anywhere. You can link
it to head office later without losing anything recorded
here.
</p>
<button type="button" className="btn" disabled={!!busy}
onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
</button>
</>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { api } from '../bridge.js'
import { usePolled } from '../hooks.js'
// One customer's timeline.
//
// The endpoint and the binding for this both already existed and nothing
// called them, which meant the product could recognise a returning customer
// and then had no screen able to say when they had been before — the single
// question staff ask about a regular.
//
// Not polled: a record sheet open on a counter should not re-query every few
// seconds, and the visit that matters is happening at the counter, not in the
// list.
export default function VisitHistory({ customer }) {
const { data, error, loading } = usePolled(
() => api.visitorHistory(customer.id, 50), 0, [customer.id])
if (loading) return <p className="note">Loading visits…</p>
if (error) return <p className="note">Could not load visits: {error}</p>
const visits = data ?? []
if (visits.length === 0) {
return <p className="note">No recorded visits yet.</p>
}
return (
<ul className="timeline">
{visits.map(v => (
<li key={v.id}>
<span className="when">{whenLabel(v.occurred_at)}</span>
<span className="where">
{v.site || 'this store'}
{v.camera_id ? <span className="note"> · {v.camera_id}</span> : null}
</span>
{v.is_new_visitor && <span className="tag new">first visit</span>}
</li>
))}
</ul>
)
}
// The server returns visit times as an instant; showing the date and the time
// of day matters more than precision here — "Tuesday afternoon" is how staff
// remember a customer.
function whenLabel(iso) {
const d = new Date(iso)
if (isNaN(d)) return '—'
return d.toLocaleString([], {
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
})
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
// Wails serves these from an embedded FS at the root, so relative asset
// paths are required - absolute ones 404 inside the webview.
assetsDir: 'assets',
emptyOutDir: true,
},
base: './',
})