A name, a voice and a door. The prompt now asks for a colleague on the shop floor - answer first, one to three sentences, the shop's name and the person's name, the one thing to do next - instead of a report with headings. Both apps put her behind the Loyaly mark in the top-right corner of every screen, because a buddy you have to find in a sidebar is not around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
119 lines
5.2 KiB
JavaScript
119 lines
5.2 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
||
import { api, message } from '../bridge.js'
|
||
import * as Icon from '../ui/icons.jsx'
|
||
import logo from '../assets/loyaly-mark.png'
|
||
|
||
// The help. A conversation with the head-office assistant, which answers from
|
||
// the shop's own data and knows how the product is set up - so "why is nobody
|
||
// being recognised" and "how do I add my camera" are both answered here, by
|
||
// the same thing, without leaving the app.
|
||
//
|
||
// The history lives in this component and is resent whole; nothing is stored
|
||
// anywhere. A PC running on its own has no head office to ask and is told so.
|
||
const SUGGESTED = [
|
||
'Is my shop working right now?',
|
||
'How do I add my camera?',
|
||
'Why has nobody been recognised today?',
|
||
'Who came in this morning?',
|
||
]
|
||
|
||
export default function Assistant({ session, onClose }) {
|
||
const [turns, setTurns] = useState([])
|
||
const [draft, setDraft] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const scroller = useRef(null)
|
||
const standalone = session?.standalone
|
||
|
||
useEffect(() => { scroller.current?.scrollTo({ top: 1e9, behavior: 'smooth' }) }, [turns, busy])
|
||
|
||
async function ask(text) {
|
||
const q = (text ?? draft).trim()
|
||
if (!q || busy) return
|
||
const history = [...turns, { role: 'user', text: q }]
|
||
setTurns(history); setDraft(''); setBusy(true); setError(null)
|
||
try {
|
||
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
|
||
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
|
||
} catch (e) {
|
||
setError(message(e))
|
||
setTurns(turns) // the question stays in the box, not in the transcript
|
||
setDraft(q)
|
||
} finally { setBusy(false) }
|
||
}
|
||
|
||
return (
|
||
<aside className="helper" role="dialog" aria-label="Loya">
|
||
<header className="helperhead">
|
||
<img src={logo} alt="" />
|
||
<div>
|
||
<b>Loya</b>
|
||
<span>Your Behavision buddy — knows your cameras, your customers and your numbers.</span>
|
||
</div>
|
||
<button className="close" onClick={onClose} aria-label="Close"><Icon.Close size={16} /></button>
|
||
</header>
|
||
|
||
<div className="helperbody" ref={scroller}>
|
||
{standalone && (
|
||
<div className="helpernote">
|
||
<Icon.CloudOff size={16} />
|
||
<div>
|
||
<b>This PC runs on its own</b>
|
||
<span>Loya lives at head office. Link this PC to a shop to talk to her; the basics are below.</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{turns.length === 0 && (
|
||
<div className="helperintro">
|
||
<div className="turn assistant"><div className="bubble">
|
||
{greeting(session)} Ask me anything about {session?.site_name || 'this shop'} — what’s happening now, or how to set something up.
|
||
</div></div>
|
||
<div className="chips">
|
||
{SUGGESTED.map(s => <button key={s} className="chip" onClick={() => ask(s)} disabled={busy || standalone}>{s}</button>)}
|
||
</div>
|
||
{standalone && <QuickHelp />}
|
||
</div>
|
||
)}
|
||
{turns.map((t, i) => (
|
||
<div key={i} className={`turn ${t.role}`}>
|
||
<div className="bubble">{t.text}</div>
|
||
{t.used?.length > 0 && <span className="used">Looked at: {t.used.join(', ')}</span>}
|
||
</div>
|
||
))}
|
||
{busy && <div className="turn assistant"><div className="bubble thinking"><span /><span /><span /></div></div>}
|
||
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
||
</div>
|
||
|
||
<form className="helperask" onSubmit={e => { e.preventDefault(); ask() }}>
|
||
<input value={draft} onChange={e => setDraft(e.target.value)} disabled={busy || standalone}
|
||
placeholder={standalone ? 'Link this PC to head office to talk to Loya' : 'Ask Loya…'} />
|
||
<button className="btn primary" disabled={busy || standalone || !draft.trim()} aria-label="Ask"><Icon.Chevron size={16} /></button>
|
||
</form>
|
||
</aside>
|
||
)
|
||
}
|
||
|
||
function greeting(session) {
|
||
const h = new Date().getHours()
|
||
const part = h < 12 ? 'Morning' : h < 17 ? 'Afternoon' : 'Evening'
|
||
const name = session?.user?.full_name?.split(' ')[0]
|
||
return name ? `${part}, ${name}.` : `${part}.`
|
||
}
|
||
|
||
// What a PC with no head office can still be told. Static on purpose: there is
|
||
// nobody to ask, and a chat box that always fails is worse than a short list.
|
||
function QuickHelp() {
|
||
return (
|
||
<dl className="quickhelp">
|
||
<dt>Adding a camera</dt>
|
||
<dd>Cameras → Add camera. The address is on a label on the camera; pick the make and the stream path fills itself in. Test, then save.</dd>
|
||
<dt>Proving it works</dt>
|
||
<dd>Press Check placement and walk past the camera like a customer for 25 seconds. Only “good” means it can recognise faces — otherwise move it to head height, facing the way people approach.</dd>
|
||
<dt>Nothing showing on Live</dt>
|
||
<dd>Check the camera is Connected and Proven. A camera aimed from above or the side streams fine and recognises nobody.</dd>
|
||
<dt>Linking to head office later</dt>
|
||
<dd>Use the link button at the bottom of the sidebar and type an installation code from head office. Nothing recorded here is lost.</dd>
|
||
</dl>
|
||
)
|
||
}
|