diff options
| author | Miqdad <ahmadmiqdad27@gmail.com> | 2025-09-02 16:24:39 +0700 |
|---|---|---|
| committer | Miqdad <ahmadmiqdad27@gmail.com> | 2025-09-02 16:24:39 +0700 |
| commit | 31ce275bf6013bf6ee2e3a2d7f22d2547cab0b70 (patch) | |
| tree | 56f6b46bed43ff47cad21c136a207a9a3df9dc28 | |
| parent | 631f103bf56f8c8858e0a16aabe0e8e25751c97b (diff) | |
| parent | 932b92af3f3a1a0a62bef60d647c00e1be483265 (diff) | |
Merge branch 'new-release' of https://bitbucket.org/altafixco/next-indoteknik into regenerate-midtrans
| -rw-r--r-- | src/lib/checkout/components/SectionQuotationExpedition.jsx | 19 | ||||
| -rw-r--r-- | src/lib/product/components/ProductFilterDesktop.jsx | 348 | ||||
| -rw-r--r-- | src/pages/api/shop/search.js | 63 |
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 *}`); |
