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:
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