import { useState, type FormEvent } from 'react' import { Modal } from '@/components/ui/Modal' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' import { Select } from '@/components/ui/Select' import { TextArea } from '@/components/ui/TextArea' import { TextField } from '@/components/ui/TextField' import { errorMessage } from '@/lib/api' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { dimensionUnitLabel, formatCm, dimensionInputMode, formatDimensionInput, isValidDimensionCm, MIN_GARDEN_GRID_CM, parseDimension, type UnitPref, } from '@/lib/units' const DEFAULT_METERS = 10 // matches the server's 10 m default const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default // What to say when a field can't be read. Naming the accepted forms beats // "invalid input", which leaves the person guessing which field and which part. function entryHint(unit: UnitPref): string { return unit === 'imperial' ? `Enter sizes as feet and inches — 8' 6", 8', or 8.5 for feet.` : 'Enter sizes in meters, e.g. 2.5.' } const unitOptions = [ { value: 'metric', label: 'Metric (m)' }, { value: 'imperial', label: 'Imperial (ft)' }, ] function dimString(cm: number | undefined, unit: UnitPref): string { return cm === undefined ? String(DEFAULT_METERS) : formatDimensionInput(cm, unit) } /** * Create (no garden) or edit (garden given) form. Dimensions are entered in the * selected unit and converted to centimeters for the API; switching units * converts the current values so the physical size is preserved. A 409 rebases * the form onto the server's fresh row. */ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: () => void }) { const isEdit = !!garden const create = useCreateGarden() const update = useUpdateGarden() const pending = create.isPending || update.isPending const [name, setName] = useState(garden?.name ?? '') const [unit, setUnit] = useState(garden?.unitPref ?? 'metric') const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric')) const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric')) const [gridSize, setGridSize] = useState(() => formatDimensionInput(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric'), ) const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [notes, setNotes] = useState(garden?.notes ?? '') const [version, setVersion] = useState(garden?.version ?? 0) const [conflict, setConflict] = useState(null) const [formError, setFormError] = useState(null) function changeUnit(next: UnitPref) { // Re-render each field in the new unit, preserving the physical size. An // unparseable field is left as typed rather than blanked. const convert = (s: string) => { const cm = parseDimension(s, unit) return cm === null ? s : formatDimensionInput(cm, next) } setWidth(convert(width)) setHeight(convert(height)) // The garden grid is a layout concern, so it lives at the same scale as the // garden's own dimensions — same helpers, same unit label. (The *bed* grid in // the object inspector is a plant-spacing concern and stays at cm/in.) setGridSize(convert(gridSize)) setUnit(next) } // Converted once per render and used by both the submit handler and the // too-fine hint, so the two can never disagree about what was entered. const gridSizeCm = parseDimension(gridSize, unit) // Soft floor: hint, don't refuse. A garden-scale grid this fine is usually // someone reaching for plant spacing, which lives on the bed instead — but it // is a legitimate choice for a very small garden, so the save still goes through. const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM async function onSubmit(e: FormEvent) { e.preventDefault() setFormError(null) setConflict(null) if (!name.trim()) { setFormError('Enter a name for the garden.') return } // Validate the converted centimeter values against the same bounds the // server enforces, so sub-cm or over-100m sizes fail here with a clear // message instead of a generic server error. const widthCm = parseDimension(width, unit) const heightCm = parseDimension(height, unit) if (widthCm === null || heightCm === null) { setFormError(entryHint(unit)) return } if (!isValidDimensionCm(widthCm) || !isValidDimensionCm(heightCm)) { setFormError('Width and height must be between 1 cm and 100 m.') return } if (gridSizeCm === null) { setFormError(entryHint(unit)) return } if (!isValidDimensionCm(gridSizeCm)) { setFormError('Grid size must be between 1 cm and 100 m.') return } const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid, } try { if (isEdit) { await update.mutateAsync({ id: garden.id, ...input, version }) } else { await create.mutateAsync(input) } onClose() } catch (err) { const current = conflictGarden(err) if (current) { // Someone else changed this garden: rebase the form onto the fresh row so // a re-save applies against the current version. setVersion(current.version) setName(current.name) setUnit(current.unitPref) setWidth(dimString(current.widthCm, current.unitPref)) setHeight(dimString(current.heightCm, current.unitPref)) setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref)) setSnapToGrid(current.snapToGrid) setNotes(current.notes) setConflict('This garden changed elsewhere. The latest values are shown — review and save again.') return } setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the garden.')) } } const unitLabel = dimensionUnitLabel(unit) const inputMode = dimensionInputMode(unit) return (
{conflict && {conflict}} setName(e.target.value)} /> setSnapToGrid(e.target.checked)} className="h-4 w-4 rounded border-border" /> Snap objects {gridTooFine && (

{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed (Bed grid in the inspector), not here.

)}