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 { CATEGORY_LABELS, PLANT_CATEGORIES, conflictPlant, useCreatePlant, useUpdatePlant, type Plant, type PlantCategory, type PlantInput, } from '@/lib/plants' import { safeExternalUrl } from '@/lib/seedLots' import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' const DEFAULT_COLOR = '#4a7c3f' const DEFAULT_ICON = '🌱' const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })) /** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */ function expandHex(color: string): string { if (/^#[0-9a-fA-F]{3}$/.test(color)) { const [, r, g, b] = color return `#${r}${r}${g}${g}${b}${b}` } return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR } /** * Create or edit a custom plant. `plant` puts it in edit mode; `template` (a * built-in or another plant, used by "Duplicate") pre-fills a fresh create. A * 409 rebases the form onto the server's current row. Spacing is entered in the * page's unit and converted to centimeters for the API. */ export function PlantFormModal({ 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(String(spacingFromCm(source?.spacingCm ?? 30, unit))) const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR)) const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON) const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '') const [vendor, setVendor] = useState(source?.vendor ?? '') const [notes, setNotes] = useState(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('Enter a name for the plant.') return } if (!icon.trim()) { setFormError('Pick an emoji icon.') return } const spacingCm = cmFromSpacing(parseFloat(spacing), unit) if (!Number.isFinite(spacingCm) || spacingCm < 1) { setFormError(`Spacing must be at least 1 ${unitLabel}.`) return } let daysToMaturity: number | null = null if (days.trim()) { // Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1. const d = Number(days) if (!Number.isInteger(d) || d < 1) { setFormError('Days to maturity must be a whole number of days, or left blank.') return } daysToMaturity = d } // The server refuses anything that isn't http(s) with a host, but say so here // rather than letting a paste of "johnnyseeds.com" come back as a generic // error with no hint about which field or why. 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: icon.trim(), daysToMaturity, sourceUrl: sourceUrl.trim(), vendor: vendor.trim(), notes: notes.trim(), } 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) { // Someone else changed this plant: rebase onto the fresh row so a re-save // applies against the current version. setVersion(current.version) setName(current.name) setCategory(current.category) setSpacing(String(spacingFromCm(current.spacingCm, unit))) setColor(expandHex(current.color)) setIcon(current.icon) setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setNotes(current.notes) setConflict('This plant changed elsewhere. The latest values are shown — review and save again.') return } setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.')) } } return (
{conflict && {conflict}} setName(e.target.value)} />
setColor(e.target.value)} className="h-10 w-full cursor-pointer rounded-md border border-border bg-surface" />
setIcon(e.target.value)} hint="A single emoji, e.g. 🍅" /> setDays(e.target.value)} /> {/* Provenance for the variety itself. What you bought and what's left of it is a seed lot, added from the plant card. */}
setVendor(e.target.value)} /> setSourceUrl(e.target.value)} />