From 6f7502dcfde00f89e7e0f1537169f6061dc0c9d1 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 9 Mar 2026 13:01:08 +0100 Subject: [PATCH 1/5] Redesign product filters with Saleor-inspired UI and refactor architecture Split monolithic ProductFilters.tsx (479 lines) into 8 focused components under products/filters/. Extract shared utilities (color-map, price-buckets, filters) into lib/utils/. Fix stale closure bug in infinite scroll by using refs. Remove all non-functional comments. Co-Authored-By: Claude Opus 4.6 --- .../CategoryProductsContent.tsx | 1 - src/app/globals.css | 12 +- src/components/products/ProductFilters.tsx | 370 ------------------ .../products/ProductListingLayout.tsx | 159 +++----- src/components/products/VariantPicker.tsx | 19 +- .../filters/AvailabilityDropdownContent.tsx | 47 +++ .../products/filters/FilterChips.tsx | 105 +++++ .../products/filters/FilterDropdown.tsx | 81 ++++ .../products/filters/MobileFilterDrawer.tsx | 292 ++++++++++++++ .../filters/OptionDropdownContent.tsx | 52 +++ .../products/filters/PriceDropdownContent.tsx | 52 +++ .../products/filters/ProductFilters.tsx | 282 +++++++++++++ .../products/filters/SortDropdownContent.tsx | 38 ++ src/components/products/filters/index.ts | 4 + src/hooks/useProductListing.ts | 62 ++- src/lib/utils/color-map.ts | 140 +++++++ src/lib/utils/filters.ts | 44 +++ src/lib/utils/price-buckets.ts | 71 ++++ src/lib/utils/product-query.ts | 2 +- src/types/filters.ts | 9 + 20 files changed, 1305 insertions(+), 537 deletions(-) delete mode 100644 src/components/products/ProductFilters.tsx create mode 100644 src/components/products/filters/AvailabilityDropdownContent.tsx create mode 100644 src/components/products/filters/FilterChips.tsx create mode 100644 src/components/products/filters/FilterDropdown.tsx create mode 100644 src/components/products/filters/MobileFilterDrawer.tsx create mode 100644 src/components/products/filters/OptionDropdownContent.tsx create mode 100644 src/components/products/filters/PriceDropdownContent.tsx create mode 100644 src/components/products/filters/ProductFilters.tsx create mode 100644 src/components/products/filters/SortDropdownContent.tsx create mode 100644 src/components/products/filters/index.ts create mode 100644 src/lib/utils/color-map.ts create mode 100644 src/lib/utils/filters.ts create mode 100644 src/lib/utils/price-buckets.ts create mode 100644 src/types/filters.ts diff --git a/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx b/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx index cb4d4566..9cb89581 100644 --- a/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx +++ b/src/app/[country]/[locale]/(storefront)/t/[...permalink]/CategoryProductsContent.tsx @@ -60,7 +60,6 @@ export function CategoryProductsContent({ = { - manual: "Manual", - best_selling: "Best Selling", - "price asc": "Price (low-high)", - "price desc": "Price (high-low)", - "available_on desc": "Newest", - "available_on asc": "Oldest", - "name asc": "Name (A-Z)", - "name desc": "Name (Z-A)", -}; - -const AVAILABILITY_LABELS: Record = { - in_stock: "In Stock", - out_of_stock: "Out of Stock", -}; - -interface ProductFiltersProps { - taxonId?: string; - filtersData: ProductFiltersResponse | null; - loading: boolean; - onFilterChange: (filters: ActiveFilters) => void; -} - -export interface ActiveFilters { - priceMin?: number; - priceMax?: number; - optionValues: string[]; // option value IDs - availability?: "in_stock" | "out_of_stock"; - sortBy?: string; -} - -export const ProductFilters = memo(function ProductFilters({ - filtersData, - loading, - onFilterChange, -}: ProductFiltersProps) { - const [activeFilters, setActiveFilters] = useState({ - optionValues: [], - }); - const [expandedSections, setExpandedSections] = useState>( - new Set(["price"]), - ); - - const updateFilters = (updater: (prev: ActiveFilters) => ActiveFilters) => { - const next = updater(activeFilters); - setActiveFilters(next); - onFilterChange(next); - }; - - const toggleSection = (sectionId: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - if (next.has(sectionId)) { - next.delete(sectionId); - } else { - next.add(sectionId); - } - return next; - }); - }; - - const handleOptionValueToggle = (optionValueId: string) => { - updateFilters((prev) => { - const newOptionValues = prev.optionValues.includes(optionValueId) - ? prev.optionValues.filter((id) => id !== optionValueId) - : [...prev.optionValues, optionValueId]; - return { ...prev, optionValues: newOptionValues }; - }); - }; - - const handlePriceChange = (min?: number, max?: number) => { - updateFilters((prev) => ({ ...prev, priceMin: min, priceMax: max })); - }; - - const handleAvailabilityChange = ( - availability?: "in_stock" | "out_of_stock", - ) => { - updateFilters((prev) => ({ ...prev, availability })); - }; - - const handleSortChange = (sortBy: string) => { - updateFilters((prev) => ({ ...prev, sortBy })); - }; - - const clearFilters = () => { - const reset: ActiveFilters = { optionValues: [] }; - setActiveFilters(reset); - onFilterChange(reset); - }; - - if (loading) { - return ( -
-
-
-
-
-
- ); - } - - if (!filtersData) { - return null; - } - - const hasActiveFilters = - activeFilters.priceMin !== undefined || - activeFilters.priceMax !== undefined || - activeFilters.optionValues.length > 0 || - activeFilters.availability !== undefined; - - return ( -
- {/* Sort */} -
- - -
- - {/* Reset Filters */} - {hasActiveFilters && ( - - )} - - {/* Filters */} - {filtersData.filters.map((filter) => { - switch (filter.type) { - case "price_range": - return ( - toggleSection(filter.id)} - > - - - ); - case "availability": - return ( - toggleSection(filter.id)} - > - - - ); - case "option": - return ( - toggleSection(filter.id)} - > - - - ); - default: - return null; - } - })} - - {/* Total count */} -
- {filtersData.total_count} products -
-
- ); -}); - -// Filter Section wrapper with expand/collapse -function FilterSection({ - title, - expanded, - onToggle, - children, -}: { - title: string; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {expanded &&
{children}
} -
- ); -} - -// Price Range Filter -function PriceFilter({ - filter, - minValue, - maxValue, - onChange, -}: { - filter: PriceRangeFilter; - minValue?: number; - maxValue?: number; - onChange: (min?: number, max?: number) => void; -}) { - const [localMin, setLocalMin] = useState(minValue?.toString() || ""); - const [localMax, setLocalMax] = useState(maxValue?.toString() || ""); - - const handleApply = () => { - onChange( - localMin ? parseFloat(localMin) : undefined, - localMax ? parseFloat(localMax) : undefined, - ); - }; - - return ( -
-
- Range: {filter.currency} {filter.min.toFixed(2)} -{" "} - {filter.max.toFixed(2)} -
-
- setLocalMin(e.target.value)} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> - - - setLocalMax(e.target.value)} - className="w-full border border-gray-300 rounded px-2 py-1 text-sm" - /> -
- -
- ); -} - -// Availability Filter -function AvailabilityFilterSection({ - filter, - selected, - onChange, -}: { - filter: AvailabilityFilter; - selected?: "in_stock" | "out_of_stock"; - onChange: (value?: "in_stock" | "out_of_stock") => void; -}) { - return ( -
- {filter.options.map((option) => ( - - ))} - {selected && ( - - )} -
- ); -} - -// Option Filter (Size, Color, etc.) -function OptionFilterSection({ - filter, - selectedValues, - onToggle, -}: { - filter: OptionFilter; - selectedValues: string[]; - onToggle: (id: string) => void; -}) { - return ( -
- {filter.options.map((option) => ( - - ))} -
- ); -} diff --git a/src/components/products/ProductListingLayout.tsx b/src/components/products/ProductListingLayout.tsx index db38d56b..184117f2 100644 --- a/src/components/products/ProductListingLayout.tsx +++ b/src/components/products/ProductListingLayout.tsx @@ -2,18 +2,11 @@ import type { ProductFiltersResponse, StoreProduct } from "@spree/sdk"; import type { RefObject } from "react"; -import { - CloseIcon, - FilterIcon, - SearchIcon, - SpinnerIcon, -} from "@/components/icons"; -import { - type ActiveFilters, - ProductFilters, -} from "@/components/products/ProductFilters"; +import { SearchIcon, SpinnerIcon } from "@/components/icons"; +import { FilterBar } from "@/components/products/filters"; import { ProductGrid } from "@/components/products/ProductGrid"; import { ProductGridSkeleton } from "@/components/products/ProductGridSkeleton"; +import type { ActiveFilters } from "@/types/filters"; interface ProductListingLayoutProps { products: StoreProduct[]; @@ -24,11 +17,9 @@ interface ProductListingLayoutProps { basePath: string; filtersData: ProductFiltersResponse | null; filtersLoading: boolean; - showMobileFilters: boolean; - setShowMobileFilters: (show: boolean) => void; + activeFilters: ActiveFilters; onFilterChange: (filters: ActiveFilters) => void; loadMoreRef: RefObject; - taxonId?: string; emptyMessage?: string; listId?: string; listName?: string; @@ -43,119 +34,61 @@ export function ProductListingLayout({ basePath, filtersData, filtersLoading, - showMobileFilters, - setShowMobileFilters, + activeFilters, onFilterChange, loadMoreRef, - taxonId, emptyMessage = "Try adjusting your filters", listId, listName, }: ProductListingLayoutProps) { return ( -
- {/* Mobile filter button */} -
- -
+
+ - {/* Mobile filter drawer */} - {showMobileFilters && ( -
-
setShowMobileFilters(false)} + {loading ? ( + + ) : products.length === 0 ? ( +
+ -
-
-

Filters

- -
-
- -
-
+

+ No products found +

+

{emptyMessage}

- )} - - {/* Desktop sidebar filters */} -
-
- + -
-
- {/* Products */} -
- {loading ? ( - - ) : products.length === 0 ? ( -
- -

- No products found -

-

{emptyMessage}

+
+ {loadingMore && ( +
+ + Loading more... +
+ )} + {!hasMore && products.length > 0 && ( +

No more products to load

+ )}
- ) : ( - <> -
-

- Showing {products.length} of {totalCount} products -

-
- - - - {/* Load more trigger */} -
- {loadingMore && ( -
- - Loading more... -
- )} - {!hasMore && products.length > 0 && ( -

- No more products to load -

- )} -
- - )} -
+ + )}
); } diff --git a/src/components/products/VariantPicker.tsx b/src/components/products/VariantPicker.tsx index dc4e4fcf..74a23971 100644 --- a/src/components/products/VariantPicker.tsx +++ b/src/components/products/VariantPicker.tsx @@ -2,6 +2,7 @@ import type { StoreOptionType, StoreVariant } from "@spree/sdk"; import { useMemo } from "react"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; interface VariantPickerProps { variants: StoreVariant[]; @@ -16,7 +17,6 @@ export function VariantPicker({ selectedVariant, onVariantChange, }: VariantPickerProps) { - // Build option values map by option type const optionValuesMap = useMemo(() => { const map: Record> = {}; @@ -35,7 +35,6 @@ export function VariantPicker({ return map; }, [variants, optionTypes]); - // Get selected options from current variant const selectedOptions = useMemo(() => { const options: Record = {}; if (selectedVariant) { @@ -46,7 +45,6 @@ export function VariantPicker({ return options; }, [selectedVariant]); - // Precompute variant lookup structures to avoid O(n) iteration per option value const { variantOptionMaps, optionValueDetailsMap } = useMemo(() => { const maps = variants.map((variant) => { const optionsMap: Record = {}; @@ -56,7 +54,6 @@ export function VariantPicker({ return { variant, optionsMap }; }); - // Build option value details index: "typeId:name" -> option value object const detailsMap: Record = {}; for (const variant of variants) { @@ -71,7 +68,6 @@ export function VariantPicker({ return { variantOptionMaps: maps, optionValueDetailsMap: detailsMap }; }, [variants]); - // Find variant matching selected options const findVariant = ( newOptions: Record, ): StoreVariant | null => { @@ -87,7 +83,6 @@ export function VariantPicker({ ); }; - // Check if an option value is available given current selections const isOptionAvailable = ( optionTypeId: string, optionValue: string, @@ -100,7 +95,6 @@ export function VariantPicker({ ); }; - // Check if a variant with these options is purchasable const isOptionPurchasable = ( optionTypeId: string, optionValue: string, @@ -121,7 +115,6 @@ export function VariantPicker({ onVariantChange(newVariant); }; - // Get option value details from precomputed map (O(1) lookup) const getOptionValueDetails = ( optionTypeId: string, optionValueName: string, @@ -138,7 +131,7 @@ export function VariantPicker({ {optionTypes.map((optionType) => { const values = Array.from(optionValuesMap[optionType.id] || []); const selectedValue = selectedOptions[optionType.id]; - const isColorOption = optionType.name.toLowerCase() === "color"; + const isColor = isColorOption(optionType.name); return (
@@ -154,8 +147,7 @@ export function VariantPicker({ )}
- {isColorOption ? ( - // Color swatches + {isColor ? (
{values.map((value) => { const optionValue = getOptionValueDetails( @@ -182,9 +174,7 @@ export function VariantPicker({ ${!isPurchasable && isAvailable ? "opacity-50" : ""} `} style={{ - backgroundColor: value - .toLowerCase() - .replace(/\s+/g, ""), + backgroundColor: resolveColor(value), }} > {!isPurchasable && isAvailable && ( @@ -197,7 +187,6 @@ export function VariantPicker({ })}
) : ( - // Regular option buttons
{values.map((value) => { const optionValue = getOptionValueDetails( diff --git a/src/components/products/filters/AvailabilityDropdownContent.tsx b/src/components/products/filters/AvailabilityDropdownContent.tsx new file mode 100644 index 00000000..020c3e53 --- /dev/null +++ b/src/components/products/filters/AvailabilityDropdownContent.tsx @@ -0,0 +1,47 @@ +import type { AvailabilityFilter } from "@spree/sdk"; +import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import type { AvailabilityStatus } from "@/types/filters"; + +interface AvailabilityDropdownContentProps { + filter: AvailabilityFilter; + selected?: AvailabilityStatus; + onChange: (value?: AvailabilityStatus) => void; +} + +export function AvailabilityDropdownContent({ + filter, + selected, + onChange, +}: AvailabilityDropdownContentProps) { + return ( +
+

Availability

+
    + {filter.options.map((option) => { + const isSelected = selected === option.id; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/FilterChips.tsx b/src/components/products/filters/FilterChips.tsx new file mode 100644 index 00000000..a88b001e --- /dev/null +++ b/src/components/products/filters/FilterChips.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { OptionFilter, ProductFiltersResponse } from "@spree/sdk"; +import { CloseIcon } from "@/components/icons"; +import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; +import { + findMatchingBucket, + type PriceBucket, +} from "@/lib/utils/price-buckets"; +import type { ActiveFilters } from "@/types/filters"; + +interface FilterChipsProps { + activeFilters: ActiveFilters; + filtersData: ProductFiltersResponse | null; + priceBuckets: PriceBucket[]; + onRemoveOptionValue: (optionValueId: string) => void; + onRemovePrice: () => void; + onRemoveAvailability: () => void; + onClearAll: () => void; +} + +export function FilterChips({ + activeFilters, + filtersData, + priceBuckets, + onRemoveOptionValue, + onRemovePrice, + onRemoveAvailability, + onClearAll, +}: FilterChipsProps) { + const chips: { key: string; label: string; onRemove: () => void }[] = []; + + if (filtersData) { + for (const optionValueId of activeFilters.optionValues) { + const optionFilter = filtersData.filters.find( + (f) => + f.type === "option" && + (f as OptionFilter).options.some((o) => o.id === optionValueId), + ) as OptionFilter | undefined; + + if (optionFilter) { + const option = optionFilter.options.find((o) => o.id === optionValueId); + if (option) { + chips.push({ + key: `option-${optionValueId}`, + label: `${optionFilter.presentation}: ${option.presentation}`, + onRemove: () => onRemoveOptionValue(optionValueId), + }); + } + } + } + } + + if ( + activeFilters.priceMin !== undefined || + activeFilters.priceMax !== undefined + ) { + const matchingBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + chips.push({ + key: "price", + label: `Price: ${matchingBucket?.label || "Custom"}`, + onRemove: onRemovePrice, + }); + } + + if (activeFilters.availability) { + chips.push({ + key: "availability", + label: `${AVAILABILITY_LABELS[activeFilters.availability] || activeFilters.availability}`, + onRemove: onRemoveAvailability, + }); + } + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((chip) => ( + + {chip.label} + + + ))} + +
+ ); +} diff --git a/src/components/products/filters/FilterDropdown.tsx b/src/components/products/filters/FilterDropdown.tsx new file mode 100644 index 00000000..32782769 --- /dev/null +++ b/src/components/products/filters/FilterDropdown.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ChevronDownIcon } from "@/components/icons"; + +interface FilterDropdownProps { + label: string; + badgeCount?: number; + isOpen: boolean; + onToggle: () => void; + onClose: () => void; + children: React.ReactNode; + align?: "left" | "right"; +} + +export function FilterDropdown({ + label, + badgeCount, + isOpen, + onToggle, + onClose, + children, + align = "left", +}: FilterDropdownProps) { + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + onClose(); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + const hasActive = badgeCount !== undefined && badgeCount > 0; + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/components/products/filters/MobileFilterDrawer.tsx b/src/components/products/filters/MobileFilterDrawer.tsx new file mode 100644 index 00000000..452bffe1 --- /dev/null +++ b/src/components/products/filters/MobileFilterDrawer.tsx @@ -0,0 +1,292 @@ +"use client"; + +import type { + AvailabilityFilter, + OptionFilter, + ProductFiltersResponse, +} from "@spree/sdk"; +import { CheckIcon, CloseIcon } from "@/components/icons"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; +import { AVAILABILITY_LABELS, getActiveFilterCount } from "@/lib/utils/filters"; +import type { PriceBucket } from "@/lib/utils/price-buckets"; +import { findMatchingBucket } from "@/lib/utils/price-buckets"; +import type { ActiveFilters, AvailabilityStatus } from "@/types/filters"; + +interface MobileFilterDrawerProps { + isOpen: boolean; + onClose: () => void; + filtersData: ProductFiltersResponse | null; + activeFilters: ActiveFilters; + priceBuckets: PriceBucket[]; + onOptionValueToggle: (optionValueId: string) => void; + onPriceChange: (min?: number, max?: number) => void; + onAvailabilityChange: (value?: AvailabilityStatus) => void; + onClearAll: () => void; +} + +export function MobileFilterDrawer({ + isOpen, + onClose, + filtersData, + activeFilters, + priceBuckets, + onOptionValueToggle, + onPriceChange, + onAvailabilityChange, + onClearAll, +}: MobileFilterDrawerProps) { + if (!isOpen) return null; + + const activeCount = getActiveFilterCount(activeFilters); + + return ( +
+
+
+
+ +

Filters

+
+
+ +
+ {filtersData?.filters.map((filter) => { + switch (filter.type) { + case "option": + return ( + + ); + case "price_range": + return ( + + ); + case "availability": + return ( + + ); + default: + return null; + } + })} +
+ +
+ {activeCount > 0 && ( + + )} + +
+
+
+ ); +} + +function MobileOptionSection({ + filter, + selectedValues, + onToggle, +}: { + filter: OptionFilter; + selectedValues: string[]; + onToggle: (id: string) => void; +}) { + const isColorFilter = isColorOption(filter.presentation); + + return ( +
+

+ {filter.presentation} +

+ {isColorFilter ? ( +
+ {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + + ); + })} +
+ ) : ( +
+ {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( + + ); + })} +
+ )} +
+ ); +} + +function MobilePriceSection({ + priceBuckets, + activeFilters, + onPriceChange, +}: { + priceBuckets: PriceBucket[]; + activeFilters: ActiveFilters; + onPriceChange: (min?: number, max?: number) => void; +}) { + if (priceBuckets.length === 0) return null; + + const selectedBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + + return ( +
+

+ Price +

+
+ {priceBuckets.map((bucket) => { + const isSelected = selectedBucket?.id === bucket.id; + return ( + + ); + })} +
+
+ ); +} + +function MobileAvailabilitySection({ + filter, + selected, + onChange, +}: { + filter: AvailabilityFilter; + selected?: AvailabilityStatus; + onChange: (value?: AvailabilityStatus) => void; +}) { + return ( +
+

+ Availability +

+
+ {filter.options.map((option) => { + const isSelected = selected === option.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/OptionDropdownContent.tsx b/src/components/products/filters/OptionDropdownContent.tsx new file mode 100644 index 00000000..8f53ce07 --- /dev/null +++ b/src/components/products/filters/OptionDropdownContent.tsx @@ -0,0 +1,52 @@ +import type { OptionFilter } from "@spree/sdk"; +import { isColorOption, resolveColor } from "@/lib/utils/color-map"; + +interface OptionDropdownContentProps { + filter: OptionFilter; + selectedValues: string[]; + onToggle: (id: string) => void; +} + +export function OptionDropdownContent({ + filter, + selectedValues, + onToggle, +}: OptionDropdownContentProps) { + const isColorFilter = isColorOption(filter.presentation); + + return ( +
+

+ {filter.presentation} +

+
    + {filter.options.map((option) => { + const isSelected = selectedValues.includes(option.id); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/PriceDropdownContent.tsx b/src/components/products/filters/PriceDropdownContent.tsx new file mode 100644 index 00000000..4a408ccb --- /dev/null +++ b/src/components/products/filters/PriceDropdownContent.tsx @@ -0,0 +1,52 @@ +import type { PriceBucket } from "@/lib/utils/price-buckets"; +import { findMatchingBucket } from "@/lib/utils/price-buckets"; +import type { ActiveFilters } from "@/types/filters"; + +interface PriceDropdownContentProps { + priceBuckets: PriceBucket[]; + activeFilters: ActiveFilters; + onPriceChange: (min?: number, max?: number) => void; +} + +export function PriceDropdownContent({ + priceBuckets, + activeFilters, + onPriceChange, +}: PriceDropdownContentProps) { + const selectedBucket = findMatchingBucket( + priceBuckets, + activeFilters.priceMin, + activeFilters.priceMax, + ); + + return ( +
+

Price Range

+
    + {priceBuckets.map((bucket) => { + const isSelected = selectedBucket?.id === bucket.id; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/products/filters/ProductFilters.tsx b/src/components/products/filters/ProductFilters.tsx new file mode 100644 index 00000000..09885e68 --- /dev/null +++ b/src/components/products/filters/ProductFilters.tsx @@ -0,0 +1,282 @@ +"use client"; + +import type { + AvailabilityFilter, + OptionFilter, + PriceRangeFilter, + ProductFiltersResponse, +} from "@spree/sdk"; +import { memo, useCallback, useMemo, useState } from "react"; +import { FilterIcon } from "@/components/icons"; +import { getActiveFilterCount } from "@/lib/utils/filters"; +import { generatePriceBuckets } from "@/lib/utils/price-buckets"; +import type { ActiveFilters, AvailabilityStatus } from "@/types/filters"; +import { AvailabilityDropdownContent } from "./AvailabilityDropdownContent"; +import { FilterChips } from "./FilterChips"; +import { FilterDropdown } from "./FilterDropdown"; +import { MobileFilterDrawer } from "./MobileFilterDrawer"; +import { OptionDropdownContent } from "./OptionDropdownContent"; +import { PriceDropdownContent } from "./PriceDropdownContent"; +import { SortDropdownContent } from "./SortDropdownContent"; + +interface FilterBarProps { + filtersData: ProductFiltersResponse | null; + filtersLoading: boolean; + activeFilters: ActiveFilters; + totalCount: number; + onFilterChange: (filters: ActiveFilters) => void; +} + +export const FilterBar = memo(function FilterBar({ + filtersData, + filtersLoading, + activeFilters, + totalCount, + onFilterChange, +}: FilterBarProps) { + const [openDropdownId, setOpenDropdownId] = useState(null); + const [showMobileDrawer, setShowMobileDrawer] = useState(false); + + const toggleDropdown = useCallback((id: string) => { + setOpenDropdownId((prev) => (prev === id ? null : id)); + }, []); + + const closeDropdown = useCallback(() => { + setOpenDropdownId(null); + }, []); + + const handleOptionValueToggle = useCallback( + (optionValueId: string) => { + const newOptionValues = activeFilters.optionValues.includes(optionValueId) + ? activeFilters.optionValues.filter((id) => id !== optionValueId) + : [...activeFilters.optionValues, optionValueId]; + onFilterChange({ ...activeFilters, optionValues: newOptionValues }); + }, + [activeFilters, onFilterChange], + ); + + const handlePriceChange = useCallback( + (min?: number, max?: number) => { + onFilterChange({ ...activeFilters, priceMin: min, priceMax: max }); + }, + [activeFilters, onFilterChange], + ); + + const handleAvailabilityChange = useCallback( + (availability?: AvailabilityStatus) => { + onFilterChange({ ...activeFilters, availability }); + }, + [activeFilters, onFilterChange], + ); + + const handleSortChange = useCallback( + (sortBy: string) => { + onFilterChange({ ...activeFilters, sortBy }); + closeDropdown(); + }, + [activeFilters, onFilterChange, closeDropdown], + ); + + const clearFilters = useCallback(() => { + onFilterChange({ optionValues: [] }); + }, [onFilterChange]); + + const priceBuckets = useMemo(() => { + if (!filtersData) return []; + const priceFilter = filtersData.filters.find( + (f) => f.type === "price_range", + ) as PriceRangeFilter | undefined; + if (!priceFilter) return []; + return generatePriceBuckets( + priceFilter.min, + priceFilter.max, + priceFilter.currency, + ); + }, [filtersData]); + + const optionFilters = useMemo(() => { + if (!filtersData) return []; + return filtersData.filters.filter( + (f) => f.type === "option", + ) as OptionFilter[]; + }, [filtersData]); + + const badgeCounts = useMemo(() => { + const counts: Record = {}; + for (const filter of optionFilters) { + counts[filter.id] = filter.options.filter((o) => + activeFilters.optionValues.includes(o.id), + ).length; + } + return counts; + }, [optionFilters, activeFilters.optionValues]); + + const priceBadge = + activeFilters.priceMin !== undefined || activeFilters.priceMax !== undefined + ? 1 + : 0; + + const availabilityBadge = activeFilters.availability ? 1 : 0; + + const totalActiveFilters = getActiveFilterCount(activeFilters); + + const hasActiveFilters = totalActiveFilters > 0; + + const activeSortBy = activeFilters.sortBy || filtersData?.default_sort; + + if (filtersLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!filtersData) return null; + + const availabilityFilter = filtersData.filters.find( + (f) => f.type === "availability", + ) as AvailabilityFilter | undefined; + + const hasPriceFilter = + filtersData.filters.some((f) => f.type === "price_range") && + priceBuckets.length > 0; + + return ( +
+
+
+ {optionFilters.map((filter) => ( + toggleDropdown(filter.id)} + onClose={closeDropdown} + > + + + ))} + + {hasPriceFilter && ( + toggleDropdown("price")} + onClose={closeDropdown} + > + + + )} + + {availabilityFilter && ( + toggleDropdown("availability")} + onClose={closeDropdown} + > + + + )} +
+ +
+ + {totalCount} {totalCount === 1 ? "product" : "products"} + + toggleDropdown("sort")} + onClose={closeDropdown} + align="right" + > + + +
+
+ +
+ + +
+ toggleDropdown("sort-mobile")} + onClose={closeDropdown} + align="right" + > + + +
+
+ + {hasActiveFilters && ( + handleOptionValueToggle(id)} + onRemovePrice={() => handlePriceChange(undefined, undefined)} + onRemoveAvailability={() => handleAvailabilityChange(undefined)} + onClearAll={clearFilters} + /> + )} + + setShowMobileDrawer(false)} + filtersData={filtersData} + activeFilters={activeFilters} + priceBuckets={priceBuckets} + onOptionValueToggle={handleOptionValueToggle} + onPriceChange={handlePriceChange} + onAvailabilityChange={handleAvailabilityChange} + onClearAll={clearFilters} + /> +
+ ); +}); diff --git a/src/components/products/filters/SortDropdownContent.tsx b/src/components/products/filters/SortDropdownContent.tsx new file mode 100644 index 00000000..0db1b75b --- /dev/null +++ b/src/components/products/filters/SortDropdownContent.tsx @@ -0,0 +1,38 @@ +import { SORT_LABELS } from "@/lib/utils/filters"; + +interface SortDropdownContentProps { + sortOptions: { id: string }[]; + activeSortBy?: string; + onSortChange: (sortBy: string) => void; +} + +export function SortDropdownContent({ + sortOptions, + activeSortBy, + onSortChange, +}: SortDropdownContentProps) { + return ( +
    + {sortOptions.map((option) => { + const isActive = activeSortBy === option.id; + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/src/components/products/filters/index.ts b/src/components/products/filters/index.ts new file mode 100644 index 00000000..085400b5 --- /dev/null +++ b/src/components/products/filters/index.ts @@ -0,0 +1,4 @@ +export { FilterChips } from "./FilterChips"; +export { FilterDropdown } from "./FilterDropdown"; +export { MobileFilterDrawer } from "./MobileFilterDrawer"; +export { FilterBar } from "./ProductFilters"; diff --git a/src/hooks/useProductListing.ts b/src/hooks/useProductListing.ts index 832ec319..eff3bfbd 100644 --- a/src/hooks/useProductListing.ts +++ b/src/hooks/useProductListing.ts @@ -7,32 +7,16 @@ import type { StoreProduct, } from "@spree/sdk"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ActiveFilters } from "@/components/products/ProductFilters"; import { getProductFilters } from "@/lib/data/products"; +import { filtersEqual } from "@/lib/utils/filters"; import { buildProductQueryParams } from "@/lib/utils/product-query"; - -/** Shallow compare two ActiveFilters objects. */ -function filtersEqual(a: ActiveFilters, b: ActiveFilters): boolean { - if (a.priceMin !== b.priceMin || a.priceMax !== b.priceMax) return false; - if (a.availability !== b.availability) return false; - if (a.sortBy !== b.sortBy) return false; - if (a.optionValues.length !== b.optionValues.length) return false; - const aVals = [...a.optionValues].sort(); - const bVals = [...b.optionValues].sort(); - for (let i = 0; i < aVals.length; i++) { - if (aVals[i] !== bVals[i]) return false; - } - return true; -} +import type { ActiveFilters } from "@/types/filters"; interface UseProductListingOptions { - /** Function that fetches a page of products given query params. */ fetchFn: ( params: ProductListParams, ) => Promise>; - /** Optional params passed to getProductFilters (e.g. { taxon_id }). */ filterParams?: ProductListParams; - /** Optional search query string. */ searchQuery?: string; } @@ -53,7 +37,6 @@ export function useProductListing({ null, ); const [filtersLoading, setFiltersLoading] = useState(true); - const [showMobileFilters, setShowMobileFilters] = useState(false); const loadMoreRef = useRef(null); const pageRef = useRef(1); const hasMoreRef = useRef(false); @@ -104,11 +87,8 @@ export function useProductListing({ [fetchProducts], ); - // Fetch filters (scoped to search query when present) + // biome-ignore lint/correctness/useExhaustiveDependencies: filterParamsKey triggers re-fetch on soft-nav useEffect(() => { - // Track filterParams changes for re-fetching on soft-nav - void filterParamsKey; - let cancelled = false; const fetchFilters = async () => { @@ -138,10 +118,8 @@ export function useProductListing({ }; }, [searchQuery, filterParamsKey]); - // Load products when search query or filter params change + // biome-ignore lint/correctness/useExhaustiveDependencies: filterParamsKey triggers re-fetch on soft-nav useEffect(() => { - // Track filterParams changes for re-fetching on soft-nav - void filterParamsKey; loadProducts(filtersRef.current, searchQuery); }, [searchQuery, loadProducts, filterParamsKey]); @@ -156,34 +134,49 @@ export function useProductListing({ [loadProducts], ); + const loadingMoreRef = useRef(false); + const loadMore = useCallback(async () => { - if (loadingMore || !hasMoreRef.current) return; + if (loadingMoreRef.current || !hasMoreRef.current) return; + loadingMoreRef.current = true; setLoadingMore(true); const currentLoadId = loadIdRef.current; const nextPage = pageRef.current + 1; - const response = await fetchProducts(nextPage, activeFilters, searchQuery); + const response = await fetchProducts( + nextPage, + filtersRef.current, + searchQueryRef.current, + ); if (response && loadIdRef.current === currentLoadId) { - setProducts((prev) => [...prev, ...response.data]); + setProducts((prev) => { + const existingIds = new Set(prev.map((p) => p.id)); + const newProducts = response.data.filter((p) => !existingIds.has(p.id)); + return [...prev, ...newProducts]; + }); const moreAvailable = nextPage < response.meta.pages; setHasMore(moreAvailable); hasMoreRef.current = moreAvailable; pageRef.current = nextPage; } + loadingMoreRef.current = false; setLoadingMore(false); - }, [fetchProducts, loadingMore, activeFilters, searchQuery]); + }, [fetchProducts]); - // Infinite scroll observer useEffect(() => { const currentRef = loadMoreRef.current; if (!currentRef || loading) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && hasMoreRef.current && !loadingMore) { + if ( + entries[0].isIntersecting && + hasMoreRef.current && + !loadingMoreRef.current + ) { loadMore(); } }, @@ -195,7 +188,7 @@ export function useProductListing({ return () => { observer.disconnect(); }; - }, [loadMore, loading, loadingMore]); + }, [loadMore, loading]); return { products, @@ -203,10 +196,9 @@ export function useProductListing({ loadingMore, hasMore, totalCount, + activeFilters, filtersData, filtersLoading, - showMobileFilters, - setShowMobileFilters, handleFilterChange, loadMoreRef, }; diff --git a/src/lib/utils/color-map.ts b/src/lib/utils/color-map.ts new file mode 100644 index 00000000..8959faa8 --- /dev/null +++ b/src/lib/utils/color-map.ts @@ -0,0 +1,140 @@ +const COLOR_MAP: Record = { + ecru: "#C2B280", + cream: "#FFFDD0", + ivory: "#FFFFF0", + offwhite: "#FAF9F6", + champagne: "#F7E7CE", + sand: "#C2B280", + taupe: "#483C32", + charcoal: "#36454F", + graphite: "#41424C", + anthracite: "#293133", + ash: "#B2BEB5", + slate: "#708090", + stone: "#928E85", + camel: "#C19A6B", + cognac: "#9A463D", + chocolate: "#7B3F00", + espresso: "#4E312D", + mocha: "#967969", + chestnut: "#954535", + rust: "#B7410E", + terracotta: "#E2725B", + cinnamon: "#D2691E", + copper: "#B87333", + bronze: "#CD7F32", + + burgundy: "#800020", + wine: "#722F37", + bordeaux: "#5C0120", + oxblood: "#4A0000", + cranberry: "#9B1B30", + cherry: "#DE3163", + scarlet: "#FF2400", + cardinal: "#C41E3A", + ruby: "#E0115F", + raspberry: "#E30B5C", + strawberry: "#FC5A8D", + blush: "#DE5D83", + rose: "#FF007F", + dustyrose: "#DCAE96", + mauve: "#E0B0FF", + fuchsia: "#FF00FF", + magenta: "#FF0055", + bubblegum: "#FFC1CC", + flamingo: "#FC8EAC", + + navy: "#000080", + cobalt: "#0047AB", + royal: "#4169E1", + sapphire: "#0F52BA", + cerulean: "#007BA7", + denim: "#1560BD", + sky: "#87CEEB", + baby: "#89CFF0", + babyblue: "#89CFF0", + powder: "#B0E0E6", + powderblue: "#B0E0E6", + ice: "#D6ECEF", + petrol: "#005F6A", + steel: "#4682B4", + midnight: "#191970", + electric: "#7DF9FF", + indigo: "#4B0082", + + mint: "#98FF98", + sage: "#BCB88A", + olive: "#808000", + emerald: "#50C878", + forest: "#228B22", + hunter: "#355E3B", + jade: "#00A86B", + moss: "#8A9A5B", + pine: "#01796F", + pistachio: "#93C572", + lime: "#32CD32", + neon: "#39FF14", + seafoam: "#93E9BE", + eucalyptus: "#44D7A8", + army: "#4B5320", + avocado: "#568203", + + mustard: "#FFDB58", + gold: "#FFD700", + honey: "#EB9605", + amber: "#FFBF00", + marigold: "#EAA221", + saffron: "#F4C430", + peach: "#FFCBA4", + apricot: "#FBCEB1", + tangerine: "#FF9966", + pumpkin: "#FF7518", + papaya: "#FFEFD5", + mango: "#FF8243", + turmeric: "#E3A857", + butterscotch: "#E09540", + caramel: "#FFD59A", + lemon: "#FFF44F", + canary: "#FFEF00", + banana: "#FFE135", + + lila: "#C8A2C8", + lilac: "#C8A2C8", + lavender: "#E6E6FA", + plum: "#8E4585", + eggplant: "#614051", + aubergine: "#614051", + amethyst: "#9966CC", + grape: "#6F2DA8", + orchid: "#DA70D6", + mulberry: "#C54B8C", + wisteria: "#C9A0DC", + periwinkle: "#CCCCFF", + heather: "#B7C3D0", + thistle: "#D8BFD8", + violet: "#8F00FF", + + coral: "#FF7F50", + salmon: "#FA8072", + brick: "#CB4154", + pewter: "#8BA8B7", + oatmeal: "#D3C4A2", + linen: "#FAF0E6", + pearl: "#EAE0C8", + platinum: "#E5E4E2", + titanium: "#878681", + gunmetal: "#2A3439", + obsidian: "#3B3B3B", + onyx: "#353839", + jet: "#343434", +}; + +export function resolveColor(name: string): string { + const key = name.toLowerCase().replace(/\s+/g, ""); + return COLOR_MAP[key] || key; +} + +export function isColorOption(name: string): boolean { + const lower = name.toLowerCase(); + return lower === "color" || lower === "colour"; +} diff --git a/src/lib/utils/filters.ts b/src/lib/utils/filters.ts new file mode 100644 index 00000000..26f17385 --- /dev/null +++ b/src/lib/utils/filters.ts @@ -0,0 +1,44 @@ +import type { ActiveFilters } from "@/types/filters"; + +export function filtersEqual(a: ActiveFilters, b: ActiveFilters): boolean { + if (a.priceMin !== b.priceMin || a.priceMax !== b.priceMax) return false; + if (a.availability !== b.availability) return false; + if (a.sortBy !== b.sortBy) return false; + if (a.optionValues.length !== b.optionValues.length) return false; + const aVals = [...a.optionValues].sort(); + const bVals = [...b.optionValues].sort(); + for (let i = 0; i < aVals.length; i++) { + if (aVals[i] !== bVals[i]) return false; + } + return true; +} + +export function getActiveFilterCount(filters: ActiveFilters): number { + return ( + filters.optionValues.length + + (filters.priceMin !== undefined || filters.priceMax !== undefined ? 1 : 0) + + (filters.availability ? 1 : 0) + ); +} + +export const SORT_LABELS: Record = { + manual: "Manual", + best_selling: "Best Selling", + price: "Price: Low to High", + "-price": "Price: High to Low", + "-available_on": "Newest", + available_on: "Oldest", + name: "Name (A-Z)", + "-name": "Name (Z-A)", + "price asc": "Price: Low to High", + "price desc": "Price: High to Low", + "available_on desc": "Newest", + "available_on asc": "Oldest", + "name asc": "Name (A-Z)", + "name desc": "Name (Z-A)", +}; + +export const AVAILABILITY_LABELS: Record = { + in_stock: "In Stock", + out_of_stock: "Out of Stock", +}; diff --git a/src/lib/utils/price-buckets.ts b/src/lib/utils/price-buckets.ts new file mode 100644 index 00000000..648cae3f --- /dev/null +++ b/src/lib/utils/price-buckets.ts @@ -0,0 +1,71 @@ +export interface PriceBucket { + id: string; + label: string; + min?: number; + max?: number; +} + +const THRESHOLDS = [50, 100, 200]; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat("en", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + } catch { + return `${currency} ${amount}`; + } +} + +export function generatePriceBuckets( + filterMin: number, + filterMax: number, + currency: string, +): PriceBucket[] { + const buckets: PriceBucket[] = []; + + if (filterMin < THRESHOLDS[0]) { + buckets.push({ + id: `under-${THRESHOLDS[0]}`, + label: `Under ${formatCurrency(THRESHOLDS[0], currency)}`, + max: THRESHOLDS[0], + }); + } + + for (let i = 0; i < THRESHOLDS.length - 1; i++) { + if (filterMax > THRESHOLDS[i] && filterMin < THRESHOLDS[i + 1]) { + buckets.push({ + id: `${THRESHOLDS[i]}-${THRESHOLDS[i + 1]}`, + label: `${formatCurrency(THRESHOLDS[i], currency)} - ${formatCurrency(THRESHOLDS[i + 1], currency)}`, + min: THRESHOLDS[i], + max: THRESHOLDS[i + 1], + }); + } + } + + const lastThreshold = THRESHOLDS[THRESHOLDS.length - 1]; + if (filterMax > lastThreshold) { + buckets.push({ + id: `${lastThreshold}-plus`, + label: `${formatCurrency(lastThreshold, currency)}+`, + min: lastThreshold, + }); + } + + return buckets; +} + +export function findMatchingBucket( + buckets: PriceBucket[], + priceMin?: number, + priceMax?: number, +): PriceBucket | undefined { + return buckets.find( + (b) => + (b.min === undefined ? priceMin === undefined : b.min === priceMin) && + (b.max === undefined ? priceMax === undefined : b.max === priceMax), + ); +} diff --git a/src/lib/utils/product-query.ts b/src/lib/utils/product-query.ts index 8fc96370..3227404f 100644 --- a/src/lib/utils/product-query.ts +++ b/src/lib/utils/product-query.ts @@ -1,5 +1,5 @@ import type { ProductListParams } from "@spree/sdk"; -import type { ActiveFilters } from "@/components/products/ProductFilters"; +import type { ActiveFilters } from "@/types/filters"; /** * Build query params from active product filters. diff --git a/src/types/filters.ts b/src/types/filters.ts new file mode 100644 index 00000000..2ffd761d --- /dev/null +++ b/src/types/filters.ts @@ -0,0 +1,9 @@ +export type AvailabilityStatus = "in_stock" | "out_of_stock"; + +export interface ActiveFilters { + priceMin?: number; + priceMax?: number; + optionValues: string[]; + availability?: AvailabilityStatus; + sortBy?: string; +} From df6117821fc7795709464588ef875321e208db35 Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 9 Mar 2026 13:33:33 +0100 Subject: [PATCH 2/5] Fix clearFilters bug, add type safety and accessibility improvements - Fix clearFilters to reset all filter fields (price, availability) not just optionValues - Replace unsafe AvailabilityStatus casts with runtime type guard - Add focus trap and ARIA attributes to MobileFilterDrawer - Normalize SORT_LABELS to remove duplicate entries Co-Authored-By: Claude Opus 4.6 --- .../filters/AvailabilityDropdownContent.tsx | 6 +- .../products/filters/MobileFilterDrawer.tsx | 67 +++++++++++++++++-- .../products/filters/ProductFilters.tsx | 10 ++- .../products/filters/SortDropdownContent.tsx | 4 +- src/lib/utils/filters.ts | 23 +++++-- src/types/filters.ts | 11 +++ 6 files changed, 102 insertions(+), 19 deletions(-) diff --git a/src/components/products/filters/AvailabilityDropdownContent.tsx b/src/components/products/filters/AvailabilityDropdownContent.tsx index 020c3e53..f23786f8 100644 --- a/src/components/products/filters/AvailabilityDropdownContent.tsx +++ b/src/components/products/filters/AvailabilityDropdownContent.tsx @@ -1,6 +1,6 @@ import type { AvailabilityFilter } from "@spree/sdk"; import { AVAILABILITY_LABELS } from "@/lib/utils/filters"; -import type { AvailabilityStatus } from "@/types/filters"; +import { type AvailabilityStatus, isAvailabilityStatus } from "@/types/filters"; interface AvailabilityDropdownContentProps { filter: AvailabilityFilter; @@ -25,8 +25,8 @@ export function AvailabilityDropdownContent({ onClick={() => { if (isSelected) { onChange(undefined); - } else { - onChange(option.id as AvailabilityStatus); + } else if (isAvailabilityStatus(option.id)) { + onChange(option.id); } }} className={`w-full flex items-center justify-between px-2 py-1.5 text-sm rounded-lg transition-colors ${ diff --git a/src/components/products/filters/MobileFilterDrawer.tsx b/src/components/products/filters/MobileFilterDrawer.tsx index 452bffe1..4c999eb0 100644 --- a/src/components/products/filters/MobileFilterDrawer.tsx +++ b/src/components/products/filters/MobileFilterDrawer.tsx @@ -5,12 +5,20 @@ import type { OptionFilter, ProductFiltersResponse, } from "@spree/sdk"; +import { useCallback, useEffect, useRef } from "react"; import { CheckIcon, CloseIcon } from "@/components/icons"; import { isColorOption, resolveColor } from "@/lib/utils/color-map"; import { AVAILABILITY_LABELS, getActiveFilterCount } from "@/lib/utils/filters"; import type { PriceBucket } from "@/lib/utils/price-buckets"; import { findMatchingBucket } from "@/lib/utils/price-buckets"; -import type { ActiveFilters, AvailabilityStatus } from "@/types/filters"; +import { + type ActiveFilters, + type AvailabilityStatus, + isAvailabilityStatus, +} from "@/types/filters"; + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; interface MobileFilterDrawerProps { isOpen: boolean; @@ -35,19 +43,68 @@ export function MobileFilterDrawer({ onAvailabilityChange, onClearAll, }: MobileFilterDrawerProps) { + const drawerRef = useRef(null); + const closeButtonRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (isOpen) { + triggerRef.current = document.activeElement; + closeButtonRef.current?.focus(); + } else if (triggerRef.current instanceof HTMLElement) { + triggerRef.current.focus(); + triggerRef.current = null; + } + }, [isOpen]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab" || !drawerRef.current) return; + + const focusable = drawerRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusable.length === 0) return; + + const first = focusable[0] as HTMLElement; + const last = focusable[focusable.length - 1] as HTMLElement; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, + [onClose], + ); + if (!isOpen) return null; const activeCount = getActiveFilterCount(activeFilters); return ( -
+
-
+
); diff --git a/src/lib/utils/filters.ts b/src/lib/utils/filters.ts index 26f17385..f9e5cfa4 100644 --- a/src/lib/utils/filters.ts +++ b/src/lib/utils/filters.ts @@ -21,7 +21,7 @@ export function getActiveFilterCount(filters: ActiveFilters): number { ); } -export const SORT_LABELS: Record = { +const SORT_LABELS_CANONICAL: Record = { manual: "Manual", best_selling: "Best Selling", price: "Price: Low to High", @@ -30,14 +30,23 @@ export const SORT_LABELS: Record = { available_on: "Oldest", name: "Name (A-Z)", "-name": "Name (Z-A)", - "price asc": "Price: Low to High", - "price desc": "Price: High to Low", - "available_on desc": "Newest", - "available_on asc": "Oldest", - "name asc": "Name (A-Z)", - "name desc": "Name (Z-A)", }; +export function normalizeSortKey(key: string): string { + if (key in SORT_LABELS_CANONICAL) return key; + const match = key.match(/^(\w+)\s+(asc|desc)$/); + if (!match) return key; + const [, field, direction] = match; + const needsNegation = + (field === "available_on" && direction === "desc") || + (field !== "available_on" && direction === "desc"); + return needsNegation ? `-${field}` : field; +} + +export function getSortLabel(key: string): string { + return SORT_LABELS_CANONICAL[normalizeSortKey(key)] || key; +} + export const AVAILABILITY_LABELS: Record = { in_stock: "In Stock", out_of_stock: "Out of Stock", diff --git a/src/types/filters.ts b/src/types/filters.ts index 2ffd761d..4e46e6df 100644 --- a/src/types/filters.ts +++ b/src/types/filters.ts @@ -1,5 +1,16 @@ export type AvailabilityStatus = "in_stock" | "out_of_stock"; +export const AVAILABILITY_STATUSES: ReadonlySet = new Set([ + "in_stock", + "out_of_stock", +]); + +export function isAvailabilityStatus( + value: string, +): value is AvailabilityStatus { + return AVAILABILITY_STATUSES.has(value); +} + export interface ActiveFilters { priceMin?: number; priceMax?: number; From 10bcd5325935318275bc339a2f13f4a9494cc1cd Mon Sep 17 00:00:00 2001 From: Filip Cichorek Date: Mon, 9 Mar 2026 14:02:55 +0100 Subject: [PATCH 3/5] Add aria-pressed accessibility and staged filters for mobile drawer - Add type="button" and aria-pressed to filter buttons in AvailabilityDropdownContent, SortDropdownContent, and all MobileFilterDrawer button groups (colors, sizes, prices, availability) - Refactor MobileFilterDrawer to use staged/draft filter state so selections are only applied on "Show results" and discarded on close - Replace relative imports with @/ alias in ProductFilters Co-Authored-By: Claude Opus 4.6 --- .../filters/AvailabilityDropdownContent.tsx | 2 + .../products/filters/MobileFilterDrawer.tsx | 80 ++++++++++++++----- .../products/filters/ProductFilters.tsx | 19 ++--- .../products/filters/SortDropdownContent.tsx | 2 + 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/components/products/filters/AvailabilityDropdownContent.tsx b/src/components/products/filters/AvailabilityDropdownContent.tsx index f23786f8..0eefad31 100644 --- a/src/components/products/filters/AvailabilityDropdownContent.tsx +++ b/src/components/products/filters/AvailabilityDropdownContent.tsx @@ -22,6 +22,8 @@ export function AvailabilityDropdownContent({ return (
  • )}
  • ); diff --git a/src/components/products/filters/SortDropdownContent.tsx b/src/components/products/filters/SortDropdownContent.tsx index 5c51d65c..dbbb945a 100644 --- a/src/components/products/filters/SortDropdownContent.tsx +++ b/src/components/products/filters/SortDropdownContent.tsx @@ -18,6 +18,8 @@ export function SortDropdownContent({ return (