Skip to content

Commit 709f380

Browse files
committed
Add collection and search filtering & sorting to skeleton template
New features: - Collection filtering with list, swatch, and price range filters - Collection sorting (Featured, Price, Best Selling, Alphabetical, Date) - Search filtering and sorting (Relevance, Price) - CollectionFilters, CollectionSort, PriceRangeFilter components - productFilters, productSort utility libraries Bug fix: - Article search result URLs now correctly include blog handle GraphQL changes: - COLLECTION_QUERY: Added filters, sortKey, reverse variables; returns filter metadata - SEARCH_QUERY: Added productFilters, sortKey, reverse variables; returns productFilters
1 parent 9efc4b6 commit 709f380

File tree

12 files changed

+960
-9
lines changed

12 files changed

+960
-9
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"skeleton": patch
3+
"@shopify/cli-hydrogen": patch
4+
"@shopify/create-hydrogen": patch
5+
---
6+
7+
Add collection and search filtering & sorting to skeleton template
8+
9+
- **Collection filtering**: Collections now support product filters (list, swatch, and price range) via URL parameters using the Storefront API's `ProductFilter` input. Filters are rendered with a new `CollectionFilters` component and managed through `productFilters` utility functions.
10+
- **Collection sorting**: Collections now support sorting via a `CollectionSort` dropdown component. Sort options include Featured, Price, Best Selling, Alphabetical, and Date.
11+
- **Search filtering & sorting**: The search page now returns and renders product filters and supports sort options (Relevance, Price).
12+
- **Bug fix**: Fixed article search result URLs from `/blogs/{articleHandle}` to `/blogs/{blogHandle}/{articleHandle}`.
13+
- **New files**: `CollectionFilters`, `CollectionSort`, `PriceRangeFilter` components and `productFilters`, `productSort` utility libraries.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {useNavigate, useLocation} from 'react-router';
2+
import {applyFilter, removeFilter} from '~/lib/productFilters';
3+
import type {ProductFilter} from '@shopify/hydrogen/storefront-api-types';
4+
import {PriceRangeFilter} from './PriceRangeFilter';
5+
import type {CollectionQuery} from 'storefrontapi.generated';
6+
7+
type CollectionFilter = NonNullable<
8+
CollectionQuery['collection']
9+
>['products']['filters'][number];
10+
11+
export function CollectionFilters({filters}: {filters: CollectionFilter[]}) {
12+
const navigate = useNavigate();
13+
const location = useLocation();
14+
15+
if (!filters || filters.length === 0) return null;
16+
17+
const toggleFilter = (filterInput: string) => {
18+
const searchParams = new URLSearchParams(location.search);
19+
20+
try {
21+
const filter = JSON.parse(filterInput) as ProductFilter;
22+
const [[filterKey, filterValue]] = Object.entries(filter);
23+
const paramKey = `filter.${filterKey}`;
24+
const paramValue = JSON.stringify(filterValue);
25+
26+
if (searchParams.getAll(paramKey).includes(paramValue)) {
27+
void navigate(`?${removeFilter(filter, searchParams).toString()}`, {
28+
replace: true,
29+
preventScrollReset: true,
30+
});
31+
} else {
32+
void navigate(`?${applyFilter(filter, searchParams).toString()}`, {
33+
replace: true,
34+
preventScrollReset: true,
35+
});
36+
}
37+
} catch (error) {
38+
console.error('Failed to toggle filter:', error);
39+
}
40+
};
41+
42+
const isFilterApplied = (filterInput: string): boolean => {
43+
const searchParams = new URLSearchParams(location.search);
44+
45+
try {
46+
const filter = JSON.parse(filterInput) as ProductFilter;
47+
const [[filterKey, filterValue]] = Object.entries(filter);
48+
return searchParams
49+
.getAll(`filter.${filterKey}`)
50+
.includes(JSON.stringify(filterValue));
51+
} catch {
52+
return false;
53+
}
54+
};
55+
56+
const clearAllFilters = () => {
57+
const searchParams = new URLSearchParams(location.search);
58+
59+
for (const key of [...searchParams.keys()]) {
60+
if (key.startsWith('filter.')) searchParams.delete(key);
61+
}
62+
63+
searchParams.delete('cursor');
64+
searchParams.delete('direction');
65+
66+
void navigate(`?${searchParams.toString()}`, {
67+
replace: true,
68+
preventScrollReset: true,
69+
});
70+
};
71+
72+
const hasActiveFilters = [
73+
...new URLSearchParams(location.search).keys(),
74+
].some((k) => k.startsWith('filter.'));
75+
76+
return (
77+
<div className="collection-filters">
78+
{hasActiveFilters && (
79+
<button
80+
type="button"
81+
className="collection-filters-clear"
82+
onClick={clearAllFilters}
83+
>
84+
Clear All Filters
85+
</button>
86+
)}
87+
{filters.map((filter) => {
88+
if (filter.type === 'PRICE_RANGE') {
89+
let maxPrice: number | undefined;
90+
for (const value of filter.values) {
91+
try {
92+
const parsed = JSON.parse(String(value.input)) as {
93+
price?: {max?: number};
94+
};
95+
if (parsed.price?.max !== undefined) maxPrice = parsed.price.max;
96+
} catch {
97+
/* ignore */
98+
}
99+
}
100+
101+
return (
102+
<div key={filter.id} className="collection-filter-group">
103+
<h3>{filter.label}</h3>
104+
<PriceRangeFilter maxPrice={maxPrice} />
105+
</div>
106+
);
107+
}
108+
109+
if (filter.type !== 'LIST') return null;
110+
111+
const hasSwatches = filter.values.some(
112+
(v) => v.swatch?.color || v.swatch?.image,
113+
);
114+
115+
return (
116+
<div key={filter.id} className="collection-filter-group">
117+
<h3>{filter.label}</h3>
118+
<div className="collection-filter-options">
119+
{filter.values.map((value) => {
120+
const inputString = String(value.input);
121+
const isApplied = isFilterApplied(inputString);
122+
const swatch = value.swatch;
123+
124+
return (
125+
<button
126+
key={value.id}
127+
type="button"
128+
className={`collection-filter-option${hasSwatches ? ' has-swatch' : ''}`}
129+
onClick={() => toggleFilter(inputString)}
130+
style={{
131+
border: isApplied ? '2px solid black' : '1px solid #ccc',
132+
}}
133+
title={`${value.label} (${value.count})`}
134+
aria-label={`${value.label}, ${value.count} products`}
135+
aria-pressed={isApplied}
136+
>
137+
{hasSwatches && swatch ? (
138+
<>
139+
{swatch.image?.previewImage?.url ? (
140+
<img
141+
src={swatch.image.previewImage.url}
142+
alt={value.label}
143+
className="swatch-image"
144+
/>
145+
) : swatch.color ? (
146+
<div
147+
aria-label={value.label}
148+
className="swatch-color"
149+
style={{backgroundColor: swatch.color}}
150+
/>
151+
) : (
152+
<span>{value.label}</span>
153+
)}
154+
</>
155+
) : (
156+
<>
157+
{value.label} ({value.count})
158+
</>
159+
)}
160+
</button>
161+
);
162+
})}
163+
</div>
164+
</div>
165+
);
166+
})}
167+
</div>
168+
);
169+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {useNavigate, useLocation} from 'react-router';
2+
import {applySortParam, type SortOption} from '~/lib/productSort';
3+
4+
export function CollectionSort({
5+
sortOptions,
6+
}: {
7+
sortOptions: Record<string, SortOption>;
8+
}) {
9+
const navigate = useNavigate();
10+
const location = useLocation();
11+
12+
const searchParams = new URLSearchParams(location.search);
13+
const sortParam = searchParams.get('sort_by');
14+
const currentSort =
15+
sortParam && sortParam in sortOptions
16+
? sortParam
17+
: Object.keys(sortOptions)[0];
18+
19+
const handleSortChange = (sortKey: string) => {
20+
void navigate(`?${applySortParam(sortKey, searchParams).toString()}`, {
21+
replace: true,
22+
preventScrollReset: true,
23+
});
24+
};
25+
26+
return (
27+
<div className="collection-sort">
28+
<label htmlFor="sort-select">Sort by:</label>
29+
<select
30+
id="sort-select"
31+
value={currentSort}
32+
onChange={(e) => handleSortChange(e.target.value)}
33+
aria-label="Sort products"
34+
>
35+
{Object.entries(sortOptions).map(([key, option]) => (
36+
<option key={key} value={key}>
37+
{option.label}
38+
</option>
39+
))}
40+
</select>
41+
</div>
42+
);
43+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {useState, useEffect} from 'react';
2+
import {useNavigate, useLocation} from 'react-router';
3+
import {applyFilter, removeFilter} from '~/lib/productFilters';
4+
import type {ProductFilter} from '@shopify/hydrogen/storefront-api-types';
5+
6+
/**
7+
* A simple price-range filter with min/max inputs and an explicit Apply button.
8+
* The component syncs its local state from the URL so that external navigation
9+
* (e.g. "Clear All Filters") is reflected immediately.
10+
*/
11+
export function PriceRangeFilter({maxPrice}: {maxPrice?: number}) {
12+
const navigate = useNavigate();
13+
const location = useLocation();
14+
15+
const [min, setMin] = useState('');
16+
const [max, setMax] = useState('');
17+
18+
// Keep local state in sync with URL params.
19+
useEffect(() => {
20+
const searchParams = new URLSearchParams(location.search);
21+
const priceParam = searchParams.get('filter.price');
22+
23+
if (priceParam) {
24+
try {
25+
const parsed = JSON.parse(priceParam) as {min?: number; max?: number};
26+
setMin(parsed.min?.toString() ?? '');
27+
setMax(parsed.max?.toString() ?? '');
28+
} catch {
29+
setMin('');
30+
setMax('');
31+
}
32+
} else {
33+
setMin('');
34+
setMax('');
35+
}
36+
}, [location.search]);
37+
38+
const handleApply = () => {
39+
let searchParams = new URLSearchParams(location.search);
40+
41+
// Remove existing price filter first.
42+
const existing = searchParams.get('filter.price');
43+
if (existing) {
44+
try {
45+
searchParams = removeFilter(
46+
{price: JSON.parse(existing)} as ProductFilter,
47+
searchParams,
48+
);
49+
} catch {
50+
/* ignore */
51+
}
52+
}
53+
54+
const minNum = min ? parseFloat(min) : undefined;
55+
const maxNum = max ? parseFloat(max) : undefined;
56+
57+
if (minNum !== undefined || maxNum !== undefined) {
58+
const priceFilter: ProductFilter = {
59+
price: {
60+
...(minNum !== undefined && {min: minNum}),
61+
...(maxNum !== undefined && {max: maxNum}),
62+
},
63+
};
64+
searchParams = applyFilter(priceFilter, searchParams);
65+
}
66+
67+
void navigate(`?${searchParams.toString()}`, {
68+
replace: true,
69+
preventScrollReset: true,
70+
});
71+
};
72+
73+
const handleClear = () => {
74+
setMin('');
75+
setMax('');
76+
77+
const searchParams = new URLSearchParams(location.search);
78+
const existing = searchParams.get('filter.price');
79+
80+
if (existing) {
81+
try {
82+
const newParams = removeFilter(
83+
{price: JSON.parse(existing)} as ProductFilter,
84+
searchParams,
85+
);
86+
void navigate(`?${newParams.toString()}`, {
87+
replace: true,
88+
preventScrollReset: true,
89+
});
90+
} catch {
91+
/* ignore */
92+
}
93+
}
94+
};
95+
96+
const hasValue = min || max;
97+
98+
return (
99+
<div className="price-range-filter" data-max={maxPrice}>
100+
<div className="price-inputs">
101+
<input
102+
type="number"
103+
placeholder="Min"
104+
value={min}
105+
onChange={(e) => setMin(e.target.value)}
106+
min="0"
107+
step="0.01"
108+
aria-label="Minimum price"
109+
/>
110+
<span className="price-separator">to</span>
111+
<input
112+
type="number"
113+
placeholder="Max"
114+
value={max}
115+
onChange={(e) => setMax(e.target.value)}
116+
min="0"
117+
step="0.01"
118+
aria-label="Maximum price"
119+
/>
120+
</div>
121+
<div className="price-actions">
122+
<button
123+
type="button"
124+
onClick={handleApply}
125+
aria-label="Apply price filter"
126+
>
127+
Apply
128+
</button>
129+
{hasValue && (
130+
<button
131+
type="button"
132+
onClick={handleClear}
133+
aria-label="Clear price filter"
134+
>
135+
Clear
136+
</button>
137+
)}
138+
</div>
139+
</div>
140+
);
141+
}

templates/skeleton/app/components/SearchResults.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function SearchResultsArticles({
4444
<div>
4545
{articles?.nodes?.map((article) => {
4646
const articleUrl = urlWithTrackingParams({
47-
baseUrl: `/blogs/${article.handle}`,
47+
baseUrl: `/blogs/${article.blog.handle}/${article.handle}`,
4848
trackingParams: article.trackingParameters,
4949
term,
5050
});

0 commit comments

Comments
 (0)