From a41b3e565fee95411a1c9e8933bb45ad737fe878 Mon Sep 17 00:00:00 2001 From: Chloe Date: Tue, 7 May 2024 22:59:30 +0700 Subject: [PATCH] feat: handle price filters Signed-off-by: Chloe --- app/search/[collection]/page.tsx | 45 ++++-- .../layout/search/filters/filters-list.tsx | 51 +++--- .../layout/search/filters/price-range.tsx | 150 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 13 ++ 5 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 components/layout/search/filters/price-range.tsx diff --git a/app/search/[collection]/page.tsx b/app/search/[collection]/page.tsx index ea52c14ab..3494d6e5b 100644 --- a/app/search/[collection]/page.tsx +++ b/app/search/[collection]/page.tsx @@ -43,7 +43,7 @@ const constructFilterInput = (filters: { }): Array => { const results = [] as Array; Object.entries(filters) - .filter(([key]) => key !== PRICE_FILTER_ID) + .filter(([key]) => !key.startsWith(PRICE_FILTER_ID)) .forEach(([key, value]) => { const [namespace, metafieldKey] = key.split('.').slice(-2); 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; }; @@ -89,7 +101,6 @@ export default async function CategoryPage({ const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort; const filtersInput = constructFilterInput(rest); - const productsData = getCollectionProducts({ collection: params.collection, sortKey, @@ -123,22 +134,22 @@ export default async function CategoryPage({
- {products.length === 0 ? ( -

{`No products found in this collection`}

- ) : ( - - -
- + + +
+ + {products.length === 0 ? ( +

{`No products found in this collection`}

+ ) : ( -
-
-
- )} + )} +
+
+
); diff --git a/components/layout/search/filters/filters-list.tsx b/components/layout/search/filters/filters-list.tsx index 1cdb72ac0..3f8653009 100644 --- a/components/layout/search/filters/filters-list.tsx +++ b/components/layout/search/filters/filters-list.tsx @@ -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 <>
- {filters.map(({ label, id, values }) => ( + {filters.map(({ label, id, values, type }) => ( - {values.map(({ id: valueId, label, count, value }) => ( - - ))} + {type === FilterType.PRICE_RANGE ? ( + + ) : ( + values.map(({ id: valueId, label, count, value }) => ( + + )) + )} ))} diff --git a/components/layout/search/filters/price-range.tsx b/components/layout/search/filters/price-range.tsx new file mode 100644 index 000000000..59a494b64 --- /dev/null +++ b/components/layout/search/filters/price-range.tsx @@ -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>(); + + 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 ( +
+
+ The highest price is +
+
+
+

{currencySymbol}

+ handleChangeMinPrice(e.target.value)} + /> +
+
+

{currencySymbol}

+ handleChangeMaxPrice(e.target.value)} + /> +
+
+
+ ); +}; + +export default PriceRange; diff --git a/package.json b/package.json index 7c2fe9c5e..669a03135 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.11", + "@types/lodash.get": "^4.4.9", "@types/node": "20.11.30", "@types/react": "18.2.72", "@types/react-dom": "18.2.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e9c96c54..dc4b504ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ devDependencies: '@tailwindcss/typography': specifier: ^0.5.11 version: 0.5.11(tailwindcss@3.4.1) + '@types/lodash.get': + specifier: ^4.4.9 + version: 4.4.9 '@types/node': specifier: 20.11.30 version: 20.11.30 @@ -733,6 +736,16 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} 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: resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==} dependencies: