Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s

Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #119.
This commit is contained in:
2026-07-22 17:07:26 +00:00
committed by steve
parent 08d8c5e47d
commit cf37e57808
8 changed files with 745 additions and 16 deletions
@@ -0,0 +1,432 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { errorMessage } from '@/lib/api'
import {
lotDefaults,
newPlantDefaults,
useCreateFromPacket,
useScanPacket,
type PacketProposal,
} from '@/lib/seedPacket'
import {
CATEGORY_LABELS,
PLANT_CATEGORIES,
type PlantCategory,
type PlantInput,
} from '@/lib/plants'
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
// 'new' is "create a new variety"; a number selects that existing candidate plant.
type Selection = number | 'new'
/**
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
* review never commits blind — the model can misread, so every committed field is
* editable and the human picks "this is an existing plant" vs "a new variety".
*
* Only offered where `capabilities.vision` is on (the caller gates the entry
* point), so a scan should always be possible; a 503 is still handled in case the
* model is torn down between the capabilities poll and the upload.
*/
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
const scan = useScanPacket()
const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null)
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
// dialog is never a trap the user can only escape by reloading the page.
const scanAbort = useRef<AbortController | null>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null)
const [error, setError] = useState<string | null>(null)
// Review-phase fields, seeded from the proposal when a scan lands.
const [selection, setSelection] = useState<Selection>('new')
const [name, setName] = useState('')
const [category, setCategory] = useState<PlantCategory>('vegetable')
const [spacing, setSpacing] = useState('')
const [days, setDays] = useState('')
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
// creating one) and the lot.
const [vendor, setVendor] = useState('')
const [quantity, setQuantity] = useState('')
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
const [sku, setSku] = useState('')
const [lotCode, setLotCode] = useState('')
const [packedForYear, setPackedForYear] = useState('')
const [cost, setCost] = useState('')
const unitLabel = spacingUnitLabel(unit)
const busy = scan.isPending || create.isPending
function onFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Reset the input so re-picking the same file fires change again (e.g. after
// an error, retrying the same photo).
e.target.value = ''
if (!file) return
setError(null)
const controller = new AbortController()
scanAbort.current = controller
scan.mutate(
{ file, signal: controller.signal },
{
onSuccess: (p) => {
const plant = newPlantDefaults(p)
const lot = lotDefaults(p.packet)
setProposal(p)
// Default to the top candidate when there is one — the likely case is
// the packet is a variety already in the catalog — else create a new one.
setSelection(p.candidates[0]?.plant.id ?? 'new')
setName(plant.name)
setCategory(plant.category)
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setVendor(lot.vendor)
setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setSku(lot.sku)
setLotCode(lot.lotCode)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
// Cost isn't on a packet, so it's the one field not reseeded from the
// proposal; clear it so a value typed before a Rescan doesn't linger.
setCost('')
},
onError: (err) => {
// An aborted scan is a user cancel, not a failure — and Cancel also
// closes the dialog, so there's nothing to report.
if ((err as Error)?.name === 'AbortError') return
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
},
},
)
}
async function onConfirm(e: FormEvent) {
e.preventDefault()
if (!proposal) return
setError(null)
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
const lot = {
vendor: vendor.trim(),
sourceUrl: '',
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents,
germinationPct: null,
notes: '',
}
let newPlant: PlantInput | undefined
let plantId: number | undefined
if (selection === 'new') {
if (!name.trim()) {
setError('Name the new variety, or pick an existing plant above.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
setError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) {
setError('Days to maturity must be a whole number of days, or left blank.')
return
}
daysToMaturity = d
}
newPlant = {
...newPlantDefaults(proposal),
name: name.trim(),
category,
spacingCm,
daysToMaturity,
vendor: vendor.trim(),
}
} else {
plantId = selection
}
try {
const res = await create.mutateAsync({ plantId, newPlant, lot })
toast.info(
res.plantIsNew
? `Added ${res.plant.name} and its seed lot.`
: `Recorded a seed lot for ${res.plant.name}.`,
)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not save the packet.'))
}
}
return (
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
{!proposal ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Take a photo of the front of a seed packet and pansy reads the details off it you review and
confirm before anything is saved.
</p>
{/* A hidden input is triggered by the buttons below. `capture` hints a
phone to open the camera; on desktop it's ignored and both buttons
open a file chooser. */}
<input
ref={fileInput}
type="file"
accept="image/*"
capture="environment"
onChange={onFile}
className="hidden"
aria-hidden
tabIndex={-1}
/>
{scan.isPending ? (
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
Reading the packet this can take a few seconds.
</p>
) : (
<Button type="button" onClick={() => fileInput.current?.click()}>
Take or choose a photo
</Button>
)}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
{/* Not disabled while scanning — this is the way out of a slow scan.
Aborting a settled/absent request is a harmless no-op. */}
<Button
type="button"
variant="ghost"
onClick={() => {
scanAbort.current?.abort()
onClose()
}}
>
Cancel
</Button>
</div>
</div>
) : (
<form onSubmit={onConfirm} className="flex flex-col gap-4">
<ReadFields proposal={proposal} unit={unit} />
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-fg">This packet is</legend>
{proposal.candidates.map((c) => (
<label
key={c.plant.id}
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
>
<input
type="radio"
name="packet-selection"
checked={selection === c.plant.id}
onChange={() => setSelection(c.plant.id)}
/>
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
<span className="text-xs text-muted">{c.reason}</span>
</label>
))}
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
<input
type="radio"
name="packet-selection"
checked={selection === 'new'}
onChange={() => setSelection('new')}
/>
<span className="flex-1 font-medium text-fg">
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
</span>
</label>
</fieldset>
{selection === 'new' && (
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
<TextField
label="Name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
hint="You can set an icon and color later from the plant card."
/>
<div className="grid grid-cols-2 gap-3">
<Select
label="Category"
name="category"
value={category}
onChange={(e) => setCategory(e.target.value as PlantCategory)}
options={categoryOptions}
/>
<TextField
label={`Spacing (${unitLabel})`}
name="spacing"
type="number"
inputMode="decimal"
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
/>
</div>
<TextField
label="Days to maturity (optional)"
name="days"
type="number"
inputMode="numeric"
step="1"
min="1"
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</div>
)}
{/* The seed lot — what you bought — recorded against whichever plant. */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-fg">Seed lot</p>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={lotUnit}
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
options={unitOptions}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
// Clear the review-phase error too, or it would show on the
// capture screen we're returning to.
setError(null)
setProposal(null)
}}
disabled={busy}
>
Rescan
</Button>
<Button type="submit" disabled={busy}>
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
</Button>
</div>
</form>
)}
</Modal>
)
}
/** A compact read-only summary of what the model pulled off the packet, so the
* user can see the extraction at a glance while they confirm. Only fields that
* came back are shown. */
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
const p = proposal.packet
const rows: [string, string][] = []
if (p.species) rows.push(['Species', p.species])
if (p.variety) rows.push(['Variety', p.variety])
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
if (rows.length === 0) return null
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{k}</dt>
<dd className="text-fg">{v}</dd>
</div>
))}
</dl>
)
}