26 KiB
Phase 23: Manual Entry Fallback - Research
Researched: 2026-04-06 Domain: React UI — inline form view state within CatalogSearchOverlay Confidence: HIGH
Summary
Phase 23 is a pure frontend phase. The goal is to give users a way to add items that do not exist in the global catalog by surfacing a manual entry form inline inside the CatalogSearchOverlay. The backend is already fully capable — POST /api/items without a globalItemId creates a standalone collection item, and createItemSchema accepts the call without that field. Nothing on the server needs to change.
The implementation has two distinct layers. First, the entry points: an "Add Manually" link in the EmptyState component and a persistent but subtle link below the results area. Second, the inline form view: a new ManualEntryForm component rendered inside CatalogSearchOverlay when manualEntryMode state is true. The form reuses established patterns from ItemForm and AddToCollectionModal but is lighter and more focused. After a successful save, the results area is replaced by a success card with a non-functional "Submit to Catalog?" button and "Add Another" / "Done" actions.
There is no new route, no new API endpoint, and no Zustand store change required. All view-switching state is local to CatalogSearchOverlay via useState. This is the simplest possible scope for the phase.
Primary recommendation: Implement the entire phase as local state inside CatalogSearchOverlay — manualEntryMode: boolean and savedItemName: string | null — with a new sibling component file ManualEntryForm.tsx.
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: "Add Manually" link appears in the catalog search empty state (when no results match) AND as a subtle persistent link below search results.
- D-02: Link text is "Add Manually" or "Can't find it? Add manually" — context-sensitive based on whether a search query exists.
- D-03: Manual entry form replaces the search results area inline within CatalogSearchOverlay. No navigation away or additional modal.
- D-04: Back arrow at the top returns from the manual form to search results.
- D-05: The form is a dedicated ManualEntryForm component rendered inside CatalogSearchOverlay when
manualEntryModestate is active. - D-06: Required: name. Optional: category (via CategoryPicker), weight (grams), price (cents), notes, purchase price, image upload, product link.
- D-07: Reuse field patterns from ItemForm (CategoryPicker, weight/price inputs) but keep the form focused and compact — not the full 315-line ItemForm.
- D-08: Submit calls
POST /api/itemswithoutglobalItemId— creates a standalone collection item. - D-09: After successful save, show an inline success card: "Added [item name] to collection" with a "Submit to Catalog?" button.
- D-10: "Submit to Catalog?" button is non-functional — shows a toast "Coming soon" when clicked. No backend action.
- D-11: Below the prompt, show "Add Another" (returns to search) and "Done" (closes overlay) buttons.
Claude's Discretion
- Exact form layout proportions and field ordering
- Whether weight/price fields use formatted inputs or plain number inputs
- Animation transitions between search results and manual entry form
- Success card visual styling
- Whether the search query auto-populates the item name field when entering manual mode
Deferred Ideas (OUT OF SCOPE)
- Actual catalog submission backend (admin review, convert to global item) — future phase
- Bulk manual entry — future phase
- Image search / URL paste to auto-populate item details — future phase </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CATFLOW-07 | Manual entry fallback when item not in catalog | Implemented via manualEntryMode local state in CatalogSearchOverlay + ManualEntryForm component; useCreateItem() hook + POST /api/items without globalItemId already supported |
| CATFLOW-08 | Non-functional "Submit to catalog?" prompt shown after manual save | Implemented as an inline success card within CatalogSearchOverlay after mutation success; "Submit to Catalog?" button calls toast("Coming soon") |
| </phase_requirements> |
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| React | 19 (project) | Component rendering and local state | Project stack |
| TanStack React Query | project version | useCreateItem() mutation |
Established data layer |
| Framer Motion | project version | Animation between search/form views | Already used in CatalogSearchOverlay |
sonner toast |
project version | Success/error/coming-soon notifications | Established project pattern |
| Zustand | project version | Overlay open/close state (no change needed) | Established UI state pattern |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| CategoryPicker | internal | Category selection with search + inline create | Required by D-06 |
| ImageUpload | internal | Image upload with preview | Required by D-06 |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Local useState for mode switching |
Zustand UIStore | Local state is simpler; no cross-component coordination needed; CONTEXT.md D-05 implies local state |
Installation: No new packages needed.
Architecture Patterns
Recommended Project Structure
No new directories. Two new files:
src/client/components/
├── CatalogSearchOverlay.tsx # Modified — add mode state, entry points, conditional rendering
└── ManualEntryForm.tsx # New — standalone form component for manual item entry
Pattern 1: Inline View Switching via Local State
What: CatalogSearchOverlay already uses local useState for viewMode, filterOpen, searchInput, etc. The same pattern extends naturally to manualEntryMode.
When to use: When an overlay needs to transition between views without navigating or spawning a new modal layer.
Example:
// Inside CatalogSearchOverlay
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);
// Reset when overlay closes (add to existing reset effect)
useEffect(() => {
if (!catalogSearchOpen) {
setManualEntryMode(false);
setSavedItemName(null);
// ... existing resets
}
}, [catalogSearchOpen]);
// Render switch
{manualEntryMode
? savedItemName
? <SuccessCard itemName={savedItemName} onAddAnother={...} onDone={...} />
: <ManualEntryForm initialName={searchInput} onSuccess={...} onBack={...} />
: <ResultsArea ... />
}
Source: CatalogSearchOverlay.tsx existing local state pattern (verified by reading the file).
Pattern 2: Compact Form Adapted from ItemForm
What: ManualEntryForm mirrors ItemForm's field patterns (validation, CategoryPicker, weight/price conversion) but is a lighter self-contained component — not tied to UIStore.
Key adaptation from ItemForm:
- No
mode: "add" | "edit"— always add-only - No
useItems()call — no need for pre-fill - No delete button
- Accepts
initialNameprop (auto-populated from search query per CONTEXT.md specifics) - Accepts
onSuccess(itemName: string)andonBack()callbacks purchasePriceCentsfield (from AddToCollectionModal) added alongside the regularpriceCentsfield
Example component signature:
interface ManualEntryFormProps {
initialName?: string;
onSuccess: (itemName: string) => void;
onBack: () => void;
}
Source: Verified by reading src/client/components/ItemForm.tsx and src/client/components/AddToCollectionModal.tsx.
Pattern 3: useCreateItem without globalItemId
What: The existing useCreateItem() mutation accepts CreateItem from src/shared/types. The createItemSchema in src/shared/schemas.ts defines globalItemId as .optional(), so omitting it is fully valid.
Example:
createItem.mutate({
name: form.name.trim(),
categoryId: form.categoryId,
weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined,
priceCents: form.priceDollars ? Math.round(Number(form.priceDollars) * 100) : undefined,
notes: form.notes || undefined,
productUrl: form.productUrl || undefined,
imageFilename: form.imageFilename ?? undefined,
purchasePriceCents: form.purchasePrice ? Math.round(Number(form.purchasePrice) * 100) : undefined,
// globalItemId deliberately omitted
}, {
onSuccess: (item) => onSuccess(item.name),
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
Source: src/shared/schemas.ts line 13 (globalItemId: z.number().int().positive().optional()), src/client/hooks/useItems.ts lines 62-72.
Pattern 4: "Add Manually" Entry Points
What: Two placements per D-01 — inside EmptyState and as a persistent link below results.
EmptyState adaptation (line 667 in CatalogSearchOverlay.tsx):
function EmptyState({ hasQuery, onAddManually }: {
hasQuery: boolean;
onAddManually: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4">
{/* existing icon + text */}
<button
type="button"
onClick={onAddManually}
className="mt-4 text-sm text-blue-600 hover:text-blue-800 underline-offset-2 hover:underline"
>
{hasQuery ? "Can't find it? Add manually" : "Add Manually"}
</button>
</div>
);
}
Persistent link below results: A div rendered after the results grid/list, always visible when not in manual entry mode.
Pattern 5: Toast for Non-functional Button
What: The "Submit to Catalog?" button (D-10) calls toast() from sonner — no backend call.
import { toast } from "sonner";
// ...
<button type="button" onClick={() => toast("Coming soon — catalog submissions are on the roadmap!")}>
Submit to Catalog?
</button>
Source: toast from sonner is the established project notification mechanism (confirmed in AddToCollectionModal.tsx and CatalogSearchOverlay usage patterns).
Anti-Patterns to Avoid
- Adding manualEntryMode to Zustand UIStore: Unnecessary. No other component needs to read this state. CONTEXT.md D-05 says it's local to CatalogSearchOverlay.
- Reusing ItemForm directly: ItemForm is 315 lines, tied to UIStore, and has edit/delete logic not needed here. Adapt the patterns, build a fresh focused component.
- Making the form a full-screen overlay on top of the overlay: The overlay stays. The form replaces the results area content only (D-03).
- Emitting a success toast AND showing the success card: Don't double-notify. The success card IS the success feedback. Skip the
toast.success()call here (unlike AddToCollectionModal which just closes and toasts).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Category selection | Custom select | CategoryPicker |
Has search, inline create, icon display |
| Image upload | Custom file input | ImageUpload |
Handles upload API call, preview, size validation |
| Price/weight conversion | Custom logic | Adapt from ItemForm | Established pattern: Math.round(Number(dollars) * 100) |
| Toast notifications | Custom UI | toast from sonner |
Global notification system already wired |
| Item creation API call | Custom fetch | useCreateItem() |
Handles query invalidation, error states |
Key insight: This phase is almost entirely wiring — the building blocks (mutation hook, form components, toast) exist. The work is composing them into a new view state inside CatalogSearchOverlay.
Common Pitfalls
Pitfall 1: Forgetting to Reset manualEntryMode on Overlay Close
What goes wrong: User closes overlay, reopens it, and the manual entry form is still showing instead of search results.
Why it happens: The reset useEffect (lines 76-87 in CatalogSearchOverlay) clears search/filter state when catalogSearchOpen changes to false — but new state like manualEntryMode and savedItemName won't be in that effect unless explicitly added.
How to avoid: Add setManualEntryMode(false) and setSavedItemName(null) to the existing reset effect that triggers on !catalogSearchOpen.
Warning signs: State persists across overlay open/close cycles during testing.
Pitfall 2: "Submit to Catalog?" Button Appearing Functional
What goes wrong: A loading spinner, disabled state, or API call is added to the "Submit to Catalog?" button, setting a false expectation.
Why it happens: Developer instinct to make buttons "do something properly."
How to avoid: The button calls toast("Coming soon") and nothing else (D-10). No isPending, no disabled, no API call.
Pitfall 3: Back Arrow Wiring
What goes wrong: Back arrow on ManualEntryForm calls closeCatalogSearch() instead of returning to search results.
Why it happens: CatalogSearchOverlay already has a back arrow in the header that closes the overlay. ManualEntryForm needs its own back behavior that sets manualEntryMode(false).
How to avoid: The ManualEntryForm header renders its own back arrow button that calls the onBack prop — setManualEntryMode(false). Alternatively, CatalogSearchOverlay can make its existing header back arrow context-sensitive: when manualEntryMode, go back to results; otherwise close overlay.
Warning signs: Pressing back from the manual form closes the entire overlay instead of returning to search.
Pitfall 4: categoryId Validation
What goes wrong: CategoryPicker is initialized with value={0} which doesn't correspond to a real category, causing categoryId: 0 to be submitted — this will fail the Zod schema (z.number().int().positive()).
Why it happens: Same issue exists in AddToCollectionModal (solved by pre-selecting categories[0].id once data loads).
How to avoid: Follow the AddToCollectionModal pattern — initialize categoryId as null, pre-select categories[0].id once categories data loads, block form submission if categoryId === null.
Pitfall 5: Search Query Not Auto-Populating Item Name
What goes wrong: User types "Ortlieb Gravel Pack" in the search, gets no results, clicks "Add Manually" — the name field is empty. User must retype the name.
Why it happens: searchInput is local state in CatalogSearchOverlay and ManualEntryForm doesn't receive it.
How to avoid: Pass searchInput (or debouncedQuery) as the initialName prop to ManualEntryForm. The CONTEXT.md specifics section explicitly calls this out. This is a Claude's Discretion item but the CONTEXT.md says it "should" happen.
Code Examples
ManualEntryForm: Minimal Skeleton
// Source: adapted from src/client/components/ItemForm.tsx + AddToCollectionModal.tsx
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { useCategories } from "../hooks/useCategories";
import { useCreateItem } from "../hooks/useItems";
import { CategoryPicker } from "./CategoryPicker";
import { ImageUpload } from "./ImageUpload";
interface ManualEntryFormProps {
initialName?: string;
onSuccess: (itemName: string) => void;
onBack: () => void;
}
export function ManualEntryForm({ initialName, onSuccess, onBack }: ManualEntryFormProps) {
const { data: categories } = useCategories();
const createItem = useCreateItem();
const [name, setName] = useState(initialName ?? "");
const [categoryId, setCategoryId] = useState<number | null>(null);
const [weightGrams, setWeightGrams] = useState("");
const [priceDollars, setPriceDollars] = useState("");
const [purchasePrice, setPurchasePrice] = useState("");
const [notes, setNotes] = useState("");
const [productUrl, setProductUrl] = useState("");
const [imageFilename, setImageFilename] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (categories && categories.length > 0 && categoryId === null) {
setCategoryId(categories[0].id);
}
}, [categories, categoryId]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) { setError("Name is required"); return; }
if (categoryId === null) { setError("Select a category"); return; }
setError(null);
createItem.mutate({
name: name.trim(),
categoryId,
weightGrams: weightGrams ? Number(weightGrams) : undefined,
priceCents: priceDollars ? Math.round(Number(priceDollars) * 100) : undefined,
purchasePriceCents: purchasePrice ? Math.round(Number(purchasePrice) * 100) : undefined,
notes: notes || undefined,
productUrl: productUrl || undefined,
imageFilename: imageFilename ?? undefined,
// globalItemId omitted — standalone item
}, {
onSuccess: (item) => onSuccess(item.name),
onError: (err) => setError(err instanceof Error ? err.message : "Failed to save"),
});
}
// ... render
}
CatalogSearchOverlay: Mode Switch Wiring
// Source: pattern from CatalogSearchOverlay.tsx local state
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);
// In existing reset effect:
useEffect(() => {
if (!catalogSearchOpen) {
// ...existing resets...
setManualEntryMode(false);
setSavedItemName(null);
}
}, [catalogSearchOpen]);
function handleEnterManualMode() {
setManualEntryMode(true);
}
function handleManualSuccess(itemName: string) {
setSavedItemName(itemName);
}
function handleAddAnother() {
setManualEntryMode(false);
setSavedItemName(null);
}
EmptyState with "Add Manually" Link
// Source: adapted from CatalogSearchOverlay.tsx EmptyState (line 667)
function EmptyState({ hasQuery, onAddManually }: {
hasQuery: boolean;
onAddManually: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center py-20 px-4">
{/* existing SVG icon */}
<p className="text-sm text-gray-500 text-center mb-3">
{hasQuery ? "No items found matching your search" : "Search the catalog to find gear"}
</p>
<button
type="button"
onClick={onAddManually}
className="text-sm text-gray-500 hover:text-gray-700 underline underline-offset-2"
>
{hasQuery ? "Can't find it? Add manually" : "Add Manually"}
</button>
</div>
);
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Static EmptyState (no action) | EmptyState with "Add Manually" CTA | Phase 23 | Removes dead end for missing catalog items |
| No manual path in catalog flow | Inline ManualEntryForm in overlay | Phase 23 | Completes CATFLOW requirements |
What changes in Phase 23:
EmptyState— acceptsonAddManuallyprop, gains a text buttonCatalogSearchOverlay— gainsmanualEntryMode+savedItemNamelocal state, renders ManualEntryForm or SuccessCard conditionally- New file:
ManualEntryForm.tsx
Open Questions
-
Back arrow context-sensitivity
- What we know: CatalogSearchOverlay header already has a back arrow that calls
closeCatalogSearch(). ManualEntryForm needs to go back to results, not close the overlay (D-04). - What's unclear: Whether the planner should (a) make the header back arrow context-aware based on
manualEntryMode, or (b) have ManualEntryForm render its own internal back arrow in the form header and keep the outer header arrow always closing. - Recommendation: Option (b) — ManualEntryForm has its own back arrow row (like CatalogSearchOverlay's context text row) to preserve single-responsibility. The planner should specify this explicitly.
- What we know: CatalogSearchOverlay header already has a back arrow that calls
-
"Add Manually" link in thread mode
- What we know:
catalogSearchModecan be "collection" or "thread". The CONTEXT.md does not restrict manual entry to collection mode only. - What's unclear: If the user is in "thread" mode (adding candidates), does "Add Manually" create a standalone collection item (D-08) or a thread candidate?
- Recommendation: D-08 locks this to
POST /api/items(collection item), regardless of mode. The planner should note this explicitly to avoid an implementation where thread mode triggers candidate creation instead.
- What we know:
Environment Availability
Step 2.6: SKIPPED — phase is purely frontend code changes with no external tool or service dependencies beyond the existing dev environment.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Bun test (unit/integration) + Playwright (E2E) |
| Config file | bunfig.toml (Bun default), playwright.config.ts |
| Quick run command | bun test tests/services/item.service.test.ts |
| Full suite command | bun test && bun run test:e2e |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CATFLOW-07 | ManualEntryForm submits POST /api/items without globalItemId |
Integration (service) | bun test tests/services/item.service.test.ts |
Partial — item creation without globalItemId covered by existing "only name and categoryId are required" test |
| CATFLOW-07 | "Add Manually" link visible in EmptyState | E2E (smoke) | bun run test:e2e (collection.spec.ts) |
Needs Wave 0 addition |
| CATFLOW-07 | ManualEntryForm renders in overlay on click | E2E | bun run test:e2e |
Needs Wave 0 addition |
| CATFLOW-07 | Back arrow returns to search results | E2E | bun run test:e2e |
Needs Wave 0 addition |
| CATFLOW-08 | "Submit to Catalog?" shows "Coming soon" toast | E2E | bun run test:e2e |
Needs Wave 0 addition |
| CATFLOW-08 | Success card shown after manual save | E2E | bun run test:e2e |
Needs Wave 0 addition |
Note: The backend
createItemservice already has tests covering the standalone item path (noglobalItemId). The existing test "only name and categoryId are required" (item.service.test.ts line 44) covers CATFLOW-07 at the service layer. The gaps are E2E tests covering the new UI flow.
Sampling Rate
- Per task commit:
bun test tests/services/item.service.test.ts - Per wave merge:
bun test - Phase gate:
bun test && bun run test:e2egreen before/gsd:verify-work
Wave 0 Gaps
e2e/collection.spec.ts— add test cases for manual entry flow (Add Manually link, ManualEntryForm render, back navigation, success card, Submit to Catalog toast)
(Service-level test infrastructure covers the backend path — no new test files needed there. E2E additions only.)
Project Constraints (from CLAUDE.md)
All directives from CLAUDE.md that apply to this phase:
| Constraint | Impact on Phase 23 |
|---|---|
| Reuse existing components (CategoryPicker, ImageUpload, etc.) | ManualEntryForm MUST use CategoryPicker and ImageUpload — not plain <select> or raw file inputs |
Prices stored as cents (priceCents: integer) |
Convert dollars → cents with Math.round(val * 100) before sending to API |
Path alias @/* maps to ./src/* |
Use @/client/... imports in new component |
| TanStack Router file-based routes — routeTree.gen.ts never edited manually | No new routes in this phase (none needed) |
| Tailwind CSS v4 | Use Tailwind for all styling — no inline styles |
toast() from sonner for notifications |
"Coming soon" and error messages use sonner toast |
Bun test runner (bun test) |
Tests written as Bun tests, not Jest/Vitest |
| Tabs, double quotes (Biome lint) | Follow formatting — run bun run lint before commit |
| UIStore for panel/dialog state only; server data lives in React Query | manualEntryMode and savedItemName are local state, not UIStore — correct by design |
Sources
Primary (HIGH confidence)
src/client/components/CatalogSearchOverlay.tsx— Full file read; confirmed local state pattern, EmptyState location (line 667), existing reset effect, framer-motion usagesrc/client/components/ItemForm.tsx— Full file read; confirmed field patterns, validation logic, CategoryPicker/ImageUpload usagesrc/client/components/AddToCollectionModal.tsx— Full file read; confirmed lightweight form pattern, purchasePriceCents field, toast.success patternsrc/client/hooks/useItems.ts— Full file read; confirmeduseCreateItem()signature and query invalidationsrc/shared/schemas.ts— Full file read; confirmedglobalItemId: z.number().int().positive().optional()(line 13)src/client/stores/uiStore.ts— Full file read; confirmed overlay open/close state, no manualEntryMode currently.planning/phases/23-manual-entry-fallback/23-CONTEXT.md— Full read; all locked decisions documentedtests/services/item.service.test.ts— Partial read; confirmed existing service test coverage for standalone item creation
Secondary (MEDIUM confidence)
- N/A — this phase is entirely within the existing codebase with no external library research needed
Tertiary (LOW confidence)
- N/A
Metadata
Confidence breakdown:
- Standard stack: HIGH — all libraries are already in the project, versions verified by reading source files
- Architecture: HIGH — patterns read directly from existing components; no speculation
- Pitfalls: HIGH — derived from reading actual code (reset effect, CategoryPicker value=0 issue in AddToCollectionModal)
Research date: 2026-04-06 Valid until: 2026-07-06 (stable — no fast-moving external dependencies)