Files
Behavision/desktop/frontend/src/App.jsx
Suriyakumarvijayanayagam 4c62fc0ef3 The Loyaly mark everywhere a person sees the product
Brand assets in brand/ (the 512px mark, sizes for each surface, a
multi-size .ico). Windows executables carry it as a compiled-in
resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the
taskbar and the installer show it; installer/build.ps1 therefore uses a
plain go build rather than wails build, which would add a second copy
and fail the link. The tray icon is the mark with a state dot over its
corner - a plain coloured circle read as a generic status light among
other icons - rendered from the embedded PNG at 32px so it survives
150% scaling. The desktop app's login, setup and sidebar marks, the
head-office web app's mark and favicon, and the engine dashboard's
favicon are the same file.

Also found while packaging: no wheel so far shipped static/, so the
engine's own dashboard at :8010 on a Windows source install would have
failed with a missing file. package-data now includes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 12:54:19 +05:30

180 lines
7.4 KiB
JavaScript

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