# Phase 21: Item & Catalog Detail Pages - Research **Researched:** 2026-04-06 **Domain:** Frontend routing, detail page patterns, edit mode toggle, panel removal **Confidence:** HIGH ## Summary This phase creates full detail pages for collection items, enhances the existing catalog detail page, adds candidate detail routes, and removes slide-out panels from the application. The entire scope is frontend-only -- no new API endpoints or service changes are required. All necessary hooks (`useItem`, `useUpdateItem`, `useDeleteItem`, `useDuplicateItem`, `useGlobalItem`, `useUpdateCandidate`, `useDeleteCandidate`) and API routes already exist. The primary complexity is the edit mode toggle pattern (read-only by default, fields transform to inputs when "Edit" is pressed) and coordinating the panel removal without breaking existing flows. TanStack Router's file-based routing makes adding new routes mechanical -- create the file, export a `Route` constant, and the route tree auto-generates. **Primary recommendation:** Build detail pages first (item, candidate, catalog enhancement), then rewire card click handlers, then remove panels and clean up UIStore in a final wave. This ordering ensures the navigation targets exist before redirecting clicks to them. ## User Constraints (from CONTEXT.md) ### Locked Decisions - D-01 through D-07: Private item detail page at `/items/:id` with hero section, personal section, edit mode toggle, back navigation, and actions (duplicate, delete). - D-08 through D-12: Public catalog detail page at `/global-items/:id` enhanced with "Add to Collection" button (stub), hero layout, no edit functionality. - D-13 through D-16: Candidate detail page at `/threads/:threadId/candidates/:candidateId` with edit mode toggle and thread-specific actions. - D-17 through D-20: Navigation changes -- ItemCard, CandidateCard, CandidateListItem, and catalog search result clicks navigate to detail pages. - D-21 through D-26: Remove SlideOutPanel instances from `__root.tsx`, remove panel state from UIStore, keep `SlideOutPanel.tsx`, `ItemForm.tsx`, `CandidateForm.tsx` files. - D-27: "Add item" flow goes through catalog search (FAB). No more direct "add item" panel. - D-28: Adding candidates to threads -- keep a simple button/form pattern (not a slide-out). ### Claude's Discretion - Exact layout proportions and spacing for detail pages - Whether to use tabs or sections for organizing detail page content - How the edit mode transition animates (if at all) - "Add Candidate" button pattern on thread page (inline form, modal, or navigate to add route) - Whether to show a "Linked to catalog" indicator on private items (subtle, not prominent) - Mobile layout adaptations for detail pages ### Deferred Ideas (OUT OF SCOPE) - Reviews/ratings section on detail pages - Community stats (average weight, price history) - Setup appearances ("This item is in 3 setups") - "Similar items" recommendation section - Image gallery with multiple photos ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | DETAIL-01 | Clicking a collection item navigates to a full detail page (`/items/:id`) showing all item data | New route file at `src/client/routes/items/$itemId.tsx`; existing `useItem(id)` hook returns all merged data via COALESCE | | DETAIL-02 | Clicking a catalog search result navigates to a public detail page (`/global-items/:id`) with "Add to Collection" button | Existing route at `src/client/routes/global-items/$globalItemId.tsx` needs enhancement; `useGlobalItem(id)` returns data with ownerCount | | DETAIL-03 | Item detail page has edit mode toggle for modifying personal fields | `useState` for edit mode boolean; reuse validation logic from `ItemForm.tsx`; `useUpdateItem` mutation for save | | DETAIL-04 | Thread candidates navigate to detail pages instead of opening slide-out panels | New route at `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`; `useThread(threadId)` returns candidates array | | DETAIL-05 | Slide-out panels for items and candidates are removed from the application | Remove from `__root.tsx` lines 189-221; clean UIStore panel state (lines 3-28 of store) | ## Project Constraints (from CLAUDE.md) - **Routing**: TanStack Router with file-based routes in `src/client/routes/`. Route tree auto-generated to `routeTree.gen.ts` -- never edit manually. - **Data fetching**: TanStack React Query via custom hooks. Mutations invalidate related query keys. - **UI state**: Zustand store for panel/dialog state only -- server data lives in React Query. - **Styling**: Tailwind CSS v4. - **Testing**: Bun test runner for unit/integration. Playwright for E2E. - **Path alias**: `@/*` maps to `./src/*`. - **Dev command**: `bun run dev` starts both client and server. - **Lint**: `bun run lint` -- Biome check (tabs, double quotes, organized imports). ## Standard Stack ### Core (already installed, no new dependencies) | Library | Purpose | Why Standard | |---------|---------|--------------| | TanStack Router | File-based routing, route params | Already used throughout app | | TanStack React Query | Data fetching, caching, mutations | All data fetching goes through this | | Zustand | UI state management | Panel/dialog state management | | Tailwind CSS v4 | Styling | Project standard | | Framer Motion | Animations (AnimatePresence) | Already used for page transitions | ### No New Dependencies Required This phase adds no new libraries. All required functionality is covered by the existing stack. ## Architecture Patterns ### New Route Files to Create ``` src/client/routes/ ├── items/ │ └── $itemId.tsx # /items/:id - private item detail ├── threads/ │ └── $threadId/ │ └── candidates/ │ └── $candidateId.tsx # /threads/:threadId/candidates/:candidateId ├── global-items/ │ └── $globalItemId.tsx # EXISTING - enhance with Add button ``` ### Pattern 1: TanStack Router File-Based Route **What:** Create route file with `createFileRoute`, export `Route` constant. **When to use:** Every new page. **Example (from existing codebase):** ```typescript // src/client/routes/global-items/$globalItemId.tsx import { createFileRoute, Link } from "@tanstack/react-router"; export const Route = createFileRoute("/global-items/$globalItemId")({ component: GlobalItemDetail, }); function GlobalItemDetail() { const { globalItemId } = Route.useParams(); // ... } ``` For nested routes like `/threads/$threadId/candidates/$candidateId`, TanStack Router requires the directory structure to match. The file goes at `src/client/routes/threads/$threadId/candidates/$candidateId.tsx` with route path `"/threads/$threadId/candidates/$candidateId"`. **IMPORTANT:** The existing `src/client/routes/threads/$threadId.tsx` must be renamed to `src/client/routes/threads/$threadId/index.tsx` (or `route.tsx`) to allow the nested candidates directory to exist alongside it. TanStack Router file-based routing requires this restructuring when adding child routes under a parameterized parent. ### Pattern 2: Edit Mode Toggle **What:** Local `useState` boolean controls read-only vs. editable view. No Zustand needed. **When to use:** Item detail and candidate detail pages. **Example:** ```typescript function ItemDetail() { const [isEditing, setIsEditing] = useState(false); const [form, setForm] = useState(/* initial */); const updateItem = useUpdateItem(); const { data: item } = useItem(Number(itemId)); // Sync form state when entering edit mode or when item data loads useEffect(() => { if (item && isEditing) { setForm(/* map item to form fields */); } }, [item, isEditing]); function handleSave() { updateItem.mutate({ id: item.id, ...payload }, { onSuccess: () => setIsEditing(false), }); } function handleCancel() { setIsEditing(false); // Form resets on next edit mode entry via useEffect } return (
{isEditing ? ( ) : (

{item.name}

)}
); } ``` ### Pattern 3: Detail Page Layout (gallery-like) **What:** Consistent layout: back nav, hero image, title/badges, content sections, action buttons. **When to use:** All three detail pages. **Example structure (from existing `$globalItemId.tsx`):** ```typescript
{/* Back navigation */} ← Back to collection {/* Hero image */}
{/* Title + badges */}

{item.name}

{/* weight, price, category badges */}
{/* Content sections */} {/* ... notes, product link, etc. */}
``` ### Pattern 4: Navigation from Card Components **What:** Replace `openEditPanel(id)` calls with TanStack Router `useNavigate()` or `Link` component. **When to use:** ItemCard, CandidateCard, CandidateListItem, CatalogSearchOverlay grid/list cards. **Example:** ```typescript // Before (ItemCard.tsx) const openEditPanel = useUIStore((s) => s.openEditPanel); onClick={() => openEditPanel(id)} // After import { useNavigate } from "@tanstack/react-router"; const navigate = useNavigate(); onClick={() => navigate({ to: "/items/$itemId", params: { itemId: String(id) } })} ``` ### Anti-Patterns to Avoid - **Do NOT edit `routeTree.gen.ts` manually** -- it auto-generates when route files change. - **Do NOT put edit form state in Zustand** -- local `useState` per detail page is correct. Zustand is for cross-component UI state only. - **Do NOT remove `ItemForm.tsx` or `CandidateForm.tsx`** -- reuse their validation logic and field definitions in the edit mode sections of detail pages. - **Do NOT remove SlideOutPanel.tsx component file** -- D-25 explicitly keeps it for potential future use. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Form validation | Custom validation from scratch | Copy validation logic from `ItemForm.tsx` / `CandidateForm.tsx` | Proven patterns, consistent behavior | | Image URL resolution | Manual URL building | `withImageUrl` / `withImageUrls` from storage service (already called in routes) | Server already returns `imageUrl` field in API response | | Loading/error states | Custom skeleton per page | Follow existing pattern from `$globalItemId.tsx` (shimmer placeholders, error state with back link) | Consistency | | Confirmation dialogs | New dialog components | Existing `ConfirmDialog` pattern in `__root.tsx` and `useUIStore.openConfirmDelete` | Already works for items; same pattern for candidates on detail pages | ## Common Pitfalls ### Pitfall 1: Nested Route File Structure for Candidates **What goes wrong:** Creating `src/client/routes/threads/$threadId/candidates/$candidateId.tsx` while `$threadId.tsx` exists as a file (not a directory) causes TanStack Router confusion. **Why it happens:** TanStack Router file-based routing treats `$threadId.tsx` and `$threadId/` directory as conflicting. You need to restructure. **How to avoid:** Rename `src/client/routes/threads/$threadId.tsx` to `src/client/routes/threads/$threadId/index.tsx` (or `route.tsx`). Then create `$threadId/candidates/$candidateId.tsx` as a sibling directory. **Warning signs:** Route tree generation errors, blank page at `/threads/:threadId`. ### Pitfall 2: Removing Panel State Before Wiring Navigation **What goes wrong:** Removing UIStore panel state and `__root.tsx` panel JSX before the card click handlers are updated causes runtime errors (calling undefined functions). **Why it happens:** Components like `ItemCard` still import `useUIStore((s) => s.openEditPanel)`. **How to avoid:** Order of operations: (1) Create detail pages, (2) Update card click handlers to navigate, (3) Then remove panel state and JSX. **Warning signs:** TypeScript errors on removed store properties, blank areas where panels used to be. ### Pitfall 3: Edit Mode Form Not Syncing with Server Data **What goes wrong:** User enters edit mode, but form shows stale or default data. **Why it happens:** `useItem(id)` fetch may not have completed when edit mode is toggled, or form state is initialized once and never updated. **How to avoid:** Use `useEffect` to sync form state when both `isEditing === true` AND item data changes. Alternatively, initialize form data from item data only when entering edit mode (in the toggle handler). **Warning signs:** Form fields show empty or previous values. ### Pitfall 4: CandidateCard/CandidateListItem Need threadId for Navigation **What goes wrong:** Navigation to `/threads/:threadId/candidates/:candidateId` requires both IDs, but the component may only have `candidateId`. **Why it happens:** CandidateCard already receives `threadId` as a prop. CandidateListItem receives it via `candidate.threadId`. Both are safe. **How to avoid:** Verify both components have access to `threadId` before changing click handlers. Both do. **Warning signs:** Navigation produces undefined params. ### Pitfall 5: Catalog Search Overlay Card Click vs. Add Button **What goes wrong:** Making the entire card clickable navigates away from the search overlay, losing context. **Why it happens:** D-19 says card click navigates to `/global-items/:id`, but the overlay is open. **How to avoid:** Close the catalog search overlay before navigating. Or use `Link` component and let the navigation naturally close the overlay (overlay checks `catalogSearchOpen` state). The card body navigates; the "Add" button stays as a separate action with `e.stopPropagation()`. **Warning signs:** Overlay stays visible on top of detail page, or user loses search context unexpectedly. ### Pitfall 6: "Add Candidate" on Thread Page After Panel Removal **What goes wrong:** The thread page currently opens the candidate add panel via `openCandidateAddPanel()`. After panels are removed, there's no way to add candidates. **Why it happens:** D-28 says to keep a simple button/form pattern, but the existing flow is entirely panel-based. **How to avoid:** Replace the "Add Candidate" button action on the thread page. Options: (1) inline form that expands on the thread page, (2) small modal dialog, (3) navigate to a dedicated add route. Recommendation: use a modal dialog -- it's the lightest change and consistent with existing dialog patterns in the app. **Warning signs:** No way to add candidates after panel removal. ## Code Examples ### Creating the Item Detail Route ```typescript // src/client/routes/items/$itemId.tsx import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { useFormatters } from "../../hooks/useFormatters"; import { useDeleteItem, useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems"; export const Route = createFileRoute("/items/$itemId")({ component: ItemDetailPage, }); ``` ### Restructuring Thread Route for Nesting ``` # Before: src/client/routes/threads/$threadId.tsx # After: src/client/routes/threads/$threadId/index.tsx # same content, path unchanged src/client/routes/threads/$threadId/candidates/ $candidateId.tsx # new candidate detail ``` The `index.tsx` file maps to `/threads/$threadId` exactly as before. The nested `candidates/$candidateId.tsx` maps to `/threads/$threadId/candidates/$candidateId`. ### UIStore Cleanup (properties to remove) ```typescript // Remove these from UIState interface and implementation: panelMode: "closed" | "add" | "edit"; editingItemId: number | null; openAddPanel: () => void; openEditPanel: (itemId: number) => void; closePanel: () => void; candidatePanelMode: "closed" | "add" | "edit"; editingCandidateId: number | null; openCandidateAddPanel: () => void; openCandidateEditPanel: (id: number) => void; closeCandidatePanel: () => void; // Keep these (still used): confirmDeleteItemId / openConfirmDelete / closeConfirmDelete // used by ConfirmDialog confirmDeleteCandidateId / openConfirmDeleteCandidate / closeConfirmDeleteCandidate // candidate delete resolveThreadId / resolveCandidateId / openResolveDialog / closeResolveDialog // resolve dialog // ... all other non-panel state ``` ### Updating ItemCard Click Handler ```typescript // src/client/components/ItemCard.tsx // Replace: import { useUIStore } from "../stores/uiStore"; const openEditPanel = useUIStore((s) => s.openEditPanel); // With: import { useNavigate } from "@tanstack/react-router"; const navigate = useNavigate(); // onClick changes from: onClick={() => openEditPanel(id)} // To: onClick={() => navigate({ to: "/items/$itemId", params: { itemId: String(id) } })} ``` ### Adding "Add to Collection" Button on Catalog Detail Page ```typescript // In global-items/$globalItemId.tsx, add after hero/header: ``` This is a stub per D-10 -- actual add flow wired in Phase 22. ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Playwright + Bun test | | Config file | `playwright.config.ts` (E2E), `bunfig.toml` (unit) | | Quick run command | `bun test tests/routes/` | | Full suite command | `bun run test:e2e` | ### Phase Requirements to Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | DETAIL-01 | Item detail page renders at `/items/:id` | E2E | `bun run test:e2e -- --grep "item detail"` | No -- Wave 0 | | DETAIL-02 | Catalog detail page has "Add to Collection" button | E2E | `bun run test:e2e -- --grep "catalog detail"` | No -- Wave 0 | | DETAIL-03 | Edit mode toggle shows/hides form inputs | E2E | `bun run test:e2e -- --grep "edit mode"` | No -- Wave 0 | | DETAIL-04 | Candidate card click navigates to detail page | E2E | `bun run test:e2e -- --grep "candidate detail"` | No -- Wave 0 | | DETAIL-05 | No SlideOutPanel instances in rendered DOM | E2E | `bun run test:e2e -- --grep "panel removed"` | No -- Wave 0 | ### Sampling Rate - **Per task commit:** `bun run lint && bun run dev` (manual visual check -- frontend-heavy) - **Per wave merge:** `bun run test:e2e` - **Phase gate:** Full E2E suite green before `/gsd:verify-work` ### Wave 0 Gaps - [ ] `e2e/item-detail.spec.ts` -- covers DETAIL-01, DETAIL-03 - [ ] `e2e/catalog-detail.spec.ts` -- covers DETAIL-02 - [ ] `e2e/candidate-detail.spec.ts` -- covers DETAIL-04 - [ ] `e2e/panel-removal.spec.ts` -- covers DETAIL-05 - [ ] E2E seed data: verify `e2e/seed.ts` creates items and threads with candidates for detail page testing ## Sources ### Primary (HIGH confidence) - Codebase inspection: `src/client/routes/global-items/$globalItemId.tsx` -- existing detail page pattern - Codebase inspection: `src/client/routes/__root.tsx` -- panel instances to remove (lines 189-221) - Codebase inspection: `src/client/stores/uiStore.ts` -- panel state to clean (full file reviewed) - Codebase inspection: `src/client/components/ItemCard.tsx` -- current click handler using `openEditPanel` - Codebase inspection: `src/client/components/CandidateCard.tsx` -- current click handler using `openCandidateEditPanel` - Codebase inspection: `src/client/components/CandidateListItem.tsx` -- current click handler - Codebase inspection: `src/client/components/ItemForm.tsx` -- form fields and validation to reuse - Codebase inspection: `src/client/components/CandidateForm.tsx` -- candidate form to reuse - Codebase inspection: `src/client/hooks/useItems.ts` -- `useItem`, `useUpdateItem`, `useDeleteItem`, `useDuplicateItem` hooks - Codebase inspection: `src/client/hooks/useCandidates.ts` -- `useUpdateCandidate`, `useDeleteCandidate` hooks - Codebase inspection: `src/client/hooks/useGlobalItems.ts` -- `useGlobalItem` hook with ownerCount - Codebase inspection: `src/server/services/item.service.ts` -- `getItemById` COALESCE merge pattern - Codebase inspection: `src/server/routes/items.ts` -- `withImageUrl` applied to single item responses ### Secondary (MEDIUM confidence) - TanStack Router file-based routing: nested parameterized routes require directory restructuring (verified against established pattern in `src/client/routes/setups/$setupId.tsx`) ## Metadata **Confidence breakdown:** - Standard stack: HIGH -- no new dependencies, all existing - Architecture: HIGH -- all patterns verified against existing codebase - Pitfalls: HIGH -- identified from direct code inspection of affected files **Research date:** 2026-04-06 **Valid until:** 2026-05-06 (stable -- frontend patterns, no external dependencies)