- Full detail page at /items/:id with hero image, name, spec badges, notes, product link - Edit mode toggle: read-only by default, editable inputs when Edit clicked - Save persists via useUpdateItem, Cancel reverts to read-only - Duplicate and Delete actions via existing hooks/dialogs - Back link to /collection, loading shimmer, error state - CategoryPicker and ImageUpload in edit mode
21 KiB
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>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01 through D-07: Private item detail page at
/items/:idwith 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/:idenhanced with "Add to Collection" button (stub), hero layout, no edit functionality. - D-13 through D-16: Candidate detail page at
/threads/:threadId/candidates/:candidateIdwith 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, keepSlideOutPanel.tsx,ItemForm.tsx,CandidateForm.tsxfiles. - 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 </user_constraints>
<phase_requirements>
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) |
| </phase_requirements> |
Project Constraints (from CLAUDE.md)
- Routing: TanStack Router with file-based routes in
src/client/routes/. Route tree auto-generated torouteTree.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 devstarts 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):
// 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:
function ItemDetail() {
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState<FormData>(/* 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 (
<div>
{isEditing ? (
<input value={form.name} onChange={...} />
) : (
<h1>{item.name}</h1>
)}
</div>
);
}
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):
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Back navigation */}
<Link to="/collection" className="text-sm text-gray-400 hover:text-gray-600">
← Back to collection
</Link>
{/* Hero image */}
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
<img ... />
</div>
{/* Title + badges */}
<h1 className="text-2xl font-bold text-gray-900 mb-3">{item.name}</h1>
<div className="flex flex-wrap gap-2 mb-6">
{/* weight, price, category badges */}
</div>
{/* Content sections */}
{/* ... notes, product link, etc. */}
</div>
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:
// 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.tsmanually -- it auto-generates when route files change. - Do NOT put edit form state in Zustand -- local
useStateper detail page is correct. Zustand is for cross-component UI state only. - Do NOT remove
ItemForm.tsxorCandidateForm.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
// 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)
// 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
// 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
// In global-items/$globalItemId.tsx, add after hero/header:
<button
type="button"
onClick={handleAddToCollection}
className="bg-gray-700 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 transition-colors"
>
Add to Collection
</button>
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-03e2e/catalog-detail.spec.ts-- covers DETAIL-02e2e/candidate-detail.spec.ts-- covers DETAIL-04e2e/panel-removal.spec.ts-- covers DETAIL-05- E2E seed data: verify
e2e/seed.tscreates 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 usingopenEditPanel - Codebase inspection:
src/client/components/CandidateCard.tsx-- current click handler usingopenCandidateEditPanel - 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,useDuplicateItemhooks - Codebase inspection:
src/client/hooks/useCandidates.ts--useUpdateCandidate,useDeleteCandidatehooks - Codebase inspection:
src/client/hooks/useGlobalItems.ts--useGlobalItemhook with ownerCount - Codebase inspection:
src/server/services/item.service.ts--getItemByIdCOALESCE merge pattern - Codebase inspection:
src/server/routes/items.ts--withImageUrlapplied 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)