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 (

Behavision

This is the Behavision window running outside the app, so it has no connection to the recognition engine. Launch the Behavision application instead.

) } if (booting) return

Starting…

// 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 if (!session?.standalone && !session?.logged_in) return const views = VIEWS.filter(v => !v.cloud || !session.standalone) const Current = views.find(v => v.id === view)?.View ?? Live if (linking) { return { setLinking(false); setSession(s) }} onCancel={() => setLinking(false)} /> } return (
) } // 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 (
{text}
{s.recognition_model && {s.recognition_model}} {s.error && {s.error}}
) }