The first launch was a code box with a link under it, then an empty Live screen with 'No cameras' in amber in a far corner, then a form asking for an IP address, and for the first few minutes of all of it the engine silently downloading 275 MB with nothing on screen but a stopped-looking status. Walked in a browser with the new mock; nobody who was not an installer would have got through it. Now: a welcome that asks the one question a shop owner can answer - managed from a head office, or on this PC only - with each path in a sentence; a Getting Started checklist on Live that reads its three steps from the engine and ticks them itself (recognition ready, camera added and connected, camera proven by a walk-past), with the one button for the next step, and that disappears the moment somebody is recognised; and the model download reported as a percentage in the tray, the sidebar and the checklist, parsed by the supervisor from the engine's own progress lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
133 lines
5.4 KiB
JavaScript
133 lines
5.4 KiB
JavaScript
import { useState } from 'react'
|
||
import { api, message } from '../bridge.js'
|
||
import * as Icon from '../ui/icons.jsx'
|
||
import logo from '../assets/loyaly-mark.png'
|
||
|
||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||
// and until now the one thing the app could not do.
|
||
//
|
||
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
|
||
// has an installation code and, quite often, no account of their own yet; the
|
||
// PC's identity is not a person's identity. The endpoint behind this is
|
||
// deliberately unauthenticated for the same reason — requiring a login first
|
||
// would mean shipping a password to every shop that installs the software.
|
||
export default function Setup({ onDone, onCancel }) {
|
||
const [code, setCode] = useState('')
|
||
const [busy, setBusy] = useState(null)
|
||
const [error, setError] = useState(null)
|
||
|
||
async function submit(e) {
|
||
e.preventDefault()
|
||
setBusy('claim'); setError(null)
|
||
try {
|
||
onDone(await api.claim(code))
|
||
} catch (err) {
|
||
setError(message(err))
|
||
} finally {
|
||
setBusy(null)
|
||
}
|
||
}
|
||
|
||
async function standalone() {
|
||
setBusy('alone'); setError(null)
|
||
try {
|
||
onDone(await api.runStandalone())
|
||
} catch (err) {
|
||
setError(message(err))
|
||
} finally {
|
||
setBusy(null)
|
||
}
|
||
}
|
||
|
||
// First launch: a choice, not a code box. Two thirds of the people who
|
||
// open this have no idea what an installation code is; the other third has
|
||
// one in their hand. Both must see their own path in the first second.
|
||
const [path, setPath] = useState(onCancel ? 'code' : null)
|
||
|
||
if (path === null) {
|
||
return (
|
||
<div className="login">
|
||
<div className="box wide">
|
||
<span className="mark"><img src={logo} alt="" /></span>
|
||
<h1>Welcome to Behavision</h1>
|
||
<p className="lead">
|
||
This PC will watch your shop’s cameras and recognise returning customers.
|
||
First, one question: is this shop managed from a head office?
|
||
</p>
|
||
<div className="choices">
|
||
<button type="button" className="choice" onClick={() => setPath('code')}>
|
||
<Icon.Cloud size={22} />
|
||
<b>Yes — I have an installation code</b>
|
||
<span>Head office gave you a code like <em>ABCDEF-123456-…</em>. This PC joins that shop and gets its cameras from there.</span>
|
||
</button>
|
||
<button type="button" className="choice" onClick={() => setPath('alone')}>
|
||
<Icon.Shield size={22} />
|
||
<b>No — set up on this PC only</b>
|
||
<span>Cameras, customers and recognition stay on this PC. Nothing is sent anywhere. You can link to a head office later.</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (path === 'alone') {
|
||
return (
|
||
<div className="login">
|
||
<div className="box">
|
||
<span className="mark"><img src={logo} alt="" /></span>
|
||
<h1>On this PC only</h1>
|
||
<p className="lead">
|
||
Behavision will run entirely here. Next you’ll add your camera — it can find it on the network for you — and walk past it once so it can prove it works.
|
||
</p>
|
||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||
<button type="button" className="btn primary" disabled={!!busy} onClick={standalone}>
|
||
{busy === 'alone' ? 'Setting up…' : 'Continue'}
|
||
</button>
|
||
<div className="alt">
|
||
<button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="login">
|
||
<div className="box">
|
||
<span className="mark"><img src={logo} alt="" /></span>
|
||
<h1>{onCancel ? 'Link to head office' : 'Join your shop'}</h1>
|
||
<p className="lead">
|
||
Type the installation code head office gave you. It works once, and this PC becomes that shop.
|
||
</p>
|
||
<form onSubmit={submit}>
|
||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||
<label className="field">
|
||
<span>Installation code</span>
|
||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||
down a phone or photographed off a screen. Spaces, dashes and
|
||
case are stripped on the server, so what is typed here can be
|
||
as untidy as it needs to be. */}
|
||
<input value={code} autoFocus required
|
||
placeholder="ABCDEF-123456-GHIJKL-789012"
|
||
autoComplete="off" spellCheck="false"
|
||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||
onChange={e => setCode(e.target.value)} />
|
||
</label>
|
||
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
|
||
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
|
||
</button>
|
||
</form>
|
||
<p className="foot">
|
||
Don’t have one? Whoever runs head office creates it under the shop: <em>Shops → the shop → Set up a shop PC</em>.
|
||
</p>
|
||
<div className="alt">
|
||
{onCancel
|
||
? <button type="button" className="linkbtn" onClick={onCancel}>Not now — go back</button>
|
||
: <button type="button" className="linkbtn" onClick={() => setPath(null)}><Icon.Back size={14} /> Back</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|