summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/lib/category/components/Breadcrumb.jsx46
-rw-r--r--src/lib/product/api/productSearchApi.js31
-rw-r--r--src/lib/product/components/ProductSearch.jsx74
-rw-r--r--src/lib/utils/batchSolrQueries.js113
-rw-r--r--src/pages/api/shop/search.js372
-rw-r--r--src/pages/api/shop/searchkey.js43
-rw-r--r--src/pages/searchkey/[slug].jsx98
-rw-r--r--src/pages/sitemap/searchkey.xml.js37
-rw-r--r--src/pages/sitemap/searchkey/[page].js42
9 files changed, 759 insertions, 97 deletions
diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx
index 8579bb14..29bc9c0a 100644
--- a/src/lib/category/components/Breadcrumb.jsx
+++ b/src/lib/category/components/Breadcrumb.jsx
@@ -11,17 +11,18 @@ import React from 'react';
import { useQuery } from 'react-query';
import useDevice from '@/core/hooks/useDevice';
-const Breadcrumb = ({ categoryId, shortDesc }) => {
+const Breadcrumb = ({ categoryId, shortDesc, currentLabel }) => {
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();
-
const items = breadcrumbs.data ?? [];
- const lastIdx = items.length - 1;
/* =========================
DESKTOP
@@ -58,29 +59,40 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
{/* Categories */}
{items.map((category, index) => {
- const isLast = index === lastIdx;
+ const isLastCategory = index === items.length - 1;
+ const isClickable = currentLabel || !isLastCategory;
+
return (
- <BreadcrumbItem key={index} isCurrentPage={isLast}>
- {isLast ? (
- <BreadcrumbLink className='block whitespace-normal break-words md:whitespace-nowrap'>
- {category.name}
- </BreadcrumbLink>
- ) : (
+ <BreadcrumbItem key={index} isCurrentPage={!isClickable}>
+ {isClickable ? (
<BreadcrumbLink
as={Link}
href={createSlug(
'/shop/category/',
category.name,
- category.id
+ category.id,
)}
className='!text-danger-500'
>
{category.name}
</BreadcrumbLink>
+ ) : (
+ <BreadcrumbLink className='block whitespace-normal break-words md:whitespace-nowrap'>
+ {category.name}
+ </BreadcrumbLink>
)}
</BreadcrumbItem>
);
})}
+
+ {/* Searchkey / Current Page */}
+ {currentLabel && (
+ <BreadcrumbItem isCurrentPage>
+ <BreadcrumbLink className='block whitespace-normal break-words md:whitespace-nowrap'>
+ {currentLabel}
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ )}
</ChakraBreadcrumb>
</Skeleton>
{shortDesc && (
@@ -119,6 +131,8 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
.join(' / ')
: '';
+ const finalLabel = currentLabel || lastCat?.name;
+
return (
<div className='container mx-auto py-2 mt-2'>
<Skeleton isLoaded={!breadcrumbs.isLoading} className='w-full'>
@@ -152,6 +166,7 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
</BreadcrumbLink>
</BreadcrumbItem>
+ {/* Jika ada kategori sebelum secondLast, tampilkan '..' (link ke beforeSecond) */}
{beforeSecond && (
<BreadcrumbItem>
<BreadcrumbLink
@@ -159,7 +174,7 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
href={createSlug(
'/shop/category/',
beforeSecond.name,
- beforeSecond.id
+ beforeSecond.id,
)}
title={hiddenText}
className='!text-danger-500'
@@ -176,7 +191,7 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
href={createSlug(
'/shop/category/',
secondLast.name,
- secondLast.id
+ secondLast.id,
)}
className='!text-danger-500'
>
@@ -185,6 +200,7 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
</BreadcrumbItem>
)}
+ {/* lastCat (current) dengan truncate & lebar dibatasi */}
{lastCat && (
<BreadcrumbItem isCurrentPage>
<span
@@ -192,7 +208,7 @@ const Breadcrumb = ({ categoryId, shortDesc }) => {
style={{ maxWidth: '60vw' }}
title={lastCat.name}
>
- {lastCat.name}
+ {finalLabel}
</span>
</BreadcrumbItem>
)}
diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js
index a84caa3c..1a6ad36a 100644
--- a/src/lib/product/api/productSearchApi.js
+++ b/src/lib/product/api/productSearchApi.js
@@ -2,8 +2,37 @@ import _ from 'lodash-contrib';
import axios from 'axios';
const productSearchApi = async ({ query, operation = 'OR' }) => {
+ // Use POST for large product ID arrays to avoid URL length limits
+ // GET request URL limit is typically 2KB-8KB; switch to POST if query string is large
+ const QUERY_SIZE_THRESHOLD = 2000; // Switch to POST if query > 2KB
+
+ if (query.length > QUERY_SIZE_THRESHOLD) {
+ console.log(
+ `[productSearchApi] Large query (${query.length} chars), using POST`,
+ );
+
+ // Parse query string into object for POST body
+ const params = new URLSearchParams(query);
+ const bodyData = {
+ ...Object.fromEntries(params),
+ operation,
+ };
+
+ const dataProductSearch = await axios.post(
+ `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/search`,
+ bodyData,
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ );
+ return dataProductSearch.data;
+ }
+
+ // Small query, use standard GET request
const dataProductSearch = await axios(
- `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/search?${query}&operation=${operation}`
+ `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/search?${query}&operation=${operation}`,
);
return dataProductSearch.data;
};
diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx
index c73c7036..3e667966 100644
--- a/src/lib/product/components/ProductSearch.jsx
+++ b/src/lib/product/components/ProductSearch.jsx
@@ -44,7 +44,7 @@ const ProductSearch = ({
const { page = 1 } = query;
const [q, setQ] = useState(query?.q || '*');
const [search, setSearch] = useState(query?.q || '*');
- const [limit, setLimit] = useState(router.query?.limit || 30);
+ const [limit, setLimit] = useState(parseInt(router.query?.limit) || 30);
const [orderBy, setOrderBy] = useState(router.query?.orderBy);
const [finalQuery, setFinalQuery] = useState({});
const [queryFinal, setQueryFinal] = useState({});
@@ -86,6 +86,21 @@ const ProductSearch = ({
}
}, [router.isReady, router.pathname, router.query?.orderBy, prefixUrl]);
+ // 🔹 Sync limit state with router.query
+ useEffect(() => {
+ if (!router.isReady) return;
+ const newLimit = parseInt(router.query?.limit) || 30;
+ setLimit(newLimit);
+ }, [router.query?.limit, router.isReady]);
+
+ // 🔹 Sync orderBy state with router.query
+ useEffect(() => {
+ if (!router.isReady) return;
+ if (router.query?.orderBy) {
+ setOrderBy(router.query.orderBy);
+ }
+ }, [router.query?.orderBy, router.isReady]);
+
const dataIdCategories = [];
useEffect(() => {
if (prefixUrl.includes('category')) {
@@ -180,20 +195,55 @@ const ProductSearch = ({
}
};
fetchCategoryData();
+ } else if (query?.from === 'searchkey' && query?.ids) {
+ const newQuery = {
+ ids: query.ids,
+ from: 'searchkey',
+ page: router.query.page ? router.query.page : 1,
+ category: router.query.category ? router.query.category : '',
+ priceFrom: router.query.priceFrom ? router.query.priceFrom : '',
+ priceTo: router.query.priceTo ? router.query.priceTo : '',
+ limit: router.query.limit ? router.query.limit : '',
+ orderBy: router.query.orderBy ? router.query.orderBy : '',
+ };
+ setFinalQuery(newQuery);
}
- }, [dataCategoriesProduct, dataLob]);
+ }, [dataCategoriesProduct, dataLob, query?.from, query?.ids, router.query]);
useEffect(() => {
if (
prefixUrl.includes('category') ||
prefixUrl.includes('lob') ||
+ query?.from === 'searchkey' ||
router.asPath.includes('penawaran')
) {
- setQueryFinal({ ...finalQuery, q, limit, orderBy });
+ setQueryFinal({
+ ...finalQuery,
+ q,
+ limit,
+ orderBy,
+ page: router.query.page || 1,
+ });
} else {
- setQueryFinal({ ...query, q, limit, orderBy });
+ setQueryFinal({
+ ...query,
+ q,
+ limit,
+ orderBy,
+ page: router.query.page || 1,
+ });
}
- }, [prefixUrl, dataCategoriesProduct, query, finalQuery]);
+ }, [
+ prefixUrl,
+ dataCategoriesProduct,
+ query,
+ finalQuery,
+ router.query,
+ router.query.page,
+ limit,
+ orderBy,
+ q,
+ ]);
const { productSearch } = useProductSearch({
query: queryFinal,
@@ -339,6 +389,7 @@ const ProductSearch = ({
let params = {
...router.query,
limit: e.target.value,
+ page: 1, // Reset to page 1 when limit changes
};
params = _.pickBy(params, _.identity);
params = toQuery(params);
@@ -541,8 +592,10 @@ const ProductSearch = ({
<Pagination
pageCount={pageCount}
- currentPage={parseInt(page)}
- url={`${prefixUrl}?${toQuery(_.omit(query, ['page', 'fq']))}`}
+ currentPage={
+ router.query.page ? parseInt(router.query.page, 10) : 1
+ }
+ url={`${prefixUrl}?${toQuery(_.omit(router.query, ['page', 'ids', 'from']))}`}
className='mt-6 mb-2'
/>
@@ -729,9 +782,10 @@ const ProductSearch = ({
<Pagination
pageCount={pageCount}
- currentPage={parseInt(page)}
- url={`${prefixUrl}?${toQuery(_.omit(query, ['page', 'fq']))}`}
- // url={prefixUrl.includes('category') || prefixUrl.includes('lob')? `${prefixUrl}?${toQuery(_.omit(finalQuery, ['page']))}` : `${prefixUrl}?${toQuery(_.omit(query, ['page']))}`}
+ currentPage={
+ router.query.page ? parseInt(router.query.page, 10) : 1
+ }
+ url={`${prefixUrl}?${toQuery(_.omit(router.query, ['page', 'ids', 'from']))}`}
className='!justify-end'
/>
</div>
diff --git a/src/lib/utils/batchSolrQueries.js b/src/lib/utils/batchSolrQueries.js
new file mode 100644
index 00000000..486f1778
--- /dev/null
+++ b/src/lib/utils/batchSolrQueries.js
@@ -0,0 +1,113 @@
+/**
+ * Batch utility functions for handling large product ID arrays in Solr queries
+ * Prevents URL length limit errors when querying with >100 product IDs
+ */
+
+/**
+ * Split an array into chunks of specified size
+ * @param {Array} array - Array to split
+ * @param {number} size - Chunk size (default: 100)
+ * @returns {Array<Array>} Array of chunks
+ */
+export const chunkArray = (array, size = 100) => {
+ if (!Array.isArray(array) || array.length === 0) return [];
+
+ const chunks = [];
+ for (let i = 0; i < array.length; i += size) {
+ chunks.push(array.slice(i, i + size));
+ }
+ return chunks;
+};
+
+/**
+ * Build a product ID OR clause for Solr query
+ * @param {Array<string|number>} ids - Array of product IDs
+ * @returns {string} Formatted OR clause: "product_id_i:123 OR product_id_i:456..."
+ */
+export const buildProductIdOrClause = (ids) => {
+ if (!Array.isArray(ids) || ids.length === 0) {
+ return '*:*';
+ }
+ return ids.map((id) => `product_id_i:${id}`).join(' OR ');
+};
+
+/**
+ * Validate query size to prevent exceeding HTTP limits
+ * @param {string} query - Query string to validate
+ * @param {number} maxSize - Maximum allowed size in characters (default: 6000)
+ * @returns {Object} { valid: boolean, message: string, size: number }
+ */
+export const validateQuerySize = (query, maxSize = 6000) => {
+ const size = query.length;
+ return {
+ valid: size <= maxSize,
+ message:
+ size > maxSize
+ ? `Query size ${size} exceeds limit of ${maxSize}`
+ : `Query size ${size} is within limit`,
+ size,
+ };
+};
+
+/**
+ * Build batched Solr query parameters for large ID arrays
+ * Chunks IDs into groups and creates separate OR clauses
+ * @param {Array<string|number>} ids - Product IDs to query
+ * @param {number} chunkSize - How many IDs per chunk (default: 100)
+ * @returns {Array<string>} Array of OR clauses, one per chunk
+ */
+export const buildBatchedOrClauses = (ids, chunkSize = 100) => {
+ if (!Array.isArray(ids) || ids.length === 0) {
+ return ['*:*'];
+ }
+
+ const chunks = chunkArray(ids, chunkSize);
+
+ if (chunks.length === 1) {
+ // Single chunk, return standard OR clause
+ return [buildProductIdOrClause(ids)];
+ }
+
+ // Multiple chunks: return OR clauses wrapped with parentheses for combining
+ return chunks.map((chunk) => `(${buildProductIdOrClause(chunk)})`);
+};
+
+/**
+ * Combine multiple OR clauses into a single query (for Solr)
+ * @param {Array<string>} orClauses - Array of OR clauses
+ * @returns {string} Combined query with OR between clauses
+ */
+export const combineOrClauses = (orClauses) => {
+ if (!Array.isArray(orClauses) || orClauses.length === 0) {
+ return '*:*';
+ }
+ if (orClauses.length === 1) {
+ return orClauses[0];
+ }
+ return orClauses.join(' OR ');
+};
+
+/**
+ * Merge Solr response documents from multiple queries
+ * Removes duplicates based on product_id_i
+ * @param {Array<Array>} responseArrays - Array of response.docs arrays
+ * @returns {Array} Merged and deduplicated docs
+ */
+export const mergeSolrResults = (responseArrays) => {
+ const seen = new Set();
+ const merged = [];
+
+ responseArrays.forEach((docs) => {
+ if (Array.isArray(docs)) {
+ docs.forEach((doc) => {
+ const id = doc.product_id_i;
+ if (id && !seen.has(id)) {
+ seen.add(id);
+ merged.push(doc);
+ }
+ });
+ }
+ });
+
+ return merged;
+};
diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js
index 89d75cd0..f7220568 100644
--- a/src/pages/api/shop/search.js
+++ b/src/pages/api/shop/search.js
@@ -1,16 +1,44 @@
import { productMappingSolr } from '@/utils/solrMapping';
import axios from 'axios';
import camelcaseObjectDeep from 'camelcase-object-deep';
+import {
+ chunkArray,
+ buildProductIdOrClause,
+ mergeSolrResults,
+} from '@/lib/utils/batchSolrQueries';
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(' ');
+ return words
+ .map((word) =>
+ specialChars.test(word) ? word.replace(specialChars, '\\$1') : word,
+ )
+ .join(' ');
+};
+
+/**
+ * Get parameter value from either GET (req.query) or POST (req.body)
+ * POST takes precedence if available
+ */
+const getParam = (req, key, defaultValue = undefined) => {
+ // POST takes precedence
+ if (req.body && req.body[key] !== undefined) {
+ return req.body[key];
+ }
+ // Fallback to GET
+ if (req.query && req.query[key] !== undefined) {
+ return req.query[key];
+ }
+ return defaultValue;
};
export default async function handler(req, res) {
+ // Support both GET and POST requests
+ const params = req.method === 'POST' ? req.body : req.query;
+
const {
q = '*',
page = 1,
@@ -23,10 +51,16 @@ export default async function handler(req, res) {
fq = '', // bisa berupa string atau array
limit = 30,
source = '',
- group = 'true',
- } = req.query;
+ group = 'true',
+ } = params;
+
+ let { stock = '' } = params;
- let { stock = '' } = req.query;
+ // Convert string parameters to appropriate types (for POST requests)
+ const pageNum = parseInt(page, 10) || 1;
+ const limitNum = parseInt(limit, 10) || 30;
+ const priceFromNum = parseFloat(priceFrom) || 0;
+ const priceToNum = parseFloat(priceTo) || 0;
// ============================================================
// [PERBAIKAN] 1. LOGIC KHUSUS COMPARE (PAKAI URLSearchParams)
@@ -37,32 +71,38 @@ export default async function handler(req, res) {
if (qCompare !== '*:*') {
qCompare = escapeSolrQuery(qCompare);
- qCompare = qCompare.split(/\s+/).map(term => {
- if (term && !term.includes('*')) {
- return term + '*';
- }
- return term;
- }).join(' ');
+ 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('rows', limitNum);
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);
+ 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%';
@@ -79,7 +119,10 @@ export default async function handler(req, res) {
}
// 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');
+ 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');
@@ -88,7 +131,7 @@ export default async function handler(req, res) {
// Logic Locking (Filter Attribute Set ID dari Frontend)
if (fq) {
if (Array.isArray(fq)) {
- fq.forEach(f => params.append('fq', f));
+ fq.forEach((f) => params.append('fq', f));
} else {
params.append('fq', fq);
}
@@ -97,34 +140,36 @@ export default async function handler(req, res) {
// 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
+ false,
);
const finalResponse = {
...result.data,
response: {
...result.data.response,
- products: mappedProducts
- }
+ 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));
+ console.error(
+ '[SOLR DETAILS]:',
+ JSON.stringify(e.response.data, null, 2),
+ );
}
return res.status(200).json({ response: { products: [], numFound: 0 } });
}
@@ -149,49 +194,49 @@ export default async function handler(req, res) {
// 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 *]')}`
+ `fq=${encodeURIComponent('-publish_b:false')}`,
+ `fq=${encodeURIComponent('price_tier1_v2_f:[1 TO *]')}`,
];
// PENTING: SEARCH DI CORE 'VARIANTS'
- const solrUrl = process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&');
+ const solrUrl =
+ process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&');
const result = await axios(solrUrl);
// 1. Mapping dasar
const mappedProducts = productMappingSolr(
result.data.response.docs,
- false
+ false,
);
// 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 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
- }
+ ...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 } });
@@ -203,7 +248,7 @@ export default async function handler(req, res) {
// ============================================================
if (source === 'sitemap') {
try {
- const offset = (page - 1) * limit;
+ const offset = (pageNum - 1) * limitNum;
const parameter = [
'q=*:*',
`rows=${limit}`,
@@ -212,9 +257,13 @@ export default async function handler(req, res) {
'wt=json',
'omitHeader=true',
];
- const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
+ const solrUrl =
+ process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
const result = await axios(solrUrl, { timeout: 25000 });
- result.data.response.products = productMappingSolr(result.data.response.docs, false);
+ result.data.response.products = productMappingSolr(
+ result.data.response.docs,
+ false,
+ );
delete result.data.response.docs;
result.data = camelcaseObjectDeep(result.data);
return res.status(200).json(result.data);
@@ -229,21 +278,45 @@ export default async function handler(req, res) {
let paramOrderBy = '';
switch (orderBy) {
- case 'flashsale-discount-desc': paramOrderBy += 'flashsale_discount_f DESC'; break;
- case 'price-asc': paramOrderBy += 'price_tier1_v2_f ASC'; break;
- case 'price-desc': paramOrderBy += 'price_tier1_v2_f DESC'; break;
- case 'popular': paramOrderBy += 'product_rating_f DESC, search_rank_i DESC,'; break;
- case 'popular-weekly': paramOrderBy += 'search_rank_weekly_i DESC'; break;
- case 'stock': paramOrderBy += 'product_rating_f DESC, stock_total_f DESC'; break;
- case 'flashsale-price-asc': paramOrderBy += 'flashsale_price_f ASC'; break;
- default: paramOrderBy += ''; break;
+ case 'flashsale-discount-desc':
+ paramOrderBy += 'flashsale_discount_f DESC';
+ break;
+ case 'price-asc':
+ paramOrderBy += 'price_tier1_v2_f ASC';
+ break;
+ case 'price-desc':
+ paramOrderBy += 'price_tier1_v2_f DESC';
+ break;
+ case 'popular':
+ paramOrderBy += 'product_rating_f DESC, search_rank_i DESC,';
+ break;
+ case 'popular-weekly':
+ paramOrderBy += 'search_rank_weekly_i DESC';
+ break;
+ case 'stock':
+ paramOrderBy += 'product_rating_f DESC, stock_total_f DESC';
+ break;
+ case 'flashsale-price-asc':
+ paramOrderBy += 'flashsale_price_f ASC';
+ break;
+ default:
+ paramOrderBy += '';
+ break;
}
let checkQ = q.trim().split(/[\s\+\-\!\(\)\{\}\[\]\^"~\*\?:\\\/]+/);
let newQ = escapeSolrQuery(q);
- const formattedQuery = `(${newQ.split(' ').map((term) => (term.length < 2 ? term : `${term}*`)).join(' ')})`;
- const mm = checkQ.length > 2 ? (checkQ.length > 5 ? '55%' : '85%') : `${checkQ.length}`;
+ const formattedQuery = `(${newQ
+ .split(' ')
+ .map((term) => (term.length < 2 ? term : `${term}*`))
+ .join(' ')})`;
+ const mm =
+ checkQ.length > 2
+ ? checkQ.length > 5
+ ? '55%'
+ : '85%'
+ : `${checkQ.length}`;
const filterQueries = [
'-publish_b:false',
@@ -256,8 +329,8 @@ export default async function handler(req, res) {
// Handle 'fq' parameter from request
let finalFq = [...filterQueries];
if (fq) {
- if (Array.isArray(fq)) finalFq.push(...fq);
- else finalFq.push(fq);
+ if (Array.isArray(fq)) finalFq.push(...fq);
+ else finalFq.push(fq);
}
let keywords = newQ;
@@ -268,7 +341,7 @@ export default async function handler(req, res) {
keywords = formattedQuery;
}
- let offset = (page - 1) * limit;
+ let offset = (pageNum - 1) * limitNum;
let parameter = [
'facet.field={!ex=brand}manufacture_name_s',
@@ -287,12 +360,14 @@ export default async function handler(req, res) {
];
// Masukkan semua Filter Query (fq)
- finalFq.forEach(f => {
- parameter.push(`fq=${encodeURIComponent(f)}`);
+ finalFq.forEach((f) => {
+ parameter.push(`fq=${encodeURIComponent(f)}`);
});
- if (priceFrom > 0 || priceTo > 0) {
- parameter.push(`fq=price_tier1_v2_f:[${priceFrom || '*'} TO ${priceTo || '*'}]`);
+ if (priceFromNum > 0 || priceToNum > 0) {
+ parameter.push(
+ `fq=price_tier1_v2_f:[${priceFromNum || '*'} TO ${priceToNum || '*'}]`,
+ );
}
let { auth } = req.cookies;
@@ -302,32 +377,187 @@ export default async function handler(req, res) {
}
if (brand) {
- const brandExpr = brand.split(',').map(m => `manufacture_name:"${encodeURIComponent(m)}"`).join(' OR ');
+ const brandExpr = brand
+ .split(',')
+ .map((m) => `manufacture_name:"${encodeURIComponent(m)}"`)
+ .join(' OR ');
parameter.push(`fq={!tag=brand}(${brandExpr})`);
}
if (category) {
- const catExpr = category.split(',').map(c => `category_name:"${encodeURIComponent(c)}"`).join(' OR ');
+ const catExpr = category
+ .split(',')
+ .map((c) => `category_name:"${encodeURIComponent(c)}"`)
+ .join(' OR ');
parameter.push(`fq={!tag=cat}(${catExpr})`);
}
if (stock) parameter.push(`fq=stock_total_f:(1 TO *)`);
+ if (typeof fq === 'string') parameter.push(`fq=${encodeURIComponent(fq)}`);
+
+ if (Array.isArray(fq))
+ parameter = parameter.concat(
+ fq.map((val) => `fq=${encodeURIComponent(val)}`),
+ );
+
+ // Searchkey with batching for large ID arrays
+ if (params.from === 'searchkey') {
+ try {
+ // Extract IDs from either query params (GET) or body (POST)
+ let ids = [];
+
+ if (req.method === 'POST' && req.body && req.body.ids) {
+ // POST request: IDs in body
+ ids = Array.isArray(req.body.ids)
+ ? req.body.ids
+ : req.body.ids.split(',').filter(Boolean);
+ } else if (req.query.ids) {
+ // GET request: IDs in query params
+ ids = req.query.ids.split(',').filter(Boolean);
+ }
+
+ if (ids.length === 0) {
+ return res.status(400).json({ error: 'No product IDs provided' });
+ }
+
+ // console.log(`[SEARCHKEY] Processing ${ids.length} product IDs`);
+
+ // If less than 100 IDs, use single query
+ if (ids.length <= 100) {
+ const q = buildProductIdOrClause(ids);
+
+ const strictQuery = [
+ `q=${encodeURIComponent(q)}`,
+ `fq=-publish_b:false AND price_tier1_v2_f:[1 TO *] AND product_rating_f:[8 TO *]`,
+ `rows=${limitNum}`,
+ `start=${offset}`,
+ `sort=${paramOrderBy}`,
+ ];
+
+ const solrUrl =
+ process.env.SOLR_HOST +
+ '/solr/product/select?' +
+ strictQuery.join('&');
+
+ // console.log('[SEARCHKEY SINGLE QUERY]', solrUrl);
+
+ const result = await axios(solrUrl);
+ result.data.response.products = productMappingSolr(
+ result.data.response.docs,
+ auth?.pricelist || false,
+ );
+
+ delete result.data.response.docs;
+ result.data = camelcaseObjectDeep(result.data);
+
+ return res.status(200).json(result.data);
+ }
+
+ // Batch large ID arrays into chunks of 100
+ const idChunks = chunkArray(ids, 100);
+ // console.log(
+ // `[SEARCHKEY BATCH] Splitting ${ids.length} IDs into ${idChunks.length} chunks`,
+ // );
+
+ // Execute all chunk queries in parallel
+ const batchQueries = idChunks.map((chunk) => {
+ const q = buildProductIdOrClause(chunk);
+ const queryParams = [
+ `q=${encodeURIComponent(q)}`,
+ `fq=-publish_b:false AND price_tier1_v2_f:[1 TO *] AND product_rating_f:[8 TO *]`,
+ `rows=100`, // Request maximum 100 rows per chunk (100 IDs per chunk)
+ `start=0`,
+ `sort=${paramOrderBy}`,
+ ];
+
+ const solrUrl =
+ process.env.SOLR_HOST +
+ '/solr/product/select?' +
+ queryParams.join('&');
+
+ console.log(
+ `[SEARCHKEY BATCH QUERY] Chunk: ${chunk.slice(0, 5).join(',')}...`,
+ );
+
+ return axios(solrUrl).catch((error) => {
+ console.error('[SEARCHKEY BATCH ERROR]', error.message);
+ throw error;
+ });
+ });
+
+ // Wait for all queries to complete
+ const batchResults = await Promise.all(batchQueries);
+
+ // Merge all documents from all chunks, removing duplicates
+ const allDocs = mergeSolrResults(
+ batchResults.map((r) => r.data.response.docs),
+ );
+
+ // console.log(
+ // `[SEARCHKEY MERGE] Merged ${allDocs.length} unique documents from ${batchResults.length} chunks`,
+ // );
+
+ // Apply pagination on merged results
+ const paginatedDocs = allDocs.slice(offset, offset + limitNum);
+
+ // Use first result's response structure as template
+ const templateResponse = batchResults[0].data;
+
+ const mergedResponse = {
+ ...templateResponse,
+ response: {
+ ...templateResponse.response,
+ numFound: allDocs.length,
+ start: offset,
+ rows: limitNum,
+ docs: paginatedDocs,
+ },
+ responseHeader: {
+ ...templateResponse.responseHeader,
+ params: {
+ ...templateResponse.responseHeader.params,
+ start: offset,
+ rows: limitNum,
+ },
+ },
+ };
+
+ mergedResponse.response.products = productMappingSolr(
+ paginatedDocs,
+ auth?.pricelist || false,
+ );
+
+ delete mergedResponse.response.docs;
+ mergedResponse.data = camelcaseObjectDeep(mergedResponse);
+
+ return res.status(200).json(mergedResponse.data);
+ } catch (e) {
+ console.error('[SEARCHKEY ERROR]', e.message);
+ return res.status(400).json({ error: e.message });
+ }
+ }
+
// SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT'
- const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
+ const solrUrl =
+ process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&');
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.rows = parseInt(
+ result.data.responseHeader.params.rows,
);
- result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start);
- result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows);
delete result.data.response.docs;
result.data = camelcaseObjectDeep(result.data);
res.status(200).json(result.data);
} catch (error) {
res.status(400).json({ error: error.message });
}
-} \ No newline at end of file
+}
diff --git a/src/pages/api/shop/searchkey.js b/src/pages/api/shop/searchkey.js
new file mode 100644
index 00000000..f5546a36
--- /dev/null
+++ b/src/pages/api/shop/searchkey.js
@@ -0,0 +1,43 @@
+import axios from 'axios';
+
+export default async function handler(req, res) {
+ const { url = '', page = 1, limit = 30, all } = req.query;
+
+ let q = '*:*';
+
+ // ✅ kalau BUKAN sitemap
+ if (!all) {
+ const cleanUrl = url.trim();
+ if (!cleanUrl) {
+ return res.status(400).json({ error: 'Missing url param' });
+ }
+ q = `keywords_s:"${cleanUrl}"`;
+ }
+
+ const offset = (page - 1) * limit;
+
+ const params = [
+ `q.op=AND`,
+ `q=${q}`,
+ `indent=true`,
+ `rows=${limit}`,
+ `start=${offset}`,
+ ];
+
+ try {
+ const result = await axios.post(
+ `${process.env.SOLR_HOST}/solr/searchkey/select`,
+ params.join('&'),
+ {
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ }
+ );
+
+ res.status(200).json(result.data);
+ } catch (error) {
+ console.error(error?.response?.data || error);
+ res.status(500).json({ error: 'Internal Server Error' });
+ }
+}
diff --git a/src/pages/searchkey/[slug].jsx b/src/pages/searchkey/[slug].jsx
new file mode 100644
index 00000000..fbe72dae
--- /dev/null
+++ b/src/pages/searchkey/[slug].jsx
@@ -0,0 +1,98 @@
+import axios from 'axios';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import Seo from '@/core/components/Seo';
+import dynamic from 'next/dynamic';
+import { capitalizeEachWord } from '../../utils/capializeFIrstWord';
+
+// ✅ Breadcrumb = default export
+import Breadcrumb from '@/lib/category/components/Breadcrumb';
+
+const BasicLayout = dynamic(
+ () => import('@/core/components/layouts/BasicLayout'),
+);
+const ProductSearch = dynamic(
+ () => import('@/lib/product/components/ProductSearch'),
+);
+
+export default function KeywordPage() {
+ const route = useRouter();
+
+ const [result, setResult] = useState(null);
+ const [query, setQuery] = useState(null);
+ const [categoryId, setCategoryId] = useState(null);
+
+ const slugRaw = route.query.slug || null;
+ const readableSlug = slugRaw
+ ? decodeURIComponent(slugRaw)
+ .replace(/-/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ : '';
+
+ // 🔹 Fetch searchkey dari Solr
+ const getSearchKeyData = async (slug) => {
+ try {
+ const res = await axios(
+ `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/searchkey?url=${slug}&from=searchkey`,
+ );
+
+ setResult(res?.data?.response?.docs?.[0] || null);
+ } catch (e) {
+ console.error('Fetching searchkey failed:', e);
+ }
+ };
+
+ // 🔹 Trigger fetch saat slug siap
+ useEffect(() => {
+ if (!route.isReady || !slugRaw) return;
+ getSearchKeyData(slugRaw);
+ }, [route.isReady, slugRaw]);
+
+ // 🔹 Ambil product_ids + categoryId dari Solr
+ useEffect(() => {
+ if (!result) return;
+
+ // product search - keep ids for API, add from marker for ProductSearch
+ const ids = result.product_ids_is || [];
+ setQuery({
+ ids: ids.join(','),
+ from: 'searchkey',
+ });
+
+ // breadcrumb category
+ const catId =
+ result.category_id_i ||
+ result.public_categ_id_i ||
+ (result.category_ids_is && result.category_ids_is[0]);
+
+ if (catId) {
+ setCategoryId(catId);
+ }
+ }, [result]);
+
+ return (
+ <BasicLayout>
+ <Seo
+ title={`Beli ${readableSlug} Original & Harga Terjangkau - indoteknik.com`}
+ description={`Beli ${readableSlug} Kirim Jakarta Surabaya Semarang Makassar Manado Denpasar.`}
+ additionalMetaTags={[
+ {
+ property: 'keywords',
+ content: `Beli ${readableSlug}, harga ${readableSlug}, ${readableSlug} murah`,
+ },
+ ]}
+ canonical={`${process.env.NEXT_PUBLIC_SELF_HOST}/searchkey/${slugRaw}`}
+ />
+
+ {/* ✅ Breadcrumb (auto fetch via component) */}
+ {categoryId && (
+ <Breadcrumb categoryId={categoryId} currentLabel={readableSlug} />
+ )}
+
+ {/* ✅ Product result */}
+ {query && (
+ <ProductSearch query={query} prefixUrl={`/searchkey/${slugRaw}`} />
+ )}
+ </BasicLayout>
+ );
+}
diff --git a/src/pages/sitemap/searchkey.xml.js b/src/pages/sitemap/searchkey.xml.js
new file mode 100644
index 00000000..b34baa8b
--- /dev/null
+++ b/src/pages/sitemap/searchkey.xml.js
@@ -0,0 +1,37 @@
+import productSearchApi from '@/lib/product/api/productSearchApi';
+import { create } from 'xmlbuilder';
+import _ from 'lodash-contrib';
+import axios from 'axios';
+
+export async function getServerSideProps({ res }) {
+ const baseUrl = process.env.SELF_HOST + '/sitemap/searchkey';
+ const limit = 5000;
+ const keywords = await axios(
+ `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/searchkey?limit=${limit}&all=1`,
+ );
+ // console.log(keywords);
+ const pageCount = Math.ceil(keywords.data.response.numFound / limit);
+ const pages = Array.from({ length: pageCount }, (_, i) => i + 1);
+ const sitemapIndex = create('sitemapindex', { encoding: 'UTF-8' }).att(
+ 'xmlns',
+ 'http://www.sitemaps.org/schemas/sitemap/0.9',
+ );
+
+ const date = new Date();
+ // const date = '2025-10-30';
+ pages.forEach((page) => {
+ const sitemap = sitemapIndex.ele('sitemap');
+ sitemap.ele('loc', `${baseUrl}/${page}.xml`);
+ sitemap.ele('lastmod', date.toISOString().slice(0, 10));
+ });
+
+ res.setHeader('Content-Type', 'text/xml');
+ res.write(sitemapIndex.end());
+ res.end();
+
+ return { props: {} };
+}
+
+export default function SitemapProducts() {
+ return null;
+}
diff --git a/src/pages/sitemap/searchkey/[page].js b/src/pages/sitemap/searchkey/[page].js
new file mode 100644
index 00000000..cee53f5e
--- /dev/null
+++ b/src/pages/sitemap/searchkey/[page].js
@@ -0,0 +1,42 @@
+import productSearchApi from '@/lib/product/api/productSearchApi';
+import { create } from 'xmlbuilder';
+import _ from 'lodash-contrib';
+import { createSlug } from '@/core/utils/slug';
+import axios from 'axios';
+
+export async function getServerSideProps({ query, res }) {
+ // const baseUrl = process.env.SELF_HOST + '/shop/product/';
+ const { page } = query;
+ const limit = 500;
+ const keywords = await axios(
+ `${
+ process.env.NEXT_PUBLIC_SELF_HOST
+ }/api/shop/searchkey?limit=${limit}&page=${page.replace('.xml', '')}&all=1`
+ );
+
+ const sitemap = create('urlset', { encoding: 'utf-8' }).att(
+ 'xmlns',
+ 'http://www.sitemaps.org/schemas/sitemap/0.9'
+ );
+
+ const date = new Date();
+ // const date = '2025-10-30';
+ keywords.data.response.docs.forEach((product) => {
+ const url = sitemap.ele('url');
+ const loc = product.url_s;
+ url.ele('loc', loc);
+ url.ele('lastmod', date.toISOString().slice(0, 10));
+ url.ele('changefreq', 'daily');
+ url.ele('priority', '0.8');
+ });
+
+ res.setHeader('Content-Type', 'text/xml');
+ res.write(sitemap.end());
+ res.end();
+
+ return { props: {} };
+}
+
+export default function SitemapProducts() {
+ return null;
+}