mirror of
https://github.com/vercel/commerce.git
synced 2025-07-23 04:36:49 +00:00
feat: handle price filters
Signed-off-by: Chloe <pinkcloudvnn@gmail.com>
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { Filter } from 'lib/shopify/types';
|
||||
import { Filter, FilterType } from 'lib/shopify/types';
|
||||
import { createUrl } from 'lib/utils';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import PriceRange from './price-range';
|
||||
import SelectedList from './selected-list';
|
||||
|
||||
const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOpen?: boolean }) => {
|
||||
@@ -35,7 +36,7 @@ const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOp
|
||||
<>
|
||||
<SelectedList filters={filters} />
|
||||
<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
|
||||
key={id}
|
||||
as="div"
|
||||
@@ -47,27 +48,31 @@ const Filters = ({ filters, defaultOpen = true }: { filters: Filter[]; defaultOp
|
||||
<ChevronDownIcon className="size-4 group-data-[open]:rotate-180" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel className="flex-grow space-y-3 overflow-auto pb-1 pl-1 pt-2">
|
||||
{values.map(({ id: valueId, label, count, value }) => (
|
||||
<label
|
||||
key={valueId}
|
||||
htmlFor={valueId}
|
||||
className={clsx('flex items-center gap-2 text-sm text-gray-600', {
|
||||
'cursor-not-allowed opacity-50': count === 0
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id={valueId}
|
||||
name={id}
|
||||
checked={searchParams.getAll(id).includes(String(value))}
|
||||
type="checkbox"
|
||||
value={String(value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-secondary focus:ring-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={count === 0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<span>{`${label} (${count})`}</span>
|
||||
</label>
|
||||
))}
|
||||
{type === FilterType.PRICE_RANGE ? (
|
||||
<PriceRange id={id} values={values} />
|
||||
) : (
|
||||
values.map(({ id: valueId, label, count, value }) => (
|
||||
<label
|
||||
key={valueId}
|
||||
htmlFor={valueId}
|
||||
className={clsx('flex items-center gap-2 text-sm text-gray-600', {
|
||||
'cursor-not-allowed opacity-50': count === 0
|
||||
})}
|
||||
>
|
||||
<input
|
||||
id={valueId}
|
||||
name={id}
|
||||
checked={searchParams.getAll(id).includes(String(value))}
|
||||
type="checkbox"
|
||||
value={String(value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-secondary focus:ring-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={count === 0}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<span>{`${label} (${count})`}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</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;
|
Reference in New Issue
Block a user