category search, ux changes for pinned parts, other tweaks

This commit is contained in:
2025-12-01 05:22:03 -05:00
parent 255b052d02
commit ba4ac2eaf6
120 changed files with 196 additions and 2498 deletions
+65 -11
View File
@@ -32,10 +32,10 @@ const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
sights: ["sight"],
};
// NEW: sort options
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// NEW: build state type + storage key (matches /gunbuilder)
// build state type + storage key (matches /gunbuilder)
type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state";
@@ -49,11 +49,12 @@ export default function CategoryPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// NEW: filter + sort state
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
// NEW: build state for this page, synced with localStorage
// build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
@@ -122,7 +123,7 @@ export default function CategoryPage() {
return () => controller.abort();
}, [categoryId]);
// NEW: persist build to localStorage whenever it changes
// persist build to localStorage whenever it changes
useEffect(() => {
if (typeof window === "undefined") return;
try {
@@ -132,7 +133,7 @@ export default function CategoryPage() {
}
}, [build]);
// NEW: handler to toggle Add / Remove for this category
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
@@ -153,7 +154,7 @@ export default function CategoryPage() {
}
};
// NEW: available brands for filter
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
@@ -162,7 +163,7 @@ export default function CategoryPage() {
[parts],
);
// NEW: filtered + sorted parts
// filtered + sorted parts with selected part pinned to top
const filteredParts = useMemo(() => {
let result = [...parts];
@@ -170,6 +171,18 @@ export default function CategoryPage() {
result = result.filter((p) => p.brand === brandFilter);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return (
name.includes(normalizedQuery) ||
brand.includes(normalizedQuery)
);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
@@ -186,8 +199,16 @@ export default function CategoryPage() {
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy]);
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
if (!category) {
return (
@@ -281,6 +302,31 @@ export default function CategoryPage() {
parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 pr-6 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 text-xs leading-none"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="brand-filter"
@@ -344,7 +390,11 @@ export default function CategoryPage() {
return (
<div
key={part.id}
className="group relative border border-zinc-700 rounded-md p-3 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10 transition-all duration-200 flex flex-col"
className={`group relative rounded-md p-3 transition-all duration-200 flex flex-col border ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex justify-between items-center gap-2 mb-3">
<div className="flex-1">
@@ -402,7 +452,11 @@ export default function CategoryPage() {
return (
<div
key={part.id}
className="group relative border border-zinc-700 rounded-md p-3 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10 transition-all duration-200"
className={`group relative rounded-md p-3 transition-all duration-200 border ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="flex-1 min-w-0">