summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/lib/checkout/components/SectionQuotationExpedition.jsx19
-rw-r--r--src/lib/product/components/ProductFilterDesktop.jsx348
-rw-r--r--src/pages/api/shop/search.js63
3 files changed, 238 insertions, 192 deletions
diff --git a/src/lib/checkout/components/SectionQuotationExpedition.jsx b/src/lib/checkout/components/SectionQuotationExpedition.jsx
index b8ea04ef..817cd21b 100644
--- a/src/lib/checkout/components/SectionQuotationExpedition.jsx
+++ b/src/lib/checkout/components/SectionQuotationExpedition.jsx
@@ -239,7 +239,7 @@ export default function SectionExpeditionQuotation({ products }) {
<div className='px-4 py-2'>
<div className='flex justify-between items-center'>
<div className='font-medium'>Pilih Ekspedisi: </div>
- <div className='w-[350px]'>
+ <div className='relative w-[350px]'>
<div
className='w-full p-2 border rounded-lg bg-white cursor-pointer'
onClick={() => setOnFocuseSelectedCourier(!onFocusSelectedCourier)}
@@ -253,7 +253,10 @@ export default function SectionExpeditionQuotation({ products }) {
)}
</div>
{onFocusSelectedCourier && (
- <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 max-h-[200px] overflow-y-auto w-[350px]'>
+ <div
+ className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50
+ max-h-[200px] overflow-y-auto w-full sm:w-[350px]'
+ >
{!isLoading ? (
<>
<div
@@ -297,8 +300,8 @@ export default function SectionExpeditionQuotation({ products }) {
{checkWeigth && (
<p className='mt-4 text-gray-600'>
- Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada barang
- yang belum memiliki berat. Silakan hubungi admin via{' '}
+ Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada
+ barang yang belum memiliki berat. Silakan hubungi admin via{' '}
<a
className='text-blue-600 underline'
href='https://api.whatsapp.com/send?phone=6281717181922'
@@ -316,7 +319,7 @@ export default function SectionExpeditionQuotation({ products }) {
selectedCourier !== 0 && (
<div className='mt-4 flex justify-between'>
<div className='font-medium mb-2'>Tipe Layanan Ekspedisi:</div>
- <div className='relative w-[350px]'>
+ <div className='relative w-full sm:w-[350px]'>
<div
className='p-2 border rounded-lg bg-white cursor-pointer'
onClick={() => setIsOpen(!isOpen)}
@@ -331,11 +334,13 @@ export default function SectionExpeditionQuotation({ products }) {
</span>
</div>
) : (
- <span className='text-gray-500'>Pilih layanan pengiriman</span>
+ <span className='text-gray-500'>
+ Pilih layanan pengiriman
+ </span>
)}
</div>
{isOpen && (
- <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 w-full'>
+ <div className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50 w-full'>
{serviceOptions.map((service) => (
<div
key={service.service_type}
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..1760be70 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) {
@@ -49,25 +48,6 @@ export default async function handler(req, res) {
break;
}
- // let suggestWord = null;
- // let keywords = q;
- // let checkQ = null;
-
- // if (q != '*') {
- // checkQ = q.trim().split(/[\s\+\-\!\(\)\{\}\[\]\^"~\*\?:\\\/]+/);
- // if (checkQ.length > 1) {
- // const dataSearchSuggest = await axios(
- // `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/suggest?q=${checkQ[1]}`
- // );
- // suggestWord = dataSearchSuggest.data.suggestions[0];
- // }
- // if (suggestWord && suggestWord?.term.split(' ').length <= 1) {
- // keywords = `"${escapeSolrQuery(checkQ[0] + ' ' + suggestWord?.term)}"`;
- // }
- // }
-
- // let newQ = keywords;
-
let checkQ = q.trim().split(/[\s\+\-\!\(\)\{\}\[\]\^"~\*\?:\\\/]+/);
let newQ = escapeSolrQuery(q);
@@ -113,8 +93,9 @@ export default async function handler(req, res) {
let offset = (page - 1) * limit;
let parameter = [
- 'facet.field=manufacture_name_s',
- 'facet.field=category_name',
+ // === Disjunctive facets: exclude brand & category filters saat hitung facet ===
+ 'facet.field={!ex=brand}manufacture_name_s',
+ 'facet.field={!ex=cat}category_name',
'facet=true',
'indent=true',
`facet.query=${escapeSolrQuery(q)}`,
@@ -143,23 +124,27 @@ export default async function handler(req, res) {
if (auth.feature.onlyReadyStock) stock = true;
}
- if (brand)
- parameter.push(
- `fq=${brand
- .split(',')
- .map(
- (manufacturer) =>
- `manufacture_name:"${encodeURIComponent(manufacturer)}"`
- )
- .join(' OR ')}`
- );
- if (category)
- parameter.push(
- `fq=${category
- .split(',')
- .map((cat) => `category_name:"${encodeURIComponent(cat)}"`)
- .join(' OR ')}`
- );
+ if (brand) {
+ // bentuk ekspresi sama seperti versi kamu, tapi dibungkus tag brand
+ const brandExpr = brand
+ .split(',')
+ .map(
+ (manufacturer) =>
+ `manufacture_name:"${encodeURIComponent(manufacturer)}"`
+ )
+ .join(' OR ');
+ parameter.push(`fq={!tag=brand}(${brandExpr})`);
+ }
+
+ if (category) {
+ // sama: tag kategori
+ const catExpr = category
+ .split(',')
+ .map((cat) => `category_name:"${encodeURIComponent(cat)}"`)
+ .join(' OR ');
+ parameter.push(`fq={!tag=cat}(${catExpr})`);
+ }
+
// if (category) parameter.push(`fq=category_name:${capitalizeFirstLetter(category.replace(/,/g, ' OR '))}`)
if (stock) parameter.push(`fq=stock_total_f:{1 TO *}`);