Files
GearBox/.planning/phases/21-item-catalog-detail-pages/21-RESEARCH.md
Jean-Luc Makiola 3228bcadbe feat(21-01): create private item detail page with edit mode toggle
- 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
2026-04-06 15:01:10 +02:00

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/: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 </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 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):

// 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>
  );
}

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">
    &larr; 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.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

// 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-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)