diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 84cf29a..859b7d8 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -63,7 +63,7 @@ Requirements for this milestone. Each maps to roadmap phases. - [ ] **DETAIL-01**: Clicking a collection item navigates to a full detail page (`/items/:id`) showing all item data - [ ] **DETAIL-02**: Clicking a catalog search result navigates to a public detail page (`/global-items/:id`) with "Add to Collection" button - [ ] **DETAIL-03**: Item detail page has edit mode toggle for modifying personal fields (notes, category, quantity, purchase price) -- [ ] **DETAIL-04**: Thread candidates navigate to detail pages instead of opening slide-out panels +- [x] **DETAIL-04**: Thread candidates navigate to detail pages instead of opening slide-out panels - [ ] **DETAIL-05**: Slide-out panels for items and candidates are removed from the application ### Tags @@ -184,7 +184,7 @@ Which phases cover which requirements. Updated during roadmap creation. | DETAIL-01 | Phase 21 | Pending | | DETAIL-02 | Phase 21 | Pending | | DETAIL-03 | Phase 21 | Pending | -| DETAIL-04 | Phase 21 | Pending | +| DETAIL-04 | Phase 21 | Complete | | DETAIL-05 | Phase 21 | Pending | **Coverage:** diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e89e123..a0da163 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -57,7 +57,7 @@ - [x] **Phase 18: Global Items & Public Profiles** — Global item catalog, user profiles, and public setup sharing (completed 2026-04-05) - [x] **Phase 19: Reference Item Model & Tags Schema** — Collection items as references to global catalog, tag system for discovery (completed 2026-04-05) - [x] **Phase 20: FAB & Full-Screen Catalog Search** — Global FAB with mini menu, full-screen catalog search with tag filtering (completed 2026-04-06) -- [ ] **Phase 21: Item & Catalog Detail Pages** — Full detail pages for collection items and catalog entries, replacing slide-out panels +- [x] **Phase 21: Item & Catalog Detail Pages** — Full detail pages for collection items and catalog entries, replacing slide-out panels (completed 2026-04-06) - [ ] **Phase 22: Add-from-Catalog & Thread Integration** — Add catalog items to collection and threads, resolution creates reference items - [ ] **Phase 23: Manual Entry Fallback** — Manual add for items not in catalog, non-functional submission prompt @@ -266,6 +266,6 @@ Plans: | 18. Global Items & Public Profiles | v2.0 | 4/5 | Complete | 2026-04-05 | | 19. Reference Item Model & Tags Schema | v2.0 | 3/3 | Complete | 2026-04-05 | | 20. FAB & Full-Screen Catalog Search | v2.0 | 2/2 | Complete | 2026-04-06 | -| 21. Item & Catalog Detail Pages | v2.0 | 1/3 | In Progress| | +| 21. Item & Catalog Detail Pages | v2.0 | 1/1 | Complete | 2026-04-06 | | 22. Add-from-Catalog & Thread Integration | v2.0 | 0/? | Not started | - | | 23. Manual Entry Fallback | v2.0 | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 8badf22..3f0e44b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.3 milestone_name: Research & Decision Tools status: planning -stopped_at: Completed 21-01-PLAN.md -last_updated: "2026-04-06T06:17:39.050Z" +stopped_at: Completed 21-02-PLAN.md +last_updated: "2026-04-06T13:03:13.009Z" last_activity: 2026-04-06 progress: - total_phases: 14 - completed_phases: 13 - total_plans: 38 - completed_plans: 36 + total_phases: 15 + completed_phases: 14 + total_plans: 39 + completed_plans: 37 percent: 0 --- @@ -25,9 +25,9 @@ See: .planning/PROJECT.md (updated 2026-04-03) ## Current Position -Phase: 21 of 21 (Item & Catalog Detail Pages) -Plan: 1 of 3 complete -Status: Executing +Phase: 20 of 18 (PostgreSQL Migration) +Plan: Not started +Status: Ready to plan Last activity: 2026-04-06 Progress: [----------] 0% (v2.0 milestone) @@ -58,8 +58,8 @@ Key decisions made during v2.0 planning: - [Phase 20]: Created tags table in schema (was missing, needed for GET /api/tags endpoint) - [Phase 20]: FAB visible on all authenticated routes, not just collection gear tab - [Phase 20]: Add button on catalog search cards is a stub (Phase 21 wires actual flow) -- [Phase 21]: Edit mode on detail pages uses local useState, not UIStore panel state -- [Phase 21]: Add to Collection button on catalog detail page is a stub (Phase 22 wires actual flow) +- [Phase 21]: Candidate data fetched from useThread hook (find in array) not new API endpoint +- [Phase 21]: AddCandidateModal inline in thread page, local modal pattern replacing UIStore panel ### Pending Todos @@ -70,6 +70,7 @@ None active. | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| | 260406-j44 | Comprehensive dev seed script for bikepacking gear data | 2026-04-06 | — | [260406-j44-comprehensive-dev-seed-script-for-bikepa](./quick/260406-j44-comprehensive-dev-seed-script-for-bikepa/) | +| Phase 21 P02 | 4min | 2 tasks | 2 files | ### Blockers/Concerns @@ -78,6 +79,6 @@ None active. ## Session Continuity -Last session: 2026-04-06T13:02:00.000Z -Stopped at: Completed 21-01-PLAN.md +Last session: 2026-04-06T13:03:13.007Z +Stopped at: Completed 21-02-PLAN.md Resume file: None diff --git a/.planning/phases/21-item-catalog-detail-pages/21-02-SUMMARY.md b/.planning/phases/21-item-catalog-detail-pages/21-02-SUMMARY.md new file mode 100644 index 0000000..ebd293e --- /dev/null +++ b/.planning/phases/21-item-catalog-detail-pages/21-02-SUMMARY.md @@ -0,0 +1,90 @@ +--- +phase: 21-item-catalog-detail-pages +plan: 02 +subsystem: ui +tags: [react, tanstack-router, candidate-detail, modal-dialog, edit-mode] + +requires: + - phase: 20-fab-full-screen-catalog-search + provides: FAB and catalog search overlay foundation +provides: + - Candidate detail page at /threads/:threadId/candidates/:candidateId with edit mode + - Restructured thread route directory for nested candidate routes + - Add-candidate modal dialog on thread page replacing slide-out panel +affects: [21-03-candidate-card-navigation-rewire] + +tech-stack: + added: [] + patterns: [nested-route-directory-structure, local-modal-pattern] + +key-files: + created: + - src/client/routes/threads/$threadId/candidates/$candidateId.tsx + modified: + - src/client/routes/threads/$threadId/index.tsx + +key-decisions: + - "StatusBadge on detail page is read-only (no onStatusChange) since status cycling happens on cards" + - "AddCandidateModal defined inline in thread index.tsx rather than separate component file" + - "Candidate data fetched from useThread hook (find in candidates array) rather than new endpoint" + +patterns-established: + - "Nested route directory: $threadId/index.tsx + $threadId/candidates/$candidateId.tsx" + - "Local modal pattern: useState in parent page controls modal visibility, no UIStore needed" + +requirements-completed: [DETAIL-04] + +duration: 4min +completed: 2026-04-06 +--- + +# Phase 21 Plan 02: Candidate Detail Page & Thread Route Restructuring Summary + +**Candidate detail page with edit mode toggle at /threads/:threadId/candidates/:candidateId, thread route directory restructured for nested routes, add-candidate modal replacing slide-out panel** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-04-06T12:57:42Z +- **Completed:** 2026-04-06T13:02:26Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments +- Restructured thread route from flat file to directory structure supporting nested candidate routes +- Created full candidate detail page with read/edit modes, image display, pros/cons, notes, and thread actions +- Replaced UIStore openCandidateAddPanel call with local modal dialog containing all form fields + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Restructure thread route and create candidate detail page** - `cecaf78` (feat) +2. **Task 2: Add candidate modal dialog on thread page** - `47b416e` (feat) + +## Files Created/Modified +- `src/client/routes/threads/$threadId/index.tsx` - Moved from $threadId.tsx, updated imports, added AddCandidateModal +- `src/client/routes/threads/$threadId/candidates/$candidateId.tsx` - New candidate detail page with edit mode toggle + +## Decisions Made +- StatusBadge rendered as read-only on detail page (status changes happen via card interactions) +- AddCandidateModal defined inline in the thread page file for simplicity +- Candidate data sourced from useThread hook (find in array) to avoid new API endpoint + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Candidate detail page is ready for Plan 03 to rewire CandidateCard clicks as navigation links +- Thread route directory structure supports the nested /candidates/:candidateId path + +--- +*Phase: 21-item-catalog-detail-pages* +*Completed: 2026-04-06* diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index 09e0c65..b2aac02 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -19,6 +19,8 @@ import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' import { Route as ItemsItemIdRouteImport } from './routes/items/$itemId' import { Route as GlobalItemsGlobalItemIdRouteImport } from './routes/global-items/$globalItemId' +import { Route as ThreadsThreadIdIndexRouteImport } from './routes/threads/$threadId/index' +import { Route as ThreadsThreadIdCandidatesCandidateIdRouteImport } from './routes/threads/$threadId/candidates/$candidateId' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -70,6 +72,17 @@ const GlobalItemsGlobalItemIdRoute = GlobalItemsGlobalItemIdRouteImport.update({ path: '/global-items/$globalItemId', getParentRoute: () => rootRouteImport, } as any) +const ThreadsThreadIdIndexRoute = ThreadsThreadIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ThreadsThreadIdRoute, +} as any) +const ThreadsThreadIdCandidatesCandidateIdRoute = + ThreadsThreadIdCandidatesCandidateIdRouteImport.update({ + id: '/candidates/$candidateId', + path: '/candidates/$candidateId', + getParentRoute: () => ThreadsThreadIdRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -78,10 +91,12 @@ export interface FileRoutesByFullPath { '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute - '/threads/$threadId': typeof ThreadsThreadIdRoute + '/threads/$threadId': typeof ThreadsThreadIdRouteWithChildren '/users/$userId': typeof UsersUserIdRoute '/collection/': typeof CollectionIndexRoute '/global-items/': typeof GlobalItemsIndexRoute + '/threads/$threadId/': typeof ThreadsThreadIdIndexRoute + '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -90,10 +105,11 @@ export interface FileRoutesByTo { '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute - '/threads/$threadId': typeof ThreadsThreadIdRoute '/users/$userId': typeof UsersUserIdRoute '/collection': typeof CollectionIndexRoute '/global-items': typeof GlobalItemsIndexRoute + '/threads/$threadId': typeof ThreadsThreadIdIndexRoute + '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -103,10 +119,12 @@ export interface FileRoutesById { '/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute '/items/$itemId': typeof ItemsItemIdRoute '/setups/$setupId': typeof SetupsSetupIdRoute - '/threads/$threadId': typeof ThreadsThreadIdRoute + '/threads/$threadId': typeof ThreadsThreadIdRouteWithChildren '/users/$userId': typeof UsersUserIdRoute '/collection/': typeof CollectionIndexRoute '/global-items/': typeof GlobalItemsIndexRoute + '/threads/$threadId/': typeof ThreadsThreadIdIndexRoute + '/threads/$threadId/candidates/$candidateId': typeof ThreadsThreadIdCandidatesCandidateIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -121,6 +139,8 @@ export interface FileRouteTypes { | '/users/$userId' | '/collection/' | '/global-items/' + | '/threads/$threadId/' + | '/threads/$threadId/candidates/$candidateId' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -129,10 +149,11 @@ export interface FileRouteTypes { | '/global-items/$globalItemId' | '/items/$itemId' | '/setups/$setupId' - | '/threads/$threadId' | '/users/$userId' | '/collection' | '/global-items' + | '/threads/$threadId' + | '/threads/$threadId/candidates/$candidateId' id: | '__root__' | '/' @@ -145,6 +166,8 @@ export interface FileRouteTypes { | '/users/$userId' | '/collection/' | '/global-items/' + | '/threads/$threadId/' + | '/threads/$threadId/candidates/$candidateId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -154,7 +177,7 @@ export interface RootRouteChildren { GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute ItemsItemIdRoute: typeof ItemsItemIdRoute SetupsSetupIdRoute: typeof SetupsSetupIdRoute - ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute + ThreadsThreadIdRoute: typeof ThreadsThreadIdRouteWithChildren UsersUserIdRoute: typeof UsersUserIdRoute CollectionIndexRoute: typeof CollectionIndexRoute GlobalItemsIndexRoute: typeof GlobalItemsIndexRoute @@ -232,9 +255,38 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GlobalItemsGlobalItemIdRouteImport parentRoute: typeof rootRouteImport } + '/threads/$threadId/': { + id: '/threads/$threadId/' + path: '/' + fullPath: '/threads/$threadId/' + preLoaderRoute: typeof ThreadsThreadIdIndexRouteImport + parentRoute: typeof ThreadsThreadIdRoute + } + '/threads/$threadId/candidates/$candidateId': { + id: '/threads/$threadId/candidates/$candidateId' + path: '/candidates/$candidateId' + fullPath: '/threads/$threadId/candidates/$candidateId' + preLoaderRoute: typeof ThreadsThreadIdCandidatesCandidateIdRouteImport + parentRoute: typeof ThreadsThreadIdRoute + } } } +interface ThreadsThreadIdRouteChildren { + ThreadsThreadIdIndexRoute: typeof ThreadsThreadIdIndexRoute + ThreadsThreadIdCandidatesCandidateIdRoute: typeof ThreadsThreadIdCandidatesCandidateIdRoute +} + +const ThreadsThreadIdRouteChildren: ThreadsThreadIdRouteChildren = { + ThreadsThreadIdIndexRoute: ThreadsThreadIdIndexRoute, + ThreadsThreadIdCandidatesCandidateIdRoute: + ThreadsThreadIdCandidatesCandidateIdRoute, +} + +const ThreadsThreadIdRouteWithChildren = ThreadsThreadIdRoute._addFileChildren( + ThreadsThreadIdRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, @@ -242,7 +294,7 @@ const rootRouteChildren: RootRouteChildren = { GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute, ItemsItemIdRoute: ItemsItemIdRoute, SetupsSetupIdRoute: SetupsSetupIdRoute, - ThreadsThreadIdRoute: ThreadsThreadIdRoute, + ThreadsThreadIdRoute: ThreadsThreadIdRouteWithChildren, UsersUserIdRoute: UsersUserIdRoute, CollectionIndexRoute: CollectionIndexRoute, GlobalItemsIndexRoute: GlobalItemsIndexRoute, diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx deleted file mode 100644 index d8c6001..0000000 --- a/src/client/routes/threads/$threadId.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { Reorder } from "framer-motion"; -import { useEffect, useState } from "react"; -import { CandidateCard } from "../../components/CandidateCard"; -import { CandidateListItem } from "../../components/CandidateListItem"; -import { ComparisonTable } from "../../components/ComparisonTable"; -import { SetupImpactSelector } from "../../components/SetupImpactSelector"; -import { - useReorderCandidates, - useUpdateCandidate, -} from "../../hooks/useCandidates"; -import { useImpactDeltas } from "../../hooks/useImpactDeltas"; -import { useSetup } from "../../hooks/useSetups"; -import { useThread } from "../../hooks/useThreads"; -import { LucideIcon } from "../../lib/iconData"; -import { useUIStore } from "../../stores/uiStore"; - -export const Route = createFileRoute("/threads/$threadId")({ - component: ThreadDetailPage, -}); - -function ThreadDetailPage() { - const { threadId: threadIdParam } = Route.useParams(); - const threadId = Number(threadIdParam); - const { data: thread, isLoading, isError } = useThread(threadId); - const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel); - const candidateViewMode = useUIStore((s) => s.candidateViewMode); - const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode); - const selectedSetupId = useUIStore((s) => s.selectedSetupId); - const updateCandidate = useUpdateCandidate(threadId); - const reorderMutation = useReorderCandidates(threadId); - const { data: setupData } = useSetup(selectedSetupId); - const { deltas } = useImpactDeltas( - thread?.candidates ?? [], - setupData?.items, - thread?.categoryId ?? 0, - ); - - const [tempItems, setTempItems] = useState< - NonNullable["candidates"] | null - >(null); - - // Clear tempItems when server data changes - // biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger - useEffect(() => { - setTempItems(null); - }, [thread?.candidates]); - - if (isLoading) { - return ( -
-
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
-
- ); - } - - if (isError || !thread) { - return ( -
-

- Thread not found -

- - Back to planning - -
- ); - } - - const isActive = thread.status === "active"; - const winningCandidate = thread.resolvedCandidateId - ? thread.candidates.find((c) => c.id === thread.resolvedCandidateId) - : null; - - const displayItems = tempItems ?? thread.candidates; - - function handleDragEnd() { - if (!tempItems) return; - reorderMutation.mutate({ - orderedIds: tempItems.map((c) => c.id), - }); - } - - return ( -
- {/* Header */} -
- - ← Back to planning - -
-

{thread.name}

- - {isActive ? "Active" : "Resolved"} - -
-
- - {/* Resolution banner */} - {!isActive && winningCandidate && ( -
-

- {winningCandidate.name} was - picked as the winner and added to your collection. -

-
- )} - - {/* Toolbar: Add candidate + view toggle */} -
- {isActive && ( - - )} - {thread.candidates.length > 0 && ( -
- - - {thread.candidates.length >= 2 && ( - - )} -
- )} - -
- - {/* Candidates */} - {thread.candidates.length === 0 ? ( -
-
- -
-

- No candidates yet -

-

- Add your first candidate to start comparing. -

-
- ) : candidateViewMode === "compare" ? ( - - ) : candidateViewMode === "list" ? ( - isActive ? ( - - {displayItems.map((candidate, index) => ( - - updateCandidate.mutate({ - candidateId: candidate.id, - status: newStatus, - }) - } - delta={deltas[candidate.id]} - onDragEnd={handleDragEnd} - /> - ))} - - ) : ( -
- {displayItems.map((candidate, index) => ( - - updateCandidate.mutate({ - candidateId: candidate.id, - status: newStatus, - }) - } - delta={deltas[candidate.id]} - /> - ))} -
- ) - ) : ( -
- {thread.candidates.map((candidate, index) => ( - - updateCandidate.mutate({ - candidateId: candidate.id, - status: newStatus, - }) - } - pros={candidate.pros} - cons={candidate.cons} - rank={index + 1} - delta={deltas[candidate.id]} - /> - ))} -
- )} -
- ); -} diff --git a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx new file mode 100644 index 0000000..6228b2a --- /dev/null +++ b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx @@ -0,0 +1,506 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { CategoryPicker } from "../../../../components/CategoryPicker"; +import { ImageUpload } from "../../../../components/ImageUpload"; +import { StatusBadge } from "../../../../components/StatusBadge"; +import { useUpdateCandidate } from "../../../../hooks/useCandidates"; +import { useFormatters } from "../../../../hooks/useFormatters"; +import { useThread } from "../../../../hooks/useThreads"; +import { LucideIcon } from "../../../../lib/iconData"; +import { useUIStore } from "../../../../stores/uiStore"; + +export const Route = createFileRoute( + "/threads/$threadId/candidates/$candidateId", +)({ + component: CandidateDetailPage, +}); + +interface FormData { + name: string; + weightGrams: string; + priceDollars: string; + categoryId: number; + notes: string; + productUrl: string; + imageFilename: string | null; + pros: string; + cons: string; +} + +function CandidateDetailPage() { + const { threadId: threadIdParam, candidateId: candidateIdParam } = + Route.useParams(); + const threadId = Number(threadIdParam); + const candidateId = Number(candidateIdParam); + const { data: thread, isLoading, isError } = useThread(threadId); + const updateCandidate = useUpdateCandidate(threadId); + const { weight, price } = useFormatters(); + const openResolveDialog = useUIStore((s) => s.openResolveDialog); + const openConfirmDeleteCandidate = useUIStore( + (s) => s.openConfirmDeleteCandidate, + ); + + const [isEditing, setIsEditing] = useState(false); + const [form, setForm] = useState({ + name: "", + weightGrams: "", + priceDollars: "", + categoryId: 1, + notes: "", + productUrl: "", + imageFilename: null, + pros: "", + cons: "", + }); + const [errors, setErrors] = useState>({}); + + const candidate = thread?.candidates.find((c) => c.id === candidateId); + const isActive = thread?.status === "active"; + + function enterEditMode() { + if (!candidate) return; + 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 ?? null, + pros: candidate.pros ?? "", + cons: candidate.cons ?? "", + }); + setErrors({}); + setIsEditing(true); + } + + function cancelEdit() { + setIsEditing(false); + setErrors({}); + } + + function validate(): boolean { + const newErrors: Record = {}; + if (!form.name.trim()) { + newErrors.name = "Name is required"; + } + if ( + form.weightGrams && + (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0) + ) { + newErrors.weightGrams = "Must be a positive number"; + } + if ( + form.priceDollars && + (Number.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 handleSave() { + if (!validate()) return; + + updateCandidate.mutate( + { + candidateId, + 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, + pros: form.pros.trim() || undefined, + cons: form.cons.trim() || undefined, + }, + { + onSuccess: () => setIsEditing(false), + }, + ); + } + + // Loading state + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + // Error / not found + if (isError || !thread || !candidate) { + return ( +
+ + ← Back to thread + +
+

Candidate not found

+
+
+ ); + } + + const imageUrl = candidate.imageFilename + ? `/uploads/${candidate.imageFilename}` + : null; + + return ( +
+ {/* Back navigation */} +
+ + ← Back to thread + +
+ + {/* Image */} + {isEditing ? ( +
+ + setForm((f) => ({ ...f, imageFilename: filename })) + } + /> +
+ ) : imageUrl ? ( +
+ {candidate.name} +
+ ) : null} + + {/* Header */} +
+ {isEditing ? ( +
+ setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full text-2xl font-bold text-gray-900 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ ) : ( +
+

+ {candidate.name} +

+ {isActive && ( + + )} +
+ )} +
+ + {/* Badges */} +
+ {isEditing ? ( + <> +
+ + + setForm((f) => ({ ...f, weightGrams: e.target.value })) + } + className="w-24 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="g" + /> + {errors.weightGrams && ( +

{errors.weightGrams}

+ )} +
+
+ + + setForm((f) => ({ ...f, priceDollars: e.target.value })) + } + className="w-24 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="$" + /> + {errors.priceDollars && ( +

{errors.priceDollars}

+ )} +
+
+ + setForm((f) => ({ ...f, categoryId: id }))} + /> +
+ + ) : ( + <> + {candidate.weightGrams != null && ( + + {weight(candidate.weightGrams)} + + )} + {candidate.priceCents != null && ( + + {price(candidate.priceCents)} + + )} + {candidate.categoryName && ( + + + {candidate.categoryName} + + )} + {}} /> + + )} +
+ + {/* Product Link */} + {isEditing ? ( +
+ + + setForm((f) => ({ ...f, productUrl: 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-gray-400 focus:border-transparent" + placeholder="https://..." + /> + {errors.productUrl && ( +

{errors.productUrl}

+ )} +
+ ) : candidate.productUrl ? ( + + ) : null} + + {/* Pros & Cons */} + {isEditing ? ( +
+
+ +