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:
97
web/src/App.jsx
Normal file
97
web/src/App.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, signedIn, clearTokens } from './api.js'
|
||||
import Login from './views/Login.jsx'
|
||||
import Sites from './views/Sites.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import CamerasView from './views/Cameras.jsx'
|
||||
import Assistant from './views/Assistant.jsx'
|
||||
import Clients from './views/Clients.jsx'
|
||||
|
||||
// A platform admin has no client of their own, so the tenant screens have
|
||||
// nothing to show them. Rather than render empty pages, they get the one screen
|
||||
// that is theirs.
|
||||
//
|
||||
// Customers and Reports were removed from the navigation - not deleted, the
|
||||
// views and their API routes are untouched - the same trim the desktop app
|
||||
// already made and for the same reason: this is the screen somebody opens to
|
||||
// find out whether their shops are WORKING. A customer search and a
|
||||
// month-on-month chart are a different job, and putting them in the same nav
|
||||
// implies the estate is healthy enough to be worth reporting on before anyone
|
||||
// has checked. They come back when there is something to say with them.
|
||||
const TENANT_VIEWS = [
|
||||
{ id: 'sites', label: 'Shops', View: Sites },
|
||||
{ id: 'live', label: 'Live', View: Live },
|
||||
{ id: 'cameras', label: 'Cameras', View: CamerasView },
|
||||
]
|
||||
const ADMIN_VIEWS = [
|
||||
{ id: 'clients', label: 'Companies', View: Clients },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(null)
|
||||
const [booting, setBooting] = useState(true)
|
||||
const [view, setView] = useState('sites')
|
||||
const [asking, setAsking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (signedIn()) {
|
||||
try { setUser(await api.me()) } catch { clearTokens() }
|
||||
}
|
||||
setBooting(false)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (booting) {
|
||||
return <div className="boot"><span className="spinner" aria-hidden="true" />Loading…</div>
|
||||
}
|
||||
if (!user) return <Login onSignedIn={setUser} />
|
||||
|
||||
const isAdmin = user.role === 'admin' && !user.client_id
|
||||
const views = isAdmin ? ADMIN_VIEWS : TENANT_VIEWS
|
||||
const current = views.find(v => v.id === view) || views[0]
|
||||
const Current = current.View
|
||||
|
||||
const signOut = async () => { await api.logout(); setUser(null) }
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<span className="mark" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Behavision</strong>
|
||||
<span className="org">{user.client_name || 'Loyaly platform'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="tabs">
|
||||
{views.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
aria-current={current.id === v.id ? 'page' : undefined}
|
||||
>{v.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="who">
|
||||
<button className={'ask-btn' + (asking ? ' on' : '')}
|
||||
onClick={() => setAsking(a => !a)}>
|
||||
<span aria-hidden="true">✳</span> Ask
|
||||
</button>
|
||||
<span className="name">{user.full_name || user.email}</span>
|
||||
<span className="role">{isAdmin ? 'platform admin' : user.role}</span>
|
||||
<button className="ghost" onClick={signOut}>Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={'with-assistant' + (asking ? ' open' : '')}>
|
||||
<main className="page">
|
||||
<Current user={user} />
|
||||
</main>
|
||||
<Assistant open={asking} onClose={() => setAsking(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
web/src/api.js
Normal file
229
web/src/api.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// The cloud API, as this browser sees it.
|
||||
//
|
||||
// Everything the platform does goes through `send` below, so there is exactly
|
||||
// one place that knows how a session is carried, refreshed and lost.
|
||||
|
||||
const ACCESS = 'bv.access'
|
||||
const REFRESH = 'bv.refresh'
|
||||
|
||||
// localStorage, not a cookie. The API authenticates with a bearer token and
|
||||
// sets no cookie of its own, so there is no CSRF surface to defend - and a
|
||||
// head-office user who closes the tab expects to still be signed in tomorrow.
|
||||
function read(k) { try { return localStorage.getItem(k) || '' } catch { return '' } }
|
||||
function write(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k) } catch { /* private mode */ } }
|
||||
|
||||
export function tokens() { return { access: read(ACCESS), refresh: read(REFRESH) } }
|
||||
export function setTokens(access, refresh) { write(ACCESS, access); write(REFRESH, refresh) }
|
||||
export function clearTokens() { setTokens('', '') }
|
||||
export function signedIn() { return !!read(REFRESH) }
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, code, message) {
|
||||
super(message)
|
||||
this.status = status
|
||||
// The server's own code, kept alongside its prose. A caller that has to
|
||||
// match on English to tell a normal absence from a fault will get it wrong
|
||||
// the first time the wording is improved.
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// One refresh at a time, ever.
|
||||
//
|
||||
// The refresh token is single-use and rotates. Four screens polling at once
|
||||
// would each spend it and three would lose, logging the user out at random -
|
||||
// so every caller that hits an expired token waits on the same promise.
|
||||
let refreshing = null
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) return refreshing
|
||||
refreshing = (async () => {
|
||||
const { refresh: rt } = tokens()
|
||||
if (!rt) throw new ApiError(401, 'unauthorized', 'Signed out.')
|
||||
const res = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rt }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearTokens()
|
||||
throw new ApiError(res.status, 'unauthorized', 'Your session has ended. Sign in again.')
|
||||
}
|
||||
const s = await res.json()
|
||||
// Persisted BEFORE anything else runs. A tab that refreshes and is then
|
||||
// closed must not come back holding a token the server already retired.
|
||||
setTokens(s.access_token, s.refresh_token)
|
||||
return s
|
||||
})().finally(() => { refreshing = null })
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function send(method, path, body, retry = true) {
|
||||
const { access } = tokens()
|
||||
const headers = {}
|
||||
if (access) headers.Authorization = 'Bearer ' + access
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
// Serialised up front: a retry has to send the same body again, and a
|
||||
// stream would be spent after the first attempt.
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.status === 204) return null
|
||||
const text = await res.text()
|
||||
let parsed = null
|
||||
try { parsed = text ? JSON.parse(text) : null } catch { /* not json */ }
|
||||
|
||||
if (res.ok) return parsed
|
||||
|
||||
const code = parsed?.error || ''
|
||||
if (code === 'token_expired' && retry) {
|
||||
// Silent. A shop assistant should not be thrown back to a login form twice
|
||||
// a day because an access token reached twelve hours old.
|
||||
await refresh()
|
||||
return send(method, path, body, false)
|
||||
}
|
||||
if (res.status === 401) clearTokens()
|
||||
throw new ApiError(res.status, code,
|
||||
parsed?.message || `Something went wrong (${res.status}).`)
|
||||
}
|
||||
|
||||
const qs = (params) => {
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params || {})) {
|
||||
if (v !== undefined && v !== null && v !== '') p.set(k, v)
|
||||
}
|
||||
const s = p.toString()
|
||||
return s ? '?' + s : ''
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(email, password) {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body?.error || '',
|
||||
body?.message || 'Could not sign in.')
|
||||
}
|
||||
setTokens(body.access_token, body.refresh_token)
|
||||
return body.user
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
|
||||
clearTokens()
|
||||
},
|
||||
|
||||
me: () => send('GET', '/api/auth/me'),
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
visitors: (q, limit = 50) => send('GET', '/api/visitors' + qs({ q, limit })),
|
||||
visitorHistory: (id, limit = 50) =>
|
||||
send('GET', `/api/visitors/${encodeURIComponent(id)}/history` + qs({ limit })),
|
||||
saveProfile: (id, profile) =>
|
||||
send('PUT', `/api/visitors/${encodeURIComponent(id)}/profile`, profile),
|
||||
|
||||
footfall: (params) => send('GET', '/api/reports/footfall' + qs(params)),
|
||||
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
|
||||
|
||||
cameras: () => send('GET', '/api/cameras'),
|
||||
createCamera: (siteID, cam) =>
|
||||
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
|
||||
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
|
||||
deleteCamera: (id) => send('DELETE', `/api/cameras/${encodeURIComponent(id)}`),
|
||||
|
||||
// Prove a camera works. `kind` is "connection" (can the shop PC open the
|
||||
// stream) or "placement" (does someone walking past produce a usable view).
|
||||
checkCamera: (id, kind, seconds) =>
|
||||
send('POST', `/api/cameras/${encodeURIComponent(id)}/check`,
|
||||
{ kind, ...(seconds ? { seconds } : {}) }),
|
||||
siteCheck: (siteID) =>
|
||||
send('GET', `/api/sites/${encodeURIComponent(siteID)}/check`),
|
||||
|
||||
// The one-shot code a shop PC is claimed with. Returned in full exactly once
|
||||
// - only a hash is stored - so whatever calls this has to show it there and
|
||||
// then, and must not expect to read it back later.
|
||||
enrolmentCode: (siteID, input) =>
|
||||
send('POST', `/api/sites/${encodeURIComponent(siteID)}/enrolment-code`,
|
||||
input || {}),
|
||||
|
||||
ask: (history) => send('POST', '/api/assistant', { history }),
|
||||
|
||||
clients: () => send('GET', '/api/admin/clients'),
|
||||
createClient: (input) => send('POST', '/api/admin/clients', input),
|
||||
}
|
||||
|
||||
// The live stream, read with fetch rather than EventSource.
|
||||
//
|
||||
// EventSource cannot set headers, so using it would mean putting the session
|
||||
// token in the query string - where it lands in server logs, browser history
|
||||
// and any screenshot of the URL bar. Reading the stream by hand costs the
|
||||
// frame parsing below and keeps the token in an Authorization header.
|
||||
//
|
||||
// The cursor is held HERE, not derived from what is on screen. That is what
|
||||
// makes a reconnect lossless: whatever arrived while the connection was down
|
||||
// is delivered on the next one.
|
||||
export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
||||
let stopped = false
|
||||
let position = cursor || ''
|
||||
|
||||
const run = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const { access } = tokens()
|
||||
const res = await fetch('/api/visits/stream' + qs({ cursor: position, site_id: siteId }), {
|
||||
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
await refresh()
|
||||
continue
|
||||
}
|
||||
if (!res.ok || !res.body) throw new Error('stream unavailable')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
// Frames are separated by a blank line. Anything after the last one
|
||||
// is a partial frame and stays in the buffer.
|
||||
let split
|
||||
while ((split = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, split)
|
||||
buf = buf.slice(split + 2)
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('id: ')) position = line.slice(4).trim()
|
||||
else if (line.startsWith('data: ')) {
|
||||
try { onPage(JSON.parse(line.slice(6))) } catch { /* partial */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stopped || signal?.aborted) return
|
||||
onError?.(err)
|
||||
}
|
||||
if (stopped) return
|
||||
// Reconnect. `position` survives, so nothing that arrived while we were
|
||||
// away is missed - that is the whole reason the cursor is held here and
|
||||
// not derived from what is on screen.
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
42
web/src/hooks.js
Normal file
42
web/src/hooks.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// usePolled runs `fn` now and every `everyMs`, and is careful about the two
|
||||
// things every screen would otherwise get wrong on its own: overlapping
|
||||
// requests when the server is slower than the interval, and setting state
|
||||
// after the component has gone.
|
||||
export function usePolled(fn, everyMs, deps = []) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const inFlight = useRef(false)
|
||||
const alive = useRef(true)
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true
|
||||
try {
|
||||
const out = await fn()
|
||||
if (!alive.current) return
|
||||
setData(out)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (!alive.current) return
|
||||
setError(err)
|
||||
} finally {
|
||||
inFlight.current = false
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
run()
|
||||
if (!everyMs) return () => { alive.current = false }
|
||||
const t = setInterval(run, everyMs)
|
||||
return () => { alive.current = false; clearInterval(t) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [run, everyMs])
|
||||
|
||||
return { data, error, loading, reload: run }
|
||||
}
|
||||
6
web/src/main.jsx
Normal file
6
web/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
499
web/src/styles.css
Normal file
499
web/src/styles.css
Normal file
@@ -0,0 +1,499 @@
|
||||
/* Behavision platform — head office.
|
||||
Shares the desktop app's palette on purpose: they are one product, and an
|
||||
owner who sees a shop PC and then this should not wonder whether they are
|
||||
looking at the same system. The layout differs because the questions do —
|
||||
the desktop is an instrument panel watched all day, this is read on a laptop
|
||||
and scrolls. */
|
||||
|
||||
: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;
|
||||
--ok-dim: #12291F;
|
||||
--warn: #E0A33A;
|
||||
--warn-dim: #2C2313;
|
||||
--bad: #E0655A;
|
||||
--bad-dim: #2B1917;
|
||||
--radius: 10px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
code { font-family: var(--mono); font-size: .9em; }
|
||||
h1 { font-size: 22px; font-weight: 620; letter-spacing: -.015em; }
|
||||
h2 { font-size: 16px; font-weight: 600; }
|
||||
h3 { font-size: 13px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); }
|
||||
.sub { color: var(--muted); font-size: 13.5px; }
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
.ok { color: var(--ok); } .warn { color: var(--warn); } .bad { color: var(--bad); }
|
||||
/* Those three are text utilities and they are inherited, so a card that carried
|
||||
`ok` as its own state class tinted every word inside it green. Card severity
|
||||
is therefore namespaced `state-*` below - a structural state and a colour
|
||||
utility must not share a name. */
|
||||
|
||||
/* ------------------------------------------------------------- shell ---- */
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; gap: 28px;
|
||||
padding: 0 24px; height: 60px;
|
||||
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 11px; }
|
||||
.brand strong { display: block; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.brand .org { display: block; font-size: 12px; color: var(--muted); }
|
||||
.mark {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 2.5px solid var(--accent);
|
||||
box-shadow: inset 0 0 0 3px var(--ground);
|
||||
flex: none;
|
||||
}
|
||||
.mark.big { width: 30px; height: 30px; border-width: 3px; margin-bottom: 14px; }
|
||||
|
||||
.tabs { display: flex; gap: 2px; margin-right: auto; }
|
||||
.tabs button {
|
||||
background: none; border: 0; border-radius: 7px; padding: 7px 13px;
|
||||
color: var(--ink-2); cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.tabs button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.tabs button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
|
||||
.who { display: flex; align-items: center; gap: 12px; }
|
||||
.who .name { font-size: 13.5px; }
|
||||
.who .role { font-size: 11px; color: var(--muted); text-transform: uppercase;
|
||||
letter-spacing: .07em; }
|
||||
|
||||
.page { flex: 1; padding: 26px 24px 64px; max-width: 1280px; width: 100%;
|
||||
margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
.head { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
||||
.head h1 { margin-right: auto; }
|
||||
.head .sub { padding-bottom: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------ buttons --- */
|
||||
button.primary {
|
||||
background: var(--accent); color: #04161B; border: 0; border-radius: 7px;
|
||||
padding: 9px 16px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button.primary:disabled { opacity: .5; cursor: default; }
|
||||
button.ghost {
|
||||
background: none; border: 1px solid var(--line); border-radius: 7px;
|
||||
padding: 7px 13px; color: var(--ink-2); cursor: pointer;
|
||||
}
|
||||
button.ghost:hover { border-color: var(--muted); color: var(--ink); }
|
||||
|
||||
.segmented { display: flex; border: 1px solid var(--line); border-radius: 8px;
|
||||
overflow: hidden; }
|
||||
.segmented button {
|
||||
background: none; border: 0; padding: 7px 14px; color: var(--ink-2);
|
||||
cursor: pointer; font-size: 13.5px;
|
||||
}
|
||||
.segmented button + button { border-left: 1px solid var(--line); }
|
||||
.segmented button[aria-current] { background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
/* -------------------------------------------------------------- cards --- */
|
||||
.card { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid.sites { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.grid.stats { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
|
||||
/* Shops, laid out like cameras: the shop's own camera view IS the card.
|
||||
The severity stripe survives on the left edge - a card that is entirely red
|
||||
reads as broken even when the shop is merely busy. */
|
||||
.card.site { overflow: hidden; padding: 0; cursor: pointer;
|
||||
border-left: 3px solid var(--line);
|
||||
transition: border-color .15s ease, transform .15s ease; }
|
||||
.card.site.state-ok { border-left-color: var(--ok); }
|
||||
.card.site.state-warn { border-left-color: var(--warn); }
|
||||
.card.site.state-bad { border-left-color: var(--bad); }
|
||||
.card.site.state-idle { border-left-color: var(--muted); }
|
||||
.card.site:hover { transform: translateY(-1px); }
|
||||
.card.site:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .card.site { transition: none; } }
|
||||
|
||||
/* Three numbers, evenly weighted, because they are three different questions
|
||||
and none of them is the headline. The headline is the verdict line below. */
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.metric { padding: 11px 6px; text-align: center; }
|
||||
.metric + .metric { border-left: 1px solid var(--line-soft); }
|
||||
.metric b { display: block; font-size: 15px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
|
||||
.metric b.ok { color: var(--ink); } .metric b.warn { color: var(--warn); }
|
||||
.metric b.bad { color: var(--bad); } .metric b.idle { color: var(--muted); }
|
||||
.metric span { display: block; margin-top: 1px; font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* A shopfront, drawn for the same reason the camera tile draws a lens: most
|
||||
deployments store no images, so an empty tile is the ordinary state and it
|
||||
should still read as a shop. */
|
||||
.shopmark { width: 38px; height: 30px; fill: none; stroke: var(--line);
|
||||
stroke-width: 2; stroke-linejoin: round; }
|
||||
|
||||
.pill {
|
||||
display: inline-block; padding: 3px 9px; border-radius: 20px; flex: none;
|
||||
font-size: 11.5px; font-weight: 550; letter-spacing: .01em;
|
||||
background: var(--surface-2); color: var(--ink-2);
|
||||
}
|
||||
.pill.ok { background: var(--ok-dim); color: var(--ok); }
|
||||
.pill.warn { background: var(--warn-dim); color: var(--warn); }
|
||||
.pill.bad { background: var(--bad-dim); color: var(--bad); }
|
||||
.pill.new { background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--muted); margin-right: 7px; vertical-align: 1px; }
|
||||
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); }
|
||||
|
||||
/* ------------------------------------------------------------- stats ---- */
|
||||
.card.stat { padding: 16px 18px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.15; }
|
||||
.stat .label { font-size: 13.5px; color: var(--ink-2); }
|
||||
.stat .note { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
|
||||
|
||||
.banner { padding: 12px 16px; border-radius: var(--radius); font-size: 14px;
|
||||
display: flex; gap: 16px; align-items: flex-start; }
|
||||
.banner.warn { background: var(--warn-dim); border: 1px solid #4A3A18; color: #F0D9A6; }
|
||||
.banner.ok { background: var(--ok-dim); border: 1px solid #1E4534; color: #C6E9D6; }
|
||||
.banner > div { flex: 1; }
|
||||
.creds { display: flex; gap: 26px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.creds dt { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); }
|
||||
.creds dd { font-family: var(--mono); font-size: 14px; user-select: all; }
|
||||
|
||||
/* ----------------------------------------------------------- arrivals --- */
|
||||
.arrivals { list-style: none; padding: 0; display: grid; gap: 8px; }
|
||||
.card.arrival { display: flex; align-items: center; gap: 14px; padding: 11px 14px; }
|
||||
.face { width: 46px; height: 46px; border-radius: 50%; flex: none;
|
||||
object-fit: cover; background: var(--surface-2); }
|
||||
.face.initials { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 15px; font-weight: 600; letter-spacing: .02em; }
|
||||
.who-col { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
|
||||
.who-col strong { font-weight: 570; }
|
||||
.attrs { font-size: 12.5px; color: var(--muted); text-transform: capitalize; }
|
||||
|
||||
/* ------------------------------------------------------------ tables ---- */
|
||||
.toolbar { display: flex; gap: 10px; }
|
||||
.search {
|
||||
flex: 1; max-width: 380px; background: var(--surface);
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: 9px 13px;
|
||||
}
|
||||
.search::placeholder { color: var(--muted); }
|
||||
/* Every placeholder, not just the search box. On this ground a browser's
|
||||
default placeholder is nearly as bright as a typed value, so an example in a
|
||||
field reads as an answer already given - an operator saw "counter PC" in the
|
||||
PC-label box and reasonably believed they had labelled it. */
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 1; }
|
||||
.tablewrap { overflow-x: auto; background: var(--surface);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
table.rows { border-collapse: collapse; width: 100%; min-width: 620px; }
|
||||
table.rows th, table.rows td { text-align: left; padding: 11px 16px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
table.rows tr:last-child td { border-bottom: 0; }
|
||||
table.rows th { font-size: 11.5px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); font-weight: 600; background: var(--surface-2); }
|
||||
table.rows tbody tr { cursor: pointer; }
|
||||
table.rows tbody tr:hover { background: var(--surface-2); }
|
||||
table.rows td.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ------------------------------------------------------------- chart ---- */
|
||||
.chart-card { padding: 18px; }
|
||||
.chart-head { margin-bottom: 16px; }
|
||||
.peak { font-size: 12.5px; color: var(--muted); margin-bottom: 8px; }
|
||||
.peak b { color: var(--ink-2); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.chart { display: flex; align-items: flex-end; gap: 3px; height: 190px;
|
||||
overflow-x: auto; padding-bottom: 4px; }
|
||||
.col { flex: 1; min-width: 16px; display: flex; flex-direction: column;
|
||||
align-items: center; gap: 6px; height: 100%; }
|
||||
.bars { flex: 1; width: 100%; display: flex; flex-direction: column;
|
||||
justify-content: flex-end; }
|
||||
.bar { display: block; width: 100%; border-radius: 2px 2px 0 0; }
|
||||
.bar.new { background: var(--accent); }
|
||||
.bar.returning { background: var(--accent-dim); border-radius: 0; }
|
||||
.col:hover .bar.returning { background: #1B4552; }
|
||||
.tick { font-size: 10.5px; color: var(--muted); white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
.legend { display: flex; gap: 18px; align-items: center; margin-top: 14px;
|
||||
padding-top: 12px; border-top: 1px solid var(--line-soft);
|
||||
font-size: 12.5px; color: var(--ink-2); }
|
||||
.legend span { display: flex; align-items: center; gap: 7px; }
|
||||
.swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.swatch.new { background: var(--accent); }
|
||||
.swatch.returning { background: var(--accent-dim); }
|
||||
.pad { padding: 24px 0; }
|
||||
|
||||
/* ------------------------------------------------------------ drawer ---- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(4, 8, 10, .62);
|
||||
display: flex; justify-content: flex-end; z-index: 50; }
|
||||
.drawer { width: min(560px, 100%); background: var(--surface);
|
||||
border-left: 1px solid var(--line); height: 100%; overflow-y: auto;
|
||||
display: flex; flex-direction: column; }
|
||||
.drawer.narrow { width: min(440px, 100%); }
|
||||
/* Sticky, and inside the scrolling panel rather than positioned against the
|
||||
fixed overlay — otherwise Close prints itself over whatever has scrolled
|
||||
under it. */
|
||||
.drawer-head { position: sticky; top: 0; z-index: 1; display: flex;
|
||||
align-items: flex-start; gap: 16px; padding: 18px 22px;
|
||||
background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||
.drawer-head h2 { margin-right: auto; }
|
||||
.drawer-head > div { flex: 1; }
|
||||
.drawer-body { padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.drawer-body + .drawer-body { border-top: 1px solid var(--line-soft); }
|
||||
fieldset { border: 0; padding: 0; margin: 0; display: flex;
|
||||
flex-direction: column; gap: 14px; }
|
||||
fieldset:disabled { opacity: .6; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; font-size: 13px;
|
||||
color: var(--ink-2); }
|
||||
label input[type="text"], label input[type="email"], label input[type="password"],
|
||||
label input:not([type]), .drawer input {
|
||||
background: var(--ground); border: 1px solid var(--line); border-radius: 7px;
|
||||
padding: 9px 12px; color: var(--ink); font-size: 14.5px;
|
||||
}
|
||||
label .hint { font-size: 12px; color: var(--muted); line-height: 1.45; }
|
||||
label.check { flex-direction: row; align-items: center; gap: 9px; }
|
||||
label.check input { width: auto; }
|
||||
|
||||
.timeline { list-style: none; padding: 0; display: grid; gap: 8px; }
|
||||
.timeline li { display: flex; align-items: center; gap: 12px; font-size: 13.5px;
|
||||
padding: 9px 12px; background: var(--ground); border-radius: 7px; }
|
||||
.timeline .when { font-variant-numeric: tabular-nums; }
|
||||
.timeline .where { color: var(--muted); margin-right: auto; }
|
||||
|
||||
/* ------------------------------------------------------------ states ---- */
|
||||
.state { display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 64px 20px; text-align: center; color: var(--ink-2); }
|
||||
.state .sub { max-width: 46ch; }
|
||||
.boot { min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
gap: 10px; color: var(--muted); }
|
||||
.error { color: var(--bad); font-size: 13.5px; }
|
||||
.spinner { width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin .7s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
|
||||
/* ------------------------------------------------------------ sign in --- */
|
||||
.signin { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.signin .card { width: min(380px, 100%); padding: 30px; display: flex;
|
||||
flex-direction: column; gap: 14px; }
|
||||
.signin h1 { font-size: 20px; }
|
||||
.signin .sub { margin-top: -8px; margin-bottom: 6px; }
|
||||
.signin .foot { font-size: 12.5px; color: var(--muted); text-align: center;
|
||||
margin-top: 4px; line-height: 1.5; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { height: auto; flex-wrap: wrap; padding: 12px 16px; gap: 12px; }
|
||||
.tabs { order: 3; width: 100%; overflow-x: auto; }
|
||||
.page { padding: 18px 16px 48px; }
|
||||
.who .name { display: none; }
|
||||
}
|
||||
|
||||
|
||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
select { background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 7px; padding: 9px 12px; color: var(--ink);
|
||||
font-size: 14.5px; }
|
||||
button.ghost.danger { border-color: #4A211D; color: var(--bad); }
|
||||
button.ghost.danger:hover { border-color: var(--bad); }
|
||||
|
||||
/* ------------------------------------------------------- camera setup --- */
|
||||
.drawer.wizard { width: min(560px, 100%); }
|
||||
|
||||
/* The step rail encodes real sequence: each step is unanswerable until the one
|
||||
before it has passed, which is why they are numbered rather than tabbed. */
|
||||
.steps { list-style: none; display: flex; gap: 4px; padding: 14px 22px;
|
||||
margin: 0; border-bottom: 1px solid var(--line-soft);
|
||||
background: var(--surface); position: sticky; top: 62px; z-index: 1; }
|
||||
.steps li { flex: 1; display: flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; color: var(--muted); min-width: 0; }
|
||||
.steps li .dot { width: 20px; height: 20px; border-radius: 50%; flex: none;
|
||||
display: grid; place-items: center; font-size: 11px;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.steps li.now { color: var(--ink); font-weight: 550; }
|
||||
.steps li.now .dot { background: var(--accent); color: #04161B; }
|
||||
.steps li.done .dot { background: var(--ok-dim); color: var(--ok); }
|
||||
|
||||
.waiting { display: flex; gap: 12px; align-items: center; padding: 14px 16px;
|
||||
background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); }
|
||||
.waiting > div { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.outcome { border: 1px solid var(--line); border-left-width: 3px;
|
||||
border-radius: var(--radius); padding: 14px 16px;
|
||||
display: flex; flex-direction: column; gap: 12px; }
|
||||
.outcome.ok { border-left-color: var(--ok); background: var(--ok-dim); }
|
||||
.outcome.warn { border-left-color: var(--warn); background: var(--warn-dim); }
|
||||
.outcome.bad { border-left-color: var(--bad); background: var(--bad-dim); }
|
||||
.outcome-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.outcome-head b { font-size: 14.5px; }
|
||||
.proof { width: 100%; border-radius: 6px; display: block; background: #05090B; }
|
||||
.advice { margin: 0; padding-left: 18px; display: grid; gap: 6px;
|
||||
font-size: 13.5px; color: var(--ink-2); line-height: 1.5; }
|
||||
|
||||
/* Connected and verified are different claims; the card states both. */
|
||||
.verified { display: flex; align-items: center; gap: 8px; margin-top: 10px;
|
||||
font-size: 13px; }
|
||||
.verified .mark { width: 17px; height: 17px; border-radius: 50%; flex: none;
|
||||
display: grid; place-items: center; font-size: 10.5px; }
|
||||
.verified.ok { color: var(--ok); }
|
||||
.verified.ok .mark { background: var(--ok-dim); }
|
||||
.verified.warn { color: var(--warn); }
|
||||
.verified.warn .mark { background: var(--warn-dim); }
|
||||
.verified.bad { color: var(--bad); }
|
||||
.verified.bad .mark { background: var(--bad-dim); }
|
||||
.verified.idle { color: var(--muted); }
|
||||
.verified.idle .mark { background: var(--surface-2); }
|
||||
|
||||
/* ------------------------------------------------------- site check ----- */
|
||||
.checklist { list-style: none; padding: 0; margin: 0; display: grid; gap: 2px; }
|
||||
.checklist li { display: flex; gap: 12px; padding: 13px 14px;
|
||||
background: var(--ground); border-radius: 8px;
|
||||
border-left: 3px solid var(--line); }
|
||||
.checklist li > div { display: flex; flex-direction: column; gap: 3px; flex: 1; }
|
||||
.checklist li.pass { border-left-color: var(--ok); }
|
||||
.checklist li.warn { border-left-color: var(--warn); }
|
||||
.checklist li.fail { border-left-color: var(--bad); }
|
||||
.checklist li.unknown { border-left-color: var(--muted); }
|
||||
.checklist .mark { width: 20px; height: 20px; border-radius: 50%; flex: none;
|
||||
display: grid; place-items: center; font-size: 11px;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.checklist li.pass .mark { background: var(--ok-dim); color: var(--ok); }
|
||||
.checklist li.warn .mark { background: var(--warn-dim); color: var(--warn); }
|
||||
.checklist li.fail .mark { background: var(--bad-dim); color: var(--bad); }
|
||||
.advice-line { font-size: 13px; color: var(--warn); line-height: 1.45; }
|
||||
.checklist li.pass .advice-line { color: var(--muted); }
|
||||
|
||||
/* ------------------------------------------------- pictures as cards --- */
|
||||
/* Shared by the camera and shop cards: the picture IS the card, and every
|
||||
technical detail lives one click away. */
|
||||
.grid.cams { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card.cam { overflow: hidden; border-left: 0; padding: 0; cursor: pointer;
|
||||
transition: border-color .15s ease, transform .15s ease; }
|
||||
.card.cam:hover { border-color: var(--muted); transform: translateY(-1px); }
|
||||
.card.cam:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .card.cam { transition: none; } }
|
||||
|
||||
.shot { position: relative; aspect-ratio: 16 / 10; background: #05090B;
|
||||
display: grid; place-items: center; }
|
||||
.shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.noshot { display: flex; flex-direction: column; align-items: center; gap: 10px;
|
||||
color: var(--muted); font-size: 12.5px; text-align: center;
|
||||
padding: 0 28px; line-height: 1.5; }
|
||||
/* A lens, drawn rather than an emoji: an empty tile that still reads as a
|
||||
camera, instead of a black hole with an apology in it. */
|
||||
.lens { width: 34px; height: 34px; border-radius: 50%;
|
||||
border: 2px solid var(--line); position: relative; }
|
||||
.lens::after { content: ''; position: absolute; inset: 7px; border-radius: 50%;
|
||||
border: 2px solid var(--line-soft); }
|
||||
|
||||
/* A gradient, so white text stays legible over a bright shop floor. */
|
||||
.shot-over { position: absolute; inset: auto 0 0 0; display: flex;
|
||||
align-items: flex-end; justify-content: space-between; gap: 10px;
|
||||
padding: 26px 14px 12px;
|
||||
background: linear-gradient(transparent, rgba(4,8,10,.85)); }
|
||||
.shot-name b { display: block; font-size: 14.5px; font-weight: 600;
|
||||
letter-spacing: -.01em; }
|
||||
.shot-name span { display: block; font-size: 11.5px; color: #9FB0BA; margin-top: 1px; }
|
||||
.status { display: flex; align-items: center; gap: 6px; flex: none;
|
||||
font-size: 11px; font-weight: 550; letter-spacing: .01em;
|
||||
padding: 4px 9px; border-radius: 20px; white-space: nowrap;
|
||||
background: rgba(14,19,23,.72); backdrop-filter: blur(6px);
|
||||
color: var(--ink-2); }
|
||||
.status i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
|
||||
.status.ok { color: #8FE0B4; } .status.ok i { background: var(--ok); }
|
||||
.status.warn { color: #EFC77C; } .status.warn i { background: var(--warn); }
|
||||
.status.bad { color: #F0A79E; } .status.bad i { background: var(--bad); }
|
||||
.status.idle i { background: var(--muted); }
|
||||
.shot-age { position: absolute; top: 10px; right: 12px; font-size: 10.5px;
|
||||
color: #9FB0BA; background: rgba(4,8,10,.6); padding: 2px 7px;
|
||||
border-radius: 20px; backdrop-filter: blur(6px); }
|
||||
|
||||
/* One line, and it is the line that matters. */
|
||||
.verdict { display: flex; align-items: center; gap: 10px; padding: 12px 14px;
|
||||
font-size: 13px; border-top: 1px solid var(--line-soft); }
|
||||
.verdict .words { flex: 1; min-width: 0; }
|
||||
.verdict .go { color: var(--muted); font-size: 14px; }
|
||||
.verdict .mark { width: 18px; height: 18px; border-radius: 50%; flex: none;
|
||||
display: grid; place-items: center; font-size: 10.5px; }
|
||||
.verdict.ok { color: var(--ok); } .verdict.ok .mark { background: var(--ok-dim); }
|
||||
.verdict.warn { color: var(--warn); } .verdict.warn .mark { background: var(--warn-dim); }
|
||||
.verdict.bad { color: var(--bad); } .verdict.bad .mark { background: var(--bad-dim); }
|
||||
.verdict.idle { color: var(--muted); }.verdict.idle .mark { background: var(--surface-2); }
|
||||
|
||||
/* Claiming a shop PC, inside the shop drawer. */
|
||||
.claim { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line-soft); }
|
||||
.claim h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||
.claim .field { display: block; margin: 12px 0; }
|
||||
.claim .field span { display: block; font-size: 12.5px; color: var(--muted); margin-bottom: 5px; }
|
||||
.claim .field input { width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 7px; padding: 9px 12px; }
|
||||
.claim .row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
/* Big, monospaced and selectable: this gets read down a phone line and typed
|
||||
into another machine, so ambiguity between O and 0 is a support call. */
|
||||
.claim .code { font-family: var(--mono); font-size: 19px; letter-spacing: .08em;
|
||||
background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 14px 12px; text-align: center;
|
||||
margin: 12px 0 10px; user-select: all; word-break: break-all; }
|
||||
|
||||
/* ---------------------------------------------------------- assistant --- */
|
||||
.with-assistant { display: flex; flex: 1; min-height: 0; }
|
||||
.with-assistant .page { flex: 1; min-width: 0; }
|
||||
.ask-btn { background: var(--accent-dim); color: var(--accent); border: 0;
|
||||
border-radius: 7px; padding: 7px 13px; cursor: pointer;
|
||||
font-size: 13.5px; font-weight: 550; display: flex; align-items: center; gap: 7px; }
|
||||
.ask-btn:hover, .ask-btn.on { background: var(--accent); color: #04161B; }
|
||||
|
||||
.assistant { width: 360px; flex: none; border-left: 1px solid var(--line);
|
||||
background: var(--surface); display: flex; flex-direction: column;
|
||||
position: sticky; top: 60px; height: calc(100vh - 60px); }
|
||||
.assistant-head { display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 15px 16px; border-bottom: 1px solid var(--line-soft); }
|
||||
.assistant-head > div { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.assistant-body { flex: 1; overflow-y: auto; padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 12px; }
|
||||
.assistant-ask { display: flex; gap: 8px; padding: 12px 14px;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.assistant-ask input { flex: 1; min-width: 0; background: var(--ground);
|
||||
border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 9px 12px; }
|
||||
|
||||
.bubble { padding: 11px 13px; border-radius: 12px; font-size: 14px;
|
||||
line-height: 1.55; white-space: pre-wrap; max-width: 100%; }
|
||||
.bubble.user { background: var(--accent-dim); color: var(--ink);
|
||||
align-self: flex-end; border-bottom-right-radius: 4px; }
|
||||
.bubble.assistant { background: var(--ground); border: 1px solid var(--line-soft);
|
||||
border-bottom-left-radius: 4px; }
|
||||
.bubble.failed { border-color: #4A211D; color: var(--bad); }
|
||||
.bubble.assistant-thinking { color: var(--muted); display: flex; align-items: center; gap: 9px; }
|
||||
.used { display: block; margin-top: 8px; padding-top: 7px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
font-size: 11.5px; color: var(--muted); }
|
||||
|
||||
.suggest { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.chip { background: var(--ground); border: 1px solid var(--line); border-radius: 20px;
|
||||
padding: 7px 13px; color: var(--ink-2); cursor: pointer; font-size: 13px;
|
||||
text-align: left; }
|
||||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.with-assistant { flex-direction: column; }
|
||||
.assistant { width: 100%; height: 60vh; position: static; border-left: 0;
|
||||
border-top: 1px solid var(--line); }
|
||||
}
|
||||
128
web/src/views/Assistant.jsx
Normal file
128
web/src/views/Assistant.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// The assistant, as a panel that follows you between screens.
|
||||
//
|
||||
// A docked panel rather than its own page, deliberately: every question worth
|
||||
// asking it is about what is on screen — "why is this shop showing zero", "is
|
||||
// this camera working" — and sending someone to a separate page to ask about
|
||||
// the page they just left is how a help feature goes unused.
|
||||
const SUGGESTIONS = [
|
||||
'Is everything working today?',
|
||||
'Why is footfall low at Chennai?',
|
||||
'How many people came in last week?',
|
||||
'Which cameras still need checking?',
|
||||
]
|
||||
|
||||
export default function Assistant({ open, onClose }) {
|
||||
const [history, setHistory] = useState([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [off, setOff] = useState(false)
|
||||
const endRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [history, busy])
|
||||
|
||||
const ask = async (text) => {
|
||||
const question = (text ?? draft).trim()
|
||||
if (!question || busy) return
|
||||
// The client holds the transcript. Nothing is stored server-side, so there
|
||||
// is no per-user chat log sitting in a database nobody agreed to.
|
||||
const next = [...history, { role: 'user', text: question }]
|
||||
setHistory(next)
|
||||
setDraft('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const answer = await api.ask(next)
|
||||
setHistory(h => [...h, { role: 'assistant', text: answer.text, used: answer.used }])
|
||||
} catch (err) {
|
||||
if (err.code === 'assistant_off') {
|
||||
setOff(true)
|
||||
} else {
|
||||
setHistory(h => [...h, { role: 'assistant', text: err.message, failed: true }])
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<aside className="assistant" role="complementary" aria-label="Assistant">
|
||||
<header className="assistant-head">
|
||||
<div>
|
||||
<b>Ask Behavision</b>
|
||||
<span className="sub">It reads your shops’ own data to answer.</span>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose} aria-label="Close assistant">✕</button>
|
||||
</header>
|
||||
|
||||
<div className="assistant-body">
|
||||
{off ? (
|
||||
<p className="sub pad">
|
||||
The assistant is not switched on for this server.
|
||||
</p>
|
||||
) : history.length === 0 ? (
|
||||
<div className="suggest">
|
||||
<p className="sub">Try asking:</p>
|
||||
{SUGGESTIONS.map(s => (
|
||||
<button key={s} className="chip" onClick={() => ask(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
history.map((m, i) => (
|
||||
<div key={i} className={'bubble ' + m.role + (m.failed ? ' failed' : '')}>
|
||||
{m.text}
|
||||
{m.used?.length > 0 && (
|
||||
// Said out loud. An assistant that silently ran a camera check
|
||||
// would be alarming, and naming what it looked at is what makes
|
||||
// a wrong answer traceable rather than mysterious.
|
||||
<span className="used">looked at: {m.used.map(pretty).join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{busy && (
|
||||
<div className="bubble assistant-thinking">
|
||||
<span className="spinner" aria-hidden="true" /> Looking…
|
||||
</div>
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{!off && (
|
||||
<form className="assistant-ask" onSubmit={e => { e.preventDefault(); ask() }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder="Ask about your shops…"
|
||||
disabled={busy}
|
||||
/>
|
||||
<button className="primary" disabled={busy || !draft.trim()}>Ask</button>
|
||||
</form>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// Tool names are internal. Nobody should have to learn them to read an answer.
|
||||
function pretty(name) {
|
||||
return {
|
||||
list_shops: 'your shops',
|
||||
check_shop: 'a shop check',
|
||||
list_cameras: 'your cameras',
|
||||
check_camera: 'a camera check',
|
||||
footfall: 'footfall',
|
||||
sales: 'sales',
|
||||
find_customer: 'customer records',
|
||||
}[name] || name
|
||||
}
|
||||
333
web/src/views/CameraSetup.jsx
Normal file
333
web/src/views/CameraSetup.jsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { MAKES, makeById } from '../../../shared/cameraMakes.js'
|
||||
|
||||
// Setting up a camera, for somebody who has never done it.
|
||||
//
|
||||
// Four steps, in the order the questions actually arise, and each one blocks
|
||||
// until it is answered. That is deliberate: the old form let you type an
|
||||
// address, press Save, and walk away believing you were finished — which is
|
||||
// exactly how a camera runs for weeks recognising nobody. Here, "added" and
|
||||
// "proven to work" are different states and the screen says which one you are
|
||||
// in.
|
||||
const STEPS = ['Camera', 'Connection', 'Test', 'Walk past']
|
||||
|
||||
export default function CameraSetup({ sites, existing, onClose, onSaved }) {
|
||||
const isNew = !existing?.id
|
||||
const [step, setStep] = useState(isNew ? 0 : 2)
|
||||
const [cam, setCam] = useState(existing?.id ? existing : null)
|
||||
const [form, setForm] = useState({
|
||||
site_id: existing?.site_id || sites[0]?.site_id || '',
|
||||
make: 'hikvision',
|
||||
label: existing?.label || '',
|
||||
host: existing?.host || '',
|
||||
port: existing?.port || 554,
|
||||
path: existing?.path || '/Streaming/Channels/101',
|
||||
username: existing?.username || '',
|
||||
password: '',
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }))
|
||||
const chooseMake = (e) => {
|
||||
const m = makeById(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.
|
||||
setForm(f => ({ ...f, make: m.id, path: m.path || f.path }))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError('')
|
||||
const body = {}
|
||||
for (const [k, v] of Object.entries(form)) {
|
||||
if (k === 'site_id' || k === 'make') continue
|
||||
if (v === '' || v == null) continue
|
||||
body[k] = k === 'port' ? Number(v) : v
|
||||
}
|
||||
try {
|
||||
const saved = cam?.id
|
||||
? await api.updateCamera(cam.id, body)
|
||||
: await api.createCamera(form.site_id, body)
|
||||
setCam(saved)
|
||||
setStep(2)
|
||||
onSaved?.(saved, { keepOpen: true })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer wizard" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{isNew ? 'Set up a camera' : (cam?.label || existing?.label)}</h2>
|
||||
<p className="sub">
|
||||
{step < 2
|
||||
? 'The shop’s PC connects to the camera — nothing needs opening to the internet.'
|
||||
: 'Prove it works before you rely on it.'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
|
||||
<ol className="steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s} className={i === step ? 'now' : i < step ? 'done' : ''}>
|
||||
<span className="dot" aria-hidden="true">{i < step ? '✓' : i + 1}</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="drawer-body">
|
||||
{isNew && (
|
||||
<label>Which shop
|
||||
<select value={form.site_id} onChange={set('site_id')}>
|
||||
{sites.map(s => <option key={s.site_id} value={s.site_id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>What should staff call it?
|
||||
<input value={form.label} onChange={set('label')}
|
||||
placeholder="Entrance" autoFocus />
|
||||
<span className="hint">
|
||||
Visits are recorded against this name, so it cannot be changed later.
|
||||
</span>
|
||||
</label>
|
||||
<label>Make of camera
|
||||
<select value={form.make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
<span className="hint">
|
||||
{makeById(form.make).note ||
|
||||
'This only fills in the stream path for you. You can change it on the next step.'}
|
||||
</span>
|
||||
</label>
|
||||
<button className="primary" disabled={!form.label.trim()}
|
||||
onClick={() => setStep(1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="drawer-body">
|
||||
<label>Camera’s address on the shop’s network
|
||||
<input value={form.host} onChange={set('host')}
|
||||
placeholder="192.168.0.138" autoFocus />
|
||||
<span className="hint">
|
||||
Not a website address. It usually starts 192.168. — look in the
|
||||
camera’s own app, on its label, or in your router’s device list.
|
||||
</span>
|
||||
</label>
|
||||
<div className="pair">
|
||||
<label>Port
|
||||
<input type="number" value={form.port} onChange={set('port')} />
|
||||
</label>
|
||||
<label>Stream path
|
||||
<input value={form.path} onChange={set('path')} />
|
||||
</label>
|
||||
</div>
|
||||
{/* autoComplete is load-bearing here, not boilerplate. Chrome sees
|
||||
a text field next to a password field and offers the saved
|
||||
Behavision login - so the first thing a shop owner did was
|
||||
submit their own email address as the camera's username, which
|
||||
fails with a message about credentials that points at the
|
||||
camera. "new-password" is the value Chrome actually honours;
|
||||
"off" it frequently ignores. */}
|
||||
<label>Camera username
|
||||
<input value={form.username} onChange={set('username')} placeholder="admin"
|
||||
name="camera-account" autoComplete="off"
|
||||
autoCorrect="off" autoCapitalize="none" spellCheck="false" />
|
||||
<span className="hint">
|
||||
The camera’s own login, not your Behavision one.
|
||||
</span>
|
||||
</label>
|
||||
<label>Camera password
|
||||
<input type="password" value={form.password} onChange={set('password')}
|
||||
name="camera-secret" autoComplete="new-password"
|
||||
placeholder={cam?.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<div className="pair">
|
||||
<button className="ghost" onClick={() => setStep(0)}>Back</button>
|
||||
<button className="primary" disabled={busy || !form.host.trim()} onClick={save}>
|
||||
{busy ? 'Saving…' : 'Save and test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step >= 2 && cam && (
|
||||
<CheckPanel
|
||||
camera={cam}
|
||||
step={step}
|
||||
onStep={setStep}
|
||||
onUpdated={(c) => { setCam(c); onSaved?.(c, { keepOpen: true }) }}
|
||||
onEdit={() => setStep(1)}
|
||||
onDone={onClose}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CheckPanel drives the two checks and shows whatever the shop PC reported.
|
||||
//
|
||||
// It polls, because the answer comes from a PC in another building that has to
|
||||
// pick the job up on its next sync. The wait is real and is stated rather than
|
||||
// hidden behind a spinner that looks stuck.
|
||||
function CheckPanel({ camera, step, onStep, onUpdated, onEdit, onDone }) {
|
||||
const [cam, setCam] = useState(camera)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const timer = useRef(null)
|
||||
|
||||
useEffect(() => setCam(camera), [camera])
|
||||
|
||||
const check = cam.check || {}
|
||||
const waiting = check.state === 'requested' || check.state === 'running'
|
||||
|
||||
// Poll only while something is outstanding. A finished check does not change.
|
||||
useEffect(() => {
|
||||
if (!waiting) return
|
||||
let alive = true
|
||||
const tick = async () => {
|
||||
try {
|
||||
const all = await api.cameras()
|
||||
const fresh = all.find(c => c.id === cam.id)
|
||||
if (alive && fresh) { setCam(fresh); onUpdated?.(fresh) }
|
||||
} catch { /* transient; the next tick tries again */ }
|
||||
}
|
||||
timer.current = setInterval(tick, 4000)
|
||||
return () => { alive = false; clearInterval(timer.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [waiting, cam.id])
|
||||
|
||||
const run = async (kind, seconds) => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const updated = await api.checkCamera(cam.id, kind, seconds)
|
||||
setCam(updated)
|
||||
onUpdated?.(updated)
|
||||
onStep(kind === 'placement' ? 3 : 2)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const connectionPassed = check.kind === 'connection' && check.state === 'done' && check.ok
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-body">
|
||||
{step === 2 ? (
|
||||
<>
|
||||
<h3>Can the shop’s PC reach this camera?</h3>
|
||||
<p className="sub">
|
||||
The PC opens the stream once and takes a single picture. Nothing is recorded.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3>Can it actually recognise a face?</h3>
|
||||
<p className="sub">
|
||||
Someone needs to <b>walk through the camera’s view and out of it</b>, the
|
||||
way a customer would. Standing still measures nothing — the check scores
|
||||
the best view of each person as they leave the frame, which is what
|
||||
recognition really uses.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{waiting && (
|
||||
<div className="waiting">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<div>
|
||||
<b>{check.state === 'running' ? 'Running on the shop’s PC…' : 'Waiting for the shop’s PC…'}</b>
|
||||
<span className="sub">
|
||||
{check.state === 'running'
|
||||
? 'This takes about ' + (check.seconds || 25) + ' seconds.'
|
||||
: 'It picks up the request within a couple of minutes.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{check.state === 'done' && <Outcome check={check} />}
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<div className="pair">
|
||||
{step === 2 ? (
|
||||
<>
|
||||
<button className="ghost" onClick={onEdit}>Change details</button>
|
||||
<button className="primary" disabled={busy || waiting}
|
||||
onClick={() => run('connection')}>
|
||||
{check.state === 'done' ? 'Test again' : 'Test connection'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="ghost" onClick={() => onStep(2)}>Back</button>
|
||||
<button className="primary" disabled={busy || waiting}
|
||||
onClick={() => run('placement', 25)}>
|
||||
{check.state === 'done' ? 'Check again' : 'Start walk-past check'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && connectionPassed && !waiting && (
|
||||
<button className="primary" onClick={() => onStep(3)}>
|
||||
Next: prove it can recognise faces
|
||||
</button>
|
||||
)}
|
||||
{step === 3 && check.state === 'done' && !waiting && (
|
||||
<button className="ghost" onClick={onDone}>Finish</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Outcome renders the shop PC's answer.
|
||||
//
|
||||
// Every word here came from the engine. It already distinguishes a refused
|
||||
// connection from a wrong path from a stream that opens and sends nothing, and
|
||||
// each needs a different thing done — so nothing is re-worded on the way.
|
||||
function Outcome({ check }) {
|
||||
const tone = check.ok ? 'ok' : check.verdict === 'marginal' ? 'warn' : 'bad'
|
||||
return (
|
||||
<div className={'outcome ' + tone}>
|
||||
<div className="outcome-head">
|
||||
<span className={'pill ' + tone}>{check.ok ? 'Working' : 'Not ready'}</span>
|
||||
<b>{check.headline || (check.ok ? 'Working' : 'Could not be verified')}</b>
|
||||
</div>
|
||||
|
||||
{check.image?.available && (
|
||||
<img className="proof" src={check.image.url}
|
||||
alt="The view from this camera" loading="lazy" />
|
||||
)}
|
||||
|
||||
{check.advice?.length > 0 && (
|
||||
<ul className="advice">
|
||||
{check.advice.map((a, i) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{check.detail?.faces != null && (
|
||||
<p className="sub">
|
||||
{check.detail.faces} {check.detail.faces === 1 ? 'person' : 'people'} walked
|
||||
past during the check.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
web/src/views/Cameras.jsx
Normal file
151
web/src/views/Cameras.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
import CameraSetup from './CameraSetup.jsx'
|
||||
|
||||
// Cameras, onboarded from head office.
|
||||
//
|
||||
// The shop PC is still the thing that CONNECTS — it is the only machine on the
|
||||
// camera's network — so everything here is desired state that its agent pulls
|
||||
// and applies. That is why a new camera reads "waiting for the shop PC" rather
|
||||
// than "connected": saying it is set up would be claiming something nobody has
|
||||
// verified yet.
|
||||
// verification is the second claim a camera card makes, and the one that
|
||||
// matters: connected says the stream opens, verified says a person walking past
|
||||
// can actually be recognised. Office1 was the first for weeks without ever
|
||||
// being the second.
|
||||
function verification(cam) {
|
||||
const c = cam.check || {}
|
||||
if (c.state === 'requested' || c.state === 'running') {
|
||||
return { tone: 'idle', mark: '…', words: 'Checking now' }
|
||||
}
|
||||
if (c.state !== 'done') {
|
||||
return { tone: 'idle', mark: '?', words: 'Not checked yet' }
|
||||
}
|
||||
if (c.kind === 'placement') {
|
||||
return c.ok
|
||||
? { tone: 'ok', mark: '✓', words: 'Recognises faces here' }
|
||||
: { tone: 'bad', mark: '✕', words: c.headline || 'Cannot recognise faces here' }
|
||||
}
|
||||
return c.ok
|
||||
? { tone: 'warn', mark: '!', words: 'Stream works — faces not checked yet' }
|
||||
: { tone: 'bad', mark: '✕', words: c.headline || 'Could not reach the camera' }
|
||||
}
|
||||
|
||||
export default function Cameras({ user }) {
|
||||
const { data: cams, error, loading, reload } =
|
||||
usePolled(() => api.cameras(), 20000, [])
|
||||
const { data: sites } = usePolled(() => api.sites(), 0, [])
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
const up = list.filter(c => c.connected).length
|
||||
const down = list.filter(c => c.connected === false).length
|
||||
const waiting = list.filter(c => c.connected == null).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Cameras</h1>
|
||||
<p className="sub">
|
||||
{list.length} {list.length === 1 ? 'camera' : 'cameras'}
|
||||
{up > 0 && <> · <b className="ok">{up} connected</b></>}
|
||||
{down > 0 && <> · <b className="bad">{down} down</b></>}
|
||||
{waiting > 0 && <> · {waiting} waiting for the shop PC</>}
|
||||
</p>
|
||||
{canEdit && (
|
||||
<button className="primary" onClick={() => setEditing({})}>
|
||||
Set up a camera
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{loading && !cams ? <Loading /> : error ? <Problem error={error} /> : (
|
||||
list.length === 0 ? (
|
||||
<div className="state">
|
||||
<h2>No cameras yet</h2>
|
||||
<p className="sub">
|
||||
Add one here and the shop’s PC will pick it up within a couple of
|
||||
minutes. Cameras already set up on a shop PC appear here on their own.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid cams">
|
||||
{list.map(c => (
|
||||
<CameraCard key={c.id} cam={c} canEdit={canEdit}
|
||||
onEdit={() => setEditing(c)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CameraSetup
|
||||
existing={editing.id ? editing : null}
|
||||
sites={sites || []}
|
||||
onClose={() => { setEditing(null); reload() }}
|
||||
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, canEdit, onEdit }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
const state = cam.connected == null ? 'idle'
|
||||
: cam.connected ? 'ok' : 'bad'
|
||||
const words = cam.connected == null ? 'Waiting for the shop PC'
|
||||
: cam.connected ? 'Connected' : 'Not connecting'
|
||||
const verified = verification(cam)
|
||||
|
||||
return (
|
||||
// The picture IS the card. A camera is a thing you look at, and the
|
||||
// previous layout made it a black rectangle sitting above a table of
|
||||
// connection settings — which is the view a developer wants, not a shop
|
||||
// owner. The technical detail moves behind Edit, where it is needed only
|
||||
// when something is being changed.
|
||||
<article className={'card cam state-' + state} onClick={canEdit ? onEdit : undefined}
|
||||
role={canEdit ? 'button' : undefined} tabIndex={canEdit ? 0 : undefined}
|
||||
onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
|
||||
<div className="shot">
|
||||
{cam.snapshot?.available
|
||||
? <img src={cam.snapshot.url} alt={`View from ${cam.label}`} loading="lazy" />
|
||||
: <div className="noshot">
|
||||
<span className="lens" aria-hidden="true" />
|
||||
{cam.snapshot?.reason || 'No picture yet.'}
|
||||
</div>}
|
||||
|
||||
{/* Over the picture, where a camera label belongs, rather than in a
|
||||
caption underneath it. */}
|
||||
<div className="shot-over">
|
||||
<div className="shot-name">
|
||||
<b>{cam.label || cam.camera_id}</b>
|
||||
<span>{cam.site}</span>
|
||||
</div>
|
||||
<span className={'status ' + state}>
|
||||
<i aria-hidden="true" />{words}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{cam.snapshot_at && (
|
||||
<span className="shot-age">{ago(cam.snapshot_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connected and verified are different claims, and the gap between them
|
||||
is where a site gets signed off broken: a camera can be streaming
|
||||
perfectly and still produce views nothing can recognise. This is the
|
||||
one fact worth carrying on the front of the card. */}
|
||||
<div className={'verdict ' + verified.tone}>
|
||||
<span className="mark" aria-hidden="true">{verified.mark}</span>
|
||||
<span className="words">{verified.words}</span>
|
||||
{canEdit && <span className="go" aria-hidden="true">→</span>}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
139
web/src/views/Clients.jsx
Normal file
139
web/src/views/Clients.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
|
||||
// Platform administration: the tenants everything else belongs to.
|
||||
//
|
||||
// Only reachable by an account with no client of its own. There is no public
|
||||
// registration — a stranger creating a tenant here is not a customer, it is a
|
||||
// row nobody asked for in a table every query joins against.
|
||||
export default function Clients() {
|
||||
const { data, error, loading, reload } = usePolled(() => api.clients(), 0, [])
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [created, setCreated] = useState(null)
|
||||
const rows = data || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Companies</h1>
|
||||
<button className="primary" onClick={() => { setCreated(null); setAdding(true) }}>
|
||||
New company
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{created && <Credentials result={created} onDismiss={() => setCreated(null)} />}
|
||||
|
||||
{loading && !data ? <Loading /> : error ? <Problem error={error} /> : (
|
||||
rows.length === 0 ? (
|
||||
<div className="state">
|
||||
<h2>No companies yet</h2>
|
||||
<p className="sub">Create one, and its owner can sign in straight away.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tablewrap">
|
||||
<table className="rows">
|
||||
<thead>
|
||||
<tr><th>Company</th><th>Short name</th><th>Sites</th><th>People</th><th>Created</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td><strong>{c.name}</strong></td>
|
||||
<td><code>{c.slug}</code></td>
|
||||
<td className="num">{c.sites}</td>
|
||||
<td className="num">{c.users}</td>
|
||||
<td className="sub">{ago(c.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<NewCompany
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(res) => { setAdding(false); setCreated(res); reload() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NewCompany({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState({
|
||||
company_name: '', owner_name: '', owner_email: '', password: '',
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
onCreated(await api.createClient(form))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<h2>New company</h2>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<form className="drawer-body" onSubmit={submit}>
|
||||
<label>Company name
|
||||
<input value={form.company_name} onChange={set('company_name')} required autoFocus />
|
||||
</label>
|
||||
<label>Owner’s name
|
||||
<input value={form.owner_name} onChange={set('owner_name')} />
|
||||
</label>
|
||||
<label>Owner’s email
|
||||
<input type="email" value={form.owner_email} onChange={set('owner_email')} required />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="text" value={form.password} onChange={set('password')}
|
||||
placeholder="Leave empty to generate one" />
|
||||
<span className="hint">
|
||||
Generated is better — a password you invent for someone else ends
|
||||
up weak and sent over chat.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create company'}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Shown once, and said so. The password is bcrypt-hashed on the way in and is
|
||||
// not recoverable — a credential support can look up later is a credential
|
||||
// everyone with support access holds.
|
||||
function Credentials({ result, onDismiss }) {
|
||||
return (
|
||||
<div className="banner ok credentials">
|
||||
<div>
|
||||
<b>{result.slug} created.</b> These sign-in details are shown once and
|
||||
cannot be recovered. Send them to the owner now.
|
||||
<dl className="creds">
|
||||
<div><dt>Email</dt><dd><code>{result.owner_email}</code></dd></div>
|
||||
<div><dt>Password</dt><dd><code>{result.password}</code></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<button className="ghost" onClick={onDismiss}>Done</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
web/src/views/Customers.jsx
Normal file
173
web/src/views/Customers.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
|
||||
export default function Customers({ user }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(null)
|
||||
|
||||
// Not polled. A customer list does not change under the person reading it,
|
||||
// and re-fetching every few seconds would fight the search box.
|
||||
const { data, error, loading, reload } = usePolled(
|
||||
() => api.visitors(query, 100), 0, [query])
|
||||
const rows = data || []
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Customers</h1>
|
||||
<p className="sub">People your cameras have recognised more than once.</p>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search by name, phone or email"
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !data ? <Loading /> : error ? <Problem error={error} /> : (
|
||||
rows.length === 0 ? (
|
||||
<div className="state">
|
||||
<h2>{query ? 'Nobody matches that' : 'No customers yet'}</h2>
|
||||
<p className="sub">
|
||||
{query
|
||||
? 'Try part of a name, or a phone number.'
|
||||
: 'Customers appear once a camera has recognised the same face twice.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tablewrap">
|
||||
<table className="rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th><th>Visits</th><th>First seen</th>
|
||||
<th>Last seen</th><th>Consent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(c => (
|
||||
<tr key={c.id} onClick={() => setOpen(c)} tabIndex={0}
|
||||
onKeyDown={e => e.key === 'Enter' && setOpen(c)}>
|
||||
<td>
|
||||
<strong>{c.full_name || c.label}</strong>
|
||||
{c.phone && <span className="sub"> · {c.phone}</span>}
|
||||
</td>
|
||||
<td className="num">{c.visit_count}</td>
|
||||
<td className="sub">{ago(c.first_seen_at)}</td>
|
||||
<td className="sub">{ago(c.last_seen_at)}</td>
|
||||
<td>{c.has_consent
|
||||
? <span className="pill ok">Given</span>
|
||||
: <span className="pill">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<Drawer
|
||||
customer={open}
|
||||
user={user}
|
||||
onClose={() => setOpen(null)}
|
||||
onSaved={() => { setOpen(null); reload() }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Drawer({ customer, user, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
full_name: customer.full_name || '',
|
||||
phone: customer.phone || '',
|
||||
email: customer.email || '',
|
||||
consent: !!customer.has_consent,
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const { data: history } = usePolled(
|
||||
() => api.visitorHistory(customer.id, 25), 0, [customer.id])
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager', 'staff'].includes(user.role)
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
await api.saveProfile(customer.id, { ...form, visitor_id: customer.id })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
{/* Sticky, so Close stays reachable and the name stays visible while
|
||||
reading a long visit history. */}
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{customer.full_name || customer.label}</h2>
|
||||
<p className="sub">{customer.visit_count} visits · last seen {ago(customer.last_seen_at)}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
|
||||
<form className="drawer-body" onSubmit={save}>
|
||||
<fieldset disabled={!canEdit}>
|
||||
<label>Name
|
||||
<input value={form.full_name}
|
||||
onChange={e => setForm({ ...form, full_name: e.target.value })} />
|
||||
</label>
|
||||
<label>Phone
|
||||
<input value={form.phone}
|
||||
onChange={e => setForm({ ...form, phone: e.target.value })} />
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })} />
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.consent}
|
||||
onChange={e => setForm({ ...form, consent: e.target.checked })} />
|
||||
<span>This customer agreed to us keeping their details</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{canEdit && (
|
||||
<button className="primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<section className="drawer-body">
|
||||
<h3>Visits</h3>
|
||||
{!history ? <p className="sub">Loading…</p> :
|
||||
history.length === 0 ? <p className="sub">No visits recorded.</p> : (
|
||||
<ol className="timeline">
|
||||
{history.map(v => (
|
||||
<li key={v.id}>
|
||||
<span className="when">{new Date(v.occurred_at).toLocaleString()}</span>
|
||||
<span className="where">{v.site}{v.camera_id ? ` · ${v.camera_id}` : ''}</span>
|
||||
{v.is_new_visitor && <span className="pill new">First visit</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
web/src/views/Live.jsx
Normal file
131
web/src/views/Live.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, streamArrivals } from '../api.js'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
|
||||
// Who just walked in, pushed as it happens.
|
||||
//
|
||||
// The list is capped rather than unbounded: this screen is left open on a wall
|
||||
// display for days, and an ever-growing array of arrivals — each holding an
|
||||
// image URL — is a tab that eventually falls over. Older arrivals are in the
|
||||
// reports; this view is about the last few minutes.
|
||||
const KEEP = 60
|
||||
|
||||
export default function Live() {
|
||||
const [arrivals, setArrivals] = useState([])
|
||||
const [status, setStatus] = useState('connecting')
|
||||
const [error, setError] = useState(null)
|
||||
const seen = useRef(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
let stop = () => {}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
// One poll for the recent window, so the screen is never blank while
|
||||
// waiting for the next person to walk through a door.
|
||||
const page = await api.arrivals({ limit: 30 })
|
||||
const rows = page.arrivals || []
|
||||
rows.forEach(a => seen.current.add(a.visit_id))
|
||||
setArrivals(rows.slice().reverse())
|
||||
setStatus('live')
|
||||
|
||||
stop = streamArrivals({
|
||||
cursor: page.cursor,
|
||||
signal: ac.signal,
|
||||
onPage: (p) => {
|
||||
const fresh = (p.arrivals || []).filter(a => !seen.current.has(a.visit_id))
|
||||
if (!fresh.length) return
|
||||
fresh.forEach(a => seen.current.add(a.visit_id))
|
||||
setStatus('live')
|
||||
// Newest first on screen; the feed delivers oldest first.
|
||||
setArrivals(prev => [...fresh.reverse(), ...prev].slice(0, KEEP))
|
||||
},
|
||||
onError: () => setStatus('reconnecting'),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => { ac.abort(); stop() }
|
||||
}, [])
|
||||
|
||||
if (error) return <Problem error={error} />
|
||||
if (status === 'connecting' && !arrivals.length) return <Loading />
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Live</h1>
|
||||
<p className="sub">
|
||||
<span className={'dot ' + (status === 'live' ? 'ok' : 'warn')} aria-hidden="true" />
|
||||
{status === 'live' ? 'Connected' : 'Reconnecting…'}
|
||||
{' · '}last {KEEP} arrivals
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{arrivals.length === 0 ? (
|
||||
<div className="state">
|
||||
<h2>Nobody yet</h2>
|
||||
<p className="sub">
|
||||
Arrivals appear here the moment a camera recognises someone.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="arrivals">
|
||||
{arrivals.map(a => <Arrival key={a.visit_id} a={a} />)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Arrival({ a }) {
|
||||
const name = a.name || a.label || 'Unidentified'
|
||||
return (
|
||||
<li className="card arrival">
|
||||
<Face image={a.image} name={name} />
|
||||
<div className="who-col">
|
||||
<strong>{name}</strong>
|
||||
<span className="sub">
|
||||
{a.site}{a.camera_id ? ` · ${a.camera_id}` : ''} · {ago(a.occurred_at)}
|
||||
</span>
|
||||
{a.attributes && <Attributes attrs={a.attributes} />}
|
||||
</div>
|
||||
{a.is_new_visitor
|
||||
? <span className="pill new">New</span>
|
||||
: <span className="pill">Returning</span>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Photos are off unless a shop turns them on, so "no photo" is the ordinary
|
||||
// case. Initials, never an error state — a screen full of red for a system
|
||||
// working exactly as configured teaches people to ignore it.
|
||||
function Face({ image, name }) {
|
||||
if (image?.available) {
|
||||
return <img className="face" src={image.url} alt="" loading="lazy" />
|
||||
}
|
||||
return (
|
||||
<span className="face initials" title={image?.reason || ''} aria-hidden="true">
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '?'
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
function Attributes({ attrs }) {
|
||||
const bits = []
|
||||
if (attrs.gender) bits.push(attrs.gender)
|
||||
if (attrs.age) bits.push(`~${Math.round(attrs.age)}`)
|
||||
if (attrs.emotion) bits.push(attrs.emotion)
|
||||
if (!bits.length) return null
|
||||
return <span className="attrs">{bits.join(' · ')}</span>
|
||||
}
|
||||
59
web/src/views/Login.jsx
Normal file
59
web/src/views/Login.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
export default function Login({ onSignedIn }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.login(email.trim(), password)
|
||||
onSignedIn(await api.me())
|
||||
} catch (err) {
|
||||
// The server's own wording. It says the same thing for an unknown address
|
||||
// and a wrong password on purpose - telling them apart turns this form
|
||||
// into a way to find out who works at a customer.
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signin">
|
||||
<form className="card" onSubmit={submit}>
|
||||
<span className="mark big" aria-hidden="true" />
|
||||
<h1>Behavision</h1>
|
||||
<p className="sub">Sign in to your company account.</p>
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
type="email" value={email} autoComplete="username" autoFocus required
|
||||
onChange={e => setEmail(e.target.value)} placeholder="you@company.com"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password" value={password} autoComplete="current-password" required
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<button className="primary" disabled={busy || !email || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<p className="foot">
|
||||
Accounts are created by Loyaly. Ask your account manager if you need one.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
web/src/views/Reports.jsx
Normal file
171
web/src/views/Reports.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { Loading, Problem } from './Sites.jsx'
|
||||
|
||||
const RANGES = [
|
||||
{ id: '7', label: 'Last 7 days', days: 6, bucket: 'day' },
|
||||
{ id: '30', label: 'Last 30 days', days: 29, bucket: 'day' },
|
||||
{ id: '90', label: 'Last 90 days', days: 89, bucket: 'week' },
|
||||
]
|
||||
|
||||
const day = (d) => d.toISOString().slice(0, 10)
|
||||
|
||||
export default function Reports() {
|
||||
const [rangeId, setRangeId] = useState('30')
|
||||
const range = RANGES.find(r => r.id === rangeId)
|
||||
|
||||
const params = useMemo(() => {
|
||||
const to = new Date()
|
||||
const from = new Date(to)
|
||||
from.setDate(from.getDate() - range.days)
|
||||
return {
|
||||
from: day(from), to: day(to), bucket: range.bucket,
|
||||
// Buckets are cut in this timezone and returned as local wall time with
|
||||
// no offset, so they must not be parsed as a Date - see Bar below.
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
}
|
||||
}, [rangeId])
|
||||
|
||||
const foot = usePolled(() => api.footfall(params), 0, [rangeId])
|
||||
const sales = usePolled(() => api.conversion(params), 0, [rangeId])
|
||||
|
||||
if (foot.loading && !foot.data) return <Loading />
|
||||
if (foot.error) return <Problem error={foot.error} />
|
||||
|
||||
const f = foot.data
|
||||
const s = sales.data
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Reports</h1>
|
||||
<div className="segmented">
|
||||
{RANGES.map(r => (
|
||||
<button key={r.id} onClick={() => setRangeId(r.id)}
|
||||
aria-current={r.id === rangeId ? 'true' : undefined}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{f.fraction_below_gate > 0.5 && (
|
||||
<p className="banner warn">
|
||||
<b>{Math.round(f.fraction_below_gate * 100)}% of faces seen were below the
|
||||
enrolment threshold{f.worst_site ? ` (worst: ${f.worst_site})` : ''}.</b>{' '}
|
||||
These figures count fewer people than actually visited. A camera needs moving.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid stats">
|
||||
<Stat
|
||||
value={f.total.toLocaleString()}
|
||||
label="Unique people"
|
||||
note={`${f.visits.toLocaleString()} visits in total`}
|
||||
/>
|
||||
<Stat
|
||||
value={sum(f.points, 'new').toLocaleString()}
|
||||
label="First-time visitors"
|
||||
note="Never seen at any of your shops before"
|
||||
/>
|
||||
{s && (
|
||||
<Stat
|
||||
value={pct(s.conversion)}
|
||||
label="Bought something"
|
||||
note={`${s.purchasers.toLocaleString()} of ${s.visitors.toLocaleString()} visitors`}
|
||||
/>
|
||||
)}
|
||||
{s && s.revenue > 0 && (
|
||||
<Stat
|
||||
value={money(s.revenue, s.currency)}
|
||||
label="Revenue"
|
||||
note={`${money(s.average_basket, s.currency)} average basket`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="card chart-card">
|
||||
<header className="chart-head">
|
||||
<h2>Visitors per {f.bucket}</h2>
|
||||
{/* Said out loud, because it looks like an error otherwise. A person
|
||||
who came Monday and Thursday is one unique visitor and two bars. */}
|
||||
<p className="sub">
|
||||
Bars count each visit. They add up to more than the {f.total.toLocaleString()}{' '}
|
||||
unique people above, because regulars appear in more than one bar.
|
||||
</p>
|
||||
</header>
|
||||
<Chart points={f.points} bucket={f.bucket} tz={f.timezone} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ value, label, note }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<span className="value">{value}</span>
|
||||
<span className="label">{label}</span>
|
||||
{note && <span className="note">{note}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chart({ points, bucket, tz }) {
|
||||
if (!points?.length) {
|
||||
return <p className="sub pad">Nothing recorded in this period.</p>
|
||||
}
|
||||
const max = Math.max(...points.map(p => p.visitors), 1)
|
||||
const peak = points.find(p => p.visitors === max)
|
||||
return (
|
||||
<>
|
||||
{/* A bar chart whose tallest bar names no value is a shape, not a
|
||||
measurement. One label on the peak is the least that makes the
|
||||
heights readable, without an axis this chart is too short to carry. */}
|
||||
<p className="peak">
|
||||
Peak <b>{max}</b> visits on {label(peak.bucket, bucket)}
|
||||
</p>
|
||||
<div className="chart" role="img"
|
||||
aria-label={`Visitors per ${bucket}, peak ${max}`}>
|
||||
{points.map(p => (
|
||||
<div className="col" key={p.bucket} title={`${label(p.bucket, bucket)} — ${p.visitors} visits`}>
|
||||
<div className="bars">
|
||||
{/* New sits on top of returning: the two are a breakdown of the
|
||||
same bar, not two competing series. They can sum to LESS than
|
||||
the total, because a visit with no identified person is real
|
||||
footfall by an unknown someone. */}
|
||||
<span className="bar returning" style={{ height: pctOf(p.returning, max) }} />
|
||||
<span className="bar new" style={{ height: pctOf(p.new, max) }} />
|
||||
</div>
|
||||
<span className="tick">{label(p.bucket, bucket)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer className="legend">
|
||||
<span><i className="swatch new" />First visit</span>
|
||||
<span><i className="swatch returning" />Returning</span>
|
||||
<span className="sub">times shown in {tz}</span>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const pctOf = (n, max) => `${Math.max(n > 0 ? 2 : 0, (n / max) * 100)}%`
|
||||
const sum = (points, key) => (points || []).reduce((t, p) => t + (p[key] || 0), 0)
|
||||
const pct = (v) => `${Math.round((v || 0) * 100)}%`
|
||||
|
||||
function money(v, currency) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined,
|
||||
{ style: 'currency', currency: currency || 'INR', maximumFractionDigits: 0 }).format(v)
|
||||
} catch { return `${Math.round(v)}` }
|
||||
}
|
||||
|
||||
// Buckets arrive as LOCAL wall time with no offset, deliberately: stamping them
|
||||
// Z would say 09:00 UTC when the shop means 09:00 in Chennai. So they are
|
||||
// sliced as text, never handed to Date, which would re-interpret them in the
|
||||
// viewer's own timezone and shift every label.
|
||||
function label(bucket, size) {
|
||||
if (size === 'hour') return bucket.slice(11, 16)
|
||||
return bucket.slice(5, 10).replace('-', '/')
|
||||
}
|
||||
171
web/src/views/SiteCheck.jsx
Normal file
171
web/src/views/SiteCheck.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// The end-to-end answer for one shop.
|
||||
//
|
||||
// Not "is a camera plugged in" but "is this shop actually working" — because
|
||||
// those came apart on a real site, which ran for weeks online, connected, and
|
||||
// recognising almost nobody. Every step names what to do when it fails, and the
|
||||
// shop is only "working" when all of them pass: a partial pass is not a working
|
||||
// shop, and calling it one is how that site got signed off.
|
||||
export default function SiteCheck({ site, onClose }) {
|
||||
const [result, setResult] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true); setError(''); setResult(null)
|
||||
try {
|
||||
setResult(await api.siteCheck(site.site_id))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>Is {site.name} working?</h2>
|
||||
<p className="sub">Checks the whole chain, from the shop’s PC to head office.</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
|
||||
<div className="drawer-body">
|
||||
{!result && !busy && (
|
||||
<>
|
||||
<p className="sub">
|
||||
Nothing is changed and no one is disturbed — this reads what the
|
||||
shop has already reported.
|
||||
</p>
|
||||
<button className="primary" onClick={run}>Run the check</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{busy && (
|
||||
<div className="waiting">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<b>Checking…</b>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<div className={'banner ' + (result.ok ? 'ok' : 'warn')}>
|
||||
<div>
|
||||
<b>{result.ok
|
||||
? 'This shop is working.'
|
||||
: 'This shop needs attention.'}</b>
|
||||
{!result.ok && (
|
||||
<> Work down the list — the first failure usually explains the rest.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="checklist">
|
||||
{result.steps.map(s => (
|
||||
<li key={s.name} className={s.status}>
|
||||
<span className="mark" aria-hidden="true">
|
||||
{s.status === 'pass' ? '✓' : s.status === 'fail' ? '✕'
|
||||
: s.status === 'warn' ? '!' : '–'}
|
||||
</span>
|
||||
<div>
|
||||
<b>{s.name}</b>
|
||||
<span className="sub">{s.detail}</span>
|
||||
{s.advice && <span className="advice-line">{s.advice}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<button className="ghost" onClick={run}>Check again</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ClaimPC site={site} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Getting the code that links a shop PC to this shop.
|
||||
//
|
||||
// It lives here because this drawer is already "everything about one shop", and
|
||||
// because the answer to the most common failure the check reports - nothing has
|
||||
// ever been heard from this shop - is precisely this: the PC was never claimed.
|
||||
// Before this existed the only way to produce a code was a shell on the server,
|
||||
// which made replacing a till PC a support ticket.
|
||||
function ClaimPC({ site }) {
|
||||
const [code, setCode] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
|
||||
const issue = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
setCode(await api.enrolmentCode(site.site_id, { label: label.trim() }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="claim">
|
||||
<h3>Set up a shop PC</h3>
|
||||
{!code ? (
|
||||
<>
|
||||
<p className="sub">
|
||||
Creates a one-time code to type into Behavision on the shop’s
|
||||
computer. Use one for a new shop, a replacement PC, or a reinstall.
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>What is this PC? (optional)</span>
|
||||
<input value={label} placeholder="counter PC"
|
||||
onChange={e => setLabel(e.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="ghost" onClick={issue} disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create an installation code'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Shown once, and said so plainly. Only a hash is stored, so nobody
|
||||
- support included - can read it back; an operator who closes this
|
||||
without copying it needs a new code, which is cheap. */}
|
||||
<p className="code" aria-live="polite">{code.code}</p>
|
||||
<p className="sub">
|
||||
Copy this now — it cannot be shown again. It works once, and stops
|
||||
working {expiry(code.expires_at)}.
|
||||
</p>
|
||||
<div className="row">
|
||||
<button className="ghost" onClick={() => copy(code.code)}>Copy</button>
|
||||
<button className="ghost" onClick={() => setCode(null)}>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function copy(text) {
|
||||
try { navigator.clipboard?.writeText(text) } catch { /* the code is on screen */ }
|
||||
}
|
||||
|
||||
function expiry(iso) {
|
||||
const t = new Date(iso).getTime()
|
||||
if (Number.isNaN(t)) return 'shortly'
|
||||
const days = Math.round((t - Date.now()) / 86400000)
|
||||
if (days <= 0) return 'today'
|
||||
return days === 1 ? 'tomorrow' : `in ${days} days`
|
||||
}
|
||||
260
web/src/views/Sites.jsx
Normal file
260
web/src/views/Sites.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import SiteCheck from './SiteCheck.jsx'
|
||||
|
||||
// The estate at a glance.
|
||||
//
|
||||
// The question this answers is not "how busy were we" — it is "is any of this
|
||||
// actually working". A shop whose PC has been unplugged for a week and a shop
|
||||
// with no customers produce the same row of zeroes everywhere else in the
|
||||
// product, and only one of them is something to act on.
|
||||
//
|
||||
// Laid out like the camera screen, and for the same reason: the first version
|
||||
// was a definition list of cameras_up, fraction_below_gate and last_heartbeat,
|
||||
// which is the view a developer wants. An owner opening this wants to SEE their
|
||||
// shops. So the shop's own camera view is the card, the numbers sit under it,
|
||||
// and one line says what to do — with the technical detail one click away
|
||||
// rather than on the surface.
|
||||
export default function Sites() {
|
||||
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
|
||||
// Cameras come from a second call and are joined here rather than server-side:
|
||||
// the picture is decoration on this screen, so it must never be able to make
|
||||
// the health list fail. If this errors the cards simply have no photograph.
|
||||
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
|
||||
const [checking, setChecking] = useState(null)
|
||||
const sites = data || []
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error) return <Problem error={error} />
|
||||
if (!sites.length) return <Empty />
|
||||
|
||||
// Counted from the same verdicts the cards show. Summarising with a second,
|
||||
// simpler rule up here is how a header ends up reading "all working" over a
|
||||
// grid containing a red card.
|
||||
const rows = sites.map(s => {
|
||||
const own = (cams || []).filter(c => c.site_id === s.site_id)
|
||||
return { site: s, cams: own, verdict: verdictFor(s, own) }
|
||||
})
|
||||
const tally = t => rows.filter(r => r.verdict.tone === t).length
|
||||
const broken = tally('bad'), watch = tally('warn'), fresh = tally('idle')
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="head">
|
||||
<h1>Shops</h1>
|
||||
<p className="sub">
|
||||
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
|
||||
{broken > 0 && <> · <b className="bad">{broken} not working</b></>}
|
||||
{watch > 0 && <> · <b className="warn">{watch} needing attention</b></>}
|
||||
{fresh > 0 && <> · {fresh} not set up yet</>}
|
||||
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid sites">
|
||||
{rows.map(r => (
|
||||
<SiteCard key={r.site.site_id} site={r.site} cams={r.cams}
|
||||
verdict={r.verdict} onCheck={() => setChecking(r.site)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reachable per shop, at last. The smoke test used to hang off a single
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The one line the card leads with, in severity order. Only the first is shown:
|
||||
// a shop that is offline AND has a bad camera needs its PC turned on first, and
|
||||
// listing both invites someone to start with the wrong one.
|
||||
//
|
||||
// This is also the ONLY place a shop's health is decided. The pill over the
|
||||
// picture, the stripe down the edge and this line all read from it, because the
|
||||
// first version computed the pill separately from online + gate and a shop with
|
||||
// two dead cameras came out labelled "Working" directly above the words "2 of 3
|
||||
// cameras not connecting". Two surfaces disagreeing about one fact is worse
|
||||
// than either being wrong on its own.
|
||||
function verdictFor(site, cams) {
|
||||
const gate = site.fraction_below_gate
|
||||
const total = site.cameras_total || cams.length
|
||||
|
||||
if (!site.online) {
|
||||
return { tone: 'bad', mark: '✕', headline: 'Offline',
|
||||
words: `Offline — last heard from ${ago(site.last_heartbeat_at)}` }
|
||||
}
|
||||
// Footfall this shop saw and can never report. Stored with GREATEST()
|
||||
// server-side so a restarted agent cannot make it quietly disappear.
|
||||
if (site.dropped > 0) {
|
||||
return { tone: 'bad', mark: '✕',
|
||||
words: `${site.dropped} visits lost and unrecoverable` }
|
||||
}
|
||||
if (total === 0) {
|
||||
return { tone: 'idle', mark: '+', headline: 'Not set up',
|
||||
words: 'No cameras set up yet' }
|
||||
}
|
||||
if (site.cameras_up === 0) {
|
||||
return { tone: 'bad', mark: '✕', words: 'No cameras connected' }
|
||||
}
|
||||
if (site.cameras_up < total) {
|
||||
return { tone: 'warn', mark: '!',
|
||||
words: `${total - site.cameras_up} of ${total} cameras not connecting` }
|
||||
}
|
||||
// The Office1 case: online, connected, and recognising almost nobody. Over
|
||||
// half is a failure and not a warning — those visitors are gone.
|
||||
if (gate > 0.5) {
|
||||
return { tone: 'bad', mark: '✕',
|
||||
words: `${pct(gate)} of faces too poor to recognise` }
|
||||
}
|
||||
if (gate > 0.2) {
|
||||
return { tone: 'warn', mark: '!',
|
||||
words: `${pct(gate)} of faces too poor to recognise` }
|
||||
}
|
||||
if (site.queued > 0) {
|
||||
return { tone: 'warn', mark: '!', words: `${site.queued} visits waiting to upload` }
|
||||
}
|
||||
return { tone: 'ok', mark: '✓',
|
||||
words: `Working — ${total} ${total === 1 ? 'camera' : 'cameras'} connected` }
|
||||
}
|
||||
|
||||
const HEADLINES = { ok: 'Working', warn: 'Needs attention',
|
||||
bad: 'Not working', idle: 'Not set up' }
|
||||
|
||||
function SiteCard({ site, cams, verdict, onCheck }) {
|
||||
const gate = site.fraction_below_gate
|
||||
const health = verdict.tone
|
||||
const headline = verdict.headline || HEADLINES[health]
|
||||
const view = bestView(cams)
|
||||
const total = site.cameras_total || cams.length
|
||||
|
||||
return (
|
||||
<article className={'card site state-' + health} onClick={onCheck} role="button"
|
||||
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
|
||||
<div className="shot">
|
||||
{view.url
|
||||
? <img src={view.url} alt={`View inside ${site.name}`} loading="lazy" />
|
||||
: <div className="noshot">
|
||||
<ShopMark />
|
||||
{view.reason && <span>{view.reason}</span>}
|
||||
</div>}
|
||||
|
||||
<div className="shot-over">
|
||||
<div className="shot-name">
|
||||
<b>{site.name}</b>
|
||||
<span>{total > 0
|
||||
? `${total} ${total === 1 ? 'camera' : 'cameras'}`
|
||||
: 'No cameras yet'}</span>
|
||||
</div>
|
||||
<span className={'status ' + health}>
|
||||
<i aria-hidden="true" />{headline}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view.at && <span className="shot-age">{ago(view.at)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Three numbers, and each one is a different question: is the hardware
|
||||
up, can it see faces well enough to recognise them, and is anyone
|
||||
home. The rest moved behind the check. */}
|
||||
<div className="metrics">
|
||||
<Metric label="Cameras"
|
||||
value={total ? `${site.cameras_up}/${total}` : '—'}
|
||||
tone={!total ? 'idle' : site.cameras_up < total ? 'bad' : 'ok'} />
|
||||
<Metric label="Faces usable"
|
||||
value={gate > 0 ? pct(1 - gate) : '—'}
|
||||
tone={!gate ? 'idle' : gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : 'ok'} />
|
||||
<Metric label="Last seen" value={ago(site.last_heartbeat_at)}
|
||||
tone={site.online ? 'ok' : 'bad'} />
|
||||
</div>
|
||||
|
||||
<div className={'verdict ' + verdict.tone}>
|
||||
<span className="mark" aria-hidden="true">{verdict.mark}</span>
|
||||
<span className="words">{verdict.words}</span>
|
||||
<span className="go" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<b className={tone}>{value}</b>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The freshest picture any of this shop's cameras has sent.
|
||||
//
|
||||
// A missing picture is a normal state, not an error — images are off by default
|
||||
// across the product — so the empty tile explains itself rather than showing a
|
||||
// black hole with an apology in it. The three absences need different words:
|
||||
// "nobody has set a camera up", "the PC has not reported yet", and "this system
|
||||
// stores no photographs" are three different next actions.
|
||||
function bestView(cams) {
|
||||
// Nothing written here for a shop with no cameras: the verdict line already
|
||||
// says exactly that, and three phrasings of one fact on one card reads as a
|
||||
// fault rather than a state.
|
||||
if (!cams.length) return {}
|
||||
let best = null
|
||||
for (const c of cams) {
|
||||
if (!c.snapshot?.available || !c.snapshot.url) continue
|
||||
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
|
||||
}
|
||||
if (best) return { url: best.snapshot.url, at: best.snapshot_at }
|
||||
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
|
||||
return { reason: reason || 'No picture from this shop yet.' }
|
||||
}
|
||||
|
||||
// Drawn, not an emoji or an icon font: an empty tile that still reads as a
|
||||
// shop, in the same spirit as the camera screen's lens.
|
||||
function ShopMark() {
|
||||
return (
|
||||
<svg className="shopmark" viewBox="0 0 40 32" aria-hidden="true">
|
||||
<path d="M4 12h32v18H4z" />
|
||||
<path d="M2 12l4-8h28l4 8" />
|
||||
<path d="M15 30v-9h10v9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function pct(f) { return `${Math.round(f * 100)}%` }
|
||||
|
||||
export function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return '—'
|
||||
const secs = Math.max(0, (Date.now() - then) / 1000)
|
||||
if (secs < 90) return 'just now'
|
||||
const mins = Math.round(secs / 60)
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 48) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} days ago`
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
|
||||
}
|
||||
|
||||
export function Problem({ error }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<p className="error" role="alert">{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="state">
|
||||
<h2>No shops yet</h2>
|
||||
<p className="sub">
|
||||
A shop appears here once its PC has been claimed with an enrolment code.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user