mirror of
https://github.com/vercel/commerce.git
synced 2025-05-18 15:36:58 +00:00
feat: handle price filters
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
parent
723afd37a9
commit
a41b3e565f
@ -43,7 +43,7 @@ const constructFilterInput = (filters: {
|
|||||||
}): Array<object> => {
|
}): Array<object> => {
|
||||||
const results = [] as Array<object>;
|
const results = [] as Array<object>;
|
||||||
Object.entries(filters)
|
Object.entries(filters)
|
||||||
.filter(([key]) => key !== PRICE_FILTER_ID)
|
.filter(([key]) => !key.startsWith(PRICE_FILTER_ID))
|
||||||
.forEach(([key, value]) => {
|
.forEach(([key, value]) => {
|
||||||
const [namespace, metafieldKey] = key.split('.').slice(-2);
|
const [namespace, metafieldKey] = key.split('.').slice(-2);
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
@ -75,6 +75,18 @@ const constructFilterInput = (filters: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const price = {} as { min?: number; max?: number };
|
||||||
|
|
||||||
|
if (filters[`${PRICE_FILTER_ID}.min`]) {
|
||||||
|
price.min = Number(filters[`${PRICE_FILTER_ID}.min`]);
|
||||||
|
}
|
||||||
|
if (filters[`${PRICE_FILTER_ID}.max`]) {
|
||||||
|
price.max = Number(filters[`${PRICE_FILTER_ID}.max`]);
|
||||||
|
}
|
||||||
|
if (price.max && !price.min) {
|
||||||
|
price.min = 0;
|
||||||
|
}
|
||||||
|
results.push({ price });
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -89,7 +101,6 @@ export default async function CategoryPage({
|
|||||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||||
|
|
||||||
const filtersInput = constructFilterInput(rest);
|
const filtersInput = constructFilterInput(rest);
|
||||||
|
|
||||||
const productsData = getCollectionProducts({
|
const productsData = getCollectionProducts({
|
||||||
collection: params.collection,
|
collection: params.collection,
|
||||||
sortKey,
|
sortKey,
|
||||||
@ -123,22 +134,22 @@ export default async function CategoryPage({
|
|||||||
<SortingMenu />
|
<SortingMenu />
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
{products.length === 0 ? (
|
<Grid className="pt-5 lg:grid-cols-3 lg:gap-x-8 xl:grid-cols-4">
|
||||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
<aside className="hidden lg:block">
|
||||||
) : (
|
<SubMenu menu={menu} collection={params.collection} />
|
||||||
<Grid className="pt-5 lg:grid-cols-3 lg:gap-x-8 xl:grid-cols-4">
|
<h3 className="sr-only">Filters</h3>
|
||||||
<aside className="hidden lg:block">
|
<FiltersList filters={filters} />
|
||||||
<SubMenu menu={menu} collection={params.collection} />
|
</aside>
|
||||||
<h3 className="sr-only">Filters</h3>
|
<div className="lg:col-span-2 xl:col-span-3">
|
||||||
<FiltersList filters={filters} />
|
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
</aside>
|
{products.length === 0 ? (
|
||||||
<div className="lg:col-span-2 xl:col-span-3">
|
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
) : (
|
||||||
<ProductGridItems products={products} />
|
<ProductGridItems products={products} />
|
||||||
</Grid>
|
)}
|
||||||
</div>
|
</Grid>
|
||||||
</Grid>
|
</div>
|
||||||
)}
|
</Grid>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Filter } from 'lib/shopify/types';
|
import { Filter, FilterType } from 'lib/shopify/types';
|
||||||
import { createUrl } from 'lib/utils';
|
import { createUrl } from 'lib/utils';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import PriceRange from './price-range';
|
||||||
import SelectedList from './selected-list';
|
import SelectedList from './selected-list';
|
||||||
|
|
||||||
const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOpen?: boolean }) => {
|
const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOpen?: boolean }) => {
|
||||||
@ -35,7 +36,7 @@ const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOp
|
|||||||
<>
|
<>
|
||||||
<SelectedList filters={filters} />
|
<SelectedList filters={filters} />
|
||||||
<form onChange={handleChange} className="space-y-5 divide-y divide-gray-200 border-b pb-3">
|
<form onChange={handleChange} className="space-y-5 divide-y divide-gray-200 border-b pb-3">
|
||||||
{filters.map(({ label, id, values }) => (
|
{filters.map(({ label, id, values, type }) => (
|
||||||
<Disclosure
|
<Disclosure
|
||||||
key={id}
|
key={id}
|
||||||
as="div"
|
as="div"
|
||||||
@ -47,27 +48,31 @@ const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOp
|
|||||||
<ChevronDownIcon className="size-4 group-data-[open]:rotate-180" />
|
<ChevronDownIcon className="size-4 group-data-[open]:rotate-180" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
|
<DisclosurePanel className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
|
||||||
{values.map(({ id: valueId, label, count, value }) => (
|
{type === FilterType.PRICE_RANGE ? (
|
||||||
<label
|
<PriceRange id={id} values={values} />
|
||||||
key={valueId}
|
) : (
|
||||||
htmlFor={valueId}
|
values.map(({ id: valueId, label, count, value }) => (
|
||||||
className={clsx('flex items-center gap-2 text-sm text-gray-600', {
|
<label
|
||||||
'cursor-not-allowed opacity-50': count === 0
|
key={valueId}
|
||||||
})}
|
htmlFor={valueId}
|
||||||
>
|
className={clsx('flex items-center gap-2 text-sm text-gray-600', {
|
||||||
<input
|
'cursor-not-allowed opacity-50': count === 0
|
||||||
id={valueId}
|
})}
|
||||||
name={id}
|
>
|
||||||
checked={searchParams.getAll(id).includes(String(value))}
|
<input
|
||||||
type="checkbox"
|
id={valueId}
|
||||||
value={String(value)}
|
name={id}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-secondary focus:ring-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
checked={searchParams.getAll(id).includes(String(value))}
|
||||||
disabled={count === 0}
|
type="checkbox"
|
||||||
onChange={() => {}}
|
value={String(value)}
|
||||||
/>
|
className="h-4 w-4 rounded border-gray-300 text-secondary focus:ring-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
<span>{`${label} (${count})`}</span>
|
disabled={count === 0}
|
||||||
</label>
|
onChange={() => {}}
|
||||||
))}
|
/>
|
||||||
|
<span>{`${label} (${count})`}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
))}
|
))}
|
||||||
|
150
components/layout/search/filters/price-range.tsx
Normal file
150
components/layout/search/filters/price-range.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Price from 'components/price';
|
||||||
|
import { Filter } from 'lib/shopify/types';
|
||||||
|
import { createUrl } from 'lib/utils';
|
||||||
|
import get from 'lodash.get';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const currencySymbol =
|
||||||
|
new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
currencyDisplay: 'narrowSymbol'
|
||||||
|
})
|
||||||
|
.formatToParts(1)
|
||||||
|
.find((part) => part.type === 'currency')?.value || '$';
|
||||||
|
|
||||||
|
const useDebounce = (value: string, delay = 500) => {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState('');
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timerRef.current = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PriceRange = ({ id, values }: { id: string; values: Filter['values'] }) => {
|
||||||
|
const highestPrice = values.reduce(
|
||||||
|
(acc, { value }) => Math.max(acc, get(value, 'price.max', 0)),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const priceMin = searchParams.get(`${id}.min`);
|
||||||
|
const priceMax = searchParams.get(`${id}.max`);
|
||||||
|
|
||||||
|
const [min, setMin] = useState(priceMin || '');
|
||||||
|
const [max, setMax] = useState(priceMax || '');
|
||||||
|
|
||||||
|
const debouncedMin = useDebounce(min);
|
||||||
|
const debouncedMax = useDebounce(max);
|
||||||
|
|
||||||
|
const minRef = useRef(min);
|
||||||
|
const maxRef = useRef(max);
|
||||||
|
|
||||||
|
const updateSearchParams = useCallback(
|
||||||
|
(priceRange: { min: string; max: string }) => {
|
||||||
|
const newSearchParams = new URLSearchParams(searchParams);
|
||||||
|
newSearchParams.set(`${id}.min`, priceRange.min);
|
||||||
|
newSearchParams.set(`${id}.max`, priceRange.max);
|
||||||
|
router.replace(createUrl(pathname, newSearchParams), { scroll: false });
|
||||||
|
},
|
||||||
|
[id, pathname, router, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeMinPrice = (value: string) => {
|
||||||
|
setMin(value);
|
||||||
|
minRef.current = value;
|
||||||
|
};
|
||||||
|
const handleChangeMaxPrice = (value: string) => {
|
||||||
|
setMax(value);
|
||||||
|
maxRef.current = value;
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedMin) {
|
||||||
|
let _minPrice = debouncedMin;
|
||||||
|
const minNum = Number(_minPrice);
|
||||||
|
if (minNum < 0) {
|
||||||
|
_minPrice = '0';
|
||||||
|
}
|
||||||
|
if (maxRef.current && minNum > Number(maxRef.current)) {
|
||||||
|
_minPrice = maxRef.current;
|
||||||
|
}
|
||||||
|
if (minNum > highestPrice) {
|
||||||
|
_minPrice = String(highestPrice);
|
||||||
|
}
|
||||||
|
if (_minPrice !== debouncedMin) {
|
||||||
|
handleChangeMinPrice(_minPrice);
|
||||||
|
}
|
||||||
|
updateSearchParams({ min: _minPrice, max: maxRef.current });
|
||||||
|
} else {
|
||||||
|
updateSearchParams({ min: '', max: maxRef.current });
|
||||||
|
}
|
||||||
|
}, [debouncedMin, highestPrice, updateSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedMax) {
|
||||||
|
let _maxPrice = debouncedMax;
|
||||||
|
const maxNum = Number(_maxPrice);
|
||||||
|
if (minRef.current && maxNum < Number(minRef.current)) {
|
||||||
|
_maxPrice = minRef.current;
|
||||||
|
}
|
||||||
|
if (maxNum > highestPrice) {
|
||||||
|
_maxPrice = String(highestPrice);
|
||||||
|
}
|
||||||
|
if (_maxPrice !== debouncedMax) {
|
||||||
|
handleChangeMaxPrice(_maxPrice);
|
||||||
|
}
|
||||||
|
updateSearchParams({ min: minRef.current, max: _maxPrice });
|
||||||
|
} else {
|
||||||
|
updateSearchParams({ min: minRef.current, max: '' });
|
||||||
|
}
|
||||||
|
}, [debouncedMax, highestPrice, updateSearchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
The highest price is <Price amount={String(highestPrice)} currencyCode="USD" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex max-w-full items-center gap-3">
|
||||||
|
<div className="flex items-center rounded border bg-white pl-3 has-[:focus]:ring-1 has-[:focus]:ring-secondary">
|
||||||
|
<p className="text-sm text-gray-500">{currencySymbol}</p>
|
||||||
|
<input
|
||||||
|
className="w-28 rounded border-none text-sm focus:ring-0 focus:ring-offset-0"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={highestPrice}
|
||||||
|
placeholder="From"
|
||||||
|
value={min}
|
||||||
|
onChange={(e) => handleChangeMinPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded border bg-white pl-3 has-[:focus]:ring-1 has-[:focus]:ring-secondary">
|
||||||
|
<p className="text-sm text-gray-500">{currencySymbol}</p>
|
||||||
|
<input
|
||||||
|
className="w-28 rounded border-none text-sm focus:ring-0 focus:ring-offset-0"
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={highestPrice}
|
||||||
|
placeholder="To"
|
||||||
|
value={max}
|
||||||
|
onChange={(e) => handleChangeMaxPrice(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriceRange;
|
@ -38,6 +38,7 @@
|
|||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.11",
|
"@tailwindcss/typography": "^0.5.11",
|
||||||
|
"@types/lodash.get": "^4.4.9",
|
||||||
"@types/node": "20.11.30",
|
"@types/node": "20.11.30",
|
||||||
"@types/react": "18.2.72",
|
"@types/react": "18.2.72",
|
||||||
"@types/react-dom": "18.2.22",
|
"@types/react-dom": "18.2.22",
|
||||||
|
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -49,6 +49,9 @@ devDependencies:
|
|||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.11
|
specifier: ^0.5.11
|
||||||
version: 0.5.11(tailwindcss@3.4.1)
|
version: 0.5.11(tailwindcss@3.4.1)
|
||||||
|
'@types/lodash.get':
|
||||||
|
specifier: ^4.4.9
|
||||||
|
version: 4.4.9
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 20.11.30
|
specifier: 20.11.30
|
||||||
version: 20.11.30
|
version: 20.11.30
|
||||||
@ -733,6 +736,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/lodash.get@4.4.9:
|
||||||
|
resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==}
|
||||||
|
dependencies:
|
||||||
|
'@types/lodash': 4.17.1
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/lodash@4.17.1:
|
||||||
|
resolution: {integrity: sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/node@20.11.30:
|
/@types/node@20.11.30:
|
||||||
resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==}
|
resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user