diff options
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductDetail.tsx | 55 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/SimilarSide.tsx | 74 | ||||
| -rw-r--r-- | src-migrate/services/product.ts | 40 | ||||
| -rw-r--r-- | src/pages/api/magento-product.ts | 29 | ||||
| -rw-r--r-- | src/pages/api/shop/search.js | 230 |
5 files changed, 242 insertions, 186 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 5f930117..cfe73628 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -67,6 +67,8 @@ const ProductDetail = ({ product }: Props) => { // State Data dari Magento const [specs, setSpecs] = useState<{ code: string; label: string; value: string }[]>([]); const [upsellIds, setUpsellIds] = useState<number[]>([]); + const [relatedIds, setRelatedIds] = useState<number[]>([]); + const [loadingSpecs, setLoadingSpecs] = useState(false); const [errorSpecs, setErrorSpecs] = useState(false); @@ -109,7 +111,7 @@ const ProductDetail = ({ product }: Props) => { }, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl]); // ========================================================================= - // LOGIC INISIALISASI VARIANT (HANDLE NAVIGASI) + // LOGIC INISIALISASI VARIANT (RESET SAAT NAVIGASI) // ========================================================================= useEffect(() => { if (typeof auth === 'object') { @@ -121,28 +123,29 @@ const ProductDetail = ({ product }: Props) => { setSelectedVariant(variantInit); - // Reset data Magento saat produk berubah + // Reset data Magento setSpecs([]); setUpsellIds([]); + setRelatedIds([]); }, [product, auth]); // ========================================================================= - // LOGIC FETCH: SPECS & UPSELLS + // LOGIC FETCH: SPECS, UPSELLS, RELATED // ========================================================================= useEffect(() => { const fetchMagentoData = async () => { - // Gunakan ID Variant (SKU Odoo) - const idToFetch = selectedVariant?.id; + // Validasi kepemilikan varian + if (!selectedVariant?.id) return; + const isVariantOwner = product.variants.some(v => Number(v.id) === Number(selectedVariant.id)); + if (!isVariantOwner) return; - if (!idToFetch) return; + const idToFetch = selectedVariant.id; setLoadingSpecs(true); setErrorSpecs(false); try { - console.log("Fetching Magento data via Proxy for ID:", idToFetch); - const endpoint = `/api/magento-product?sku=${encodeURIComponent(String(idToFetch))}`; const response = await fetch(endpoint, { @@ -151,61 +154,75 @@ const ProductDetail = ({ product }: Props) => { }); if (!response.ok) { - console.warn(`Magento API status: ${response.status}`); setSpecs([]); setUpsellIds([]); + setRelatedIds([]); return; } const data = await response.json(); - // 1. Simpan Data Spesifikasi + // Double Check + if (Number(idToFetch) !== Number(selectedVariant.id)) return; + + // 1. Specs if (data.specs && Array.isArray(data.specs)) { setSpecs(data.specs); } else { setSpecs([]); } - // 2. Simpan Data Up-Sells (ID) + // 2. Upsell if (data.upsell_ids && Array.isArray(data.upsell_ids)) { setUpsellIds(data.upsell_ids); } else { setUpsellIds([]); } + // 3. Related + if (data.related_ids && Array.isArray(data.related_ids)) { + setRelatedIds(data.related_ids); + } else { + setRelatedIds([]); + } + } catch (error) { console.error("Gagal mengambil data Magento:", error); setErrorSpecs(true); setSpecs([]); setUpsellIds([]); + setRelatedIds([]); } finally { setLoadingSpecs(false); } }; fetchMagentoData(); - }, [selectedVariant, product.id]); + }, [selectedVariant, product]); // ========================================================================= - // HELPER: RENDER SPEC VALUE (SIMPLE TEXT/HTML ONLY) + // HELPER: RENDER SPEC VALUE (SIMPLE - NO LINK DETECT) // ========================================================================= const renderSpecValue = (item: { code: string; label: string; value: string }) => { const val = item.value; if (!val) return '-'; - // Cek apakah mengandung tag HTML sederhana (<p>, <a>, <ul>, dll) - if (val.includes('<') && val.includes('>')) { + const cleanVal = val.trim(); + + // 1. JIKA HTML (Legacy Data) + // Deteksi tag HTML sederhana + if (cleanVal.includes('<') && cleanVal.includes('>')) { return ( <div className="prose prose-sm text-gray-700" - dangerouslySetInnerHTML={{ __html: val }} + dangerouslySetInnerHTML={{ __html: cleanVal }} /> ); } - // Default: Teks Biasa - return val; + // 2. TEKS BIASA + return cleanVal; }; @@ -544,7 +561,7 @@ const ProductDetail = ({ product }: Props) => { <div className='h-6' /> <div className={style['heading']}>Produk Serupa</div> <div className='h-4' /> - <SimilarSide product={product} /> + <SimilarSide product={product} relatedIds={relatedIds} /> </div> )} diff --git a/src-migrate/modules/product-detail/components/SimilarSide.tsx b/src-migrate/modules/product-detail/components/SimilarSide.tsx index d70a314d..51d9eff7 100644 --- a/src-migrate/modules/product-detail/components/SimilarSide.tsx +++ b/src-migrate/modules/product-detail/components/SimilarSide.tsx @@ -1,33 +1,75 @@ import { Skeleton } from '@chakra-ui/react' +import { useQuery } from 'react-query' import ProductCard from '~/modules/product-card' -import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar' -import { IProductDetail } from '~/types/product' +// Import service +import { getProductSimilar, getProductsByIds } from '~/services/product' +// TAMBAHKAN 'IProduct' DISINI +import { IProduct, IProductDetail } from '~/types/product' type Props = { product: IProductDetail + relatedIds?: number[] } -const SimilarSide = ({ product }: Props) => { - const productSimilar = useProductSimilar({ - name: product.name, - except: { productId: product.id, manufactureId: product.manufacture.id }, - }) +const SimilarSide = ({ product, relatedIds = [] }: Props) => { + + const hasRelated = relatedIds.length > 0; - const products = productSimilar.data?.products || [] + // 1. Fetch Related by ID + const relatedQuery = useQuery({ + queryKey: ['product-related', relatedIds], + queryFn: () => getProductsByIds({ ids: relatedIds }), + enabled: hasRelated, + staleTime: 1000 * 60 * 5, + }); + + // 2. Fetch Similar Biasa + const similarQuery = useQuery({ + queryKey: ['product-similar-side', product.name], + queryFn: () => getProductSimilar({ + name: product.name, + except: { + productId: product.id, + manufactureId: product.manufacture?.id + } + }), + enabled: !hasRelated, + staleTime: 1000 * 60 * 5, + }); + + // ============================================================ + // PERBAIKAN: Definisikan tipe array secara eksplisit (IProduct[]) + // ============================================================ + let products: IProduct[] = []; + let isLoading = false; + + if (hasRelated) { + // Cast ke any dulu jika tipe return service belum sempurna terdeteksi, lalu ambil products + // Atau jika getProductsByIds me-return { products: IProduct[] }, ambil .products + // Sesuai kode service terakhir, getProductsByIds me-return GetProductSimilarRes yg punya .products + products = (relatedQuery.data as any)?.products || []; + isLoading = relatedQuery.isLoading; + } else { + products = similarQuery.data?.products || []; + isLoading = similarQuery.isLoading; + } + + if (!isLoading && products.length === 0) return null; return ( <Skeleton - isLoaded={!productSimilar.isLoading} - className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg" + isLoaded={!isLoading} + className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg p-2" rounded='lg' > - {products.map((product) => ( - <ProductCard - key={product.id} - product={product} - layout='horizontal' - /> + {products.map((item) => ( + <div key={item.id} className="pt-2 first:pt-0"> + <ProductCard + product={item} + layout='horizontal' + /> + </div> ))} </Skeleton> ) diff --git a/src-migrate/services/product.ts b/src-migrate/services/product.ts index f77fc3ec..fa9dae54 100644 --- a/src-migrate/services/product.ts +++ b/src-migrate/services/product.ts @@ -76,29 +76,43 @@ export interface GetProductsByIdsProps { export const getProductsByIds = async ({ ids, }: GetProductsByIdsProps): Promise<GetProductSimilarRes> => { - // Jika array ID kosong, kembalikan object kosong agar tidak error if (!ids || ids.length === 0) { - return { - products: [], - num_found: 0, - num_found_exact: true, - start: 0 - }; + return { products: [], num_found: 0, num_found_exact: true, start: 0 }; } - // Buat query Solr format: product_id_i:(224102 OR 88019 OR ...) const idQuery = ids.join(' OR '); const query = [ - `q=*`, // Query wildcard (ambil semua) - `fq=product_id_i:(${idQuery})`, // TAPI difilter hanya ID yang ada di list upsell + `q=*`, + `fq=(id:(${idQuery}) OR product_id_i:(${idQuery}))`, 'rows=20', `source=upsell`, ]; const url = `${SELF_HOST}/api/shop/search?${query.join('&')}`; - return await fetch(url) - .then((res) => res.json()) - .then((res) => snakeCase(res.response)); + // Request + const res = await fetch(url).then((res) => res.json()); + + // LOG 2: Hasil Pencarian SOLR + console.group("🔍 2. [Solr Search Result]"); + console.log("Request URL:", url); + console.log("Requested IDs:", ids); + + const foundDocs = res.response?.docs || []; + const foundIds = foundDocs.map((doc: any) => doc.id || doc.product_id_i); + + console.log("Found Products Count:", res.response?.numFound); + console.log("Found IDs:", foundIds); + + // Cek ID mana yang hilang + const missingIds = ids.filter((reqId) => !foundIds.includes(String(reqId)) && !foundIds.includes(Number(reqId))); + if (missingIds.length > 0) { + console.warn("⚠️ MISSING / NOT FOUND IDs:", missingIds); + } else { + console.log("✅ All IDs Found!"); + } + console.groupEnd(); + + return snakeCase(res.response); };
\ No newline at end of file diff --git a/src/pages/api/magento-product.ts b/src/pages/api/magento-product.ts index c494b05d..32dd0e5c 100644 --- a/src/pages/api/magento-product.ts +++ b/src/pages/api/magento-product.ts @@ -54,19 +54,14 @@ export default async function handler( let specsWithLabels: any[] = []; - // Cek apakah ada custom_attributes if (data.custom_attributes) { - // Filter atribut yang kodenya dimulai dengan 'z' - // FIX: Menghapus filter 'Pakai link' agar MSDS tetap muncul const zAttributes = data.custom_attributes.filter((attr: any) => attr.attribute_code.startsWith('z') ); - // Fetch detail label untuk setiap atribut secara paralel specsWithLabels = await Promise.all( zAttributes.map(async (attr: any) => { try { - // Endpoint untuk ambil detail atribut (Label) const attrUrl = `https://pimdev.1211.my.id/rest/V1/products/attributes/${attr.attribute_code}`; const attrRes = await fetch(attrUrl, { @@ -80,7 +75,7 @@ export default async function handler( if (attrRes.ok) { const attrData = await attrRes.json(); return { - code: attr.attribute_code, // FIX: Kirim code agar bisa dideteksi frontend (z_doc_) + code: attr.attribute_code, label: attrData.default_frontend_label || attr.attribute_code, value: attr.value }; @@ -89,12 +84,11 @@ export default async function handler( console.error(`Failed to fetch label for ${attr.attribute_code}`); } - // Fallback: Format manual const fallbackLabel = attr.attribute_code .substring(1).replace(/_/g, ' ').trim(); return { - code: attr.attribute_code, // FIX: Kirim code di fallback juga + code: attr.attribute_code, label: fallbackLabel, value: attr.value }; @@ -103,15 +97,24 @@ export default async function handler( } // ===================================================================== - // TAMBAHAN 2: AMBIL UP-SELLS (product_links) + // TAMBAHAN 2: AMBIL UP-SELLS (product_links type = upsell) // ===================================================================== let upsellIds: number[] = []; if (data.product_links && Array.isArray(data.product_links)) { upsellIds = data.product_links - // Filter hanya link type 'upsell' .filter((link: any) => link.link_type === 'upsell') - // Ambil SKU (yang isinya ID Odoo) dan ubah ke number + .map((link: any) => Number(link.linked_product_sku)); + } + + // ===================================================================== + // TAMBAHAN 3: AMBIL RELATED PRODUCTS (product_links type = related) + // ===================================================================== + let relatedIds: number[] = []; + + if (data.product_links && Array.isArray(data.product_links)) { + relatedIds = data.product_links + .filter((link: any) => link.link_type === 'related') .map((link: any) => Number(link.linked_product_sku)); } @@ -121,10 +124,12 @@ export default async function handler( const responseData = { ...data, specs: specsWithLabels, // Data Spesifikasi (z_*) - upsell_ids: upsellIds // Data Upsell ID (product_links) + upsell_ids: upsellIds, // Data Upsell ID + related_ids: relatedIds // Data Related ID (BARU) }; res.status(200).json(responseData); + console.log(responseData); } catch (error) { console.error('Proxy Server Error:', error); diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index 42d16100..7d4adfcb 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -12,7 +12,7 @@ export default async function handler(req, res) { priceTo = 0, orderBy = '', operation = 'AND', - fq = '', + fq = '', // bisa berupa string atau array limit = 30, source = '', } = req.query; @@ -20,107 +20,119 @@ export default async function handler(req, res) { let { stock = '' } = req.query; // ============================================================ - // SITEMAP + // LOGIC KHUSUS UPSELL (Simple & Direct) // ============================================================ - if (source === 'sitemap') { + if (source === 'upsell') { try { - const offset = (page - 1) * limit; + // 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}`, - - // // ❌ EXCLUDE PROMOTION - // 'fq=-(name_s:*promotion* OR display_name_s:*promotion* OR variants_name_t:*promotion*)', - - // // ❌ EXCLUDE DUMMY PRODUCT - // 'fq=-(name_s:*dummy* OR display_name_s:*dummy* OR variants_name_t:*dummy* OR default_code_s:A.*)', + // PENTING: SEARCH DI CORE 'VARIANTS' + const solrUrl = process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&'); - // 'fl=product_id_i,name_s,default_code_s,image_s,category_name', - // 'wt=json', - // 'omitHeader=true', - // ]; + const result = await axios(solrUrl); - 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( + // 1. Mapping dasar + const mappedProducts = productMappingSolr( result.data.response.docs, false ); - delete result.data.response.docs; + // 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); - result.data = camelcaseObjectDeep(result.data); + } catch (e) { + console.error('[UPSELL ERROR]', e.response?.data || e.message); + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + // ============================================================ + // SITEMAP (Biarkan tetap sama) + // ============================================================ + 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('&'); + const result = await axios(solrUrl, { timeout: 25000 }); + 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); } catch (e) { - console.error('[SITEMAP ERROR]', e); return res.status(500).json({ error: 'Sitemap query failed' }); } } // ============================================================ - // SEARCH NORMAL + // SEARCH NORMAL (LOGIKA LAMA) // ============================================================ 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', @@ -128,23 +140,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,16 +172,16 @@ 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 - }]` - ); + parameter.push(`fq=price_tier1_v2_f:[${priceFrom || '*'} TO ${priceTo || '*'}]`); } let { auth } = req.cookies; @@ -183,49 +191,28 @@ export default async function handler(req, res) { } if (brand) { - const brandExpr = brand - .split(',') - .map( - (manufacturer) => - `manufacture_name:"${encodeURIComponent(manufacturer)}"` - ) - .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((cat) => `category_name:"${encodeURIComponent(cat)}"`) - .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)}`) - ); - - const solrUrl = - process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); - - const result = await axios(solrUrl); + // SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT' + 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 ); - 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); @@ -236,17 +223,8 @@ export default async function handler(req, res) { 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(' '); -}; + return words.map((word) => specialChars.test(word) ? word.replace(specialChars, '\\$1') : word).join(' '); +};
\ No newline at end of file |
