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 { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field' import { errorMessage } from '@/lib/api' import { CATEGORY_LABELS, PLANT_CATEGORIES, conflictPlant, useCreatePlant, useUpdatePlant, type Plant, type PlantCategory, type PlantInput, } from '@/lib/plants' import { safeExternalUrl } from '@/lib/seedLots' import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units' import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches' // Markers are monograms now, but the API still carries an icon per plant; a // plant made here gets the neutral sprout so nothing downstream sees an empty one. const DEFAULT_ICON = '🌱' const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })) /** * "A new plant" — or edit (`plant`), or a fresh create prefilled from another * (`template`, the Duplicate action; the only way to customize a built-in). * Spacing is typed in the page's unit and stored in centimeters — as a * LengthField, so a Save that didn't touch it sends the centimeters that were * loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the * server's current row. */ export function PlantDialog({ plant, template, unit, onClose, }: { plant?: Plant template?: Plant unit: UnitPref onClose: () => void }) { const isEdit = !!plant const source = plant ?? template const create = useCreatePlant() const update = useUpdatePlant() const pending = create.isPending || update.isPending const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') const [category, setCategory] = useState(source?.category ?? 'vegetable') const [spacing, setSpacing] = useState(() => spacingField(source?.spacingCm ?? 30, unit)) const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0])) const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [vendor, setVendor] = useState(source?.vendor ?? '') const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '') const [notes, setNotes] = useState(source?.notes ?? '') const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || source?.notes)) const [version, setVersion] = useState(plant?.version ?? 0) const [conflict, setConflict] = useState(null) const [formError, setFormError] = useState(null) const unitLabel = spacingUnitLabel(unit) async function onSubmit(e: FormEvent) { e.preventDefault() setFormError(null) setConflict(null) if (!name.trim()) { setFormError('Give the plant a name.') return } const spacingCm = spacing.cm if (spacingCm === null || spacingCm < 1) { setFormError(`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) { setFormError('Days to maturity is a whole number of days, or blank.') return } daysToMaturity = d } if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) { setFormError('The source link needs to be a full http:// or https:// address.') return } const input: PlantInput = { name: name.trim(), category, spacingCm, color, icon: source?.icon || DEFAULT_ICON, daysToMaturity, sourceUrl: sourceUrl.trim(), vendor: vendor.trim(), notes: notes.trim(), } // Nothing changed: close without a request, so a look-and-Save doesn't bump // the version for every garden that shares the plant. if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) { onClose() return } try { if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version }) else await create.mutateAsync(input) onClose() } catch (err) { const current = conflictPlant(err) if (current) { setVersion(current.version) setName(current.name) setCategory(current.category) setSpacing(spacingField(current.spacingCm, unit)) setColor(expandHex(current.color)) setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setVendor(current.vendor) setSourceUrl(current.sourceUrl) setNotes(current.notes) setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.') return } setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.')) } } return (
{conflict && {conflict}} setName(e.target.value)} />
setCategory(e.target.value as PlantCategory)} options={categoryOptions} wrapperClassName="flex-1" /> setSpacing(editSpacingField(e.target.value, unit))} wrapperClassName="flex-1" />
setDays(e.target.value)} /> {!more ? ( ) : (
setVendor(e.target.value)} wrapperClassName="flex-1" /> setSourceUrl(e.target.value)} wrapperClassName="flex-1" />
setNotes(e.target.value)} />
)} {formError && {formError}}
) }