The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
141 lines
6.9 KiB
TypeScript
141 lines
6.9 KiB
TypeScript
import { useState, type FormEvent } from 'react'
|
|
import { Alert } from '@/components/ui/Alert'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Dialog } from '@/components/ui/Dialog'
|
|
import { SelectField, TextAreaField, TextField } from '@/components/ui/Field'
|
|
import { errorMessage } from '@/lib/api'
|
|
import type { Plant } from '@/lib/plants'
|
|
import {
|
|
conflictSeedLot,
|
|
LOT_UNITS,
|
|
safeExternalUrl,
|
|
useCreateSeedLot,
|
|
useUpdateSeedLot,
|
|
type LotUnit,
|
|
type SeedLot,
|
|
} from '@/lib/seedLots'
|
|
|
|
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
|
|
|
|
/**
|
|
* Record a purchase, or correct one. Everything except quantity and unit is
|
|
* optional — the point is to make writing it down cheap enough to bother with.
|
|
*/
|
|
export function SeedLotDialog({ plant, lot, onClose }: { plant: Plant; lot?: SeedLot; onClose: () => void }) {
|
|
const isEdit = !!lot
|
|
const create = useCreateSeedLot()
|
|
const update = useUpdateSeedLot()
|
|
const pending = create.isPending || update.isPending
|
|
|
|
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
|
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
|
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
|
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
|
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
|
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
|
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
|
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
|
const [sku, setSku] = useState(lot?.sku ?? '')
|
|
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
|
const [notes, setNotes] = useState(lot?.notes ?? '')
|
|
const [version, setVersion] = useState(lot?.version ?? 0)
|
|
const [conflict, setConflict] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setConflict(null)
|
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
|
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
|
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim()))
|
|
return setError('The source link needs to be a full http:// or https:// address.')
|
|
let year: number | null = null
|
|
if (packedForYear.trim()) {
|
|
const y = Number(packedForYear)
|
|
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
|
|
year = y
|
|
}
|
|
let costCents: number | null = null
|
|
if (cost.trim()) {
|
|
const c = Number(cost)
|
|
if (!Number.isFinite(c) || c < 0) return setError('Cost must be an amount, or blank.')
|
|
costCents = Math.round(c * 100)
|
|
}
|
|
let germinationPct: number | null = null
|
|
if (germination.trim()) {
|
|
const g = Number(germination)
|
|
if (!Number.isFinite(g) || g < 0 || g > 100) return setError('Germination is a percentage between 0 and 100.')
|
|
germinationPct = g
|
|
}
|
|
const input = {
|
|
plantId: plant.id,
|
|
vendor: vendor.trim(),
|
|
sourceUrl: sourceUrl.trim(),
|
|
sku: sku.trim(),
|
|
lotCode: lotCode.trim(),
|
|
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
|
packedForYear: year,
|
|
quantity: qty,
|
|
unit,
|
|
costCents,
|
|
germinationPct,
|
|
notes: notes.trim(),
|
|
}
|
|
try {
|
|
if (isEdit) await update.mutateAsync({ id: lot.id, version, ...input })
|
|
else await create.mutateAsync(input)
|
|
onClose()
|
|
} catch (err) {
|
|
const current = conflictSeedLot(err)
|
|
if (current) {
|
|
setVersion(current.version)
|
|
setVendor(current.vendor)
|
|
setSourceUrl(current.sourceUrl)
|
|
setQuantity(String(current.quantity))
|
|
setUnit(current.unit)
|
|
setPurchasedAt(current.purchasedAt ?? '')
|
|
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
|
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
|
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
|
setSku(current.sku)
|
|
setLotCode(current.lotCode)
|
|
setNotes(current.notes)
|
|
setConflict('This lot changed elsewhere. The latest values are shown — look them over and save again.')
|
|
return
|
|
}
|
|
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog title={isEdit ? 'Edit the lot' : `A lot of ${plant.name}`} onClose={onClose} busy={pending} width={460}>
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
|
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
<TextField label="Quantity" name="quantity" type="number" inputMode="decimal" step="any" min="0" autoFocus value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
|
<SelectField label="Unit" name="unit" value={unit} onChange={(e) => setUnit(e.target.value as LotUnit)} options={unitOptions} />
|
|
<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)} />
|
|
<TextField label="Purchased" name="purchasedAt" type="date" value={purchasedAt} onChange={(e) => setPurchasedAt(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)} />
|
|
<TextField label="Germination %" name="germination" type="number" inputMode="decimal" step="any" min="0" max="100" value={germination} onChange={(e) => setGermination(e.target.value)} />
|
|
<TextField label="Source link" name="sourceUrl" type="url" inputMode="url" placeholder="https://…" value={sourceUrl} onChange={(e) => setSourceUrl(e.target.value)} />
|
|
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
|
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
|
</div>
|
|
<TextAreaField label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
{error && <Alert>{error}</Alert>}
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" onClick={onClose} disabled={pending}>
|
|
Never mind
|
|
</Button>
|
|
<Button type="submit" variant="primary" disabled={pending}>
|
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record the lot'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Dialog>
|
|
)
|
|
}
|