diff options
| author | IT Fixcomart <it@fixcomart.co.id> | 2026-02-19 10:15:38 +0000 |
|---|---|---|
| committer | IT Fixcomart <it@fixcomart.co.id> | 2026-02-19 10:15:38 +0000 |
| commit | 87733bb59bdd6ee3c1b54d006f8465ec1763a0aa (patch) | |
| tree | 130ef14de3f003aab37e003b1ba15dcda166eff4 | |
| parent | 7ef19bc5b5dc64fc0fb8126cec02943f06a4237a (diff) | |
| parent | 6aecb5c1a2ee384b8ea2847a543142bfaa9c48f2 (diff) | |
Merged in cr_renca_keyword (pull request #474)
FEat renca keyword
| -rw-r--r-- | src/lib/category/components/Breadcrumb.jsx | 46 | ||||
| -rw-r--r-- | src/lib/product/api/productSearchApi.js | 31 | ||||
| -rw-r--r-- | src/lib/product/components/ProductSearch.jsx | 74 | ||||
| -rw-r--r-- | src/lib/utils/batchSolrQueries.js | 113 | ||||
| -rw-r--r-- | src/pages/api/shop/search.js | 372 | ||||
| -rw-r--r-- | src/pages/api/shop/searchkey.js | 43 | ||||
| -rw-r--r-- | src/pages/searchkey/[slug].jsx | 98 | ||||
| -rw-r--r-- | src/pages/sitemap/searchkey.xml.js | 37 | ||||
| -rw-r--r-- | src/pages/sitemap/searchkey/[page].js | 42 |
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; +} |
