// Plant catalog data layer: zod-validated shapes for /api/v1/plants plus the // react-query hooks the /plants page and PlantPicker use. Built-ins (ownerId // null) are seeded and read-only; a user's own rows are editable. "Cloning" a // built-in is just a create of a copy (no special endpoint). import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' import { ApiError, api } from './api' export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const export type PlantCategory = (typeof PLANT_CATEGORIES)[number] export const CATEGORY_LABELS: Record = { vegetable: 'Vegetable', herb: 'Herb', flower: 'Flower', fruit: 'Fruit', tree_shrub: 'Tree / shrub', cover: 'Cover crop', } const plantCategorySchema = z.enum(PLANT_CATEGORIES) export const plantSchema = z.object({ id: z.number(), // Absent (built-in) or a user id. The API omits ownerId for built-ins. ownerId: z.number().nullable().optional(), name: z.string(), category: plantCategorySchema, spacingCm: z.number(), color: z.string(), icon: z.string(), daysToMaturity: z.number().nullable().optional(), // Provenance for the VARIETY — the page you'd go back to to buy it again. // What you actually bought, and how much is left, lives in seedLots.ts. sourceUrl: z.string().default(''), vendor: z.string().default(''), notes: z.string(), version: z.number(), createdAt: z.string(), updatedAt: z.string(), }) export type Plant = z.infer /** A built-in (seeded, read-only) plant has no owner. */ export function isBuiltin(p: Plant): boolean { return p.ownerId == null } /** Category filter value: a specific category, or 'all'. Shared by the catalog * page and the PlantPicker so their chip rows and filtering stay in sync. */ export type CategoryFilter = PlantCategory | 'all' /** Filter a plant list by a name query (case-insensitive substring) and * category. The single source of truth for both the /plants grid and the * PlantPicker list. */ export function filterPlants(plants: Plant[], query: string, category: CategoryFilter): Plant[] { const q = query.trim().toLowerCase() return plants.filter( (p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)), ) } const plantsKey = ['plants'] as const export const plantsQueryOptions = queryOptions({ queryKey: plantsKey, queryFn: async (): Promise => z.array(plantSchema).parse(await api.get('/plants')), }) export function usePlants() { return useQuery(plantsQueryOptions) } export interface PlantInput { name: string category: PlantCategory spacingCm: number color: string icon: string daysToMaturity: number | null sourceUrl: string vendor: string notes: string } export function useCreatePlant() { const qc = useQueryClient() return useMutation({ mutationFn: async (input: PlantInput): Promise => plantSchema.parse(await api.post('/plants', input)), onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }), }) } export interface PlantUpdate extends PlantInput { id: number version: number } export function useUpdatePlant() { const qc = useQueryClient() return useMutation({ // id/version travel with the mutation variables so a single hook serves any row. mutationFn: async ({ id, ...body }: PlantUpdate): Promise => plantSchema.parse(await api.patch(`/plants/${id}`, body)), onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }), }) } export function useDeletePlant() { const qc = useQueryClient() return useMutation({ mutationFn: (id: number) => api.delete(`/plants/${id}`), onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }), }) } /** * If err is a 409 version conflict, return the fresh server row it carries * (under `current`), so a form can rebase onto it; otherwise null. */ export function conflictPlant(err: unknown): Plant | null { if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') { const parsed = plantSchema.safeParse((err.body as { current?: unknown }).current) if (parsed.success) return parsed.data } return null }