diff options
Diffstat (limited to 'src-migrate/modules/product-detail/components/ProductComparisonModal.tsx')
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductComparisonModal.tsx | 1042 |
1 files changed, 1042 insertions, 0 deletions
diff --git a/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx new file mode 100644 index 00000000..260b6713 --- /dev/null +++ b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx @@ -0,0 +1,1042 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + Drawer, + DrawerOverlay, + DrawerContent, + DrawerHeader, + DrawerBody, + DrawerCloseButton, + Button, + Text, + Box, + Badge, + Grid, + GridItem, + Image, + Input, + InputGroup, + InputLeftElement, + InputRightElement, + VStack, + HStack, + IconButton, + Flex, + Icon, + Spinner, + List, + ListItem, + useToast, + useOutsideClick, + useBreakpointValue, + Divider, + ScaleFade +} from '@chakra-ui/react'; + +import { + AutoComplete, + AutoCompleteInput, + AutoCompleteItem, + AutoCompleteList, +} from '@choc-ui/chakra-autocomplete'; + +import { Search, Trash2, ChevronDown, X, Plus } from 'lucide-react'; + +import AddToCart from './AddToCart'; + +// --- 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, ''); + return String(val); +}; + +const extractAttribute = (item: any) => { + if (item.attributes && item.attributes.length > 0) { + return item.attributes[0]; + } + + const textToParse = item.displayName || item.name || ''; + const match = textToParse.match(/\(([^)]+)\)$/); + + if (match) { + return match[1]; + } + + const code = item.code || item.defaultCode || ''; + return textToParse.replace(`[${code}]`, '').replace(code, '').trim(); +}; + +const getVariantLabel = (v: any) => { + const attr = extractAttribute(v); + return `${v.code} - ${attr}`; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + mainProduct: any; + selectedVariant: any; +}; + +const ProductComparisonModal = ({ isOpen, onClose, mainProduct, selectedVariant }: Props) => { + const toast = useToast(); + const isMobile = useBreakpointValue({ base: true, md: false }); + + const [products, setProducts] = useState<(any | 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); + + const [disableVariantFilter, setDisableVariantFilter] = useState(false); + + const searchWrapperRef = useRef<HTMLDivElement>(null); + + useOutsideClick({ + ref: searchWrapperRef, + handler: () => { + if (activeSearchSlot !== null) { + setActiveSearchSlot(null); + setSearchResults([]); + } + }, + }); + + // =========================================================================== + // 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 || v.displayName || v.display_name, + displayName: v.displayName || v.name, + price: v.price?.price || v.price || 0, + image: v.image, + attributes: v.attributes || [] + })) || []; + + if (variantOptions.length === 0) { + variantOptions.push({ + id: targetId, + code: displayCode, + name: mainProduct.name, + displayName: mainProduct.displayName || mainProduct.name, + price: activeItem.price?.price || activeItem.price || 0, + image: activeItem.image || mainProduct.image, + attributes: [] + }); + } + + const displayName = activeItem.name || activeItem.displayName || mainProduct.name; + + const tempActiveVar = { + code: displayCode, + name: displayName, + displayName: displayName, + attributes: activeItem.attributes || [] + }; + + const productSlot1 = { + id: targetId, + sku: targetId, + realCode: displayCode, + name: displayName, + price: activeItem.price?.price || activeItem.price || mainProduct.lowest_price?.price || 0, + image: activeItem.image || mainProduct.image, + variants: variantOptions, + inputValue: getVariantLabel(tempActiveVar) + }; + + 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 > 0 && searchQuery.length < 3) { + setSearchResults([]); + return; + } + + if (activeSearchSlot === null) return; + + const attrSetId = selectedVariant?.attribute_set_id || mainProduct?.attribute_set_id; + + if (!attrSetId) { + setSearchResults([]); + setIsSearching(false); + return; + } + + setIsSearching(true); + try { + let queryParam = '*'; + + if (searchQuery !== '') { + const words = searchQuery.trim().split(/\s+/); + queryParam = words.map(w => `*${w}*`).join(' '); + } + + const params = new URLSearchParams({ + source: 'compare', + q: queryParam, + limit: '20', + fq: `attribute_set_id_i:${attrSetId}`, + group: 'false' + }); + + const res = await fetch(`/api/shop/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, activeSearchSlot]); + + // =========================================================================== + // 4. HANDLERS + // =========================================================================== + + const handleInputChange = (slotIndex: number, newValue: string) => { + setDisableVariantFilter(false); + const newProducts = [...products]; + if (newProducts[slotIndex]) { + newProducts[slotIndex] = { + ...newProducts[slotIndex], + inputValue: newValue + }; + setProducts(newProducts); + } + }; + + const handleVariantChange = (slotIndex: number, selectedValueString: string) => { + const currentProduct = products[slotIndex]; + if (!currentProduct || !currentProduct.variants) return; + + const selectedVar = currentProduct.variants.find((v: any) => { + return getVariantLabel(v) === selectedValueString; + }); + + if (selectedVar) { + const isDuplicate = products.some((p, idx) => + idx !== slotIndex && + p !== null && + String(p.id) === String(selectedVar.id) + ); + + if (isDuplicate) { + toast({ + title: "Varian sudah ada", + description: "Varian produk ini sudah ada di slot perbandingan lain.", + status: "warning", + position: "top", + duration: 3000 + }); + return; + } + + setDisableVariantFilter(true); + + const newProducts = [...products]; + newProducts[slotIndex] = { + ...currentProduct, + id: selectedVar.id, + sku: selectedVar.id, + name: selectedVar.name, + realCode: selectedVar.code, + price: selectedVar.price, + image: selectedVar.image, + inputValue: getVariantLabel(selectedVar) + }; + setProducts(newProducts); + } + }; + + const handleAddProduct = async (searchItem: any, slotIndex: number) => { + if (products.some(p => p !== null && String(p.id) === String(searchItem.id))) { + toast({ title: "Produk sudah ada", status: "warning", position: "top" }); + return; + } + setDisableVariantFilter(true); + + const idToAdd = searchItem.id; + const codeToAdd = searchItem.defaultCode || searchItem.default_code || searchItem.code; + const nameToAdd = searchItem.displayName || searchItem.name; + const imageToAdd = searchItem.image || searchItem.imageS || searchItem.image_s; + const priceToAdd = searchItem.lowestPrice?.price || searchItem.priceTier1V2F || searchItem.price || 0; + + let parentId = searchItem.templateId || + searchItem.templateIdI || + searchItem.template_id_i || + searchItem.template_id; + + if (!parentId) { + try { + const checkParams = new URLSearchParams({ source: 'upsell', q: '*:*', fq: `id:${idToAdd}` }); + const checkRes = await fetch(`/api/shop/search?${checkParams.toString()}`); + if (checkRes.ok) { + const checkData = await checkRes.json(); + const freshItem = checkData.response?.products?.[0]; + if (freshItem) { + const serverReturnedId = freshItem.id; + if (String(serverReturnedId) !== String(idToAdd)) { + parentId = serverReturnedId; + } else { + parentId = freshItem.templateId || freshItem.templateIdI || idToAdd; + } + } + } + } catch (e) { + console.error("Gagal validasi parent:", e); + parentId = idToAdd; + } + } + + const tempVar = { + code: codeToAdd, + name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, + attributes: searchItem.attributes || [] + }; + const initialLabel = getVariantLabel(tempVar); + + const newProductEntry = { + id: idToAdd, + sku: idToAdd, + realCode: codeToAdd, + name: nameToAdd, + price: priceToAdd, + image: imageToAdd, + variants: [{ + id: idToAdd, + code: codeToAdd, + name: nameToAdd, + displayName: searchItem.displayName || nameToAdd, + price: priceToAdd, + image: imageToAdd, + attributes: searchItem.attributes || [] + }], + inputValue: initialLabel + }; + + setProducts((prev) => { + const newSlots = [...prev]; + newSlots[slotIndex] = newProductEntry; + return newSlots; + }); + + setActiveSearchSlot(null); + setSearchQuery(''); + setSearchResults([]); + + if (parentId) { + try { + const params = new URLSearchParams({ + source: 'upsell', + limit: '100', + fq: `template_id_i:${parentId}` + }); + + const res = await fetch(`/api/shop/search?${params.toString()}`); + + if (res.ok) { + const data = await res.json(); + const siblings = data.response?.products || []; + + if (siblings.length > 0) { + const allVariants = siblings.map((s: any) => ({ + id: s.variantId || s.productIdI || s.id, + code: s.defaultCode || s.default_code || s.code, + name: s.displayName || s.name || s.nameS, + displayName: s.displayName, + price: s.lowestPrice?.price || s.priceTier1V2F || 0, + image: s.image || s.imageS, + attributes: [] + })); + + allVariants.sort((a: any, b: any) => + String(a.code).localeCompare(String(b.code)) + ); + + setProducts((prev) => { + const updated = [...prev]; + if (updated[slotIndex] && String(updated[slotIndex].id) === String(idToAdd)) { + updated[slotIndex] = { + ...updated[slotIndex], + variants: allVariants + }; + } + return updated; + }); + } + } + } catch (error) { + console.error("Gagal fetch variant lain:", error); + } + } + }; + + const handleRemoveProduct = (index: number) => { + const newProducts = [...products]; + + if (newProducts.length > 2) { + newProducts.splice(index, 1); + } else { + newProducts[index] = null; + } + + setProducts(newProducts); + if (newProducts.every(p => p === null)) setSpecsMatrix([]); + }; + + const handleAddSlot = () => { + if (products.length < 4) { + setProducts([...products, null]); + } + }; + + // --- RENDER SLOT ITEM (REUSABLE) --- + const renderProductSlot = (product: any, index: number) => { + let content; + if (product) { + + const productPayload = { + ...mainProduct, + id: product.id, + name: product.name, + price: product.price, + image: product.image + }; + + content = ( + <VStack align="stretch" spacing={3} h="100%"> + {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} + /> + )} + <Box h="160px" display="flex" alignItems="center" justifyContent="center" bg="white" borderRadius="md" p={2}> + <Image + src={product.image || '/images/no-image-compare.svg'} + alt={product.name} maxH="100%" objectFit="contain" + onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} + /> + </Box> + <Box> + <Text color="red.600" fontWeight="bold" fontSize="md"> + {product.price > 0 ? formatPrice(product.price) : 'Hubungi Admin'} + </Text> + <Text fontSize="xs" fontWeight="bold" noOfLines={3} h="45px" title={product.name} mb={2}> + {product.name} + </Text> + </Box> + + <Box w="100%"> + <AutoComplete + openOnFocus + disableFilter={disableVariantFilter} + onChange={(val) => handleVariantChange(index, val)} + value={product.inputValue} + > + <InputGroup size="sm"> + <AutoCompleteInput + variant="outline" + fontSize="xs" + borderRadius="md" + placeholder="Cari Varian..." + value={product.inputValue} + onChange={(e) => handleInputChange(index, e.target.value)} + onFocus={() => setDisableVariantFilter(true)} + /> + <InputRightElement h="100%" pointerEvents="none"> + <Icon as={ChevronDown} color="gray.400" size={14} /> + </InputRightElement> + </InputGroup> + + <AutoCompleteList fontSize="xs" maxH="250px" overflowY="auto" p={0}> + {product.variants && product.variants.map((v: any, vIdx: number) => { + const attributeText = extractAttribute(v); + const label = `${v.code} - ${attributeText}`; + return ( + <AutoCompleteItem + key={`option-${vIdx}`} + value={label} + textTransform="capitalize" + _selected={{ bg: 'red.50', borderLeft: '3px solid red' }} + _focus={{ bg: 'gray.50' }} + p={2} + borderBottom="1px dashed" + borderColor="gray.200" + > + <Flex justify="space-between" align="start" w="100%"> + <Box flex={1} mr={2} overflow="hidden"> + <Text fontWeight="bold" fontSize="xs" color="gray.700">{v.code}</Text> + <Text fontSize="xs" color="gray.500" noOfLines={1} title={attributeText} textTransform="capitalize"> + {attributeText} + </Text> + </Box> + <Text color="red.600" fontWeight="bold" fontSize="xs" whiteSpace="nowrap" bg="red.50" px={2} py={0.5} borderRadius="md" h="fit-content"> + {formatPrice(v.price)} + </Text> + </Flex> + </AutoCompleteItem> + ); + })} + </AutoCompleteList> + </AutoComplete> + </Box> + +{/* [UBAH BAGIAN TOMBOL ACTION INI] */} + <HStack spacing={2} w="100%" pt={2}> + + {/* 1. TOMBOL KERANJANG */} + {/* Bungkus dengan Box w="auto" agar ukurannya pas mengikuti icon */} + <Box w="auto"> + <AddToCart + products={productPayload} + variantId={product.id} + quantity={1} + > + {({ onClick, isLoading }) => ( + <IconButton + aria-label="Cart" + icon={<Image src="/images/keranjang.svg" w="15px" h="15px" objectFit="contain" />} + variant="outline" + colorScheme="red" + size="sm" + onClick={onClick} + isLoading={isLoading} + isDisabled={!product.price} + /> + )} + </AddToCart> + </Box> + + {/* 2. TOMBOL BELI SEKARANG */} + {/* Bungkus dengan Box flex={1} agar mengisi sisa ruang (Sesuai kode lama) */} + <Box flex={1}> + <AddToCart + source="buy" + products={productPayload} + variantId={product.id} + quantity={1} + > + {({ onClick, isLoading }) => ( + <Button + colorScheme="red" + size="sm" + fontSize="xs" + w="100%" // Paksa lebar 100% mengikuti parent Box + onClick={onClick} + isLoading={isLoading} + isDisabled={!product.price} + > + Beli Sekarang + </Button> + )} + </AddToCart> + </Box> + + </HStack> + </VStack> + ); + } else { + // TAMPILAN KOSONG + content = ( + <VStack align="stretch" spacing={3} h="100%" position="relative"> + {index !== 0 && products.length > 2 && ( + <IconButton + aria-label="Hapus Kolom" icon={<X size={16} />} + size="xs" position="absolute" top={-2} right={-2} + colorScheme="gray" variant="solid" borderRadius="full" zIndex={2} + onClick={() => handleRemoveProduct(index)} + /> + )} + + <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)} + /> + {activeSearchSlot === index && searchQuery && ( + <InputRightElement cursor="pointer" onClick={() => { setSearchQuery(''); setActiveSearchSlot(null); }}> + <Icon as={X} color="gray.400" size={14}/> + </InputRightElement> + )} + </InputGroup> + + {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"> + {!selectedVariant?.attribute_set_id && !mainProduct?.attribute_set_id ? ( + <Box p={4} fontSize="xs" color="orange.600" textAlign="center" bg="orange.50"> + <Text fontWeight="bold" mb={1}>Perbandingan Tidak Tersedia</Text> + <Text>Produk utama tidak memiliki data kategori yang valid untuk dibandingkan.</Text> + </Box> + ) : ( + <> + {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={3} + borderBottom="1px solid #f0f0f0" + _hover={{ bg: 'red.50', cursor: 'pointer' }} + onClick={() => handleAddProduct(res, index)} + > + <Flex align="flex-start" gap={3}> + <Image + src={res.image || '/images/no-image-compare.svg'} + boxSize="40px" + objectFit="contain" + onError={(e) => { (e.target as HTMLImageElement).src = '/images/no-image-compare.svg'; }} + flexShrink={0} + mt={1} + /> + <Box flex={1} w="0"> + <Text fontSize="xs" fontWeight="bold" noOfLines={2} lineHeight="shorter" whiteSpace="normal" mb={1} title={res.displayName || res.name}> + {res.displayName || res.name} + </Text> + <Text fontSize="xs" color="red.500" fontWeight="bold"> + {formatPrice(res.lowestPrice?.price || 0)} + </Text> + </Box> + </Flex> + </ListItem> + ))} + </List> + ) : ( + <Box p={3} fontSize="xs" color="gray.500" textAlign="center"> + {searchQuery === '' ? 'Menampilkan rekomendasi...' : 'Produk tidak ditemukan.'} + </Box> + )} + </> + )} + </Box> + )} + </Box> + + <Flex + direction="column" + align="center" + justify="center" + flex={1} + bg="white" + borderRadius="md" + > + <Image + src="/images/no-image-compare.svg" + alt="Empty Slot" + boxSize="125px" + mb={2} + opacity={0.6} + /> + <Text fontSize="xs" color="gray.500" textAlign="center"> + Produk Belum Ditambahkan + </Text> + </Flex> + </VStack> + ); + } + + // Animasi Wrapper ScaleFade + return ( + <ScaleFade initialScale={0.9} in={true}> + {content} + </ScaleFade> + ); + }; + + // --- RENDER MOBILE CONTENT --- + const renderMobileContent = () => { + const mobileProducts = products.slice(0, 2); + + return ( + <Box pb={6}> + {/* Sticky Header */} + <Box + position="sticky" top="-16px" zIndex={10} + bg="white" pt={4} pb={2} mx="-16px" px="16px" + borderBottom="1px solid" borderColor="gray.100" shadow="sm" + > + <Grid templateColumns="1fr 1fr" gap={4} mb={4}> + {mobileProducts.map((p, i) => ( + <GridItem key={i} position="relative"> + {renderProductSlot(p, i)} + </GridItem> + ))} + </Grid> + + <Flex justify="center" align="center" gap={2}> + <Text fontSize="md" fontWeight="bold" color="gray.700"> + Spesifikasi Teknis + </Text> + </Flex> + </Box> + + {/* Specs List with Loader per Line */} + <Box mt={4}> + {specsMatrix.length > 0 ? ( + <VStack spacing={0} align="stretch" divider={<Divider />}> + {specsMatrix.map((row, rIdx) => ( + <Box key={rIdx} py={4}> + <Grid templateColumns="1fr 1fr" gap={4}> + {mobileProducts.map((p, cIdx) => { + const val = p ? (row.values[String(p.sku)] || '-') : '-'; + const isItemLoading = isLoadingMatrix && p && !row.values[String(p.sku)]; + + return ( + <VStack key={cIdx} spacing={1} align="center"> + {isItemLoading ? ( + <Spinner size="xs" color="red.500" /> + ) : ( + <Box + fontSize="12px" + fontWeight="semibold" + color="gray.800" + w="100%" + textAlign="center" + sx={{ + '& ul, & ol': { + textAlign: 'center', + paddingLeft: '1.2rem', + listStylePosition: 'outside', + margin: 0, + width: '100%' + }, + '& li': { + textAlign: 'center', + marginBottom: '4px', + fontWeight: 'normal' + }, + '& strong': { + display: 'block', + marginBottom: '2px' + }, + '& p': { + textAlign: 'center', + margin: 0 + } + }} + dangerouslySetInnerHTML={{ __html: renderSpecValue(val) }} + /> + )} + <Text fontSize="10px" color="gray.600" fontWeight="bold" textAlign="center"> + {row.label} + </Text> + </VStack> + ) + })} + </Grid> + </Box> + ))} + </VStack> + ) : ( + <Box textAlign="center" py={10} color="gray.500"> + {isLoadingMatrix ? <VStack><Spinner color="red.500" /><Text fontSize="xs">Memuat data...</Text></VStack> : "Data spesifikasi tidak tersedia"} + </Box> + )} + </Box> + </Box> + ); + }; + + // --- MAIN RENDER --- + + if (isMobile) { + return ( + <Drawer isOpen={isOpen} placement="bottom" onClose={onClose}> + <DrawerOverlay /> + <DrawerContent borderTopRadius="20px" h="88vh" bg="white"> + <DrawerCloseButton zIndex={20} /> + <DrawerHeader borderBottomWidth="1px" fontSize="md" textAlign="center">Bandingkan Produk</DrawerHeader> + <DrawerBody + p={4} + overflowY="auto" + css={{ + '&::-webkit-scrollbar': { width: '4px' }, + '&::-webkit-scrollbar-track': { width: '6px' }, + '&::-webkit-scrollbar-thumb': { background: '#cbd5e0', borderRadius: '24px' }, + }} + > + {renderMobileContent()} + </DrawerBody> + </DrawerContent> + </Drawer> + ); + } + + // Tampilan Desktop (Modal 6XL) - DYNAMIC GRID + const totalColumns = 1 + products.length + (products.length < 4 ? 1 : 0); + const productColumnsCount = products.length + (products.length < 4 ? 1 : 0); + + const SLOT_WIDTH_PX = 200; + const LABEL_WIDTH_PX = 200; + const GAP_PX = 16; + const PADDING_PX = 48; + + const calculatedWidth = LABEL_WIDTH_PX + (productColumnsCount * SLOT_WIDTH_PX) + (productColumnsCount * GAP_PX) + PADDING_PX + 'px'; + + return ( + <Modal + isOpen={isOpen} + onClose={onClose} + scrollBehavior="inside" + isCentered + > + <ModalOverlay /> + + <ModalContent + height="90vh" + maxW="95vw" + w={calculatedWidth} + transition="width 0.4s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.4s ease" + > + <ModalHeader borderBottom="1px solid #eee" pb={2}> + <HStack spacing={3}> + <Text fontSize="xl" fontWeight="bold">Bandingkan Produk</Text> + <Badge colorScheme="red" variant="solid" borderRadius="full" px={2} textTransform="none"> + Baru + </Badge> + </HStack> + <Text fontSize="sm" color="gray.500" fontWeight="normal" mt={1}> + Detail Spesifikasi Produk yang kamu pilih + </Text> + </ModalHeader> + <ModalCloseButton /> + + <ModalBody p={6} bg="white" overflowX="auto"> + <Grid + templateColumns={`${LABEL_WIDTH_PX}px repeat(${productColumnsCount}, ${SLOT_WIDTH_PX}px)`} + gap={4} + // [TAMBAH] Animasi Transisi Grid + transition="all 0.4s ease" + > + + <GridItem /> + {products.map((product, index) => ( + <GridItem key={index} position="relative" minW="0"> + {renderProductSlot(product, index)} + </GridItem> + ))} + + {/* Render Tombol Tambah Slot (Jika slot < 4) */} + {products.length < 4 && ( + <GridItem display="flex" alignItems="center" justifyContent="center"> + {/* [TAMBAH] Animasi Wrapper ScaleFade untuk Tombol */} + <ScaleFade initialScale={0.9} in={true} style={{ width: '100%', height: '100%' }}> + <Button + onClick={handleAddSlot} + variant="outline" + border="2px dashed" + borderColor="gray.300" + color="gray.500" + h="100%" + w="100%" + flexDirection="column" + gap={2} + _hover={{ bg: 'gray.50', borderColor: 'gray.400' }} + > + <Box bg="gray.100" p={2} borderRadius="full"> + <Plus size={24} /> + </Box> + <Text fontSize="sm">Tambah Produk</Text> + <Text fontSize="9px" fontWeight="normal">Bandingkan hingga 4 produk</Text> + </Button> + </ScaleFade> + </GridItem> + )} + + <GridItem colSpan={1 + productColumnsCount} 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 && specsMatrix.length === 0 ? ( + <GridItem colSpan={1 + productColumnsCount} 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" + 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" + opacity={isLoadingMatrix ? 0.6 : 1} + transition="opacity 0.2s" + > + {isLoadingMatrix && product && !row.values[String(product.sku)] ? ( + <Spinner size="xs" color="gray.400" /> + ) : ( + <Box + fontSize="sm" + color="gray.600" + w="100%" + sx={{ + '& ul, & ol': { + textAlign: 'center', + paddingLeft: '1.2rem', + listStylePosition: 'outside', + margin: 0, + width: '100%' + }, + '& li': { + textAlign: 'center', + marginBottom: '4px', + fontWeight: 'normal' + }, + '& strong': { + display: 'block', + marginBottom: '2px' + }, + '& p': { + textAlign: 'center', + margin: 0 + } + }} + dangerouslySetInnerHTML={{ __html: renderSpecValue(val) }} + /> + )} + </GridItem> + ); + })} + {products.length < 4 && ( + <GridItem + bg={rowIndex % 2 !== 0 ? "white" : "gray.50"} + borderBottom="1px solid" + borderColor="gray.100" + /> + )} + </React.Fragment> + )) + ) : ( + <GridItem colSpan={1 + productColumnsCount} py={10} textAlign="center" color="gray.500" bg="gray.50"> + <Text>Data spesifikasi belum tersedia untuk produk ini.</Text> + </GridItem> + )} + </Grid> + </ModalBody> + </ModalContent> + </Modal> + ); +}; + +export default ProductComparisonModal;
\ No newline at end of file |
