From 7d043a858592a83bfb7ffc7e8d2260e40a428295 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 11:46:19 +0100 Subject: [PATCH] feat(02-02): add thread detail page with candidate CRUD and resolution flow - Create CandidateCard with edit, delete, and pick winner actions - Create CandidateForm with same fields as ItemForm for candidate add/edit - Build thread detail page with candidate grid and resolution banner - Update root layout with candidate panel, delete dialog, and resolve dialog - Hide FAB on thread detail pages, keep it for gear tab - Resolution navigates back to planning tab after success --- src/client/components/CandidateCard.tsx | 91 ++++++++ src/client/components/CandidateForm.tsx | 290 ++++++++++++++++++++++++ src/client/routes/__root.tsx | 257 +++++++++++++++++++-- src/client/routes/threads/$threadId.tsx | 138 ++++++++++- 4 files changed, 748 insertions(+), 28 deletions(-) create mode 100644 src/client/components/CandidateCard.tsx create mode 100644 src/client/components/CandidateForm.tsx diff --git a/src/client/components/CandidateCard.tsx b/src/client/components/CandidateCard.tsx new file mode 100644 index 0000000..40223b3 --- /dev/null +++ b/src/client/components/CandidateCard.tsx @@ -0,0 +1,91 @@ +import { formatWeight, formatPrice } from "../lib/formatters"; +import { useUIStore } from "../stores/uiStore"; + +interface CandidateCardProps { + id: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryName: string; + categoryEmoji: string; + imageFilename: string | null; + threadId: number; + isActive: boolean; +} + +export function CandidateCard({ + id, + name, + weightGrams, + priceCents, + categoryName, + categoryEmoji, + imageFilename, + threadId, + isActive, +}: CandidateCardProps) { + const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); + const openConfirmDeleteCandidate = useUIStore( + (s) => s.openConfirmDeleteCandidate, + ); + const openResolveDialog = useUIStore((s) => s.openResolveDialog); + + return ( +
+ {imageFilename && ( +
+ {name} +
+ )} +
+

+ {name} +

+
+ {weightGrams != null && ( + + {formatWeight(weightGrams)} + + )} + {priceCents != null && ( + + {formatPrice(priceCents)} + + )} + + {categoryEmoji} {categoryName} + +
+
+ + + {isActive && ( + + )} +
+
+
+ ); +} diff --git a/src/client/components/CandidateForm.tsx b/src/client/components/CandidateForm.tsx new file mode 100644 index 0000000..c0b2ad5 --- /dev/null +++ b/src/client/components/CandidateForm.tsx @@ -0,0 +1,290 @@ +import { useState, useEffect } from "react"; +import { + useCreateCandidate, + useUpdateCandidate, +} from "../hooks/useCandidates"; +import { useThread } from "../hooks/useThreads"; +import { useUIStore } from "../stores/uiStore"; +import { CategoryPicker } from "./CategoryPicker"; +import { ImageUpload } from "./ImageUpload"; + +interface CandidateFormProps { + mode: "add" | "edit"; + threadId: number; + candidateId?: number | null; +} + +interface FormData { + name: string; + weightGrams: string; + priceDollars: string; + categoryId: number; + notes: string; + productUrl: string; + imageFilename: string | null; +} + +const INITIAL_FORM: FormData = { + name: "", + weightGrams: "", + priceDollars: "", + categoryId: 1, + notes: "", + productUrl: "", + imageFilename: null, +}; + +export function CandidateForm({ + mode, + threadId, + candidateId, +}: CandidateFormProps) { + const { data: thread } = useThread(threadId); + const createCandidate = useCreateCandidate(threadId); + const updateCandidate = useUpdateCandidate(threadId); + const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel); + + const [form, setForm] = useState(INITIAL_FORM); + const [errors, setErrors] = useState>({}); + + // Pre-fill form when editing + useEffect(() => { + if (mode === "edit" && candidateId != null && thread?.candidates) { + const candidate = thread.candidates.find((c) => c.id === candidateId); + if (candidate) { + setForm({ + name: candidate.name, + weightGrams: + candidate.weightGrams != null ? String(candidate.weightGrams) : "", + priceDollars: + candidate.priceCents != null + ? (candidate.priceCents / 100).toFixed(2) + : "", + categoryId: candidate.categoryId, + notes: candidate.notes ?? "", + productUrl: candidate.productUrl ?? "", + imageFilename: candidate.imageFilename, + }); + } + } else if (mode === "add") { + setForm(INITIAL_FORM); + } + }, [mode, candidateId, thread?.candidates]); + + function validate(): boolean { + const newErrors: Record = {}; + if (!form.name.trim()) { + newErrors.name = "Name is required"; + } + if ( + form.weightGrams && + (isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0) + ) { + newErrors.weightGrams = "Must be a positive number"; + } + if ( + form.priceDollars && + (isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0) + ) { + newErrors.priceDollars = "Must be a positive number"; + } + if ( + form.productUrl && + form.productUrl.trim() !== "" && + !form.productUrl.match(/^https?:\/\//) + ) { + newErrors.productUrl = "Must be a valid URL (https://...)"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + const payload = { + name: form.name.trim(), + weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined, + priceCents: form.priceDollars + ? Math.round(Number(form.priceDollars) * 100) + : undefined, + categoryId: form.categoryId, + notes: form.notes.trim() || undefined, + productUrl: form.productUrl.trim() || undefined, + imageFilename: form.imageFilename ?? undefined, + }; + + if (mode === "add") { + createCandidate.mutate(payload, { + onSuccess: () => { + setForm(INITIAL_FORM); + closeCandidatePanel(); + }, + }); + } else if (candidateId != null) { + updateCandidate.mutate( + { candidateId, ...payload }, + { onSuccess: () => closeCandidatePanel() }, + ); + } + } + + const isPending = createCandidate.isPending || updateCandidate.isPending; + + return ( +
+ {/* Name */} +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="e.g. Osprey Talon 22" + autoFocus + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Weight */} +
+ + + setForm((f) => ({ ...f, weightGrams: e.target.value })) + } + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="e.g. 680" + /> + {errors.weightGrams && ( +

{errors.weightGrams}

+ )} +
+ + {/* Price */} +
+ + + setForm((f) => ({ ...f, priceDollars: e.target.value })) + } + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="e.g. 129.99" + /> + {errors.priceDollars && ( +

{errors.priceDollars}

+ )} +
+ + {/* Category */} +
+ + setForm((f) => ({ ...f, categoryId: id }))} + /> +
+ + {/* Notes */} +
+ +