From 31853dc731c6e4105c9cf9bd373c63e6e989caa4 Mon Sep 17 00:00:00 2001 From: FIN-IT_AndriFP Date: Tue, 16 Dec 2025 14:35:33 +0700 Subject: (andri) try compare with data --- .../components/ProductComparisonModal.tsx | 547 ++++++++++++++------- .../product-detail/components/ProductDetail.tsx | 4 +- src/pages/api/shop/search.js | 94 +++- 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; +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([]); + const [isLoadingMatrix, setIsLoadingMatrix] = useState(false); + + // Search State + const [activeSearchSlot, setActiveSearchSlot] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + 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 ( @@ -114,158 +258,189 @@ const ProductComparisonModal = ({ isOpen, onClose }: Props) => { Bandingkan Produk - Baru + {products.filter(p => p !== null).length} Item - Detail Spesifikasi Produk yang kamu pilih + Detail Spesifikasi Produk yang kamu pilih - - {/* Main Grid Layout: 5 Columns */} + - {/* Cell 1: Top Left Empty Space */} + {/* Cell 1: Kosong */} - {/* Cell 2-5: Render Products */} - {DUMMY_PRODUCTS.map((product, index) => ( - + {/* Loop Slot Produk */} + {products.map((product, index) => ( + {product ? ( - // --- KARTU PRODUK TERISI --- - - {/* Search Bar (Pakai Icon Lucide) */} - - - - - - + + + {/* Tombol Hapus */} + {index !== 0 && ( + } + size="xs" position="absolute" top={-2} right={-2} + colorScheme="red" onClick={() => handleRemoveProduct(index)} zIndex={2} + /> + )} - {/* Gambar Produk */} - + {/* Gambar */} + {product.name} { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} /> - {/* Harga */} + {/* Info Harga & Nama */} - - {product.price} + + {product.price > 0 ? formatPrice(product.price) : 'Hubungi Admin'} - - {product.originalPrice} + {/* Margin Bottom agar tidak tertutup dropdown */} + + {product.name} - {/* Nama Produk */} - - {product.name} - - {/* Dropdown Varian */} - handleVariantChange(index, e.target.value)} + isDisabled={false} + bg="white" + > + {product.variants && product.variants.map((v: any) => ( + + ))} - {/* Tombol Aksi (Pakai SVG Custom untuk Keranjang) */} + {/* Tombol */} - } - variant="outline" - colorScheme="red" - size="sm" - w="40px" - /> - + } variant="outline" colorScheme="red" size="sm" /> + ) : ( - // --- SLOT KOSONG --- - - - - - - + // SLOT KOSONG + + + + { setActiveSearchSlot(index); setSearchQuery(''); }} + onChange={(e) => setSearchQuery(e.target.value)} + /> + + {activeSearchSlot === index && searchQuery.length > 0 && ( + + {isSearching ? ( + + ) : searchResults.length > 0 ? ( + + {searchResults.map((res) => ( + handleAddProduct(res, index)} + > + + { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} /> + + {res.display_name_s || res.name_s} + {formatPrice(res.price_tier1_v2_f || 0)} + + + + ))} + + ) : ( + Tidak ditemukan. + )} + + )} - - {/* Placeholder Image (SVG Custom) */} - - - Produk belum ditambahkan - + + + Tambah produk
untuk membandingkan
)}
))} - {/* --- SEPARATOR HEADER SPEC --- */} + {/* --- BAGIAN SPESIFIKASI --- */} - + Spesifikasi Teknis - Detail Spesifikasi Produk yang kamu pilih - {/* --- TABLE SPEC --- */} - {SPEC_LABELS.map((label, rowIndex) => ( - - {/* Kolom Label */} - - - {label} - - + {isLoadingMatrix ? ( + + + Memuat data... + + ) : specsMatrix.length > 0 ? ( + specsMatrix.map((row, rowIndex) => ( + + {/* Label (Kiri) */} + + {row.label} + - {/* Kolom Value */} - {DUMMY_PRODUCTS.map((product, colIndex) => ( - - - {product ? (product.specs[label] || '-') : '-'} - - - ))} - - ))} + {/* Value (Rata Tengah) */} + {products.map((product, colIndex) => { + const val = product ? (row.values[String(product.sku)] || '-') : ''; + + return ( + + {renderSpecValue(val)} + + ); + })} + + )) + ) : ( + + Data spesifikasi belum tersedia untuk produk ini. + + )}
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 */} setCompareOpen(false)} + onClose={() => setCompareOpen(false)} + mainProduct={product} + selectedVariant={selectedVariant} />
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 = '*', @@ -19,6 +27,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) // ============================================================ @@ -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 -- cgit v1.2.3