diff --git a/src/client/routes/global-items/index.tsx b/src/client/routes/global-items/index.tsx index 5f1f4ef..cdfb925 100644 --- a/src/client/routes/global-items/index.tsx +++ b/src/client/routes/global-items/index.tsx @@ -1,8 +1,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, LayoutGrid, LayoutList, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { GlobalItemCard } from "../../components/GlobalItemCard"; +import { useFormatters } from "../../hooks/useFormatters"; import { useGlobalItems } from "../../hooks/useGlobalItems"; +import { useTags } from "../../hooks/useTags"; export const Route = createFileRoute("/global-items/")({ component: GlobalItemsCatalog, @@ -11,84 +14,652 @@ export const Route = createFileRoute("/global-items/")({ }), }); +type ViewMode = "grid" | "list"; + function GlobalItemsCatalog() { const { q } = Route.useSearch(); - const { data: items, isLoading } = useGlobalItems(q || undefined); + const [searchInput, setSearchInput] = useState(q ?? ""); + const [debouncedQuery, setDebouncedQuery] = useState(q ?? ""); + const [selectedTags, setSelectedTags] = useState([]); + const [viewMode, setViewMode] = useState("grid"); + + // Range filters (client-side) + const [weightMin, setWeightMin] = useState(0); + const [weightMax, setWeightMax] = useState(5000); + const [priceMin, setPriceMin] = useState(0); + const [priceMax, setPriceMax] = useState(100000); + + // Popover states + const [tagFilterOpen, setTagFilterOpen] = useState(false); + const [weightFilterOpen, setWeightFilterOpen] = useState(false); + const [priceFilterOpen, setPriceFilterOpen] = useState(false); + + const tagRef = useRef(null); + const weightRef = useRef(null); + const priceRef = useRef(null); + + const { weight, price } = useFormatters(); + const { data: tags } = useTags(); + const { data: rawItems, isLoading } = useGlobalItems( + debouncedQuery || undefined, + selectedTags.length > 0 ? selectedTags : undefined, + ); + + // Client-side range filtering + const hasRangeFilters = + weightMin > 0 || weightMax < 5000 || priceMin > 0 || priceMax < 100000; + + const items = useMemo(() => { + if (!rawItems || !hasRangeFilters) return rawItems; + return rawItems.filter((item) => { + if (item.weightGrams != null) { + if (item.weightGrams < weightMin || item.weightGrams > weightMax) + return false; + } + if (item.priceCents != null) { + if (item.priceCents < priceMin || item.priceCents > priceMax) + return false; + } + return true; + }); + }, [rawItems, weightMin, weightMax, priceMin, priceMax, hasRangeFilters]); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchInput); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + // Close popovers on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (tagRef.current && !tagRef.current.contains(e.target as Node)) { + setTagFilterOpen(false); + } + if (weightRef.current && !weightRef.current.contains(e.target as Node)) { + setWeightFilterOpen(false); + } + if (priceRef.current && !priceRef.current.contains(e.target as Node)) { + setPriceFilterOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function toggleTag(tagName: string) { + setSelectedTags((prev) => + prev.includes(tagName) + ? prev.filter((t) => t !== tagName) + : [...prev, tagName], + ); + } + + function removeTag(tagName: string) { + setSelectedTags((prev) => prev.filter((t) => t !== tagName)); + } + + function clearAllFilters() { + setSelectedTags([]); + setWeightMin(0); + setWeightMax(5000); + setPriceMin(0); + setPriceMax(100000); + } + + const hasAnyFilter = selectedTags.length > 0 || hasRangeFilters; + + const showRow2 = (tags && tags.length > 0) || hasAnyFilter; return ( -
- {/* Back link */} -
- - - Discover - -
+
+ {/* Sticky toolbar */} +
+ {/* Row 1: Back link, search, view toggle */} +
+
+ {/* Back link */} + + + Discover + - {/* Title row */} -
-

- Global Gear Catalog -

- {q && ( -

- Showing results for "{q}" -

+ {/* Search input */} +
+ setSearchInput(e.target.value)} + placeholder="Search the catalog..." + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent transition-colors" + /> + {searchInput && ( + + )} +
+ + {/* View toggle */} +
+ + +
+
+
+ + {/* Row 2: Filter pills + active pills */} + {showRow2 && ( +
+
+ {/* Tags filter pill */} + {tags && tags.length > 0 && ( +
+ + + {tagFilterOpen && ( +
+
+ {tags.map((tag) => { + const isActive = selectedTags.includes(tag.name); + return ( + + ); + })} +
+
+ )} +
+ )} + + {/* Weight filter pill */} +
+ + + {weightFilterOpen && ( +
+

+ Weight range +

+
+
+ + + setWeightMin( + Math.min(Number(e.target.value), weightMax - 50), + ) + } + className="w-full h-1.5 bg-gray-200 rounded-full appearance-none accent-blue-500" + /> +
+
+ + + setWeightMax( + Math.max(Number(e.target.value), weightMin + 50), + ) + } + className="w-full h-1.5 bg-gray-200 rounded-full appearance-none accent-blue-500" + /> +
+
+ {weight(weightMin)} + {weight(weightMax)} +
+
+ {(weightMin > 0 || weightMax < 5000) && ( + + )} +
+ )} +
+ + {/* Price filter pill */} +
+ + + {priceFilterOpen && ( +
+

+ Price range +

+
+
+ + + setPriceMin( + Math.min(Number(e.target.value), priceMax - 500), + ) + } + className="w-full h-1.5 bg-gray-200 rounded-full appearance-none accent-green-500" + /> +
+
+ + + setPriceMax( + Math.max(Number(e.target.value), priceMin + 500), + ) + } + className="w-full h-1.5 bg-gray-200 rounded-full appearance-none accent-green-500" + /> +
+
+ {price(priceMin)} + {price(priceMax)} +
+
+ {(priceMin > 0 || priceMax < 100000) && ( + + )} +
+ )} +
+ + {/* Active filter pills */} + {selectedTags.map((tag) => ( + + ))} + + {weightMin > 0 && ( + + ≥{weight(weightMin)} + + + )} + {weightMax < 5000 && ( + + ≤{weight(weightMax)} + + + )} + {priceMin > 0 && ( + + ≥{price(priceMin)} + + + )} + {priceMax < 100000 && ( + + ≤{price(priceMax)} + + + )} + + {hasAnyFilter && ( + + )} +
+
)}
{/* Results */} - {isLoading ? ( -
- {["a", "b", "c", "d", "e", "f"].map((id) => ( -
-
-
-
-
-
-
-
-
-
+
+ {isLoading ? ( + viewMode === "grid" ? ( + + ) : ( + + ) + ) : items && items.length > 0 ? ( + viewMode === "grid" ? ( +
+ {items.map((item) => ( + + ))}
- ))} -
- ) : items && items.length > 0 ? ( -
- {items.map((item) => ( - - ))} -
- ) : ( -
- - - -

- {q - ? "No items found matching your search" - : "No items in the global catalog yet"} -

-
- )} + ) : ( +
+ {items.map((item) => ( + + ))} +
+ ) + ) : ( + 0 || hasRangeFilters + } + /> + )} +
+
+ ); +} + +// ── List Row ─────────────────────────────────────────────────────────── + +interface ListRowProps { + item: { + id: number; + brand: string; + model: string; + category: string | null; + weightGrams: number | null; + priceCents: number | null; + imageUrl: string | null; + }; + weight: (g: number | null) => string; + price: (cents: number | null) => string; +} + +function GlobalItemListRow({ item, weight, price }: ListRowProps) { + return ( + + {/* Thumbnail */} +
+ {item.imageUrl ? ( + {`${item.brand} + ) : ( +
+ + + +
+ )} +
+ + {/* Info */} +
+

+ {item.brand} {item.model} +

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

+ {hasQuery + ? "No items found matching your search" + : "No items in the global catalog yet"} +

); }