diff options
3 files changed, 449 insertions, 196 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx index 14b177ad..a58ad5b2 100644 --- a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx +++ b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Modal, ModalOverlay, @@ -18,94 +18,238 @@ import { InputLeftElement, VStack, HStack, - Select, IconButton, Flex, - Icon, // Dibutuhkan untuk membungkus icon dari Lucide + Icon, + Spinner, + List, + ListItem, + useToast, + Select } from '@chakra-ui/react'; -// Import hanya icon standar UI dari lucide-react (pengganti @chakra-ui/icons) -import { Search } from 'lucide-react'; - -// --- Dummy Data Types --- -type Product = { - id: string; - name: string; - price: string; - originalPrice: string; - image: string; - variants: string[]; - specs: Record<string, string>; +import { Search, ShoppingCart, Trash2 } from 'lucide-react'; + +// --- HELPER FORMATTING --- +const formatPrice = (price: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(price); +}; + +const renderSpecValue = (val: any) => { + if (!val || val === '-') return '-'; + return String(val).replace(/<[^>]*>?/gm, ''); }; type Props = { isOpen: boolean; onClose: () => void; + mainProduct: any; + selectedVariant: any; }; -// --- Dummy Data Configuration --- -const SPEC_LABELS = [ - 'Tipe Baterai', - 'Kecepatan Tanpa Beban', - 'Torsi Maksimum', - 'Ukuran Drive', - 'Berat Bersih', - 'Garansi', -]; - -const DUMMY_PRODUCTS: (Product | null)[] = [ - { - id: '1', - name: 'TEKIRO Cordless Impact Wrench 1/2 Inch XV Brushless', - price: 'Rp 999.999', - originalPrice: 'Rp 1.500.000', - image: '/images/no-image-compare.svg', - variants: ['Unit Only', '1 Baterai Kit', '2 Baterai Kit'], - specs: { - 'Tipe Baterai': '20V Lithium-Ion', - 'Kecepatan Tanpa Beban': '0-2400 RPM', - 'Torsi Maksimum': '300 N.m', - 'Ukuran Drive': '1/2 Inch', - 'Berat Bersih': '1.5 Kg', - 'Garansi': '1 Tahun', - }, - }, - { - id: '2', - name: 'Makita Cordless Impact Wrench TW001GZ (40V)', - price: 'Rp 2.450.000', - originalPrice: 'Rp 3.000.000', - image: '/images/no-image-compare.svg', - variants: ['Unit Only', 'Full Set'], - specs: { - 'Tipe Baterai': '40V Max XGT', - 'Kecepatan Tanpa Beban': '0-2500 RPM', - 'Torsi Maksimum': '1350 N.m', - 'Ukuran Drive': '3/4 Inch', - 'Berat Bersih': '2.8 Kg', - 'Garansi': '2 Tahun', - }, - }, - { - id: '3', - name: 'DEWALT Max Brushless 1/2 High Torque Impact', - price: 'Rp 3.100.000', - originalPrice: 'Rp 3.500.000', - image: '/images/no-image-compare.svg', - variants: ['Unit Only'], - specs: { - 'Tipe Baterai': '20V XR Li-Ion', - 'Kecepatan Tanpa Beban': '0-1900 RPM', - 'Torsi Maksimum': '950 N.m', - 'Ukuran Drive': '1/2 Inch', - 'Berat Bersih': '2.6 Kg', - 'Garansi': '3 Tahun', - }, - }, - null, // Slot kosong -]; - -const ProductComparisonModal = ({ isOpen, onClose }: Props) => { +const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }: Props) => { + const toast = useToast(); + + // --- STATE --- + const [products, setProducts] = useState<(any | null)[]>([null, null, null, null]); + const [specsMatrix, setSpecsMatrix] = useState<any[]>([]); + const [isLoadingMatrix, setIsLoadingMatrix] = useState(false); + + // Search State + const [activeSearchSlot, setActiveSearchSlot] = useState<number | null>(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<any[]>([]); + const [isSearching, setIsSearching] = useState(false); + + // =========================================================================== + // 1. LOGIC UTAMA: ISI SLOT 1 + // =========================================================================== + useEffect(() => { + if (isOpen && mainProduct) { + let activeItem = selectedVariant; + + if (!activeItem && mainProduct.variants && mainProduct.variants.length > 0) { + activeItem = mainProduct.variants[0]; + } + if (!activeItem) { + activeItem = mainProduct; + } + + const targetId = activeItem.id; + const displayCode = activeItem.default_code || activeItem.code || activeItem.sku || mainProduct.default_code || mainProduct.code; + + const variantOptions = mainProduct.variants?.map((v: any) => ({ + id: v.id, + code: v.default_code || v.code || v.sku, + name: v.name, + price: v.price?.price || v.price || 0, + image: v.image + })) || []; + + if (variantOptions.length === 0) { + variantOptions.push({ + id: targetId, + code: displayCode, + name: mainProduct.name, + price: activeItem.price?.price || activeItem.price || 0, + image: activeItem.image || mainProduct.image + }); + } + + const productSlot1 = { + id: targetId, + sku: targetId, // ID untuk API + realCode: displayCode, + name: mainProduct.name, + price: activeItem.price?.price || activeItem.price || mainProduct.lowest_price?.price || 0, + image: activeItem.image || mainProduct.image, + variants: variantOptions + }; + + setProducts((prev) => { + const newSlots = [...prev]; + if (!newSlots[0] || String(newSlots[0].id) !== String(targetId)) { + newSlots[0] = productSlot1; + } + return newSlots; + }); + } + }, [isOpen, mainProduct, selectedVariant]); + + // =========================================================================== + // 2. FETCH SPECS + // =========================================================================== + useEffect(() => { + const validProducts = products.filter(p => p !== null); + if (!isOpen || validProducts.length === 0) return; + + const fetchSpecs = async () => { + setIsLoadingMatrix(true); + try { + const allSkus = validProducts.map(p => p.sku).join(','); + const mainSku = validProducts[0]?.sku; + + const res = await fetch(`/api/magento-product?skus=${allSkus}&main_sku=${mainSku}`); + if (!res.ok) return; + + const data = await res.json(); + if (data.specsMatrix) { + setSpecsMatrix(data.specsMatrix); + } + } catch (err) { + console.error(err); + } finally { + setIsLoadingMatrix(false); + } + }; + + fetchSpecs(); + }, [products, isOpen]); + + // =========================================================================== + // 3. SEARCH LOGIC + // =========================================================================== + useEffect(() => { + const delayDebounceFn = setTimeout(async () => { + if (searchQuery.length < 3) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const attrSetId = selectedVariant?.attribute_set_id || mainProduct?.attribute_set_id; + const params = new URLSearchParams({ + source: 'compare', + q: searchQuery, + limit: '5', + fq: attrSetId ? `attribute_set_id_i:${attrSetId}` : '' + }); + + const res = await fetch(`/api/search?${params.toString()}`); + if (res.ok) { + const data = await res.json(); + setSearchResults(data.response?.products || []); + } else { + setSearchResults([]); + } + } catch (e) { + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, 500); + + return () => clearTimeout(delayDebounceFn); + }, [searchQuery, mainProduct, selectedVariant]); + + // =========================================================================== + // 4. HANDLERS + // =========================================================================== + const handleVariantChange = (slotIndex: number, newId: string) => { + const currentProduct = products[slotIndex]; + if (!currentProduct || !currentProduct.variants) return; + + const selectedVar = currentProduct.variants.find((v: any) => String(v.id) === String(newId)); + + if (selectedVar) { + const newProducts = [...products]; + newProducts[slotIndex] = { + ...currentProduct, + id: selectedVar.id, + sku: selectedVar.id, + realCode: selectedVar.code, + price: selectedVar.price, + image: selectedVar.image + }; + setProducts(newProducts); + } + }; + + const handleAddProduct = (solrProduct: any, slotIndex: number) => { + const newProducts = [...products]; + + const idToAdd = solrProduct.product_id_i || solrProduct.id; + const codeToAdd = solrProduct.default_code_s || solrProduct.sku; + + if (newProducts.find(p => p && String(p.id) === String(idToAdd))) { + toast({ title: "Produk sudah ada", status: "warning", position: "top" }); + return; + } + + newProducts[slotIndex] = { + id: idToAdd, + sku: idToAdd, + realCode: codeToAdd, + name: solrProduct.display_name_s || solrProduct.name_s, + price: solrProduct.price_tier1_v2_f || 0, + image: solrProduct.image_s, + variants: [{ + id: idToAdd, + code: codeToAdd, + name: solrProduct.name_s, + price: solrProduct.price_tier1_v2_f, + image: solrProduct.image_s + }] + }; + + setProducts(newProducts); + setActiveSearchSlot(null); + setSearchQuery(''); + setSearchResults([]); + }; + + const handleRemoveProduct = (index: number) => { + const newProducts = [...products]; + newProducts[index] = null; + setProducts(newProducts); + if (newProducts.every(p => p === null)) setSpecsMatrix([]); + }; + return ( <Modal isOpen={isOpen} onClose={onClose} size="6xl" scrollBehavior="inside"> <ModalOverlay /> @@ -114,158 +258,189 @@ const ProductComparisonModal = ({ isOpen, onClose }: Props) => { <HStack spacing={3}> <Text fontSize="xl" fontWeight="bold">Bandingkan Produk</Text> <Badge colorScheme="red" variant="solid" borderRadius="full" px={2}> - Baru + {products.filter(p => p !== null).length} Item </Badge> </HStack> <Text fontSize="sm" color="gray.500" fontWeight="normal" mt={1}> - Detail Spesifikasi Produk yang kamu pilih + Detail Spesifikasi Produk yang kamu pilih </Text> </ModalHeader> <ModalCloseButton /> - <ModalBody p={6}> - {/* Main Grid Layout: 5 Columns */} + <ModalBody p={6} bg="white"> <Grid templateColumns="200px repeat(4, 1fr)" gap={4}> - {/* Cell 1: Top Left Empty Space */} + {/* Cell 1: Kosong */} <GridItem /> - {/* Cell 2-5: Render Products */} - {DUMMY_PRODUCTS.map((product, index) => ( - <GridItem key={index}> + {/* Loop Slot Produk */} + {products.map((product, index) => ( + <GridItem key={index} position="relative" minW="0"> {product ? ( - // --- KARTU PRODUK TERISI --- - <VStack align="stretch" spacing={3}> - {/* Search Bar (Pakai Icon Lucide) */} - <InputGroup size="sm"> - <InputLeftElement pointerEvents="none"> - <Icon as={Search} color="gray.300" /> - </InputLeftElement> - <Input placeholder="Cari Produk lain" borderRadius="md" /> - </InputGroup> + <VStack align="stretch" spacing={3} h="100%"> + + {/* Tombol Hapus */} + {index !== 0 && ( + <IconButton + aria-label="Hapus" icon={<Trash2 size={16}/>} + size="xs" position="absolute" top={-2} right={-2} + colorScheme="red" onClick={() => handleRemoveProduct(index)} zIndex={2} + /> + )} - {/* Gambar Produk */} - <Box h="180px" display="flex" alignItems="center" justifyContent="center"> + {/* Gambar */} + <Box h="160px" display="flex" alignItems="center" justifyContent="center" bg="gray.50" borderRadius="md" p={2}> <Image - src={product.image} + src={product.image || '/images/noimage.jpeg'} alt={product.name} - maxH="100%" - objectFit="contain" + maxH="100%" objectFit="contain" + onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} /> </Box> - {/* Harga */} + {/* Info Harga & Nama */} <Box> - <Text color="red.500" fontWeight="bold" fontSize="lg"> - {product.price} + <Text color="red.600" fontWeight="bold" fontSize="md"> + {product.price > 0 ? formatPrice(product.price) : 'Hubungi Admin'} </Text> - <Text as="s" color="gray.400" fontSize="xs"> - {product.originalPrice} + {/* Margin Bottom agar tidak tertutup dropdown */} + <Text fontSize="xs" fontWeight="bold" noOfLines={2} h="35px" title={product.name} mb={2}> + {product.name} </Text> </Box> - {/* Nama Produk */} - <Text fontSize="sm" fontWeight="bold" noOfLines={2} h="40px"> - {product.name} - </Text> - {/* Dropdown Varian */} - <Select size="sm" placeholder="Pilih Varian" borderRadius="md"> - {product.variants.map((v) => ( - <option key={v} value={v}>{v}</option> - ))} + <Select + size="sm" + borderRadius="md" + fontSize="xs" + value={product.id} + onChange={(e) => handleVariantChange(index, e.target.value)} + isDisabled={false} + bg="white" + > + {product.variants && product.variants.map((v: any) => ( + <option key={v.id} value={v.id}> + {v.code} + </option> + ))} </Select> - {/* Tombol Aksi (Pakai SVG Custom untuk Keranjang) */} + {/* Tombol */} <HStack spacing={2}> - <IconButton - aria-label="Add to cart" - icon={<Image src="/images/keranjang-compare.svg" w="20px" />} - variant="outline" - colorScheme="red" - size="sm" - w="40px" - /> - <Button colorScheme="red" size="sm" flex={1} fontSize="xs"> - Beli Sekarang - </Button> + <IconButton aria-label="Cart" icon={<Icon as={ShoppingCart} />} variant="outline" colorScheme="red" size="sm" /> + <Button as="a" href={`/product/${product.id}`} target="_blank" colorScheme="red" size="sm" flex={1} fontSize="xs"> + Lihat Detail + </Button> </HStack> </VStack> ) : ( - // --- SLOT KOSONG --- - <VStack align="stretch" spacing={3} h="100%"> - <InputGroup size="sm"> - <InputLeftElement pointerEvents="none"> - <Icon as={Search} color="gray.300" /> - </InputLeftElement> - <Input placeholder="Cari Produk lain" borderRadius="md" /> + // SLOT KOSONG + <VStack align="stretch" spacing={3} h="100%" position="relative"> + <InputGroup size="sm"> + <InputLeftElement pointerEvents="none"><Icon as={Search} color="gray.300" /></InputLeftElement> + <Input + placeholder="Cari Produk..." borderRadius="md" + value={activeSearchSlot === index ? searchQuery : ''} + onFocus={() => { setActiveSearchSlot(index); setSearchQuery(''); }} + onChange={(e) => setSearchQuery(e.target.value)} + /> </InputGroup> + + {activeSearchSlot === index && searchQuery.length > 0 && ( + <Box position="absolute" top="35px" left={0} right={0} bg="white" boxShadow="lg" zIndex={10} borderRadius="md" border="1px solid" borderColor="gray.200" maxH="250px" overflowY="auto"> + {isSearching ? ( + <Box p={4} textAlign="center"><Spinner size="sm" color="red.500"/></Box> + ) : searchResults.length > 0 ? ( + <List spacing={0}> + {searchResults.map((res) => ( + <ListItem + key={res.product_id_i || res.id} + p={2} borderBottom="1px solid #f0f0f0" + _hover={{ bg: 'red.50', cursor: 'pointer' }} + onClick={() => handleAddProduct(res, index)} + > + <Flex align="center" gap={2}> + <Image src={res.image_s || '/images/noimage.jpeg'} boxSize="30px" objectFit="contain" onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} /> + <Box> + <Text fontSize="xs" fontWeight="bold" noOfLines={1}>{res.display_name_s || res.name_s}</Text> + <Text fontSize="xs" color="red.500">{formatPrice(res.price_tier1_v2_f || 0)}</Text> + </Box> + </Flex> + </ListItem> + ))} + </List> + ) : ( + <Box p={3} fontSize="xs" color="gray.500" textAlign="center">Tidak ditemukan.</Box> + )} + </Box> + )} - <Flex - direction="column" - align="center" - justify="center" - h="300px" - border="2px dashed" - borderColor="gray.200" - borderRadius="md" - bg="gray.50" - > - {/* Placeholder Image (SVG Custom) */} - <Image - src="/images/no-image-compare.svg" - w="60px" - mb={4} - opacity={0.5} - /> - <Text fontSize="sm" color="gray.400" textAlign="center"> - Produk belum ditambahkan - </Text> + <Flex direction="column" align="center" justify="center" flex={1} border="2px dashed" borderColor="gray.200" borderRadius="md" bg="gray.50" color="gray.400"> + <Icon as={Search} w={8} h={8} opacity={0.3} mb={2} /> + <Text fontSize="xs" textAlign="center">Tambah produk<br/>untuk membandingkan</Text> </Flex> </VStack> )} </GridItem> ))} - {/* --- SEPARATOR HEADER SPEC --- */} + {/* --- BAGIAN SPESIFIKASI --- */} <GridItem colSpan={5} py={6}> - <Box> + <Box borderBottom="2px solid" borderColor="gray.100" pb={2}> <Text fontSize="lg" fontWeight="bold">Spesifikasi Teknis</Text> - <Text fontSize="sm" color="gray.500">Detail Spesifikasi Produk yang kamu pilih</Text> </Box> </GridItem> - {/* --- TABLE SPEC --- */} - {SPEC_LABELS.map((label, rowIndex) => ( - <React.Fragment key={label}> - {/* Kolom Label */} - <GridItem - py={3} - borderBottom="1px solid" - borderColor="gray.100" - bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} - > - <Text fontWeight="bold" fontSize="sm" color="gray.700"> - {label} - </Text> - </GridItem> + {isLoadingMatrix ? ( + <GridItem colSpan={5} textAlign="center" py={10}> + <Spinner color="red.500" thickness="4px" size="xl" /> + <Text mt={2} color="gray.500">Memuat data...</Text> + </GridItem> + ) : specsMatrix.length > 0 ? ( + specsMatrix.map((row, rowIndex) => ( + <React.Fragment key={row.code || rowIndex}> + {/* Label (Kiri) */} + <GridItem + py={3} + px={2} + borderBottom="1px solid" + borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" + alignItems="center" + > + <Text fontWeight="bold" fontSize="sm" color="gray.700">{row.label}</Text> + </GridItem> - {/* Kolom Value */} - {DUMMY_PRODUCTS.map((product, colIndex) => ( - <GridItem - key={`${label}-${colIndex}`} - py={3} - borderBottom="1px solid" - borderColor="gray.100" - bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} - > - <Text fontSize="sm" color="gray.600"> - {product ? (product.specs[label] || '-') : '-'} - </Text> - </GridItem> - ))} - </React.Fragment> - ))} + {/* Value (Rata Tengah) */} + {products.map((product, colIndex) => { + const val = product ? (row.values[String(product.sku)] || '-') : ''; + + return ( + <GridItem + key={`${row.code}-${colIndex}`} + py={3} + px={2} + borderBottom="1px solid" + borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" // [FIX] Flex untuk centering + alignItems="center" // [FIX] Vertical Center + justifyContent="center" // [FIX] Horizontal Center + textAlign="center" // [FIX] Text Align Center + > + <Text fontSize="sm" color="gray.600">{renderSpecValue(val)}</Text> + </GridItem> + ); + })} + </React.Fragment> + )) + ) : ( + <GridItem colSpan={5} py={10} textAlign="center" color="gray.500" bg="gray.50"> + <Text>Data spesifikasi belum tersedia untuk produk ini.</Text> + </GridItem> + )} </Grid> </ModalBody> diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 0f851560..58276f3c 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -334,7 +334,9 @@ const ProductDetail = ({ product }: Props) => { {/* Render di luar layout utama agar tidak tertutup elemen lain */} <ProductComparisonModal isOpen={isCompareOpen} - onClose={() => setCompareOpen(false)} + onClose={() => setCompareOpen(false)} + mainProduct={product} + selectedVariant={selectedVariant} /> <div className='relative'> diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index 7d4adfcb..90841e09 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -2,6 +2,14 @@ import { productMappingSolr } from '@/utils/solrMapping'; import axios from 'axios'; import camelcaseObjectDeep from 'camelcase-object-deep'; +const escapeSolrQuery = (query) => { + if (query == '*') return query; + query = query.replace(/-/g, ' '); + const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; + const words = query.split(/\s+/); + return words.map((word) => specialChars.test(word) ? word.replace(specialChars, '\\$1') : word).join(' '); +}; + export default async function handler(req, res) { const { q = '*', @@ -20,6 +28,82 @@ export default async function handler(req, res) { let { stock = '' } = req.query; // ============================================================ + // [BARU] 1. LOGIC KHUSUS COMPARE (Wajib ditaruh paling atas) + // ============================================================ + if (source === 'compare') { + try { + let qCompare = q === '*' ? '*:*' : q; + + // Sanitasi Query + if (qCompare !== '*:*') { + const escaped = escapeSolrQuery(qCompare); + qCompare = `*${escaped}*`; + } + + // Susun Parameter Solr + const parameter = [ + `q=${encodeURIComponent(qCompare)}`, + `rows=${limit}`, + 'wt=json', + 'indent=true', + 'defType=edismax', + + // Grouping agar varian tidak banjir (per template) + 'group=true', + 'group.field=template_id_i', + 'group.limit=1', + 'group.main=true', + + // Field Wajib (Perhatikan: kita butuh product_id_i/default_code_s) + '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 Dasar + 'fq=-publish_b:false', + 'fq=price_tier1_v2_f:[1 TO *]' + ]; + + // Logic Locking (Filter Attribute Set ID dari Frontend) + // Frontend akan mengirim fq="attribute_set_id_i:9" + if (fq) { + if (Array.isArray(fq)) { + fq.forEach(f => parameter.push(`fq=${encodeURIComponent(f)}`)); + } else { + parameter.push(`fq=${encodeURIComponent(fq)}`); + } + } + + // Target Core: VARIANTS (Karena compare butuh data spesifik) + const solrUrl = process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&'); + + const result = await axios(solrUrl); + + // Mapping Result + const mappedProducts = productMappingSolr( + result.data.response.docs, + false + ); + + const finalResponse = { + ...result.data, + response: { + ...result.data.response, + products: mappedProducts + } + }; + + delete finalResponse.response.docs; + const camelCasedData = camelcaseObjectDeep(finalResponse); + + return res.status(200).json(camelCasedData); + + } catch (e) { + console.error('[COMPARE SEARCH ERROR]', e.message); + // Return JSON valid meski kosong, agar frontend tidak error syntax + return res.status(200).json({ response: { products: [], numFound: 0 } }); + } + } + + // ============================================================ // LOGIC KHUSUS UPSELL (Simple & Direct) // ============================================================ if (source === 'upsell') { @@ -219,12 +303,4 @@ export default async function handler(req, res) { } catch (error) { res.status(400).json({ error: error.message }); } -} - -const escapeSolrQuery = (query) => { - if (query == '*') return query; - query = query.replace(/-/g, ' '); - const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; - const words = query.split(/\s+/); - return words.map((word) => specialChars.test(word) ? word.replace(specialChars, '\\$1') : word).join(' '); -};
\ No newline at end of file +}
\ No newline at end of file |
