diff options
| author | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2025-12-22 09:22:14 +0700 |
|---|---|---|
| committer | FIN-IT_AndriFP <andrifebriyadiputra@gmail.com> | 2025-12-22 09:22:14 +0700 |
| commit | 190bd5c5f66fc319d91d7ad3979ef8ff125dbd52 (patch) | |
| tree | fde8b945bd04c5fc5e8cd3fe531f6b392bc9ddc6 | |
| parent | cb447f728d30e31f0cc4f4cb9f7363274925ceef (diff) | |
(andri) fix view
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductComparisonModal.tsx | 233 |
1 files changed, 147 insertions, 86 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx index 43f43ac4..1190e95d 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, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; // Tambah useRef import { Modal, ModalOverlay, @@ -25,10 +25,11 @@ import { List, ListItem, useToast, - Select + Select, + useOutsideClick // Tambah import ini } from '@chakra-ui/react'; -import { Search, ShoppingCart, Trash2 } from 'lucide-react'; +import { Search, Trash2 } from 'lucide-react'; // --- HELPER FORMATTING --- const formatPrice = (price: number) => { @@ -65,6 +66,20 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const [searchResults, setSearchResults] = useState<any[]>([]); const [isSearching, setIsSearching] = useState(false); + // --- REF & OUTSIDE CLICK --- + const searchWrapperRef = useRef<HTMLDivElement>(null); // Ref untuk mendeteksi klik + + useOutsideClick({ + ref: searchWrapperRef, + handler: () => { + // Jika user klik di luar area search yang aktif, tutup search + if (activeSearchSlot !== null) { + setActiveSearchSlot(null); + setSearchResults([]); // Opsional: bersihkan hasil juga + } + }, + }); + // =========================================================================== // 1. LOGIC UTAMA: ISI SLOT 1 // =========================================================================== @@ -80,7 +95,6 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant } const targetId = activeItem.id; - // Ambil code dari properti yang tersedia const displayCode = activeItem.default_code || activeItem.code || activeItem.sku || mainProduct.default_code || mainProduct.code; const variantOptions = mainProduct.variants?.map((v: any) => ({ @@ -103,8 +117,8 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const productSlot1 = { id: targetId, - sku: targetId, // ID untuk API & Key - realCode: displayCode, // String untuk Tampilan + sku: targetId, + realCode: displayCode, name: mainProduct.name, price: activeItem.price?.price || activeItem.price || mainProduct.lowest_price?.price || 0, image: activeItem.image || mainProduct.image, @@ -131,7 +145,6 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const fetchSpecs = async () => { setIsLoadingMatrix(true); try { - // Kirim ID ke API const allSkus = validProducts.map(p => p.sku).join(','); const mainSku = validProducts[0]?.sku; @@ -153,34 +166,26 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }, [products, isOpen]); // =========================================================================== - // 3. SEARCH LOGIC (MATCHED WITH API JSON) + // 3. SEARCH LOGIC // =========================================================================== useEffect(() => { - // Gunakan timeout untuk debounce (menunggu user selesai ketik) const delayDebounceFn = setTimeout(async () => { - - // LOGIKA BARU: - // Jika ada text tapi kurang dari 3 huruf -> Jangan search (hemat API) - // Jika text KOSONG (0) -> Lanjut search (ini untuk fitur 'klik muncul barang') if (searchQuery.length > 0 && searchQuery.length < 3) { setSearchResults([]); return; } - // Jangan trigger search kalau slot belum dipilih (mencegah fetch saat modal baru buka) if (activeSearchSlot === null) return; setIsSearching(true); try { const attrSetId = selectedVariant?.attribute_set_id || mainProduct?.attribute_set_id; - - // Tentukan Query: Kalau kosong pakai bintang (*), kalau ada isi pakai isinya const queryParam = searchQuery === '' ? '*' : searchQuery; const params = new URLSearchParams({ source: 'compare', - q: queryParam, // <-- Pakai logika wildcard - limit: '20', // <-- Ubah limit jadi 20 + q: queryParam, + limit: '20', fq: attrSetId ? `attribute_set_id_i:${attrSetId}` : '' }); @@ -228,16 +233,12 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant const handleAddProduct = (searchItem: any, slotIndex: number) => { const newProducts = [...products]; - // [FIX] MAPPING DARI JSON API ANDA (CAMELCASE) - // JSON: { id: 88019, code: "RX-SP0006", displayName: "...", lowestPrice: { price: ... } } - const idToAdd = searchItem.id; - const codeToAdd = searchItem.code; // Langsung 'code', bukan 'default_code_s' + const codeToAdd = searchItem.code; const nameToAdd = searchItem.displayName || searchItem.name; const imageToAdd = searchItem.image; const priceToAdd = searchItem.lowestPrice?.price || 0; - // Cek Duplikat if (newProducts.find(p => p && String(p.id) === String(idToAdd))) { toast({ title: "Produk sudah ada", status: "warning", position: "top" }); return; @@ -245,8 +246,8 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant newProducts[slotIndex] = { id: idToAdd, - sku: idToAdd, // ID untuk API - realCode: codeToAdd, // Code String untuk Tampilan + sku: idToAdd, + realCode: codeToAdd, name: nameToAdd, price: priceToAdd, image: imageToAdd, @@ -284,7 +285,7 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant </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 /> @@ -331,95 +332,155 @@ const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant </Select> <HStack spacing={2}> - <IconButton aria-label="Cart" icon={<Icon as={ShoppingCart} />} variant="outline" colorScheme="red" size="sm" /> + <IconButton + aria-label="Cart" + icon={<Image src="/images/keranjang-compare.svg" w="15px" h="15px" objectFit="contain" />} + 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 + Beli Sekarang </Button> </HStack> </VStack> ) : ( <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> - - {/* --- HASIL SEARCH (MAPPING FIX) --- */} - {activeSearchSlot === index && ( - <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"> - - {/* Tampilkan Loading jika sedang searching */} - {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.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 || '/images/no-image-compare.svg'} boxSize="30px" objectFit="contain" onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} /> - <Box> - <Text fontSize="xs" fontWeight="bold" noOfLines={1}>{res.displayName || res.name}</Text> - <Text fontSize="xs" color="red.500"> - {formatPrice(res.lowestPrice?.price || 0)} - </Text> - </Box> - </Flex> - </ListItem> - ))} - </List> - ) : ( - // Logic tambahan: Jika input kosong tapi hasil 0 (biasanya belum kelar loading awal), jangan tampilkan "Tidak Ditemukan" - // Tapi kalau sudah ngetik dan hasil 0, baru tampilkan "Tidak Ditemukan" - <Box p={3} fontSize="xs" color="gray.500" textAlign="center"> - {searchQuery === '' ? 'Menampilkan rekomendasi...' : 'Tidak ditemukan.'} - </Box> - )} - </Box> - )} - <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> + {/* WRAPPER SEARCH DENGAN REF */} + {/* Hanya berikan ref jika ini adalah slot yang sedang aktif dicari */} + <Box position="relative" w="100%" ref={activeSearchSlot === index ? searchWrapperRef : null}> + <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> + + {/* HASIL SEARCH */} + {activeSearchSlot === index && ( + <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.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 || '/images/no-image-compare.svg'} boxSize="30px" objectFit="contain" onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} /> + <Box> + <Text fontSize="xs" fontWeight="bold" noOfLines={1}>{res.displayName || res.name}</Text> + <Text fontSize="xs" color="red.500"> + {formatPrice(res.lowestPrice?.price || 0)} + </Text> + </Box> + </Flex> + </ListItem> + ))} + </List> + ) : ( + <Box p={3} fontSize="xs" color="gray.500" textAlign="center"> + {searchQuery === '' ? 'Menampilkan rekomendasi...' : 'Tidak ditemukan.'} + </Box> + )} + </Box> + )} + </Box> + + {/* SLOT KOSONG */} + <Flex + direction="column" + align="center" + justify="center" + flex={1} + bg="gray.50" + borderRadius="md" + > + <Image + src="/images/no-image-compare.svg" + alt="Empty Slot" + boxSize="100px" + mb={2} + opacity={0.6} + /> + <Text fontSize="xs" color="gray.500" textAlign="center"> + Produk Belum Ditambahkan + </Text> </Flex> </VStack> )} </GridItem> ))} - <GridItem colSpan={5} py={6}> - <Box borderBottom="2px solid" borderColor="gray.100" pb={2}><Text fontSize="lg" fontWeight="bold">Spesifikasi Teknis</Text></Box> + {/* --- HEADER SPESIFIKASI --- */} + <GridItem colSpan={5} py={6} display="flex" alignItems="center" justifyContent="space-between"> + <Box borderBottom="2px solid" borderColor="gray.100" pb={2} width="100%"> + <HStack> + <Text fontSize="lg" fontWeight="bold">Spesifikasi Teknis</Text> + {isLoadingMatrix && specsMatrix.length > 0 && ( + <HStack spacing={2}> + <Spinner size="xs" color="red.500" /> + <Text fontSize="xs" color="gray.500">Updating...</Text> + </HStack> + )} + </HStack> + </Box> </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> + {/* --- MATRIX SPEK --- */} + {isLoadingMatrix && specsMatrix.length === 0 ? ( + <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}> - <GridItem py={3} px={2} borderBottom="1px solid" borderColor="gray.100" bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} display="flex" alignItems="center"> + <GridItem + py={3} px={2} + borderBottom="1px solid" borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" alignItems="center" + opacity={isLoadingMatrix ? 0.6 : 1} + transition="opacity 0.2s" + > <Text fontWeight="bold" fontSize="sm" color="gray.700">{row.label}</Text> </GridItem> + {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" alignItems="center" justifyContent="center" textAlign="center"> - <Text fontSize="sm" color="gray.600">{renderSpecValue(val)}</Text> + <GridItem + key={`${row.code}-${colIndex}`} + py={3} px={2} + borderBottom="1px solid" borderColor="gray.100" + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + display="flex" alignItems="center" justifyContent="center" textAlign="center" + opacity={isLoadingMatrix ? 0.6 : 1} + transition="opacity 0.2s" + > + {isLoadingMatrix && product && !row.values[String(product.sku)] ? ( + <Spinner size="xs" color="gray.400"/> + ) : ( + <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> + <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> |
