Files

392 lines
17 KiB
Markdown

---
phase: 22-add-from-catalog-thread-integration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/stores/uiStore.ts
- src/client/components/AddToCollectionModal.tsx
- src/client/components/CatalogSearchOverlay.tsx
- src/client/routes/global-items/$globalItemId.tsx
- src/client/routes/__root.tsx
autonomous: true
requirements: [CATFLOW-03]
must_haves:
truths:
- "Clicking Add on a catalog search card in collection mode opens the AddToCollectionModal"
- "AddToCollectionModal shows category dropdown, optional notes, optional purchase price, and submit/cancel buttons"
- "Submitting the modal creates a reference item with globalItemId and personal fields"
- "Success toast appears after adding item to collection"
- "Clicking Add to Collection on the global item detail page opens the same modal"
artifacts:
- path: "src/client/components/AddToCollectionModal.tsx"
provides: "Add-to-collection confirmation modal"
min_lines: 80
- path: "src/client/stores/uiStore.ts"
provides: "Modal state slices for addToCollectionModal, addToThreadModal, and catalogSessionThreadId"
contains: "addToCollectionModal"
- path: "src/client/routes/__root.tsx"
provides: "Toaster and AddToCollectionModal rendered at root"
contains: "Toaster"
key_links:
- from: "src/client/components/CatalogSearchOverlay.tsx"
to: "src/client/stores/uiStore.ts"
via: "openAddToCollection call replacing handleAddStub"
pattern: "openAddToCollection"
- from: "src/client/components/AddToCollectionModal.tsx"
to: "/api/items"
via: "useCreateItem mutation with globalItemId"
pattern: "useCreateItem"
- from: "src/client/routes/global-items/$globalItemId.tsx"
to: "src/client/stores/uiStore.ts"
via: "openAddToCollection on button click"
pattern: "openAddToCollection"
- from: "src/client/stores/uiStore.ts"
to: "src/client/components/AddToThreadModal.tsx (Plan 02)"
via: "addToThreadModal state slice and catalogSessionThreadId consumed by Plan 02"
pattern: "addToThreadModal|catalogSessionThreadId"
---
<objective>
Wire the add-to-collection flow: install sonner for toasts, extend UIStore with modal states, build the AddToCollectionModal component, and replace the stub handler in CatalogSearchOverlay and global item detail page for collection mode.
Purpose: CATFLOW-03 -- users can add catalog items to their collection as reference items with personal fields (category, notes, purchase price).
Output: Working add-to-collection flow from both catalog search and global item detail page.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/22-add-from-catalog-thread-integration/22-CONTEXT.md
@.planning/phases/22-add-from-catalog-thread-integration/22-RESEARCH.md
<interfaces>
<!-- Key types and contracts the executor needs -->
From src/shared/schemas.ts:
```typescript
export const createItemSchema = z.object({
name: z.string().min(1, "Name is required"),
weightGrams: z.number().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
categoryId: z.number().int().positive(),
notes: z.string().optional(),
productUrl: z.string().url().optional().or(z.literal("")),
imageFilename: z.string().optional(),
imageSourceUrl: z.string().url().optional().or(z.literal("")),
quantity: z.number().int().positive().optional(),
globalItemId: z.number().int().positive().optional(),
purchasePriceCents: z.number().int().nonnegative().optional(),
});
```
From src/client/hooks/useItems.ts:
```typescript
export function useCreateItem() // mutationFn: (data: CreateItem) => apiPost<ItemWithCategory>("/api/items", data)
// onSuccess invalidates ["items"] and ["totals"]
```
From src/client/hooks/useCategories.ts:
```typescript
export function useCategories() // queryKey: ["categories"], returns Category[]
```
From src/client/stores/uiStore.ts (existing pattern):
```typescript
catalogSearchOpen: boolean;
catalogSearchMode: "collection" | "thread" | null;
openCatalogSearch: (mode: "collection" | "thread") => void;
closeCatalogSearch: () => void;
```
From src/client/components/CreateThreadModal.tsx (modal pattern):
```typescript
// Modal pattern: fixed inset-0 z-50, bg-black/50 backdrop, onClick={handleClose}
// Inner div: w-full max-w-md bg-white rounded-xl shadow-xl p-6
// Form with local state, UIStore for open/close, mutation hook for submit
```
From CatalogSearchOverlay.tsx CardProps:
```typescript
interface CardProps {
item: {
id: number;
brand: string;
model: string;
category: string | null;
weightGrams: number | null;
priceCents: number | null;
imageUrl: string | null;
};
onAdd: (e: React.MouseEvent) => void;
onCardClick: () => void;
weight: (g: number) => string;
price: (cents: number) => string;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: UIStore extension + sonner setup + AddToCollectionModal</name>
<files>
src/client/stores/uiStore.ts
src/client/components/AddToCollectionModal.tsx
src/client/routes/__root.tsx
</files>
<read_first>
src/client/stores/uiStore.ts
src/client/components/CreateThreadModal.tsx
src/client/hooks/useItems.ts
src/client/hooks/useCategories.ts
src/shared/schemas.ts
src/client/routes/__root.tsx
</read_first>
<action>
**Step 1: Install sonner**
```bash
bun add sonner
```
**Step 2: Extend UIStore** (`src/client/stores/uiStore.ts`)
Add to the `UIState` interface:
```typescript
// Add-to-collection modal (per D-20)
addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
openAddToCollection: (globalItemId: number, globalItemName: string) => void;
closeAddToCollection: () => void;
// Add-to-thread modal (per D-21)
addToThreadModal: { open: boolean; globalItemId: number | null; globalItemName: string | null };
openAddToThread: (globalItemId: number, globalItemName: string) => void;
closeAddToThread: () => void;
// Session thread tracking (per D-22)
catalogSessionThreadId: number | null;
setCatalogSessionThreadId: (id: number | null) => void;
```
Add to the `create` implementation:
```typescript
// Add-to-collection modal
addToCollectionModal: { open: false, globalItemId: null, globalItemName: null },
openAddToCollection: (globalItemId, globalItemName) =>
set({ addToCollectionModal: { open: true, globalItemId, globalItemName } }),
closeAddToCollection: () =>
set({ addToCollectionModal: { open: false, globalItemId: null, globalItemName: null } }),
// Add-to-thread modal
addToThreadModal: { open: false, globalItemId: null, globalItemName: null },
openAddToThread: (globalItemId, globalItemName) =>
set({ addToThreadModal: { open: true, globalItemId, globalItemName } }),
closeAddToThread: () =>
set({ addToThreadModal: { open: false, globalItemId: null, globalItemName: null } }),
// Session thread tracking
catalogSessionThreadId: null,
setCatalogSessionThreadId: (id) => set({ catalogSessionThreadId: id }),
```
Also update `closeCatalogSearch` to reset session thread (per D-22):
```typescript
closeCatalogSearch: () =>
set({ catalogSearchOpen: false, catalogSearchMode: null, catalogSessionThreadId: null }),
```
**Step 3: Create AddToCollectionModal** (`src/client/components/AddToCollectionModal.tsx`)
Follow CreateThreadModal pattern exactly (per D-05). Component structure:
- Read `addToCollectionModal` from UIStore (`open`, `globalItemId`, `globalItemName`)
- Read `closeAddToCollection` from UIStore
- Use `useCategories()` for category dropdown
- Use `useCreateItem()` for the mutation
- Local state: `categoryId` (number | null), `notes` (string), `purchasePriceCents` (number | undefined)
- Auto-match category: when categories load, find category where `c.name.toLowerCase() === globalItemCategory?.toLowerCase()`, fall back to `categories?.[0]?.id`. Since we only have `globalItemName` in UIStore (not category), skip auto-match for now -- default to first category.
- If `!open || !globalItemId` return null
- Render modal: `fixed inset-0 z-50 flex items-center justify-center bg-black/50`
- Inner form: `w-full max-w-md bg-white rounded-xl shadow-xl p-6`
- Header: "Add to Collection" h2
- Show item name as a subheading: `<p className="text-sm text-gray-500 mb-4">{globalItemName}</p>`
- Category dropdown (per D-02): `<select>` with categories, same pattern as CreateThreadModal
- Notes textarea (per D-02): `<textarea>` optional, placeholder "Personal notes (optional)"
- Purchase price field (per D-02): `<input type="number">` for price in dollars (display), convert to cents on submit by multiplying by 100 and rounding. Placeholder "Purchase price (optional)". Label "Purchase Price ($)".
- Submit button: "Add to Collection", disabled during `isPending`
- Cancel button: calls `closeAddToCollection()`
- On submit (per D-03): call `createItem.mutate({ name: globalItemName ?? "Unknown Item", categoryId, globalItemId, notes: notes || undefined, purchasePriceCents: purchasePriceCents || undefined })`
- On success (per D-04): call `toast.success("Added to Collection")` then `closeAddToCollection()`
- On error: show error message inline
- Reset form state when modal closes via `useEffect` watching `open`
Import `toast` from `sonner`.
**Step 4: Add Toaster and AddToCollectionModal to root layout** (`src/client/routes/__root.tsx`)
Add imports:
```typescript
import { Toaster } from "sonner";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
```
In the RootLayout JSX, after the `<CatalogSearchOverlay />` line (around line 205), add:
```jsx
<AddToCollectionModal />
<Toaster position="bottom-right" richColors />
```
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5 && bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `src/client/stores/uiStore.ts` contains `addToCollectionModal: { open: boolean; globalItemId: number | null; globalItemName: string | null }`
- `src/client/stores/uiStore.ts` contains `openAddToCollection:`
- `src/client/stores/uiStore.ts` contains `closeAddToCollection:`
- `src/client/stores/uiStore.ts` contains `addToThreadModal:`
- `src/client/stores/uiStore.ts` contains `catalogSessionThreadId: number | null`
- `src/client/stores/uiStore.ts` closeCatalogSearch resets `catalogSessionThreadId: null`
- `src/client/components/AddToCollectionModal.tsx` exists with `export function AddToCollectionModal`
- `AddToCollectionModal.tsx` contains `useCreateItem` import
- `AddToCollectionModal.tsx` contains `useCategories` import
- `AddToCollectionModal.tsx` contains `toast.success("Added to Collection")`
- `AddToCollectionModal.tsx` contains `globalItemId` in the mutate call
- `AddToCollectionModal.tsx` contains `purchasePriceCents`
- `src/client/routes/__root.tsx` contains `import { Toaster } from "sonner"`
- `src/client/routes/__root.tsx` contains `<AddToCollectionModal />`
- `src/client/routes/__root.tsx` contains `<Toaster`
- `bun run build` exits with code 0
- `bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts` passes (no regressions)
</acceptance_criteria>
<done>UIStore has all Phase 22 modal states (addToCollectionModal, addToThreadModal, catalogSessionThreadId). AddToCollectionModal renders with category dropdown, notes, purchase price. Sonner Toaster is in root layout. Build passes. Service tests pass.</done>
</task>
<task type="auto">
<name>Task 2: Wire CatalogSearchOverlay and global item detail page for collection mode</name>
<files>
src/client/components/CatalogSearchOverlay.tsx
src/client/routes/global-items/$globalItemId.tsx
</files>
<read_first>
src/client/components/CatalogSearchOverlay.tsx
src/client/routes/global-items/$globalItemId.tsx
src/client/stores/uiStore.ts
src/client/components/AddToCollectionModal.tsx
</read_first>
<action>
**Step 1: Update CatalogSearchOverlay** (`src/client/components/CatalogSearchOverlay.tsx`)
Replace the `handleAddStub` function (lines 111-114) with a proper handler (per D-16, D-17, D-18):
```typescript
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
function handleAdd(e: React.MouseEvent, item: { id: number; brand: string; model: string }) {
e.stopPropagation();
const itemName = `${item.brand} ${item.model}`;
if (catalogSearchMode === "collection") {
openAddToCollection(item.id, itemName);
} else if (catalogSearchMode === "thread") {
openAddToThread(item.id, itemName);
}
}
```
Update `onAdd` prop usage in both `GridCard` and `ListRow` renders. Currently:
```typescript
onAdd={handleAddStub}
```
Change to:
```typescript
onAdd={(e) => handleAdd(e, item)}
```
The `onAdd` prop type on `CardProps` stays `(e: React.MouseEvent) => void` -- the item data is captured in the closure.
**Step 2: Update global item detail page** (`src/client/routes/global-items/$globalItemId.tsx`)
Add imports:
```typescript
import { useUIStore } from "../../stores/uiStore";
```
Inside `GlobalItemDetail` function, add:
```typescript
const openAddToCollection = useUIStore((s) => s.openAddToCollection);
const openAddToThread = useUIStore((s) => s.openAddToThread);
```
Replace the existing "Add to Collection" button (line 131-135) that has `console.log`:
```jsx
<div className="flex gap-3 mb-6">
<button
type="button"
onClick={() => openAddToCollection(item.id, `${item.brand} ${item.model}`)}
className="bg-gray-700 text-white rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-800 transition-colors"
>
Add to Collection
</button>
<button
type="button"
onClick={() => openAddToThread(item.id, `${item.brand} ${item.model}`)}
className="bg-white text-gray-700 border border-gray-200 rounded-lg px-5 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
>
Add to Thread
</button>
</div>
```
Per D-14 and D-15: both buttons on the detail page. "Add to Collection" is primary (filled), "Add to Thread" is secondary (outlined).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/GearBox && bun run build 2>&1 | tail -5 && bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- `src/client/components/CatalogSearchOverlay.tsx` does NOT contain `handleAddStub`
- `CatalogSearchOverlay.tsx` contains `openAddToCollection`
- `CatalogSearchOverlay.tsx` contains `openAddToThread`
- `CatalogSearchOverlay.tsx` contains `catalogSearchMode === "collection"`
- `CatalogSearchOverlay.tsx` contains `catalogSearchMode === "thread"`
- `src/client/routes/global-items/$globalItemId.tsx` does NOT contain `console.log`
- `$globalItemId.tsx` contains `openAddToCollection(item.id`
- `$globalItemId.tsx` contains `openAddToThread(item.id`
- `$globalItemId.tsx` contains `Add to Thread`
- `$globalItemId.tsx` imports `useUIStore`
- `bun run build` exits with code 0
- `bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts` passes
</acceptance_criteria>
<done>CatalogSearchOverlay dispatches to correct modal based on catalogSearchMode. Global item detail page has both "Add to Collection" and "Add to Thread" buttons wired to UIStore. handleAddStub is fully replaced. No console.log stubs remain. Service tests pass.</done>
</task>
</tasks>
<verification>
1. `bun run build` passes with no type errors
2. `bun test tests/services/item.service.test.ts tests/services/thread.service.test.ts` passes
3. In collection mode, clicking Add on catalog card opens AddToCollectionModal
4. Submitting modal with category creates a reference item (POST /api/items with globalItemId)
5. Toast "Added to Collection" appears after successful add
6. Global item detail page shows both "Add to Collection" and "Add to Thread" buttons
</verification>
<success_criteria>
- CATFLOW-03 is functional: user can add catalog item to collection with category picker + notes + purchase price
- AddToCollectionModal creates reference items via existing useCreateItem hook with globalItemId
- Sonner toast system operational for success feedback
- Both entry points (catalog search overlay + global item detail page) wire to the modal
- "Add to Thread" button exists on detail page (wired to UIStore, modal built in Plan 02)
</success_criteria>
<output>
After completion, create `.planning/phases/22-add-from-catalog-thread-integration/22-01-SUMMARY.md`
</output>