From 720460852c5956220a19e47fd3fffc4c9f2d412f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 08:02:59 +0200 Subject: [PATCH] feat(20-02): add FabMenu and CatalogSearchOverlay components - FabMenu with animated mini menu (Add to Collection, Start Thread, New Setup) - CatalogSearchOverlay with debounced search, tag chip filtering, result cards - Loading skeleton grid and empty state - Framer Motion animations for menu entrance/exit and overlay transitions --- .../components/CatalogSearchOverlay.tsx | 262 ++++++++++++++++++ src/client/components/FabMenu.tsx | 115 ++++++++ 2 files changed, 377 insertions(+) create mode 100644 src/client/components/CatalogSearchOverlay.tsx create mode 100644 src/client/components/FabMenu.tsx diff --git a/src/client/components/CatalogSearchOverlay.tsx b/src/client/components/CatalogSearchOverlay.tsx new file mode 100644 index 0000000..913096b --- /dev/null +++ b/src/client/components/CatalogSearchOverlay.tsx @@ -0,0 +1,262 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowLeft } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useFormatters } from "../hooks/useFormatters"; +import { useGlobalItems } from "../hooks/useGlobalItems"; +import { useTags } from "../hooks/useTags"; +import { useUIStore } from "../stores/uiStore"; + +export function CatalogSearchOverlay() { + const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen); + const catalogSearchMode = useUIStore((s) => s.catalogSearchMode); + const closeCatalogSearch = useUIStore((s) => s.closeCatalogSearch); + + const [searchInput, setSearchInput] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + + const { weight, price } = useFormatters(); + const { data: tags } = useTags(); + const { data: items, isLoading } = useGlobalItems( + debouncedQuery || undefined, + selectedTags.length > 0 ? selectedTags : undefined, + ); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchInput); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + // Lock body scroll when overlay is open + useEffect(() => { + if (catalogSearchOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [catalogSearchOpen]); + + // Reset state when overlay closes + useEffect(() => { + if (!catalogSearchOpen) { + setSearchInput(""); + setDebouncedQuery(""); + setSelectedTags([]); + } + }, [catalogSearchOpen]); + + function toggleTag(tagName: string) { + setSelectedTags((prev) => + prev.includes(tagName) + ? prev.filter((t) => t !== tagName) + : [...prev, tagName], + ); + } + + function handleAddStub() { + // Stub: actual add-to-collection / add-to-thread wired in Phase 21 + } + + const contextText = + catalogSearchMode === "collection" + ? "Adding to Collection" + : "Starting a Thread"; + + return ( + + {catalogSearchOpen && ( + + {/* Header */} +
+
+ + + {contextText} + +
+ + {/* Search input */} +
+ setSearchInput(e.target.value)} + placeholder="Search the catalog..." + className="w-full text-lg px-4 py-3 border border-gray-200 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300 transition-colors" + autoFocus + /> +
+ + {/* Tag chips */} + {tags && tags.length > 0 && ( +
+ {tags.map((tag) => { + const isActive = selectedTags.includes(tag.name); + return ( + + ); + })} +
+ )} +
+ + {/* Results */} +
+ {isLoading ? ( + + ) : items && items.length > 0 ? ( +
+ {items.map((item) => ( +
+
+ {item.imageUrl ? ( + {`${item.brand} + ) : ( +
+ + + +
+ )} +
+
+

+ {item.brand} +

+

+ {item.model} +

+
+ {item.weightGrams != null && ( + + {weight(item.weightGrams)} + + )} + {item.priceCents != null && ( + + {price(item.priceCents)} + + )} + {item.category && ( + + {item.category} + + )} +
+ +
+
+ ))} +
+ ) : ( + 0} + /> + )} +
+
+ )} +
+ ); +} + +function SkeletonGrid() { + return ( +
+ {[1, 2, 3, 4, 5, 6].map((id) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function EmptyState({ hasQuery }: { hasQuery: boolean }) { + return ( +
+ + + +

+ {hasQuery + ? "No items found matching your search" + : "Search the catalog to find gear"} +

+
+ ); +} diff --git a/src/client/components/FabMenu.tsx b/src/client/components/FabMenu.tsx new file mode 100644 index 0000000..2b76c8f --- /dev/null +++ b/src/client/components/FabMenu.tsx @@ -0,0 +1,115 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Package, Plus, Search } from "lucide-react"; +import { useUIStore } from "../stores/uiStore"; + +interface FabMenuProps { + isSetupsPage: boolean; +} + +const spring = { type: "spring", stiffness: 400, damping: 25 } as const; + +interface MenuItem { + label: string; + icon: React.ReactNode; + onClick: () => void; +} + +export function FabMenu({ isSetupsPage }: FabMenuProps) { + const fabMenuOpen = useUIStore((s) => s.fabMenuOpen); + const openFabMenu = useUIStore((s) => s.openFabMenu); + const closeFabMenu = useUIStore((s) => s.closeFabMenu); + const openCatalogSearch = useUIStore((s) => s.openCatalogSearch); + const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen); + + // Hide FAB when catalog search overlay is open + if (catalogSearchOpen) return null; + + const menuItems: MenuItem[] = [ + { + label: "Add to Collection", + icon: , + onClick: () => openCatalogSearch("collection"), + }, + { + label: "Start New Thread", + icon: , + onClick: () => openCatalogSearch("thread"), + }, + ]; + + if (isSetupsPage) { + menuItems.push({ + label: "New Setup", + icon: , + onClick: () => { + closeFabMenu(); + // Stub: setup creation is handled by the setups page itself + }, + }); + } + + function handleFabClick() { + if (fabMenuOpen) { + closeFabMenu(); + } else { + openFabMenu(); + } + } + + return ( + <> + {/* Backdrop */} + + {fabMenuOpen && ( + + )} + + + {/* Menu items */} + + {fabMenuOpen && ( +
+ {menuItems.map((item, index) => ( + + {item.icon} + + {item.label} + + + ))} +
+ )} +
+ + {/* FAB button */} + + + + + ); +}