summaryrefslogtreecommitdiff
path: root/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src-migrate/modules/product-detail/components/ProductComparisonModal.tsx')
-rw-r--r--src-migrate/modules/product-detail/components/ProductComparisonModal.tsx1042
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