summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMqdd <ahmadmiqdad27@gmail.com>2026-02-18 15:05:34 +0700
committerMqdd <ahmadmiqdad27@gmail.com>2026-02-18 15:05:34 +0700
commit9118c586d2c4fdab43c11409db91cf7b51839261 (patch)
treef2c2c9415e613c153a51f8ed2760933f4ebd8bc9 /src
parent8c4d73ff159cb7b5df4f83f1eb76e8a06c7179ce (diff)
parent7ef19bc5b5dc64fc0fb8126cec02943f06a4237a (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.jsx20
-rw-r--r--src/lib/category/components/Breadcrumb.jsx58
-rw-r--r--src/lib/category/components/styles/breadcrumb.module.css3
-rw-r--r--src/lib/checkout/api/getRatesCourier.js3
-rw-r--r--src/lib/checkout/components/SectionExpedition.jsx2
-rw-r--r--src/lib/checkout/components/SectionQuotationExpedition.jsx3
-rw-r--r--src/lib/product/components/ProductSearch.jsx76
-rw-r--r--src/pages/api/magento-product.ts168
-rw-r--r--src/pages/api/shop/search.js296
-rw-r--r--src/pages/shop/category/[slug].jsx30
-rw-r--r--src/utils/solrMapping.js3
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)}
&nbsp;dari&nbsp;
@@ -474,7 +490,8 @@ const ProductSearch = ({
&nbsp;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)}
&nbsp;dari&nbsp;
@@ -629,12 +648,11 @@ const ProductSearch = ({
) : (
''
)}
- {productFound}
+ <strong>{productFound}</strong>
&nbsp;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,