19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 23-manual-entry-fallback | 01 | execute | 1 |
|
true |
|
|
Purpose: Complete the catalog-driven gear flow by ensuring users are never stuck when a catalog item doesn't exist. The "Add Manually" path creates standalone collection items and plants the seed for future catalog submissions.
Output: ManualEntryForm component + CatalogSearchOverlay wired with entry points, inline form rendering, and post-save success card.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/23-manual-entry-fallback/23-CONTEXT.md @.planning/phases/23-manual-entry-fallback/23-RESEARCH.md@src/client/components/CatalogSearchOverlay.tsx @src/client/components/AddToCollectionModal.tsx @src/client/components/ItemForm.tsx @src/client/hooks/useItems.ts @src/client/hooks/useCategories.ts @src/shared/schemas.ts
From src/client/hooks/useItems.ts:
export function useCreateItem(): UseMutationResult<ItemWithCategory, Error, CreateItem>;
// CreateItem comes from src/shared/types.ts, inferred from createItemSchema
// globalItemId is optional — omitting it creates a standalone item
From src/shared/schemas.ts:
export const createItemSchema = z.object({
name: z.string().min(1).max(200),
categoryId: z.number().int().positive(),
weightGrams: z.number().int().nonnegative().optional(),
priceCents: z.number().int().nonnegative().optional(),
notes: z.string().max(2000).optional(),
productUrl: z.string().url().max(500).optional(),
imageFilename: z.string().optional(),
globalItemId: z.number().int().positive().optional(),
purchasePriceCents: z.number().int().nonnegative().optional(),
quantity: z.number().int().positive().optional(),
});
From src/client/components/CatalogSearchOverlay.tsx:
// Local state pattern — all view state managed via useState
const [searchInput, setSearchInput] = useState("");
const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen);
const catalogSearchMode = useUIStore((s) => s.catalogSearchMode);
const closeCatalogSearch = useUIStore((s) => s.closeCatalogSearch);
// EmptyState at line 667 — currently no onAddManually prop
function EmptyState({ hasQuery }: { hasQuery: boolean }) { ... }
// Reset effect at line 76 — must add new state resets here
useEffect(() => { if (!catalogSearchOpen) { /* resets */ } }, [catalogSearchOpen]);
From src/client/components/CategoryPicker.tsx:
interface CategoryPickerProps {
value: number;
onChange: (categoryId: number) => void;
}
From src/client/components/ImageUpload.tsx:
interface ImageUploadProps {
value: string | null;
onChange: (filename: string | null) => void;
}
<read_first> src/client/components/AddToCollectionModal.tsx src/client/components/ItemForm.tsx src/client/hooks/useItems.ts src/client/hooks/useCategories.ts src/shared/schemas.ts src/client/components/CategoryPicker.tsx src/client/components/ImageUpload.tsx </read_first>
Create `src/client/components/ManualEntryForm.tsx` — a compact, focused form for adding items manually (per D-05, D-06, D-07).Component interface (per D-05):
interface ManualEntryFormProps {
initialName?: string; // Auto-populated from search query
onSuccess: (itemName: string) => void; // Called after successful save
onBack: () => void; // Returns to search results (per D-04)
}
Form state fields (per D-06):
name(string, required) — initialized frominitialNamepropcategoryId(number | null) — pre-select first category once loaded (follow AddToCollectionModal pattern to avoid categoryId=0 Zod error)weightGrams(string, for input) — plain number input, converted to integer on submitpriceDollars(string, for input) — plain number input, converted to cents viaMath.round(Number(val) * 100)purchasePrice(string, for input) — same cents conversionnotes(string)productUrl(string)imageFilename(string | null) — managed viaImageUploadcomponenterror(string | null) — validation/API error display
Hooks to use:
useCategories()from@/client/hooks/useCategoriesfor CategoryPicker datauseCreateItem()from@/client/hooks/useItemsfor the mutation
Form layout (top to bottom):
- Back arrow row:
<button>withArrowLefticon (from lucide-react) + "Add Manually" title text — clicking callsonBack(per D-04) - Name input (text, required, full width)
- Category picker via
<CategoryPicker value={categoryId} onChange={setCategoryId} /> - Two-column row: Weight (grams) input | Price input (dollars, label says "MSRP")
- Purchase price input (dollars, label says "Purchase Price")
- Product URL input (text)
- Notes textarea (3 rows)
- ImageUpload component
- Error message display (red text, conditionally shown)
- Submit button: "Add to Collection" — disabled when
createItem.isPendingor!name.trim()orcategoryId === null
Category initialization pattern (from AddToCollectionModal):
const { data: categories } = useCategories();
useEffect(() => {
if (categories && categories.length > 0 && categoryId === null) {
setCategoryId(categories[0].id);
}
}, [categories, categoryId]);
Submit handler (per D-08):
- Validate name is non-empty, categoryId is not null
- Call
createItem.mutate()with:name: name.trim()categoryIdweightGrams: weightGrams ? Number(weightGrams) : undefinedpriceCents: priceDollars ? Math.round(Number(priceDollars) * 100) : undefinedpurchasePriceCents: purchasePrice ? Math.round(Number(purchasePrice) * 100) : undefinednotes: notes || undefinedproductUrl: productUrl || undefinedimageFilename: imageFilename ?? undefined- NO
globalItemId— deliberately omitted for standalone item
onSuccesscallback: callonSuccess(item.name)where item is the mutation resultonErrorcallback:setError(err instanceof Error ? err.message : "Failed to save")
Styling: Tailwind CSS v4 classes. Use @/ path alias for imports. Match the visual density of AddToCollectionModal (compact, not full-page).
Do NOT:
-
Import or reuse ItemForm directly (it's 315 lines with edit/delete logic — per D-07)
-
Add
manualEntryModeto Zustand UIStore (local state only — per research anti-patterns) -
Show a
bun run linttoast.success()on save — the success card in CatalogSearchOverlay handles that (per research anti-patterns)<acceptance_criteria>
- File
src/client/components/ManualEntryForm.tsxexists - File contains
export function ManualEntryForm( - File contains
interface ManualEntryFormPropswithinitialName,onSuccess,onBack - File contains
useCreateItem()import from@/client/hooks/useItems - File contains
useCategories()import from@/client/hooks/useCategories - File contains
<CategoryPickerusage (not a plain<select>) - File contains
<ImageUploadusage (not a raw file input) - File contains
createItem.mutate(call - File does NOT contain
globalItemIdin the mutate call payload - File does NOT contain
toast.successortoast("(no self-toasting) - File contains
ArrowLeftimport fromlucide-react - File contains
onBackbeing called (back arrow handler) - File contains
Math.round(Number(for price conversion to cents bun run lintexits 0 </acceptance_criteria>
ManualEntryForm component exists with all form fields per D-06, uses CategoryPicker and ImageUpload, calls useCreateItem without globalItemId, has back arrow calling onBack, and passes lint.
- File
<read_first> src/client/components/CatalogSearchOverlay.tsx src/client/components/ManualEntryForm.tsx </read_first>
Modify `src/client/components/CatalogSearchOverlay.tsx` to add manual entry mode, entry points, and post-save success card.1. Add imports at top of file:
import { ManualEntryForm } from "./ManualEntryForm";
import { toast } from "sonner";
2. Add local state (after existing useState declarations, around line 21):
const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);
3. Add resets to existing reset effect (line 76-87):
Inside the if (!catalogSearchOpen) block, add:
setManualEntryMode(false);
setSavedItemName(null);
4. Add handler functions (after existing toggleTag/removeTag, around line 99):
function handleEnterManualMode() {
setManualEntryMode(true);
}
function handleManualSuccess(itemName: string) {
setSavedItemName(itemName);
}
function handleAddAnother() {
setManualEntryMode(false);
setSavedItemName(null);
}
5. Modify the header back arrow (line 136-141) to be context-sensitive (per D-04):
Change the back arrow onClick from closeCatalogSearch to:
onClick={manualEntryMode ? () => { setManualEntryMode(false); setSavedItemName(null); } : closeCatalogSearch}
Also update the context text (line 143-145) — when manualEntryMode is true and savedItemName is null, show "Manual Entry" instead of the mode text. When savedItemName is not null, show "Item Added".
6. Replace the Results area content (lines 410-452) with conditional rendering (per D-03):
The {/* Results */} div at line 409 should contain:
if manualEntryMode && savedItemName:
→ render SuccessCard (inline, see below)
else if manualEntryMode && !savedItemName:
→ render <ManualEntryForm initialName={searchInput} onSuccess={handleManualSuccess} onBack={() => { setManualEntryMode(false); setSavedItemName(null); }} />
else:
→ existing results/loading/empty rendering (unchanged)
When NOT in manualEntryMode, also hide the search input row and filter bar (the form doesn't need them). Actually, keep the header visible for the back arrow — just conditionally render the search input/filters. When manualEntryMode is true, hide the search input row and filter toggle and view toggle, but keep the back arrow + context text row.
7. Modify EmptyState component (line 667) to accept onAddManually (per D-01, D-02):
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 unchanged */}
<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>
);
}
Update the <EmptyState call site (line 447) to pass onAddManually={handleEnterManualMode}.
8. Add persistent "Add Manually" link below results (per D-01):
After the results grid/list (after the closing </div> of the grid at line 431 or list at line 444), but still inside the results conditional block (items && items.length > 0), add:
<div className="flex justify-center py-6">
<button
type="button"
onClick={handleEnterManualMode}
className="text-sm text-gray-400 hover:text-gray-600 underline underline-offset-2"
>
Can't find it? Add manually
</button>
</div>
9. Create inline SuccessCard (per D-09, D-10, D-11):
Add a local component or inline JSX for the success card, rendered when manualEntryMode && savedItemName:
<div className="flex flex-col items-center justify-center py-20 px-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-gray-900 mb-1">Added {savedItemName} to collection</p>
<button
type="button"
onClick={() => toast("Coming soon — catalog submissions are on the roadmap!")}
className="mt-3 text-sm text-blue-600 hover:text-blue-800 underline underline-offset-2"
>
Submit to Catalog?
</button>
<div className="flex gap-4 mt-6">
<button
type="button"
onClick={handleAddAnother}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Add Another
</button>
<button
type="button"
onClick={closeCatalogSearch}
className="px-4 py-2 text-sm text-white bg-gray-900 rounded-lg hover:bg-gray-800 transition-colors"
>
Done
</button>
</div>
</div>
"Submit to Catalog?" button (per D-10): Calls toast("Coming soon — catalog submissions are on the roadmap!") only. No loading state, no disabled, no API call.
Do NOT:
-
Add any state to Zustand UIStore
-
Show a separate
toast.success()when the item is saved (the success card IS the feedback) -
Make "Add Manually" only visible in collection mode — show it in both modes (per research open question 2, D-08 locks to POST /api/items regardless)
bun run lint<acceptance_criteria>
CatalogSearchOverlay.tsxcontainsimport { ManualEntryForm }from./ManualEntryFormCatalogSearchOverlay.tsxcontainsimport { toast } from "sonner"CatalogSearchOverlay.tsxcontainsuseState(false)formanualEntryModeCatalogSearchOverlay.tsxcontainsuseState<string | null>(null)forsavedItemNameCatalogSearchOverlay.tsxcontainssetManualEntryMode(false)inside the reset effect (theif (!catalogSearchOpen)block)CatalogSearchOverlay.tsxcontainssetSavedItemName(null)inside the reset effectEmptyStatefunction signature containsonAddManuallyEmptyStaterenders text "Can't find it? Add manually" (per D-02)EmptyStaterenders text "Add Manually" (per D-02)CatalogSearchOverlay.tsxcontains<ManualEntryFormwithinitialName={searchInput}propCatalogSearchOverlay.tsxcontainsonSuccess={handleManualSuccess}on ManualEntryForm- There is a persistent "Add manually" button/link rendered after search results (outside EmptyState)
- Success card contains text "Added" and "to collection"
- Success card contains "Submit to Catalog?" button text
toast("Coming soonappears in the file (for Submit to Catalog button)- Success card contains "Add Another" button text
- Success card contains "Done" button text
- "Done" button calls
closeCatalogSearch - "Add Another" button calls
handleAddAnother - Back arrow onClick is context-sensitive (checks
manualEntryMode) bun run lintexits 0 </acceptance_criteria>
CatalogSearchOverlay has "Add Manually" links in both empty state and below results, renders ManualEntryForm inline when manualEntryMode is active, shows success card with non-functional "Submit to Catalog?" button after save, and properly resets state on overlay close.
<success_criteria>
- ManualEntryForm.tsx exists as a focused, compact form using CategoryPicker and ImageUpload
- CatalogSearchOverlay renders "Add Manually" links in empty state and below results
- Clicking "Add Manually" shows the form inline, pre-populated with search query as item name
- Submitting the form creates a standalone item (no globalItemId) via useCreateItem
- Success card shows with "Submit to Catalog?" (toast only), "Add Another", and "Done"
- Back arrow returns to search results, not closes overlay
- All state resets when overlay closes
- No new Zustand store state added
- Lint and existing tests pass </success_criteria>