Fixed on the web arrivals feed and not here, which is the failure this codebase already warns about: two surfaces disagreeing about one fact. Taking the first letter of each word of "Visitor 13" gives "V1" - and so do "Visitor 10" and "Visitor 15", so three different customers wear the same badge and it reads as the V-1 reference for a fourth. Shows the number itself, same rule as the web app. customerRef, not ref: React reserves that prop name and it would never arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
184 lines
7.5 KiB
JavaScript
184 lines
7.5 KiB
JavaScript
import { useState } from 'react'
|
|
import { api, message } from '../bridge.js'
|
|
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
|
|
import VisitHistory from './VisitHistory.jsx'
|
|
import EraseCustomer from './EraseCustomer.jsx'
|
|
|
|
// The in-store form. Two jobs in one sheet: capture who this person is, and
|
|
// record what they bought — because staff have the customer in front of them
|
|
// once, and asking them to open a second screen means the sale never gets
|
|
// recorded.
|
|
export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
|
const [f, setF] = useState({
|
|
full_name: customer.full_name ?? '',
|
|
phone: customer.phone ?? '',
|
|
email: customer.email ?? '',
|
|
gender: '',
|
|
date_of_birth: '',
|
|
notes: '',
|
|
consent: customer.has_consent ?? false,
|
|
})
|
|
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
|
|
const [busy, setBusy] = useState(false)
|
|
const [error, setError] = useState(null)
|
|
const [erasing, setErasing] = useState(false)
|
|
const [photo, setPhoto] = useState(null)
|
|
const fetched = useCustomerPhoto(customer.id)
|
|
const shown = photo ?? fetched
|
|
|
|
// Erasure destroys a record permanently, so it is a manager's decision. The
|
|
// server enforces this too — this only avoids offering a button that would
|
|
// come back 403.
|
|
const role = session?.user?.role
|
|
const canErase = ['admin', 'owner', 'manager'].includes(role)
|
|
|
|
const set = k => e => setF({ ...f, [k]: e.target.value })
|
|
|
|
async function save(e) {
|
|
e.preventDefault()
|
|
setBusy(true); setError(null)
|
|
try {
|
|
await api.saveProfile({ visitor_id: customer.id, ...f })
|
|
const amount = parseFloat(purchase.amount)
|
|
if (!isNaN(amount) && amount > 0) {
|
|
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
|
|
await api.recordPurchase(customer.id, amount, items, purchase.notes)
|
|
}
|
|
onSaved()
|
|
} catch (err) {
|
|
setError(message(err))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
|
<div className="panel">
|
|
<div className="who">
|
|
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
|
customerRef={customer.ref}
|
|
onBroken={() => setPhoto({ available: false,
|
|
reason: 'The photo could not be loaded.' })} />
|
|
<div className="grow">
|
|
<h3>{customer.full_name || customer.label}</h3>
|
|
<p className="note">
|
|
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
|
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
|
</p>
|
|
{shown && !shown.available && shown.reason &&
|
|
<p className="note">{shown.reason}</p>}
|
|
</div>
|
|
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
|
</div>
|
|
|
|
<form onSubmit={save}>
|
|
{error && <div className="err">{error}</div>}
|
|
|
|
<div className="card" style={{ marginBottom: 14 }}>
|
|
<h3>Customer details</h3>
|
|
<label className="field">
|
|
<span>Full name</span>
|
|
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
|
</label>
|
|
<div className="fieldrow">
|
|
<label className="field">
|
|
<span>Phone</span>
|
|
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
|
|
</label>
|
|
<label className="field">
|
|
<span>Email</span>
|
|
<input value={f.email} onChange={set('email')} type="email" />
|
|
</label>
|
|
</div>
|
|
<div className="fieldrow">
|
|
<label className="field">
|
|
<span>Gender</span>
|
|
<select value={f.gender} onChange={set('gender')}>
|
|
<option value="">Not recorded</option>
|
|
<option>Female</option><option>Male</option><option>Other</option>
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>Date of birth</span>
|
|
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
|
|
</label>
|
|
</div>
|
|
<label className="field">
|
|
<span>Notes</span>
|
|
<textarea value={f.notes} onChange={set('notes')}
|
|
placeholder="Preferences, sizes, anything worth remembering" />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: 14 }}>
|
|
<h3>Purchase (optional)</h3>
|
|
<div className="fieldrow">
|
|
<label className="field">
|
|
<span>Amount</span>
|
|
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
|
|
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>Items</span>
|
|
<input value={purchase.items} placeholder="shirt, belt"
|
|
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
|
|
</label>
|
|
</div>
|
|
<p className="note">Leave the amount blank if they did not buy anything —
|
|
a visit without a sale is still worth recording.</p>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: 16 }}>
|
|
<h3>Consent</h3>
|
|
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
|
fontSize: 13, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
|
onChange={e => setF({ ...f, consent: e.target.checked })} />
|
|
<span>This customer agreed to us keeping their details and
|
|
recognising them on future visits.</span>
|
|
</label>
|
|
<p className="note" style={{ marginTop: 10 }}>
|
|
Recorded with the date and who collected it. They can withdraw it
|
|
at any time, which erases their face data.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: 14 }}>
|
|
<h3>Visits</h3>
|
|
<VisitHistory customer={customer} />
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="btn primary" disabled={busy}>
|
|
{busy ? 'Saving…' : 'Save'}
|
|
</button>
|
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
</div>
|
|
|
|
{canErase && (
|
|
<div className="card danger-zone">
|
|
<h3>At the customer's request</h3>
|
|
{erasing
|
|
? <EraseCustomer customer={customer}
|
|
onCancel={() => setErasing(false)}
|
|
onDone={onSaved} />
|
|
: <>
|
|
<p className="note">
|
|
Erase this person's face data, photo and details. Their
|
|
past visits stay in your footfall figures, without their
|
|
name.
|
|
</p>
|
|
<button type="button" className="btn danger"
|
|
onClick={() => setErasing(true)}>
|
|
Erase this customer…
|
|
</button>
|
|
</>}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|