category search, ux changes for pinned parts, other tweaks
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user