diff options
| author | Mqdd <ahmadmiqdad27@gmail.com> | 2026-02-18 15:05:34 +0700 |
|---|---|---|
| committer | Mqdd <ahmadmiqdad27@gmail.com> | 2026-02-18 15:05:34 +0700 |
| commit | 9118c586d2c4fdab43c11409db91cf7b51839261 (patch) | |
| tree | f2c2c9415e613c153a51f8ed2760933f4ebd8bc9 /src | |
| parent | 8c4d73ff159cb7b5df4f83f1eb76e8a06c7179ce (diff) | |
| parent | 7ef19bc5b5dc64fc0fb8126cec02943f06a4237a (diff) | |
Merge branch 'new-release' of https://bitbucket.org/altafixco/next-indoteknik into cr_renca_keyword
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/components/elements/Navbar/NavbarDesktop.jsx | 20 | ||||
| -rw-r--r-- | src/lib/category/components/Breadcrumb.jsx | 58 | ||||
| -rw-r--r-- | src/lib/category/components/styles/breadcrumb.module.css | 3 | ||||
| -rw-r--r-- | src/lib/checkout/api/getRatesCourier.js | 3 | ||||
| -rw-r--r-- | src/lib/checkout/components/SectionExpedition.jsx | 2 | ||||
| -rw-r--r-- | src/lib/checkout/components/SectionQuotationExpedition.jsx | 3 | ||||
| -rw-r--r-- | src/lib/product/components/ProductSearch.jsx | 76 | ||||
| -rw-r--r-- | src/pages/api/magento-product.ts | 168 | ||||
| -rw-r--r-- | src/pages/api/shop/search.js | 296 | ||||
| -rw-r--r-- | src/pages/shop/category/[slug].jsx | 30 | ||||
| -rw-r--r-- | src/utils/solrMapping.js | 3 |
11 files changed, 526 insertions, 136 deletions
diff --git a/src/core/components/elements/Navbar/NavbarDesktop.jsx b/src/core/components/elements/Navbar/NavbarDesktop.jsx index db4fcbb8..2f3f8682 100644 --- a/src/core/components/elements/Navbar/NavbarDesktop.jsx +++ b/src/core/components/elements/Navbar/NavbarDesktop.jsx @@ -13,6 +13,7 @@ import { MenuItem, MenuList, useDisclosure, + Badge, } from '@chakra-ui/react'; import { ChevronDownIcon, HeartIcon } from '@heroicons/react/24/outline'; import dynamic from 'next/dynamic'; @@ -271,11 +272,11 @@ const NavbarDesktop = () => { aria-label='Promo' className={`${ router.asPath === '/shop/promo' && 'bg-gray_r-3' - } flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group relative`} // Added relative position + } flex-[1.5] flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group relative`} target='_blank' rel='noreferrer' > - {showPopup && ( + {/* {showPopup && ( <div className='w-full h-full relative justify-end items-start'> <Image src='/images/penawaran-terbatas.jpg' @@ -288,9 +289,12 @@ const NavbarDesktop = () => { loading='eager' /> </div> - )} - <span className='absolute inset-0 flex justify-center items-center group-hover:scale-105 group-hover:text-red-500 transition-transform duration-200 z-10'> + )} */} + <span className='absolute inset-0 flex justify-center items-center gap-2 group-hover:scale-105 group-hover:text-red-500 transition-transform duration-200 z-10'> Semua Promo + <Badge colorScheme="red" variant="solid" borderRadius="full" px={2} textTransform="none"> + Baru + </Badge> </span> </Link> {/* {showPopup && router.pathname === '/' && ( @@ -306,7 +310,7 @@ const NavbarDesktop = () => { aria-label='Brand' className={`${ router.asPath === '/shop/brands' && 'bg-gray_r-3' - } p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} + } px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} target='_blank' rel='noreferrer' > @@ -320,7 +324,7 @@ const NavbarDesktop = () => { className={`${ router.asPath.includes('/shop/search?orderBy=stock') && 'bg-gray_r-3' - } p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} + } px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group`} target='_blank' rel='noreferrer' > @@ -331,7 +335,7 @@ const NavbarDesktop = () => { <Link href='https://blog.indoteknik.com/' aria-label='Blog Indoteknik' - className='p-4 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group' + className='px-2 flex-1 flex justify-center items-center !text-gray_r-12/80 hover:bg-gray_r-3 idt-transition group' target='_blank' rel='noreferrer noopener' > @@ -506,4 +510,4 @@ const SocialMedias = () => ( </div> ); -export default NavbarDesktop; +export default NavbarDesktop;
\ No newline at end of file diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx index acd2cbff..fa2846e4 100644 --- a/src/lib/category/components/Breadcrumb.jsx +++ b/src/lib/category/components/Breadcrumb.jsx @@ -11,11 +11,14 @@ import React from 'react'; import { useQuery } from 'react-query'; import useDevice from '@/core/hooks/useDevice'; -const Breadcrumb = ({ categoryId, currentLabel }) => { +const Breadcrumb = ({ categoryId, shortDesc }) => { const breadcrumbs = useQuery( ['category-breadcrumbs', categoryId], async () => - await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) + await odooApi( + 'GET', + `/api/v1/category/${categoryId}/category-breadcrumb`, + ), ); const { isDesktop, isMobile } = useDevice(); @@ -23,7 +26,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { /* ========================= DESKTOP - ========================== */ + ========================= */ if (isDesktop) { return ( <div className='container mx-auto py-4 md:py-6'> @@ -67,7 +70,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', category.name, - category.id + category.id, )} className='!text-danger-500' > @@ -92,13 +95,28 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { )} </ChakraBreadcrumb> </Skeleton> + {shortDesc && ( + <div + className=' + w-full mt-2 + text-sm text-neutral-600 + leading-7 + text-justify + break-words + [hyphens:auto] + max-w-none + ' + > + {shortDesc} + </div> + )} </div> ); } /* ========================= MOBILE - ========================== */ + ========================= */ if (isMobile) { const n = items.length; const lastCat = n >= 1 ? items[n - 1] : null; @@ -148,7 +166,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbLink> </BreadcrumbItem> - {/* Ellipsis */} + {/* Jika ada kategori sebelum secondLast, tampilkan '..' (link ke beforeSecond) */} {beforeSecond && ( <BreadcrumbItem> <BreadcrumbLink @@ -156,10 +174,9 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', beforeSecond.name, - beforeSecond.id + beforeSecond.id, )} title={hiddenText} - aria-label={`Kembali ke ${beforeSecond.name}`} className='!text-danger-500' > .. @@ -167,7 +184,6 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbItem> )} - {/* Second last category */} {secondLast && ( <BreadcrumbItem> <BreadcrumbLink @@ -175,7 +191,7 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { href={createSlug( '/shop/category/', secondLast.name, - secondLast.id + secondLast.id, )} className='!text-danger-500' > @@ -184,13 +200,13 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { </BreadcrumbItem> )} - {/* Current */} - {finalLabel && ( + {/* lastCat (current) dengan truncate & lebar dibatasi */} + {lastCat && ( <BreadcrumbItem isCurrentPage> <span className='inline-block truncate align-bottom' style={{ maxWidth: '60vw' }} - title={finalLabel} + title={lastCat.name} > {finalLabel} </span> @@ -198,6 +214,22 @@ const Breadcrumb = ({ categoryId, currentLabel }) => { )} </ChakraBreadcrumb> </Skeleton> + + {shortDesc && ( + <div + className=' + w-full mt-2 + text-sm text-neutral-600 + leading-7 + text-justify + break-words + [hyphens:auto] + max-w-none + ' + > + {shortDesc} + </div> + )} </div> ); } diff --git a/src/lib/category/components/styles/breadcrumb.module.css b/src/lib/category/components/styles/breadcrumb.module.css new file mode 100644 index 00000000..dee4e1b4 --- /dev/null +++ b/src/lib/category/components/styles/breadcrumb.module.css @@ -0,0 +1,3 @@ +.category-short-desc { + flex: 0 0 100%; +} diff --git a/src/lib/checkout/api/getRatesCourier.js b/src/lib/checkout/api/getRatesCourier.js index 30cfe6e1..0108a3b8 100644 --- a/src/lib/checkout/api/getRatesCourier.js +++ b/src/lib/checkout/api/getRatesCourier.js @@ -5,8 +5,7 @@ const GetRatesCourierBiteship = async ({ destination, items }) => { const couriers = process.env.NEXT_PUBLIC_BITESHIP_CODE_COURIERS; let body = { ...destination, - couriers: - 'gojek, grab, deliveree, lalamove, jne, tiki, ninja, lion, rara, sicepat, jnt, pos, idexpress, rpx, wahana, jdl, pos, anteraja, sap, paxel, borzo', + couriers: couriers, items: items, }; diff --git a/src/lib/checkout/components/SectionExpedition.jsx b/src/lib/checkout/components/SectionExpedition.jsx index 66182589..16a2c664 100644 --- a/src/lib/checkout/components/SectionExpedition.jsx +++ b/src/lib/checkout/components/SectionExpedition.jsx @@ -250,7 +250,7 @@ export default function SectionExpedition({ products }) { let body = { ...destination, couriers: - 'gojek,grab,deliveree,lalamove,jne,tiki,ninja,lion,rara,sicepat,jnt,pos,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', + 'gojek,grab,deliveree,lalamove,jne,ninja,lion,rara,sicepat,jnt,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', items: items, }; try { diff --git a/src/lib/checkout/components/SectionQuotationExpedition.jsx b/src/lib/checkout/components/SectionQuotationExpedition.jsx index 817cd21b..718e096c 100644 --- a/src/lib/checkout/components/SectionQuotationExpedition.jsx +++ b/src/lib/checkout/components/SectionQuotationExpedition.jsx @@ -155,8 +155,7 @@ export default function SectionExpeditionQuotation({ products }) { const fetchExpedition = async () => { const body = { ...destination, - couriers: - 'gojek,grab,deliveree,lalamove,jne,tiki,ninja,lion,rara,sicepat,jnt,pos,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', + couriers: 'gojek,grab,deliveree,lalamove,jne,ninja,lion,rara,sicepat,jnt,idexpress,rpx,wahana,jdl,pos,anteraja,sap,paxel,borzo', items, }; const response = await axios.get(`/api/biteship-service`, { diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index 850d00cc..c73c7036 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -6,7 +6,10 @@ import { HStack, Image, Tag, TagCloseButton, TagLabel } from '@chakra-ui/react'; import axios from 'axios'; import _ from 'lodash'; import { toQuery } from 'lodash-contrib'; -import { FunnelIcon, AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline'; +import { + FunnelIcon, + AdjustmentsHorizontalIcon, +} from '@heroicons/react/24/outline'; import odooApi from '@/core/api/odooApi'; import searchSpellApi from '@/core/api/searchSpellApi'; import Link from '@/core/components/elements/Link/Link'; @@ -57,9 +60,15 @@ const ProductSearch = ({ if (!router.isReady) return; const onBrandsPage = router.pathname.includes('brands'); - const hasOrder = typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; - - if (onBrandsPage && !hasOrder && !appliedDefaultBrandOrder.current) { + const onSearchPage = prefixUrl === '/shop/search'; + const hasOrder = + typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; + + if ( + (onBrandsPage || onSearchPage) && + !hasOrder && + !appliedDefaultBrandOrder.current + ) { let params = { ...router.query, orderBy: 'popular', @@ -83,7 +92,7 @@ const ProductSearch = ({ const loadProduct = async () => { const getCategoriesId = await odooApi( 'GET', - `/api/v1/category/numFound?parent_id=${categoryId}` + `/api/v1/category/numFound?parent_id=${categoryId}`, ); if (getCategoriesId) { setDataCategoriesProduct(getCategoriesId); @@ -94,7 +103,7 @@ const ProductSearch = ({ const loadProduct = async () => { const lobData = await odooApi( 'GET', - `/api/v1/lob_homepage/${categoryId}/category_id` + `/api/v1/lob_homepage/${categoryId}/category_id`, ); if (lobData) { @@ -175,7 +184,11 @@ const ProductSearch = ({ }, [dataCategoriesProduct, dataLob]); useEffect(() => { - if (prefixUrl.includes('category') || prefixUrl.includes('lob') || router.asPath.includes('penawaran')) { + if ( + prefixUrl.includes('category') || + prefixUrl.includes('lob') || + router.asPath.includes('penawaran') + ) { setQueryFinal({ ...finalQuery, q, limit, orderBy }); } else { setQueryFinal({ ...query, q, limit, orderBy }); @@ -198,10 +211,10 @@ const ProductSearch = ({ ? router.query.brand ? router.query.brand.split(',') : [] - : [] + : [], ); const [categoryValues, setCategory] = useState( - router.query?.category?.split(',') || router.query?.category?.split(',') + router.query?.category?.split(',') || router.query?.category?.split(','), ); const [priceFrom, setPriceFrom] = useState(router.query?.priceFrom || null); @@ -217,11 +230,11 @@ const ProductSearch = ({ if (productFound == 0 && query.q && !spellings) { searchSpellApi({ query: query.q }).then((response) => { const oddIndexSuggestions = response.data.spellcheck.suggestions.filter( - (_, index) => index % 2 === 1 + (_, index) => index % 2 === 1, ); const oddIndexCollations = response.data.spellcheck.collations.filter( - (_, index) => index % 2 === 1 + (_, index) => index % 2 === 1, ); const dataSpellings = oddIndexSuggestions.reduce((acc, curr) => { @@ -246,7 +259,7 @@ const ProductSearch = ({ useEffect(() => { const checkIfBrand = async () => { const brand = await axios( - `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/brands?params=search&q=${search}` + `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/brands?params=search&q=${search}`, ); if (brand.data.length > 0) { @@ -265,7 +278,7 @@ const ProductSearch = ({ const loadCategories = async () => { const getCategories = await odooApi( 'GET', - `/api/v1/category/child?parent_id=${categoryId}` + `/api/v1/category/child?parent_id=${categoryId}`, ); if (getCategories) { setDataCategories(getCategories); @@ -335,15 +348,15 @@ const ProductSearch = ({ if (router.pathname.includes('search')) { const getBannerHeader = await odooApi( 'GET', - '/api/v1/banner?type=promotion-header' + '/api/v1/banner?type=promotion-header', ); const getBannerFooter = await odooApi( 'GET', - '/api/v1/banner?type=promotion-footer' + '/api/v1/banner?type=promotion-footer', ); var randomIndex = Math.floor(Math.random() * getBannerHeader.length); var randomIndexFooter = Math.floor( - Math.random() * getBannerFooter.length + Math.random() * getBannerFooter.length, ); setBannerPromotionHeader(getBannerHeader[randomIndex]); setBannerPromotionFooter(getBannerFooter[randomIndexFooter]); @@ -430,7 +443,9 @@ const ProductSearch = ({ <div className='p-4 pt-0'> {isNotReadyStockPage && isBrand && isBrand.logo && ( <div className='mb-3'> - <h1 className='mb-2 font-semibold text-h-sm'>Brand Pencarian {q}</h1> + <h1 className='mb-2 font-semibold text-h-sm'> + Brand Pencarian {q} + </h1> <Link href={createSlug('/shop/brands/', isBrand.name, isBrand.id)} className='inline' @@ -462,7 +477,8 @@ const ProductSearch = ({ {pageCount > 1 ? ( <> {productStart + 1}- - {parseInt(productStart) + parseInt(productRows) > productFound + {parseInt(productStart) + parseInt(productRows) > + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -474,7 +490,8 @@ const ProductSearch = ({ produk{' '} {query.q && ( <> - untuk pencarian <span className='font-semibold'>{query.q}</span> + untuk pencarian{' '} + <span className='font-semibold'>{query.q}</span> </> )} </> @@ -512,7 +529,9 @@ const ProductSearch = ({ </div> )} {!!dataLob?.length && <LobSectionCategory categories={dataLob} />} - {!!dataCategories?.length && <CategorySection categories={dataCategories} />} + {!!dataCategories?.length && ( + <CategorySection categories={dataCategories} /> + )} <div className='grid grid-cols-2 gap-3'> {products && products.map((product) => ( @@ -621,7 +640,7 @@ const ProductSearch = ({ <> {productStart + 1}- {parseInt(productStart) + parseInt(productRows) > - productFound + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -629,12 +648,11 @@ const ProductSearch = ({ ) : ( '' )} - {productFound} + <strong>{productFound}</strong> produk{' '} {query.q && ( <> - untuk pencarian{' '} - <span className='font-semibold'>{query.q}</span> + untuk pencarian <strong>{query.q}</strong> </> )} </> @@ -697,8 +715,8 @@ const ProductSearch = ({ href={ query?.q ? whatsappUrl('productSearch', { - name: query.q, - }) + name: query.q, + }) : whatsappUrl() } className='text-danger-500' @@ -783,9 +801,9 @@ const FilterChoicesComponent = ({ </Tag> )} {brandValues?.length > 0 || - categoryValues?.length > 0 || - priceFrom || - priceTo ? ( + categoryValues?.length > 0 || + priceFrom || + priceTo ? ( <span> <button className='btn-transparent py-2 px-5 h-[40px] text-red-700' diff --git a/src/pages/api/magento-product.ts b/src/pages/api/magento-product.ts new file mode 100644 index 00000000..28738878 --- /dev/null +++ b/src/pages/api/magento-product.ts @@ -0,0 +1,168 @@ +// pages/api/magento-product.ts +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + // Kita terima 'skus' (banyak) dan 'main_sku' (utama/pertama) + const { skus, main_sku } = req.query; + + if (!skus) { + return res.status(400).json({ error: 'SKUs are required' }); + } + + const token = process.env.MAGENTO_API_KEY || ''; + const baseUrl = process.env.MAGENTO_API_HOST || ''; + + try { + const skuList = String(skus).split(','); // Contoh: ['221', '222', '223'] + const mainSku = String(main_sku || skuList[0]).trim(); // Fallback ke yang pertama + + // ===================================================================== + // 1. FETCH SEMUA VARIAN SEKALIGUS (Optimasi 'IN' Operator) + // ===================================================================== + const searchParams = new URLSearchParams({ + 'searchCriteria[filter_groups][0][filters][0][field]': 'sku', + 'searchCriteria[filter_groups][0][filters][0][value]': skuList.join(','), + 'searchCriteria[filter_groups][0][filters][0][condition_type]': 'in' + }); + + const productUrl = `${baseUrl}/products?${searchParams.toString()}`; + + const productResponse = await fetch(productUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!productResponse.ok) { + return res.status(200).json({ specsMatrix: [], upsell_ids: [], related_ids: [] }); + } + + const productData = await productResponse.json(); + const items = productData.items || []; + + if (items.length === 0) { + return res.status(200).json({ specsMatrix: [], upsell_ids: [], related_ids: [] }); + } + + const cleanAttributeValue = (val: any) => { + if (val === null || val === undefined) return ''; + let str = String(val).trim(); + if (str.length >= 2 && str.startsWith('"') && str.endsWith('"')) { + str = str.slice(1, -1).trim(); + } + return str; + }; + + // ===================================================================== + // 2. BUILD SPECS MATRIX + // ===================================================================== + + // Kumpulkan semua kode atribut unik + const allAttributeCodes = new Set<string>(); + items.forEach((p: any) => { + if (p.custom_attributes) { + p.custom_attributes.forEach((attr: any) => { + if (attr.attribute_code.startsWith('z')) { + allAttributeCodes.add(attr.attribute_code); + } + }); + } + }); + + // Fetch Label untuk atribut-atribut tersebut (Sekali jalan) + const labelsMap: Record<string, string> = {}; + await Promise.all(Array.from(allAttributeCodes).map(async (code) => { + try { + const attrUrl = `${baseUrl}/products/attributes/${code}`; + const res = await fetch(attrUrl, { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) { + const json = await res.json(); + labelsMap[code] = json.default_frontend_label || code; + } + } catch (e) {} + + // Fallback label jika gagal + if (!labelsMap[code]) { + labelsMap[code] = code.substring(1).replace(/_/g, ' ').trim(); + } + })); + + // Susun Matrix + // Struktur: { code, label, values: { [sku]: value } } + const matrix: any[] = []; + + Array.from(allAttributeCodes).forEach((code) => { + const row: any = { + code: code, + label: labelsMap[code], + values: {} + }; + + let hasData = false; + + items.forEach((p: any) => { + const attr = p.custom_attributes.find((a: any) => a.attribute_code === code); + // Gunakan helper cleanAttributeValue disini + const rawVal = attr ? cleanAttributeValue(attr.value) : ''; + + if (rawVal !== '' && rawVal !== '-') { + hasData = true; + } + row.values[p.sku] = rawVal; + }); + + if (hasData) { + matrix.push(row); + } + }); + + // Deskripsi produk per varian + const descriptions:Record<string, string> = {}; + items.forEach((p: any) => { + const descAttr = p.custom_attributes.find((a: any) => a.attribute_code === 'description' || a.attribute_code === 'short_description'); + descriptions[p.sku] = descAttr ? descAttr.value : ''; + }); + + const warranties: Record<string, string> = {}; + items.forEach((p: any) => { + const warAttr = p.custom_attributes.find((a: any) => a.attribute_code === 'z_warranty'); + warranties[p.sku] = warAttr ? cleanAttributeValue(warAttr.value) : ''; + }); + + // ===================================================================== + // 3. AMBIL LINKS (UPSELL & RELATED) DARI MAIN VARIANT SAJA + // ===================================================================== + const mainProduct = items.find((p: any) => String(p.sku) === mainSku) || items[0]; + + let upsellIds: number[] = []; + let relatedIds: number[] = []; + + if (mainProduct && mainProduct.product_links) { + mainProduct.product_links.forEach((link: any) => { + if (link.link_type === 'upsell') { + upsellIds.push(Number(link.linked_product_sku)); + } else if (link.link_type === 'related') { + relatedIds.push(Number(link.linked_product_sku)); + } + }); + } + + // Response + res.status(200).json({ + specsMatrix: matrix, + upsell_ids: upsellIds, + related_ids: relatedIds, + descriptions: descriptions, + warranties: warranties, + }); + + } catch (error) { + console.error('Proxy Error:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}
\ No newline at end of file diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index 5f77b5c6..1f636f28 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -2,6 +2,18 @@ import { productMappingSolr } from '@/utils/solrMapping'; import axios from 'axios'; import camelcaseObjectDeep from 'camelcase-object-deep'; +const escapeSolrQuery = (query) => { + if (query == '*') return query; + query = query.replace(/-/g, ' '); + const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; + const words = query.split(/\s+/); + return words + .map((word) => + specialChars.test(word) ? word.replace(specialChars, '\\$1') : word, + ) + .join(' '); +}; + export default async function handler(req, res) { const { q = '*', @@ -12,71 +24,226 @@ export default async function handler(req, res) { priceTo = 0, orderBy = '', operation = 'AND', - fq = '', + fq = '', // bisa berupa string atau array limit = 30, source = '', + group = 'true', } = req.query; let { stock = '' } = req.query; // ============================================================ - // SITEMAP + // [PERBAIKAN] 1. LOGIC KHUSUS COMPARE (PAKAI URLSearchParams) // ============================================================ - if (source === 'sitemap') { + if (source === 'compare') { try { - const offset = (page - 1) * limit; + let qCompare = q === '*' ? '*:*' : q; + + if (qCompare !== '*:*') { + qCompare = escapeSolrQuery(qCompare); + qCompare = qCompare + .split(/\s+/) + .map((term) => { + if (term && !term.includes('*')) { + return term + '*'; + } + return term; + }) + .join(' '); + } + + // [SOLUSI] Gunakan URLSearchParams untuk menyusun URL dengan aman + const params = new URLSearchParams(); + + params.append('q', qCompare); + params.append('rows', limit); + params.append('wt', 'json'); + params.append('indent', 'true'); + + // Gunakan eDisMax parser (Otak Cerdas) + params.append('defType', 'edismax'); + + // Set Prioritas Pencarian (Boost ^) + // 1. default_code_s^20 : SKU persis (Prioritas Tertinggi) + // 2. search_keywords_t^10 : Field baru (Case insensitive) + // 3. display_name_s^1 : Cadangan + params.append( + 'qf', + 'default_code_s^20 search_keywords_t^10 display_name_s^1', + ); + + const compareWords = qCompare.split(/\s+/).filter((w) => w.length > 0); + let compareMm = '100%'; + if (compareWords.length >= 3) { + compareMm = '75%'; + } + params.append('mm', compareMm); + + if (group === 'false') { + params.append('group', 'false'); + } else { + params.append('group', 'true'); + params.append('group.field', 'template_id_i'); + params.append('group.limit', '1'); + params.append('group.main', 'true'); + } + + // Field List (fl) + params.append( + 'fl', + 'id,display_name_s,default_code_s,image_s,price_tier1_v2_f,attribute_set_id_i,attribute_set_name_s,template_id_i,product_id_i', + ); + + // Filter Query (fq) Dasar + params.append('fq', '-publish_b:false'); + params.append('fq', 'price_tier1_v2_f:[1 TO *]'); + + // Logic Locking (Filter Attribute Set ID dari Frontend) + if (fq) { + if (Array.isArray(fq)) { + fq.forEach((f) => params.append('fq', f)); + } else { + params.append('fq', fq); + } + } + + // Target Core: VARIANTS + // HAPUS parameter manual dari string URL, gunakan params object + const solrUrl = process.env.SOLR_HOST + '/solr/variants/select'; + + // Axios akan otomatis handle encoding % dan & dengan benar + const result = await axios.get(solrUrl, { params: params }); + + // Mapping Result + const mappedProducts = productMappingSolr( + result.data.response.docs, + false, + ); + + const finalResponse = { + ...result.data, + response: { + ...result.data.response, + products: mappedProducts, + }, + }; + + delete finalResponse.response.docs; + const camelCasedData = camelcaseObjectDeep(finalResponse); + + return res.status(200).json(camelCasedData); + } catch (e) { + console.error('[COMPARE SEARCH ERROR]', e.message); + if (e.response && e.response.data) { + // Log detail error dari Solr + console.error( + '[SOLR DETAILS]:', + JSON.stringify(e.response.data, null, 2), + ); + } + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + + // ============================================================ + // LOGIC KHUSUS UPSELL (KODE LAMA ANDA) + // ============================================================ + if (source === 'upsell') { + try { + // Ambil fq dari query (format: product_id_i:(...)) + // Pastikan fq adalah string tunggal + let fqUpsell = Array.isArray(fq) ? fq.join(' OR ') : fq; + fqUpsell = decodeURIComponent(fqUpsell); const parameter = [ 'q=*:*', `rows=${limit}`, - `start=${offset}`, - 'fl=product_id_i,name_s,default_code_s,image_s,category_name', 'wt=json', - 'omitHeader=true', + 'indent=true', + 'defType=edismax', + // Filter Query khusus Upsell + `fq=${encodeURIComponent(fqUpsell)}`, + // Tetap filter yang publish & ada harga agar produk valid + `fq=${encodeURIComponent('-publish_b:false')}`, + `fq=${encodeURIComponent('price_tier1_v2_f:[1 TO *]')}`, ]; - // const parameter = [ - // 'q=*:*', - // `rows=${limit}`, - // `start=${offset}`, + // PENTING: SEARCH DI CORE 'VARIANTS' + const solrUrl = + process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&'); - // // ❌ EXCLUDE PROMOTION - // 'fq=-(name_s:*promotion* OR display_name_s:*promotion* OR variants_name_t:*promotion*)', + const result = await axios(solrUrl); - // // ❌ EXCLUDE DUMMY PRODUCT - // 'fq=-(name_s:*dummy* OR display_name_s:*dummy* OR variants_name_t:*dummy* OR default_code_s:A.*)', + // 1. Mapping dasar + const mappedProducts = productMappingSolr( + result.data.response.docs, + false, + ); - // 'fl=product_id_i,name_s,default_code_s,image_s,category_name', - // 'wt=json', - // 'omitHeader=true', - // ]; + // 2. FIX URL LINK: Override ID Varian dengan Template ID + const rawDocs = result.data.response.docs; + + const fixedProducts = mappedProducts.map((p, index) => { + const raw = rawDocs[index]; + if (raw && raw.template_id_i) { + return { + ...p, + id: raw.template_id_i, // Ganti ID Varian jadi ID Template agar link valid + variantId: raw.product_id_i, + }; + } + return p; + }); + + const finalResponse = { + ...result.data, + response: { + ...result.data.response, + products: fixedProducts, + }, + }; + + delete finalResponse.response.docs; + const camelCasedData = camelcaseObjectDeep(finalResponse); + + return res.status(200).json(camelCasedData); + } catch (e) { + console.error('[UPSELL ERROR]', e.response?.data || e.message); + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + // ============================================================ + // SITEMAP (KODE LAMA ANDA) + // ============================================================ + if (source === 'sitemap') { + try { + const offset = (page - 1) * limit; + const parameter = [ + 'q=*:*', + `rows=${limit}`, + `start=${offset}`, + 'fl=product_id_i,name_s,default_code_s,image_s,category_name', + 'wt=json', + 'omitHeader=true', + ]; const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); - - // console.log('[SITEMAP SOLR QUERY]', solrUrl); - const result = await axios(solrUrl, { timeout: 25000 }); - - // mapping seperti biasa result.data.response.products = productMappingSolr( result.data.response.docs, - false + false, ); - delete result.data.response.docs; - result.data = camelcaseObjectDeep(result.data); - return res.status(200).json(result.data); } catch (e) { - console.error('[SITEMAP ERROR]', e); return res.status(500).json({ error: 'Sitemap query failed' }); } } // ============================================================ - // SEARCH NORMAL + // SEARCH NORMAL (KODE LAMA ANDA) // ============================================================ let paramOrderBy = ''; @@ -114,7 +281,6 @@ export default async function handler(req, res) { .split(' ') .map((term) => (term.length < 2 ? term : `${term}*`)) .join(' ')})`; - const mm = checkQ.length > 2 ? checkQ.length > 5 @@ -128,23 +294,19 @@ export default async function handler(req, res) { 'price_tier1_v2_f:[1 TO *]', ]; - if (orderBy === 'stock') { - filterQueries.push('stock_total_f:[1 TO *]'); - } + if (orderBy === 'stock') filterQueries.push('stock_total_f:[1 TO *]'); - if (fq && source != 'similar' && typeof fq != 'string') { - fq.push(...filterQueries); + // Handle 'fq' parameter from request + let finalFq = [...filterQueries]; + if (fq) { + if (Array.isArray(fq)) finalFq.push(...fq); + else finalFq.push(fq); } - const fq_ = filterQueries.join(' AND '); - let keywords = newQ; if (source === 'similar' || checkQ.length < 3) { - if (checkQ.length < 2 || checkQ[1].length < 2) { - keywords = newQ; - } else { - keywords = newQ + '*'; - } + if (checkQ.length < 2 || checkQ[1].length < 2) keywords = newQ; + else keywords = newQ + '*'; } else { keywords = formattedQuery; } @@ -164,15 +326,17 @@ export default async function handler(req, res) { `start=${parseInt(offset)}`, `rows=${limit}`, `sort=${paramOrderBy}`, - `fq=${encodeURIComponent(fq_)}`, `mm=${encodeURIComponent(mm)}`, ]; + // Masukkan semua Filter Query (fq) + finalFq.forEach((f) => { + parameter.push(`fq=${encodeURIComponent(f)}`); + }); + if (priceFrom > 0 || priceTo > 0) { parameter.push( - `fq=price_tier1_v2_f:[${priceFrom == '' ? '*' : priceFrom} TO ${ - priceTo == '' ? '*' : priceTo - }]` + `fq=price_tier1_v2_f:[${priceFrom || '*'} TO ${priceTo || '*'}]`, ); } @@ -185,10 +349,7 @@ export default async function handler(req, res) { if (brand) { const brandExpr = brand .split(',') - .map( - (manufacturer) => - `manufacture_name:"${encodeURIComponent(manufacturer)}"` - ) + .map((m) => `manufacture_name:"${encodeURIComponent(m)}"`) .join(' OR '); parameter.push(`fq={!tag=brand}(${brandExpr})`); } @@ -196,7 +357,7 @@ export default async function handler(req, res) { if (category) { const catExpr = category .split(',') - .map((cat) => `category_name:"${encodeURIComponent(cat)}"`) + .map((c) => `category_name:"${encodeURIComponent(c)}"`) .join(' OR '); parameter.push(`fq={!tag=cat}(${catExpr})`); } @@ -207,7 +368,7 @@ export default async function handler(req, res) { if (Array.isArray(fq)) parameter = parameter.concat( - fq.map((val) => `fq=${encodeURIComponent(val)}`) + fq.map((val) => `fq=${encodeURIComponent(val)}`), ); // Searchkey @@ -235,7 +396,7 @@ export default async function handler(req, res) { try { result.data.response.products = productMappingSolr( result.data.response.docs, - auth?.pricelist || false + auth?.pricelist || false, ); delete result.data.response.docs; @@ -247,21 +408,21 @@ export default async function handler(req, res) { } } + // SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT' const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); - const result = await axios(solrUrl); - try { + const result = await axios(solrUrl); result.data.response.products = productMappingSolr( result.data.response.docs, - auth?.pricelist || false + auth?.pricelist || false, ); result.data.responseHeader.params.start = parseInt( - result.data.responseHeader.params.start + result.data.responseHeader.params.start, ); result.data.responseHeader.params.rows = parseInt( - result.data.responseHeader.params.rows + result.data.responseHeader.params.rows, ); delete result.data.response.docs; result.data = camelcaseObjectDeep(result.data); @@ -270,20 +431,3 @@ export default async function handler(req, res) { res.status(400).json({ error: error.message }); } } - -const escapeSolrQuery = (query) => { - if (query == '*') return query; - - query = query.replace(/-/g, ' '); - - const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; - const words = query.split(/\s+/); - const escapedWords = words.map((word) => { - if (specialChars.test(word)) { - return word.replace(specialChars, '\\$1'); - } - return word; - }); - - return escapedWords.join(' '); -}; diff --git a/src/pages/shop/category/[slug].jsx b/src/pages/shop/category/[slug].jsx index 11840d47..e515e3f4 100644 --- a/src/pages/shop/category/[slug].jsx +++ b/src/pages/shop/category/[slug].jsx @@ -16,13 +16,14 @@ const ProductSearch = dynamic(() => ); const CategorySection = dynamic(() => import('@/lib/product/components/CategorySection') -) +); export default function CategoryDetail() { const router = useRouter(); const { slug = '', page = 1 } = router.query; - const [dataCategories, setDataCategories] = useState([]) + const [dataCategories, setDataCategories] = useState([]); + const [shortDesc, setShortDesc] = useState(''); const categoryName = getNameFromSlug(slug); const categoryId = getIdFromSlug(slug); const q = router?.query.q || null; @@ -33,6 +34,22 @@ export default function CategoryDetail() { if (q) { query.q = q; } + useEffect(() => { + if (!router.isReady) return; + if (!categoryId) return; + + const loadShortDesc = async () => { + const res = await odooApi( + 'GET', + `/api/v1/category/${categoryId}/short-desc` + ); + + const desc = res?.shortDesc || ''; + setShortDesc(desc); + }; + + loadShortDesc(); + }, [router.isReady, categoryId]); return ( <BasicLayout> @@ -47,11 +64,14 @@ export default function CategoryDetail() { ]} /> - <Breadcrumb categoryId={categoryId} /> - + <Breadcrumb categoryId={categoryId} shortDesc={shortDesc} /> {!_.isEmpty(router.query) && ( - <ProductSearch query={query} categories ={categoryId} prefixUrl={`/shop/category/${slug}`} /> + <ProductSearch + query={query} + categories={categoryId} + prefixUrl={`/shop/category/${slug}`} + /> )} </BasicLayout> ); diff --git a/src/utils/solrMapping.js b/src/utils/solrMapping.js index 33f0cbaf..8c0abcf1 100644 --- a/src/utils/solrMapping.js +++ b/src/utils/solrMapping.js @@ -127,6 +127,9 @@ export const variantsMappingSolr = (parent, products, pricelist) => { variantTotal: product.variant_total_i || 0, stockTotal: product.stock_total_f || 0, weight: product.weight_f || 0, + attribute_set_id: product.attribute_set_id_i || 0, + attribute_set_name: product.attribute_set_name_s || '', + search_keywords: product.search_keywords_t || '', manufacture: {}, parent: {}, qtySold: product?.qty_sold_f || 0, |
