summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIT Fixcomart <it@fixcomart.co.id>2025-08-30 06:56:09 +0000
committerIT Fixcomart <it@fixcomart.co.id>2025-08-30 06:56:09 +0000
commit58e75871483917ac842c7d95dfbf0bdd65ecaafd (patch)
treea7deedd39dabac00eeb2e8b0ccc2ad7554568162
parent39c537b0036a08f2d54ba425bb2bddfa4164d924 (diff)
parent7735e705142e9a56f37c90b09ea5e6ba80d2bfa3 (diff)
Merged in filter-search (pull request #452)
<Miqdad> Done apply filter without button
-rw-r--r--src/lib/product/components/ProductFilterDesktop.jsx348
-rw-r--r--src/pages/api/shop/search.js41
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('&')
);