diff options
| author | IT Fixcomart <it@fixcomart.co.id> | 2025-08-30 06:56:09 +0000 |
|---|---|---|
| committer | IT Fixcomart <it@fixcomart.co.id> | 2025-08-30 06:56:09 +0000 |
| commit | 58e75871483917ac842c7d95dfbf0bdd65ecaafd (patch) | |
| tree | a7deedd39dabac00eeb2e8b0ccc2ad7554568162 | |
| parent | 39c537b0036a08f2d54ba425bb2bddfa4164d924 (diff) | |
| parent | 7735e705142e9a56f37c90b09ea5e6ba80d2bfa3 (diff) | |
Merged in filter-search (pull request #452)
<Miqdad> Done apply filter without button
| -rw-r--r-- | src/lib/product/components/ProductFilterDesktop.jsx | 348 | ||||
| -rw-r--r-- | src/pages/api/shop/search.js | 41 |
2 files changed, 225 insertions, 164 deletions
diff --git a/src/lib/product/components/ProductFilterDesktop.jsx b/src/lib/product/components/ProductFilterDesktop.jsx index d2ecb4d9..440e1795 100644 --- a/src/lib/product/components/ProductFilterDesktop.jsx +++ b/src/lib/product/components/ProductFilterDesktop.jsx @@ -1,7 +1,6 @@ import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import _ from 'lodash'; -import { toQuery } from 'lodash-contrib'; import { Accordion, AccordionButton, @@ -9,7 +8,6 @@ import { AccordionItem, AccordionPanel, Box, - Button, Checkbox, Input, InputGroup, @@ -17,136 +15,200 @@ import { Stack, VStack, } from '@chakra-ui/react'; -import Image from '@/core/components/elements/Image/Image'; import { formatCurrency } from '@/core/utils/formatValue'; const ProductFilterDesktop = ({ - brands, - categories, + brands, // bisa [{id,name,qty}] atau [{brand,qty}] + categories, // [{name, qty}] prefixUrl, - defaultBrand = null, }) => { const router = useRouter(); - const { query } = router; - const [order, setOrder] = useState(query?.orderBy); - const [brandValues, setBrand] = useState(query?.brand?.split(',') || []); + + const [order, setOrder] = useState(router.query?.orderBy); + const [brandValues, setBrand] = useState( + typeof router.query?.brand === 'string' && router.query.brand + ? router.query.brand.split(',').filter(Boolean) + : [] + ); const [categoryValues, setCategory] = useState( - query?.category?.split(',') || [] + typeof router.query?.category === 'string' && router.query.category + ? router.query.category.split(',').filter(Boolean) + : [] ); - const [priceFrom, setPriceFrom] = useState(query?.priceFrom); - const [priceTo, setPriceTo] = useState(query?.priceTo); - const [stock, setStock] = useState(query?.stock); + const [priceFrom, setPriceFrom] = useState(router.query?.priceFrom ?? ''); + const [priceTo, setPriceTo] = useState(router.query?.priceTo ?? ''); + const [stock, setStock] = useState(router.query?.stock ?? null); const [activeRange, setActiveRange] = useState(null); - const [activeIndeces, setActiveIndeces] = useState([]); + + const handlePriceKeyDown = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + // keluar dari preset kalau user input manual + setActiveRange(null); + + // pakai state terkini untuk apply + const fromVal = priceFrom === '' ? '' : String(priceFrom); + const toVal = priceTo === '' ? '' : String(priceTo); + + applyFilters({ priceFrom: fromVal, priceTo: toVal }); + }; + + // --- normalisasi data brand agar tahan banting --- + const normBrands = useMemo(() => { + return (brands ?? []) + .map((b, i) => ({ + id: String(b.id ?? b.val ?? b.brand ?? i), + name: String(b.name ?? b.brand ?? b.label ?? b.val ?? '').trim(), + qty: b.qty ?? b.count, + })) + .filter((b) => b.name); + }, [brands]); const priceRange = [ - { - priceFrom: 100000, - priceTo: 200000, - }, - { - priceFrom: 200000, - priceTo: 300000, - }, - { - priceFrom: 300000, - priceTo: 400000, - }, - { - priceFrom: 400000, - priceTo: 500000, - }, + { priceFrom: 100000, priceTo: 200000 }, + { priceFrom: 200000, priceTo: 300000 }, + { priceFrom: 300000, priceTo: 400000 }, + { priceFrom: 400000, priceTo: 500000 }, ]; - const indexRange = priceRange.findIndex((range) => { - return ( - range.priceFrom === parseInt(priceFrom) && - range.priceTo == parseInt(priceTo) - ); - }); - - const handleCategoriesChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setCategory([...categoryValues, value]); - } else { - setCategory(categoryValues.filter((val) => val !== value)); - } - }; - const handleBrandsChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setBrand([...brandValues, value]); - } else { - setBrand(brandValues.filter((val) => val !== value)); + const indexRange = priceRange.findIndex( + (r) => r.priceFrom === parseInt(priceFrom) && r.priceTo == parseInt(priceTo) + ); + + const applyFilters = (changes = {}) => { + const params = new URLSearchParams(); + + // 1) salin SEMUA param yang ada sekarang (jangan hilangkan apapun) + Object.entries(router.query).forEach(([k, v]) => { + if (v == null) return; + if (Array.isArray(v)) { + // penting: fq bisa multi-value; gunakan append, bukan join + v.forEach((item) => params.append(k, String(item))); + } else { + params.set(k, String(v)); + } + }); + + // 2) baca nilai dasar langsung dari URL (hindari state stale) + const arr = (val) => + typeof val === 'string' && val ? val.split(',').filter(Boolean) : []; + + const nextBrand = + 'brandValues' in changes ? changes.brandValues : arr(router.query.brand); + const nextCategory = + 'categoryValues' in changes + ? changes.categoryValues + : arr(router.query.category); + const nextPriceFrom = + 'priceFrom' in changes ? changes.priceFrom : router.query.priceFrom ?? ''; + const nextPriceTo = + 'priceTo' in changes ? changes.priceTo : router.query.priceTo ?? ''; + const nextStock = + 'stock' in changes ? changes.stock : router.query.stock ?? null; + const nextOrder = + 'order' in changes ? changes.order : router.query.orderBy ?? ''; + + const setOrDel = (key, val) => { + const empty = + val == null || val === '' || (Array.isArray(val) && val.length === 0); + if (empty) params.delete(key); + else params.set(key, Array.isArray(val) ? val.join(',') : String(val)); + }; + + setOrDel('brand', nextBrand); + setOrDel('category', nextCategory); + setOrDel('priceFrom', nextPriceFrom); + setOrDel('priceTo', nextPriceTo); + setOrDel('stock', nextStock); + setOrDel('orderBy', nextOrder); + + // 3) kalau ada perubahan filter utama → reset page ke 1 + const changedKeys = Object.keys(changes); + const touched = [ + 'brandValues', + 'categoryValues', + 'priceFrom', + 'priceTo', + 'stock', + 'order', + ]; + if (changedKeys.some((k) => touched.includes(k))) { + params.set('page', '1'); } + + // 4) shallow replace (tanpa reload penuh) + const base = router.asPath.split('?')[0]; + router.replace(`${base}?${params.toString()}`, undefined, { + shallow: true, + scroll: false, + }); }; - const handleReadyStockChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setStock(value); - } else { - setStock(null); - } + // debounce untuk input harga (biar nggak spam) + const debouncedApply = useMemo(() => _.debounce(applyFilters, 350), []); // eslint-disable-line + useEffect(() => () => debouncedApply.cancel(), [debouncedApply]); + + // === handlers === + const handleCategoriesChange = (e) => { + const { value, checked } = e.target; + const next = checked + ? [...categoryValues, value] + : categoryValues.filter((v) => v !== value); + setCategory(next); + applyFilters({ categoryValues: next }); }; - const handlePriceFromChange = async (priceFromr, priceTor, index) => { - await setPriceFrom(priceFromr); - await setPriceTo(priceTor); - setActiveRange(index); + const handleBrandsChange = (e) => { + const { value, checked } = e.target; // value = brand ID/name (string) + const next = checked + ? [...brandValues, value] + : brandValues.filter((v) => v !== value); + setBrand(next); + applyFilters({ brandValues: next }); }; - const handleSubmit = () => { - let params = { - penawaran: router.query.penawaran, - q: router.query.q, - orderBy: order, - brand: brandValues.join(','), - category: categoryValues.join(','), - priceFrom, - priceTo, - stock: stock, - }; - params = _.pickBy(params, _.identity); - params = toQuery(params); + const handleReadyStockChange = (e) => { + const { checked, value } = e.target; + const next = checked ? value : null; + setStock(next); + applyFilters({ stock: next }); + }; - const slug = Array.isArray(router.query.slug) - ? router.query.slug[0] - : router.query.slug; + const handlePriceRangeClick = async (pf, pt, idx) => { + await setPriceFrom(pf); + await setPriceTo(pt); + setActiveRange(idx); + applyFilters({ priceFrom: pf, priceTo: pt }); + }; - if (slug) { - if (prefixUrl.includes('category') || prefixUrl.includes('lob')) { - router.push(`${prefixUrl}?${params}`); - } else { - router.push(`${prefixUrl}/${slug}?${params}`); - } - } else { - router.push(`${prefixUrl}?${params}`); - } + const onPriceFromInput = (e) => { + setPriceFrom(e.target.value); }; - /*const handleIndexAccordion = async () => { - if (brandValues) { - await setActiveIndeces([...activeIndeces, 0]) - } - if (categoryValues) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 1 : 0]) - } - if (priceRange) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 2 : 1]) - } - if (stock) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 3 : 2]) - } - }*/ + const onPriceToInput = (e) => { + setPriceTo(e.target.value); + }; useEffect(() => { setActiveRange(indexRange); - }, []); + }, []); // init active range + + useEffect(() => { + setBrand( + router.query?.brand + ? String(router.query.brand).split(',').filter(Boolean) + : [] + ); + setCategory( + router.query?.category + ? String(router.query.category).split(',').filter(Boolean) + : [] + ); + setPriceFrom(router.query?.priceFrom ?? ''); + setPriceTo(router.query?.priceTo ?? ''); + setStock(router.query?.stock ?? null); + setOrder(router.query?.orderBy ?? ''); + }, [router.query]); return ( <> @@ -159,23 +221,24 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {brands && brands.length > 0 ? ( - brands.map((brand, index) => ( - <div className='flex items-center gap-2 ' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {normBrands.length > 0 ? ( + normBrands.map((b) => ( + <div className='flex items-center gap-2' key={b.id}> <Checkbox - isChecked={brandValues.includes(brand.brand)} + isChecked={brandValues.includes(String(b.id))} onChange={handleBrandsChange} - value={brand.brand} + value={String(b.id)} // idealnya ID brand size='md' > <div className='flex items-center gap-2'> - <span>{brand.brand} </span> - <span className='text-sm text-gray-600'> - ({brand.qty}) - </span> + <span>{b.name}</span> + {b.qty !== undefined && ( + <span className='text-sm text-gray-600'> + ({b.qty}) + </span> + )} </div> </Checkbox> </div> @@ -197,23 +260,20 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {categories && categories.length > 0 ? ( - categories.map((category, index) => ( - <div className='flex items-center gap-2' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {(categories ?? []).length > 0 ? ( + categories.map((c, i) => ( + <div className='flex items-center gap-2' key={i}> <Checkbox - isChecked={categoryValues.includes(category.name)} + isChecked={categoryValues.includes(c.name)} onChange={handleCategoriesChange} - value={category.name} + value={c.name} size='md' > <div className='flex items-center gap-2'> - <span>{category.name} </span> - <span className='text-sm text-gray-600'> - ({category.qty}) - </span> + <span>{c.name}</span> + <span className='text-sm text-gray-600'>({c.qty})</span> </div> </Checkbox> </div> @@ -234,7 +294,6 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <VStack gap={4}> <InputGroup> @@ -243,32 +302,34 @@ const ProductFilterDesktop = ({ type='number' placeholder='Harga minimum' value={priceFrom} - onChange={(e) => setPriceFrom(e.target.value)} + onChange={onPriceFromInput} + onKeyDown={handlePriceKeyDown} // ⟵ apply saat Enter /> </InputGroup> + <InputGroup> <InputLeftAddon>Rp</InputLeftAddon> <Input type='number' - placeholder='Harga maximum' + placeholder='Harga maksimum' value={priceTo} - onChange={(e) => setPriceTo(e.target.value)} + onChange={onPriceToInput} + onKeyDown={handlePriceKeyDown} // ⟵ apply saat Enter /> </InputGroup> + <div className='grid grid-cols-2 gap-x-3 gap-y-2'> - {priceRange.map((price, i) => ( + {priceRange.map((p, i) => ( <button key={i} onClick={() => - handlePriceFromChange(price.priceFrom, price.priceTo, i) + handlePriceRangeClick(p.priceFrom, p.priceTo, i) } className={`w-full border ${ i === activeRange ? 'border-red-600' : 'border-gray-400' - } - py-2 p-3 rounded-full text-sm whitespace-nowrap`} + } py-2 p-3 rounded-full text-sm whitespace-nowrap`} > - {formatCurrency(price.priceFrom)} -{' '} - {formatCurrency(price.priceTo)} + {formatCurrency(p.priceFrom)} - {formatCurrency(p.priceTo)} </button> ))} </div> @@ -279,27 +340,22 @@ const ProductFilterDesktop = ({ {/* <AccordionItem> <AccordionButton padding={[2, 4]}> <Box as='span' flex='1' textAlign='left' fontWeight='semibold'> - Ketersedian Stok + Ketersediaan Stok </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <Checkbox isChecked={stock === 'ready stock'} onChange={handleReadyStockChange} - value={'ready stock'} + value='ready stock' size='md' > - Ketersedian Stock + Ready Stock </Checkbox> </AccordionPanel> </AccordionItem> */} </Accordion> - - <Button className='w-full mt-6' colorScheme='red' onClick={handleSubmit}> - Terapkan - </Button> </> ); }; diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index 3d258a97..81cc22c5 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -19,7 +19,6 @@ export default async function handler(req, res) { source = '', } = req.query; - let { stock = '' } = req.query; let paramOrderBy = ''; switch (orderBy) { @@ -73,7 +72,7 @@ export default async function handler(req, res) { const formattedQuery = `(${newQ .split(' ') - .map((term) => (term.length < 2 ? term : `${term}*`)) // Tambahkan '*' hanya jika panjang kata >= 2 + .map((term) => (term.length < 2 ? term : `${term}*`)) .join(' ')})`; const mm = @@ -90,8 +89,7 @@ export default async function handler(req, res) { ]; if (orderBy === 'stock') { - filterQueries.push('stock_total_f:{1 TO *}&sort=stock_total_f desc'); - // filterQueries.push(`stock_total_f DESC`) + filterQueries.push('stock_total_f:[1 TO *]'); } if (fq && source != 'similar' && typeof fq != 'string') { @@ -113,8 +111,9 @@ export default async function handler(req, res) { let offset = (page - 1) * limit; let parameter = [ - 'facet.field=manufacture_name_s', - 'facet.field=category_name', + // === Facet disjunctive: exclude filter brand/cat saat hitung facet === + 'facet.field={!ex=brand}manufacture_name_s', + 'facet.field={!ex=cat}category_name', 'facet=true', 'indent=true', `facet.query=${escapeSolrQuery(q)}`, @@ -145,23 +144,28 @@ export default async function handler(req, res) { if (brand) parameter.push( - `fq=${brand - .split(',') - .map( - (manufacturer) => - `manufacture_name:"${encodeURIComponent(manufacturer)}"` - ) - .join(' OR ')}` + // tag filter brand + `fq=${encodeURIComponent( + `{!tag=brand}manufacture_name_s:(${brand + .split(',') + .map((manufacturer) => `"${manufacturer.replace(/"/g, '\\"')}"`) + .join(' OR ')})` + )}` ); + if (category) parameter.push( - `fq=${category - .split(',') - .map((cat) => `category_name:"${encodeURIComponent(cat)}"`) - .join(' OR ')}` + // tag filter category + `fq=${encodeURIComponent( + `{!tag=cat}category_name:(${category + .split(',') + .map((cat) => `"${cat.replace(/"/g, '\\"')}"`) + .join(' OR ')})` + )}` ); + // if (category) parameter.push(`fq=category_name:${capitalizeFirstLetter(category.replace(/,/g, ' OR '))}`) - if (stock) parameter.push(`fq=stock_total_f:{1 TO *}`); + if (stock) parameter.push(`fq=stock_total_f:{1 TO *]`); // Single fq in url params if (typeof fq === 'string') parameter.push(`fq=${encodeURIComponent(fq)}`); @@ -170,6 +174,7 @@ export default async function handler(req, res) { parameter = parameter.concat( fq.map((val) => `fq=${encodeURIComponent(val)}`) ); + let result = await axios( process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&') ); |
