summaryrefslogtreecommitdiff
path: root/src-migrate/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src-migrate/modules')
-rw-r--r--src-migrate/modules/product-detail/components/AddToCart.tsx25
-rw-r--r--src-migrate/modules/product-detail/components/AddToQuotation.tsx91
-rw-r--r--src-migrate/modules/product-detail/components/Information.tsx229
-rw-r--r--src-migrate/modules/product-detail/components/PriceAction.tsx38
-rw-r--r--src-migrate/modules/product-detail/components/ProductComparisonModal.tsx1042
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx717
-rw-r--r--src-migrate/modules/product-detail/components/SimilarBottom.tsx55
-rw-r--r--src-migrate/modules/product-detail/components/SimilarSide.tsx74
8 files changed, 1994 insertions, 277 deletions
diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx
index 0dc39c1c..18f90012 100644
--- a/src-migrate/modules/product-detail/components/AddToCart.tsx
+++ b/src-migrate/modules/product-detail/components/AddToCart.tsx
@@ -1,6 +1,6 @@
import BottomPopup from '@/core/components/elements/Popup/BottomPopup';
import style from '../styles/price-action.module.css';
-import { Button, color, Link, useToast } from '@chakra-ui/react';
+import { Button, ButtonProps, Link, useToast } from '@chakra-ui/react';
import product from 'next-seo/lib/jsonld/product';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -26,6 +26,9 @@ type Props = {
quantity?: number;
source?: 'buy' | 'add_to_cart';
products: IProductDetail;
+
+ buttonProps?: ButtonProps;
+ children?: (props: { onClick: () => Promise<void>; isLoading: boolean }) => React.ReactNode;
};
type Status = 'idle' | 'loading' | 'success';
@@ -35,6 +38,9 @@ const AddToCart = ({
quantity = 1,
source = 'add_to_cart',
products,
+
+ buttonProps,
+ children,
}: Props) => {
let auth = getAuth();
const router = useRouter();
@@ -140,7 +146,10 @@ const AddToCart = ({
});
setStatus('idle');
setRefreshCart(true);
- setAddCartAlert(true);
+
+ if (!children) {
+ setAddCartAlert(true);
+ }
gtagAddToCart(activeVariant, quantity);
@@ -164,6 +173,14 @@ const AddToCart = ({
}, 3000);
}, [status]);
+ if (children) {
+ return (
+ <div className='w-full'>
+ {children({ onClick: handleButton, isLoading: status === 'loading' })}
+ </div>
+ );
+ }
+
const btnConfig = {
add_to_cart: {
colorScheme: 'red',
@@ -186,6 +203,8 @@ const AddToCart = ({
variant={btnConfig[source].variant}
className='w-full'
isDisabled={!hasPrice || status === 'loading'}
+
+ {...buttonProps}
>
{btnConfig[source].text}
</Button>
@@ -198,6 +217,8 @@ const AddToCart = ({
variant={btnConfig[source].variant}
className='w-full'
isDisabled={!hasPrice || status === 'loading'}
+
+ {...buttonProps}
>
{btnConfig[source].text}
</Button>
diff --git a/src-migrate/modules/product-detail/components/AddToQuotation.tsx b/src-migrate/modules/product-detail/components/AddToQuotation.tsx
index 3e811330..e26e271f 100644
--- a/src-migrate/modules/product-detail/components/AddToQuotation.tsx
+++ b/src-migrate/modules/product-detail/components/AddToQuotation.tsx
@@ -1,7 +1,7 @@
import BottomPopup from '@/core/components/elements/Popup/BottomPopup';
import style from '../styles/price-action.module.css';
import { Button, Link, useToast } from '@chakra-ui/react';
-import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
+// import { ScaleIcon } from '@heroicons/react/24/outline'; // Tidak perlu lagi
import product from 'next-seo/lib/jsonld/product';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -17,12 +17,15 @@ import { createSlug } from '~/libs/slug';
import formatCurrency from '~/libs/formatCurrency';
import { useProductDetail } from '../stores/useProductDetail';
import useDevice from '@/core/hooks/useDevice';
+import DesktopView from '@/core/components/views/DesktopView';
+import MobileView from '@/core/components/views/MobileView';
type Props = {
variantId: number | null;
quantity?: number;
source?: 'buy' | 'add_to_cart';
products: IProductDetail;
+ onCompare?: () => void;
};
type Status = 'idle' | 'loading' | 'success';
@@ -32,6 +35,7 @@ const AddToQuotation = ({
quantity = 1,
source = 'add_to_cart',
products,
+ onCompare
}: Props) => {
const auth = getAuth();
const router = useRouter();
@@ -106,37 +110,60 @@ const AddToQuotation = ({
}, 3000);
}, [status]);
- const btnConfig = {
- add_to_cart: {
- colorScheme: 'red',
- variant: 'outline',
- text: 'Keranjang',
- },
- buy: {
- colorScheme: 'red',
- variant: 'solid',
- text: 'Beli',
- },
- };
-
return (
- <div className='w-full'>
- <Button
- onClick={handleButton}
- color={'red'}
- colorScheme='white'
- className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center'
- isDisabled={!hasPrice}
- >
- <ImageNext
- src={isDesktop ? '/images/doc_red.svg' : '/images/doc.svg'}
- alt='penawaran instan'
- className=''
- width={25}
- height={25}
- />
- {isDesktop ? 'Penawaran Harga Instan' : ''}
- </Button>
+ <div className='w-full flex flex-col gap-3'>
+
+ {/* 3. TAMPILAN DESKTOP: GRID 2 KOLOM (Bandingkan & Penawaran) */}
+ <DesktopView>
+ <div className="grid grid-cols-2 gap-3 w-full">
+ {/* Tombol Kiri: Bandingkan */}
+ <Button
+ onClick={onCompare}
+ variant="outline"
+ colorScheme="gray"
+ className="w-full border border-gray-300 p-2 gap-2 flex items-center justify-center text-gray-600 hover:text-red-600 hover:border-red-600 transition-all font-normal text-sm"
+ >
+ {/* UPDATE ICON DISINI */}
+ <ImageNext src="/images/logo-bandingkan.svg" width={15} height={15} alt="bandingkan" />
+ Bandingkan
+ </Button>
+
+ {/* Tombol Kanan: Penawaran (Link WA) */}
+ <Button
+ as={Link}
+ href={askAdminUrl}
+ target='_blank'
+ variant="outline"
+ colorScheme="gray"
+ className="w-full border border-gray-300 p-2 gap-2 flex items-center justify-center text-gray-600 hover:text-red-600 hover:border-red-600 transition-all font-normal text-sm"
+ _hover={{ textDecoration: 'none' }}
+ onClick={handleButton}
+ >
+ <ImageNext src="/images/doc_red.svg" width={20} height={20} alt="penawaran" />
+ Penawaran
+ </Button>
+ </div>
+ </DesktopView>
+
+ {/* TAMPILAN MOBILE */}
+ <MobileView>
+ <Button
+ onClick={handleButton}
+ color={'red'}
+ colorScheme='white'
+ className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center'
+ isDisabled={!hasPrice}
+ >
+ <ImageNext
+ src='/images/doc.svg'
+ alt='penawaran instan'
+ className=''
+ width={25}
+ height={25}
+ />
+ </Button>
+ </MobileView>
+
<BottomPopup
className='!container'
title='Berhasil Ditambahkan'
@@ -243,4 +270,4 @@ const AddToQuotation = ({
);
};
-export default AddToQuotation;
+export default AddToQuotation; \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/Information.tsx b/src-migrate/modules/product-detail/components/Information.tsx
index 813b6bf5..ce848267 100644
--- a/src-migrate/modules/product-detail/components/Information.tsx
+++ b/src-migrate/modules/product-detail/components/Information.tsx
@@ -9,17 +9,24 @@ import style from '../styles/information.module.css';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
+import axios from 'axios';
import currencyFormat from '@/core/utils/currencyFormat';
-import { InputGroup, InputRightElement } from '@chakra-ui/react';
+import { InputGroup, InputRightElement, SimpleGrid, Flex, Text, Box, Center, Icon } from '@chakra-ui/react';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
-import Image from 'next/image';
+import ImageNext from 'next/image';
import { formatToShortText } from '~/libs/formatNumber';
import { createSlug } from '~/libs/slug';
import { IProductDetail } from '~/types/product';
import { useProductDetail } from '../stores/useProductDetail';
import useVariant from '../hook/useVariant';
+// Import View Components
+import MobileView from '@/core/components/views/MobileView'; // Pastikan path import benar
+
+// Import Modal Compare
+import ProductComparisonModal from './ProductComparisonModal';
+
const Skeleton = dynamic(() =>
import('@chakra-ui/react').then((mod) => mod.Skeleton)
);
@@ -29,22 +36,53 @@ type Props = {
};
const Information = ({ product }: Props) => {
- const { selectedVariant, setSelectedVariant, setSla, sla } =
- useProductDetail();
+ const { selectedVariant, setSelectedVariant, setSla, sla } = useProductDetail();
const [inputValue, setInputValue] = useState<string>('');
const [disableFilter, setDisableFilter] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
// source of truth
- const variantOptions = product.variants;
+ // const variantOptions = product.variants;
+ const [variantOptions, setVariantOptions] = useState<any[]>(
+ product?.variants
+ );
const variantId = selectedVariant?.id;
const { slaVariant, isLoading } = useVariant({ variantId });
- /* ======================
- * Sync input text
- * ====================== */
+ const [warranties, setWarranties] = useState<Record<string, string>>({});
+ const [loadingWarranty, setLoadingWarranty] = useState(false);
+
+ // State untuk Modal Compare
+ const [isCompareOpen, setIsCompareOpen] = useState(false);
+
+ useEffect(() => {
+ const fetchWarrantyDirectly = async () => {
+ if (!product?.variants || product.variants.length === 0) return;
+
+ setLoadingWarranty(true);
+ try {
+ const skus = product.variants.map((v) => v.id).join(',');
+ const mainSku = product.variants[0].id;
+
+ const res = await axios.get('/api/magento-product', {
+ params: { skus, main_sku: mainSku }
+ });
+
+ if (res.data && res.data.warranties) {
+ setWarranties(res.data.warranties);
+ }
+ } catch (error) {
+ console.error("Gagal ambil garansi:", error);
+ } finally {
+ setLoadingWarranty(false);
+ }
+ };
+
+ fetchWarrantyDirectly();
+ }, [product]);
+
useEffect(() => {
if (!selectedVariant) return;
@@ -72,14 +110,19 @@ const Information = ({ product }: Props) => {
/* ======================
* Handlers
* ====================== */
- const handleOnChange = (value: string) => {
+ const handleOnChange = (vals: any) => {
setDisableFilter(true);
-
- const variant = variantOptions.find((item) => String(item.id) === value);
-
- if (!variant) return;
-
- setSelectedVariant(variant);
+ let code = vals.replace(/\s-\s.*$/, '').trim();
+ let variant = product?.variants.find((item) => item.code === code);
+
+ if (variant) {
+ setSelectedVariant(variant);
+ setInputValue(
+ variant?.code +
+ (variant?.attributes[0] ? ' - ' + variant?.attributes[0] : '')
+ );
+ setVariantOptions(product?.variants);
+ }
};
const handleOnKeyUp = (e: any) => {
@@ -87,6 +130,14 @@ const Information = ({ product }: Props) => {
setInputValue(e.target.value);
};
+ const rowStyle = {
+ backgroundColor: '#ffffff',
+ fontSize: '13px',
+ borderBottom: '1px dashed #e2e8f0',
+ padding: '8px 0',
+ marginBottom: '0px'
+ };
+
return (
<div className={style['wrapper']}>
{/* ===== Variant Selector ===== */}
@@ -120,10 +171,17 @@ const Information = ({ product }: Props) => {
</InputGroup>
<AutoCompleteList>
- {variantOptions.map((option) => (
+ {variantOptions
+ .sort((a: any, b: any) => {
+ return a.code.localeCompare(b.code, undefined, { numeric: true, sensitivity: 'base' });
+ })
+ .map((option, cid) => (
<AutoCompleteItem
key={option.id}
- value={String(option.id)}
+ // value={String(option.id)}
+ value={option.code +
+ (option.attributes?.[0] ? ` - ${option.attributes[0]}` : '')
+ }
_selected={
option.id === selectedVariant?.id
? { bg: 'gray.300' }
@@ -135,15 +193,8 @@ const Information = ({ product }: Props) => {
{option.code}
{option.attributes?.[0] ? ` - ${option.attributes[0]}` : ''}
</div>
-
- <div
- className={
- option.price?.discount_percentage
- ? 'flex gap-x-4 items-center'
- : ''
- }
- >
- {option.price?.discount_percentage > 0 && (
+ <div className={option?.price?.discount_percentage ? 'flex gap-x-4 items-center justify-between' : ''}>
+ {option?.price?.discount_percentage > 0 && (
<>
<div className='badge-solid-red text-xs'>
{Math.floor(option.price.discount_percentage)}%
@@ -162,16 +213,49 @@ const Information = ({ product }: Props) => {
))}
</AutoCompleteList>
</AutoComplete>
+
+ {/* === TOMBOL BANDINGKAN PRODUK (HANYA MOBILE) === */}
+ <MobileView>
+ <div
+ className="w-full flex items-center justify-between py-3 px-1 mt-3 bg-white border-t border-b border-black-100 cursor-pointer hover:bg-gray-50 transition-colors group"
+ onClick={() => setIsCompareOpen(true)}
+ >
+ <div className="flex items-center gap-3">
+ <div className="bg-red-50 p-2 rounded-full group-hover:bg-red-100 transition-colors">
+ <ImageNext src="/images/logo-bandingkan.svg" width={15} height={15} alt="bandingkan" />
+ </div>
+ <div className="flex flex-col">
+ <span className="text-sm font-bold text-gray-800">Bandingkan Produk</span>
+ <span className="text-xs text-gray-500">Coba bandingkan dengan produk lainnya</span>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="bg-red-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full">Baru</span>
+ <Icon as={ChevronDownIcon} className="w-4 h-4 text-gray-400 transform -rotate-90" />
+ </div>
+ </div>
+ </MobileView>
+
+ {/* Render Modal (Logic open/close ada di dalam component) */}
+ {isCompareOpen && (
+ <ProductComparisonModal
+ isOpen={isCompareOpen}
+ onClose={() => setIsCompareOpen(false)}
+ mainProduct={product}
+ selectedVariant={selectedVariant}
+ />
+ )}
</div>
- {/* ===== Info Rows ===== */}
- <div className={style['row']}>
- <div className={style['label']}>Item Code</div>
+ {/* ITEM CODE */}
+ <div className={style['row']} style={rowStyle}>
+ <div className={style['label']} style={{ color: '#6b7280' }}>Item Code</div>
<div className={style['value']}>{selectedVariant?.code}</div>
</div>
- <div className={style['row']}>
- <div className={style['label']}>Manufacture</div>
+ {/* MANUFACTURE */}
+ <div className={style['row']} style={rowStyle}>
+ <div className={style['label']} style={{ color: '#6b7280' }}>Manufacture</div>
<div className={style['value']}>
{!!product.manufacture.name ? (
<Link
@@ -181,8 +265,8 @@ const Information = ({ product }: Props) => {
product.manufacture.id.toString()
)}
>
- {product.manufacture.logo ? (
- <Image
+ {product?.manufacture.logo ? (
+ <ImageNext
height={50}
width={100}
src={product.manufacture.logo}
@@ -201,32 +285,83 @@ const Information = ({ product }: Props) => {
</div>
</div>
- <div className={style['row']}>
- <div className={style['label']}>Berat Barang</div>
+ {/* BERAT BARANG */}
+ <div className={style['row']} style={rowStyle}>
+ <div className={style['label']} style={{ color: '#6b7280' }}>Berat Barang</div>
<div className={style['value']}>
{selectedVariant?.weight > 0 ? `${selectedVariant.weight} Kg` : '-'}
</div>
</div>
- <div className={style['row']}>
- <div className={style['label']}>Terjual</div>
+ {/* TERJUAL */}
+ <div className={style['row']} style={{ ...rowStyle, borderBottom: 'none' }}>
+ <div className={style['label']} style={{ color: '#6b7280' }}>Terjual</div>
<div className={style['value']}>
{product.qty_sold > 0 ? formatToShortText(product.qty_sold) : '-'}
</div>
</div>
- <div className={style['row']}>
- <div className={style['label']}>Persiapan Barang</div>
- {isLoading ? (
- <div className={style['value']}>
- <Skeleton height={5} width={100} />
- </div>
- ) : (
- <div className={style['value']}>{sla?.sla_date}</div>
- )}
+ {/* === DETAIL INFORMASI PRODUK === */}
+ <div className="mt-6 border-t pt-4">
+ <h2 className="hidden md:block font-bold text-gray-800 text-sm mb-4">
+ Detail Informasi Produk
+ </h2>
+
+ <SimpleGrid columns={{ base: 3, md: 3 }} spacing={{ base: 2, md: 10 }}>
+ <Flex
+ direction={{ base: 'column', md: 'row' }}
+ align="center"
+ textAlign={{ base: 'center', md: 'left' }}
+ gap={{ base: 2, md: 3 }}
+ >
+ <img src="/images/produk_asli.svg" alt="Distributor Resmi" className="w-8 h-8 md:w-10 md:h-10 shrink-0" />
+ <Box>
+ <Text fontSize={{ base: "10px", md: "11px" }} color="gray.500" lineHeight="short" mb="1px">Distributor Resmi</Text>
+ <Text fontSize={{ base: "10px", md: "12px" }} fontWeight="bold" color="gray.800" lineHeight="1.2">Jaminan Produk Asli</Text>
+ </Box>
+ </Flex>
+
+ <Flex
+ direction={{ base: 'column', md: 'row' }}
+ align="center"
+ textAlign={{ base: 'center', md: 'left' }}
+ gap={{ base: 2, md: 3 }}
+ >
+ <img src="/images/estimasi.svg" alt="Estimasi Penyiapan" className="w-8 h-8 md:w-9 md:h-9 shrink-0" />
+ <Box>
+ <Text fontSize={{ base: "10px", md: "11px" }} color="gray.500" lineHeight="short" mb="1px">Estimasi Penyiapan</Text>
+ {isLoading ? (
+ <Center><Skeleton height="10px" width="50px" mt="2px" /></Center>
+ ) : (
+ <Text fontSize={{ base: "10px", md: "12px" }} fontWeight="bold" color="gray.800" lineHeight="1.2">
+ {sla?.sla_date || '-'}
+ </Text>
+ )}
+ </Box>
+ </Flex>
+
+ <Flex
+ direction={{ base: 'column', md: 'row' }}
+ align="center"
+ textAlign={{ base: 'center', md: 'left' }}
+ gap={{ base: 2, md: 3 }}
+ >
+ <img src="/images/garansi.svg" alt="Garansi Produk" className="w-8 h-8 md:w-10 md:h-10 shrink-0" />
+ <Box>
+ <Text fontSize={{ base: "10px", md: "11px" }} color="gray.500" lineHeight="short" mb="1px">Garansi Produk</Text>
+ {loadingWarranty ? (
+ <Center><Skeleton height="10px" width="50px" mt="2px" /></Center>
+ ) : (
+ <Text fontSize={{ base: "10px", md: "12px" }} fontWeight="bold" color="gray.800" lineHeight="1.2">
+ {selectedVariant && warranties[selectedVariant.id] ? warranties[selectedVariant.id] : '-'}
+ </Text>
+ )}
+ </Box>
+ </Flex>
+ </SimpleGrid>
</div>
</div>
);
};
-export default Information;
+export default Information; \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx
index d73ab5f6..ee8009ef 100644
--- a/src-migrate/modules/product-detail/components/PriceAction.tsx
+++ b/src-migrate/modules/product-detail/components/PriceAction.tsx
@@ -1,5 +1,4 @@
import style from '../styles/price-action.module.css';
-
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState } from 'react';
@@ -15,14 +14,17 @@ import { Button, Skeleton } from '@chakra-ui/react';
import DesktopView from '@/core/components/views/DesktopView';
import MobileView from '@/core/components/views/MobileView';
+// 1. Tambahkan onCompare (Optional) di sini
type Props = {
product: IProductDetail;
+ onCompare?: () => void;
};
const PPN: number = process.env.NEXT_PUBLIC_PPN
? parseFloat(process.env.NEXT_PUBLIC_PPN)
: 0;
-const PriceAction = ({ product }: Props) => {
+
+const PriceAction = ({ product, onCompare }: Props) => {
const {
activePrice,
setActive,
@@ -146,19 +148,6 @@ const PriceAction = ({ product }: Props) => {
</>
)}
- {/* {!!activePrice && activePrice.price === 0 && (
- <span>
- Hubungi kami untuk dapatkan harga terbaik,{' '}
- <Link
- href={askAdminUrl}
- target='_blank'
- className={style['contact-us']}
- >
- klik disini
- </Link>
- </span>
- )} */}
-
<DesktopView>
<div className='h-4' />
<div className='flex gap-x-5 items-center'>
@@ -227,9 +216,6 @@ const PriceAction = ({ product }: Props) => {
)}
</div>
</div>
- {/* <span className='text-[12px] text-red-500 italic'>
- * {qtyPickUp} barang bisa di pickup
- </span> */}
</DesktopView>
{/* ===== MOBILE: grid kiri-kanan, kanan hanya qty ===== */}
@@ -263,12 +249,6 @@ const PriceAction = ({ product }: Props) => {
</Link>
)}
</div>
-
- {/* {qtyPickUp > 0 && (
- <div className='text-[12px] mt-1 text-red-500 italic'>
- * {qtyPickUp} barang bisa di pickup
- </div>
- )} */}
</div>
{/* Kanan: hanya qty, rata kanan */}
@@ -295,9 +275,9 @@ const PriceAction = ({ product }: Props) => {
value={quantityInput}
onChange={(e) => setQuantityInput(e.target.value)}
className='h-11 md:h-12 w-16 md:w-20 text-center text-lg md:text-xl outline-none border-x
- [appearance:textfield]
- [&::-webkit-outer-spin-button]:appearance-none
- [&::-webkit-inner-spin-button]:appearance-none'
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none'
disabled={!hasPrice}
/>
@@ -335,11 +315,13 @@ const PriceAction = ({ product }: Props) => {
)}
</div>
<div className='mt-4'>
+ {/* 2. TERUSKAN onCompare KE SINI */}
<AddToQuotation
source='buy'
products={product}
variantId={activeVariantId}
quantity={Number(quantityInput)}
+ onCompare={onCompare}
/>
</div>
</DesktopView>
@@ -376,4 +358,4 @@ const PriceAction = ({ product }: Props) => {
);
};
-export default PriceAction;
+export default PriceAction; \ No newline at end of file
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
diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx
index 129ca8de..35726437 100644
--- a/src-migrate/modules/product-detail/components/ProductDetail.tsx
+++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx
@@ -2,15 +2,37 @@ import style from '../styles/product-detail.module.css';
import Link from 'next/link';
import { useRouter } from 'next/router';
-import { useEffect, useRef, useState, UIEvent } from 'react';
-
-import { Button } from '@chakra-ui/react';
+import { useEffect, useRef, useState, UIEvent, useMemo } from 'react';
+
+// Import komponen Chakra UI
+import {
+ Button,
+ Tabs,
+ TabList,
+ TabPanels,
+ Tab,
+ TabPanel,
+ Table,
+ Tbody,
+ Tr,
+ Td,
+ Th,
+ Thead,
+ Box,
+ Spinner,
+ Center,
+ Text,
+ Stack
+} from '@chakra-ui/react';
+
+// Import Icons
import {
- AlertCircle,
AlertTriangle,
MessageCircleIcon,
Share2Icon,
+ ExternalLink
} from 'lucide-react';
+
import { LazyLoadComponent } from 'react-lazy-load-image-component';
import useDevice from '@/core/hooks/useDevice';
@@ -28,7 +50,11 @@ import SimilarBottom from './SimilarBottom';
import SimilarSide from './SimilarSide';
import dynamic from 'next/dynamic';
+// 1. IMPORT MODAL (Baru)
+import ProductComparisonModal from './ProductComparisonModal';
+
import { gtagProductDetail } from '@/core/utils/googleTag';
+import Skeleton from 'react-loading-skeleton';
type Props = {
product: IProductDetail;
@@ -39,12 +65,61 @@ const RWebShare = dynamic(
{ ssr: false }
);
+// 1. STYLE DESKTOP (Tebal, Jelas, dengan Border/Padding)
+const cssScrollbarDesktop = {
+ '&::-webkit-scrollbar': {
+ width: '10px',
+ height: '10px',
+ },
+ '&::-webkit-scrollbar-track': {
+ background: '#f1f1f1',
+ borderRadius: '4px',
+ },
+ '&::-webkit-scrollbar-thumb': {
+ backgroundColor: '#9ca3af', // Gray-400
+ borderRadius: '6px',
+ border: '2px solid #f1f1f1', // Efek padding
+ },
+ '&::-webkit-scrollbar-thumb:hover': {
+ backgroundColor: '#6b7280',
+ },
+};
+
+// 2. STYLE MOBILE (Tipis, Minimalis, Tanpa Border)
+const cssScrollbarMobile = {
+ '&::-webkit-scrollbar': {
+ width: '3px', // Sangat tipis vertikal
+ height: '3px', // Sangat tipis horizontal
+ },
+ '&::-webkit-scrollbar-track': {
+ background: 'transparent',
+ },
+ '&::-webkit-scrollbar-thumb': {
+ backgroundColor: '#cbd5e1', // Gray-300
+ borderRadius: '3px',
+ },
+};
+
const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST;
const ProductDetail = ({ product }: Props) => {
const { isDesktop, isMobile } = useDevice();
const router = useRouter();
const [auth, setAuth] = useState<any>(null);
+
+ console.log('Render ProductDetail for product ID:', product);
+
+ // State Data dari Magento
+ const [specsMatrix, setSpecsMatrix] = useState<any[]>([]);
+ const [upsellIds, setUpsellIds] = useState<number[]>([]);
+ const [relatedIds, setRelatedIds] = useState<number[]>([]);
+ const [descriptionMap, setDescriptionMap] = useState<Record<string, string>>({});
+
+ const [loadingSpecs, setLoadingSpecs] = useState(false);
+
+ // 2. STATE MODAL COMPARE (Baru)
+ const [isCompareOpen, setCompareOpen] = useState(false);
+
useEffect(() => {
try {
setAuth(getAuth() ?? null);
@@ -61,8 +136,8 @@ const ProductDetail = ({ product }: Props) => {
activeVariantId,
setIsApproval,
isApproval,
+ selectedVariant,
setSelectedVariant,
- setSla,
} = useProductDetail();
useEffect(() => {
@@ -95,15 +170,183 @@ const ProductDetail = ({ product }: Props) => {
// });
// }, [product?.id]);
+ // 1. LOGIC INISIALISASI VARIANT
useEffect(() => {
if (typeof auth === 'object') {
setIsApproval(auth?.feature?.soApproval);
}
- const selectedVariant =
+ const variantInit =
product?.variants?.find((variant) => variant.is_in_bu) ||
product?.variants?.[0];
- setSelectedVariant(selectedVariant);
- }, []);
+
+ setSelectedVariant(variantInit);
+
+ setSpecsMatrix([]);
+ setUpsellIds([]);
+ setRelatedIds([]);
+
+ }, [product, auth]);
+
+ // 2. LOGIC FETCH DATA
+ useEffect(() => {
+ const fetchMagentoData = async () => {
+ const allVariantIds = product.variants.map(v => v.id);
+
+ if (allVariantIds.length === 0) return;
+
+ const mainId = allVariantIds[0];
+
+ setLoadingSpecs(true);
+
+ try {
+ const params = new URLSearchParams({
+ skus: allVariantIds.join(','),
+ main_sku: String(mainId)
+ });
+
+ const endpoint = `/api/magento-product?${params.toString()}`;
+
+ const response = await fetch(endpoint, {
+ method: 'GET',
+ headers: { 'Content-Type': 'application/json' }
+ });
+
+ if (!response.ok) {
+ setSpecsMatrix([]);
+ setUpsellIds([]);
+ setRelatedIds([]);
+ return;
+ }
+
+ const data = await response.json();
+
+ // 1. Specs Matrix (Processed Grouping)
+ if (data.specsMatrix && Array.isArray(data.specsMatrix)) {
+ // const filteredMatrix = data.specsMatrix.filter((item: any) => {
+ // const code = item.code || '';
+ // return !code.includes('z_brand');
+ // });
+ const processed = processMatrixData(data.specsMatrix);
+ setSpecsMatrix(processed);
+ // const processed = processMatrixData(filteredMatrix);
+ // setSpecsMatrix(processed);
+ } else {
+ setSpecsMatrix([]);
+ }
+
+ if (data.descriptions){
+ setDescriptionMap(data.descriptions);
+ }
+
+ // 2. Upsell & Related
+ if (data.upsell_ids && Array.isArray(data.upsell_ids)) setUpsellIds(data.upsell_ids);
+ else setUpsellIds([]);
+
+ if (data.related_ids && Array.isArray(data.related_ids)) setRelatedIds(data.related_ids);
+ else setRelatedIds([]);
+
+ } catch (error) {
+ console.error("Gagal mengambil data Magento:", error);
+ setSpecsMatrix([]);
+ } finally {
+ setLoadingSpecs(false);
+ }
+ };
+
+ fetchMagentoData();
+
+ }, [product.id]);
+
+ // HELPER 1: GROUPING DATA BY LABEL
+ const processMatrixData = (rawMatrix: any[]) => {
+ const groups: any = {};
+ const result: any[] = [];
+
+ rawMatrix.forEach(item => {
+ if (item.label && item.label.includes(' : ')) {
+ const parts = item.label.split(' : ');
+ const groupName = parts[0].trim();
+ const childLabel = parts.slice(1).join(' : ').trim();
+
+ if (!groups[groupName]) {
+ groups[groupName] = {
+ type: 'group',
+ label: groupName,
+ children: []
+ };
+ result.push(groups[groupName]);
+ }
+
+ groups[groupName].children.push({
+ ...item,
+ label: childLabel
+ });
+
+ } else {
+ result.push({ ...item, type: 'single' });
+ }
+ });
+
+ return result;
+ };
+
+
+ // HELPER 2: RENDER SPEC VALUE
+ const renderSpecValue = (val: any) => {
+ if (!val) return '-';
+ const strVal = String(val).trim();
+
+ const isUrl = !strVal.includes(' ') && (
+ strVal.startsWith('http') ||
+ strVal.startsWith('www.')
+ );
+ if (isUrl) {
+ const href = strVal.startsWith('http') ? strVal : `https://${strVal}`;
+ return (
+ <a
+ href={href}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-red-600 hover:underline inline-flex items-center gap-1"
+ >
+ <ExternalLink size={14} /> Link
+ </a>
+ );
+ }
+
+ if (strVal.includes('<') && strVal.includes('>')) {
+ return (
+ <Box
+ className="prose prose-sm text-gray-700"
+ sx={{
+ '& ul, & ol': {
+ paddingLeft: '1.2rem',
+ margin: 0,
+ textAlign: 'left'
+ },
+ '& li': {
+ fontWeight: 'normal',
+ marginBottom: '4px',
+ textAlign: 'left'
+ },
+ '& strong': {
+ display: 'block',
+ marginBottom: '2px',
+ fontWeight: 'bold'
+ },
+ '& p': {
+ margin: 0,
+ textAlign: 'left'
+ }
+ }}
+ dangerouslySetInnerHTML={{ __html: strVal }}
+ />
+ );
+ }
+
+ return strVal;
+ };
+
const allImages = (() => {
const arr: string[] = [];
@@ -146,15 +389,59 @@ const ProductDetail = ({ product }: Props) => {
};
const scrollToIndex = (i: number) => {
- const el = sliderRef.current;
- if (!el) return;
+ const el = sliderRef.current;
+ if (!el) return;
el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' });
setCurrentIdx(i);
setMainImage(allImages[i] || '');
};
+ const sortedVariants = useMemo(() => {
+ if (!product?.variants) return [];
+
+ return [...product.variants].sort((a, b) => {
+ const labelA = a.attributes && a.attributes.length > 0
+ ? a.attributes.join(' - ')
+ : a.code || '';
+
+ const labelB = b.attributes && b.attributes.length > 0
+ ? b.attributes.join(' - ')
+ : b.code || '';
+
+ const getNumber = (str: string) => {
+ const match = String(str).match(/(\d+(\.\d+)?)/);
+ return match ? parseFloat(match[0]) : null;
+ };
+
+ const numA = getNumber(labelA);
+ const numB = getNumber(labelB);
+
+ if (numA !== null && numB !== null && numA !== numB) {
+ return numA - numB;
+ }
+
+ return String(labelA).localeCompare(String(labelB), undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ });
+ });
+ }, [product.variants]);
+
+ const activeMagentoDesc = selectedVariant?.id ? descriptionMap[String(selectedVariant.id)] : '';
+ const finalDescription = activeMagentoDesc || product.description || 'Deskripsi produk tidak tersedia.';
+ const cleanDescription = finalDescription === '<p><br></p>' ? 'Deskripsi produk tidak tersedia.' : finalDescription;
+
return (
<>
+ {/* 3. MODAL POPUP DIRENDER DISINI */}
+ {/* Render di luar layout utama agar tidak tertutup elemen lain */}
+ <ProductComparisonModal
+ isOpen={isCompareOpen}
+ onClose={() => setCompareOpen(false)}
+ mainProduct={product}
+ selectedVariant={selectedVariant}
+ />
+
<div className='relative'>
{isDesktop && !hasPrice && (
<div className='absolute inset-0 z-[20] flex items-center justify-center pointer-events-none select-none'>
@@ -188,93 +475,39 @@ const ProductDetail = ({ product }: Props) => {
<div className='md:flex md:flex-wrap'>
{/* ===== Kolom kiri: gambar ===== */}
<div className='md:w-4/12'>
- {/* === MOBILE: Slider swipeable, tanpa thumbnail carousel === */}
+ {/* ... Image Slider ... */}
{isMobile ? (
<div className='relative'>
- <div
- ref={sliderRef}
- onScroll={handleMobileScroll}
- className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar'
- style={{
- scrollBehavior: 'smooth',
- msOverflowStyle: 'none',
- scrollbarWidth: 'none',
- }}
- >
+ <div ref={sliderRef} onScroll={handleMobileScroll} className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar' style={{ scrollBehavior: 'smooth', msOverflowStyle: 'none', scrollbarWidth: 'none' }}>
{allImages.length > 0 ? (
allImages.map((img, i) => (
- <div
- key={i}
- className='w-full flex-shrink-0 snap-center flex justify-center items-center'
- >
- {/* gambar diperkecil */}
- <img
- src={img}
- alt={`Gambar ${i + 1}`}
- className='w-[85%] aspect-square object-contain'
- onError={(e) => {
- (e.target as HTMLImageElement).src =
- '/images/noimage.jpeg';
- }}
- />
+ <div key={i} className='w-full flex-shrink-0 snap-center flex justify-center items-center'>
+ <img src={img} alt={`Gambar ${i + 1}`} className='w-[85%] aspect-square object-contain' onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} />
</div>
))
) : (
<div className='w-full flex-shrink-0 snap-center flex justify-center items-center'>
- <img
- src={mainImage || '/images/noimage.jpeg'}
- alt='Gambar produk'
- className='w-[85%] aspect-square object-contain'
- />
+ <img src={mainImage || '/images/noimage.jpeg'} alt='Gambar produk' className='w-[85%] aspect-square object-contain' />
</div>
)}
</div>
-
- {/* Dots indicator */}
{allImages.length > 1 && (
<div className='absolute bottom-2 left-0 right-0 flex justify-center gap-2'>
{allImages.map((_, i) => (
- <button
- key={i}
- aria-label={`Ke slide ${i + 1}`}
- className={`w-2 h-2 rounded-full ${
- currentIdx === i ? 'bg-gray-800' : 'bg-gray-300'
- }`}
- onClick={() => scrollToIndex(i)}
- />
+ <button key={i} aria-label={`Ke slide ${i + 1}`} className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300'}`} onClick={() => scrollToIndex(i)} />
))}
</div>
)}
</div>
) : (
<>
- {/* === DESKTOP: Tetap seperti sebelumnya === */}
<ProductImage product={{ ...product, image: mainImage }} />
-
- {/* Carousel horizontal (thumbnail) – hanya desktop */}
{allImages.length > 0 && (
<div className='mt-4 overflow-x-auto'>
<div className='flex space-x-3 pb-3'>
{allImages.map((img, index) => (
- <div
- key={index}
- className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${
- mainImage === img
- ? 'border-red-500 ring-2 ring-red-200'
- : 'border-gray-200 hover:border-gray-300'
- }`}
- onClick={() => setMainImage(img)}
- >
- <img
- src={img}
- alt={`Thumbnail ${index + 1}`}
- className='w-full h-full object-cover rounded-sm'
- loading='lazy'
- onError={(e) => {
- (e.target as HTMLImageElement).src =
- '/images/noimage.jpeg';
- }}
- />
+ <div key={index} className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${mainImage === img ? 'border-red-500 ring-2 ring-red-200' : 'border-gray-200 hover:border-gray-300'}`} onClick={() => setMainImage(img)}>
+ <img src={img} alt={`Thumbnail ${index + 1}`} className='w-full h-full object-cover rounded-sm' loading='lazy' onError={(e) => { (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; }} />
</div>
))}
</div>
@@ -283,7 +516,6 @@ const ProductDetail = ({ product }: Props) => {
</>
)}
</div>
- {/* <<=== TUTUP kolom kiri */}
{/* ===== Kolom kanan: info ===== */}
{isDesktop && (
@@ -294,9 +526,9 @@ const ProductDetail = ({ product }: Props) => {
size={18}
className='text-red-600 shrink-0 mx-2'
/>
- <div className='text-red-600 font-normal text-h-sm p-2'>
+ <h1 className='text-red-600 font-normal text-h-sm'>
Maaf untuk saat ini Produk yang anda cari tidak tersedia
- </div>
+ </h1>
</div>
)}
<div className='h-6 md:h-0' />
@@ -314,27 +546,27 @@ const ProductDetail = ({ product }: Props) => {
size={18}
className='text-red-600 shrink-0 mx-2'
/>
- <div className='text-red-600 font-normal text-h-sm p-2'>
+ <h1 className='text-red-600 font-normal text-h-sm'>
Maaf untuk saat ini Produk yang anda cari tidak tersedia
- </div>
+ </h1>
</div>
)}
<h1 className={style['title']}>{product.name}</h1>
<div className='h-3 md:h-0' />
<Information product={product} />
- <div className='h-6' />
+ <div className='h-2' />
</div>
)}
</div>
<div className='h-full'>
{isMobile && (
- <div className='px-4 pt-6'>
+ <div className='px-4 pt-2'>
<PriceAction product={product} />
</div>
)}
- <div className='h-4 md:h-10' />
+ <div className='h-2 md:h-10' />
{!!activeVariantId && !isApproval && (
<ProductPromoSection
product={product}
@@ -344,94 +576,295 @@ const ProductDetail = ({ product }: Props) => {
<div className='h-0 md:h-6' />
+ {/* === SECTION TABS: DESKRIPSI & SPESIFIKASI === */}
<div className={style['section-card']}>
- <h2 className={style['heading']}>Informasi Produk</h2>
- <div className='h-4' />
- <div className='overflow-x-auto'>
- <div
- className={style['description']}
- dangerouslySetInnerHTML={{
- __html:
- !product.description ||
- product.description == '<p><br></p>'
- ? 'Belum ada deskripsi'
- : product.description,
- }}
- />
- </div>
+ <Tabs variant="unstyled">
+ <TabList borderBottom="1px solid" borderColor="gray.200">
+ <Tab
+ _selected={{ color: 'red.600', borderColor: 'red.600', borderBottomWidth: '3px', fontWeight: 'bold', marginBottom: '-1.5px' }}
+ color="gray.500" fontWeight="medium" fontSize="sm" px={4} py={3}
+ >
+ Deskripsi
+ </Tab>
+ <Tab
+ _selected={{ color: 'red.600', borderColor: 'red.600', borderBottomWidth: '3px', fontWeight: 'bold', marginBottom: '-1.5px' }}
+ color="gray.500" fontWeight="medium" fontSize="sm" px={4} py={3}
+ >
+ Spesifikasi
+ </Tab>
+ {/* <Tab
+ _selected={{ color: 'red.600', borderColor: 'red.600', borderBottomWidth: '3px', fontWeight: 'bold', marginBottom: '-1.5px' }}
+ color="gray.500" fontWeight="medium" fontSize="sm" px={4} py={3}
+ >
+ Detail Lainnya
+ </Tab> */}
+ </TabList>
+
+ <TabPanels>
+ {/* DESKRIPSI */}
+ <TabPanel px={0} py={6}>
+ <div className='overflow-x-auto text-sm text-gray-700'>
+ {loadingSpecs ? (
+ <Stack spacing={4}>
+ <Skeleton height='20px' width="100%" />
+ <Skeleton height='20px' width="90%" />
+ <Skeleton height='20px' width="95%" />
+ <Skeleton height='20px' width="70%" />
+ </Stack>
+ ) : (
+ <Box
+ className={style['description']}
+ sx={{
+ 'ul, ol': {
+ marginTop: '0.5em !important',
+ marginBottom: '1em !important',
+ marginLeft: '0 !important',
+ listStylePosition: 'outside !important',
+ paddingLeft: '1.5em !important'
+ },
+ 'ul': { listStyleType: 'disc !important' },
+ 'ol': { listStyleType: 'decimal !important' },
+ 'li': {
+ marginBottom: '0.4em !important',
+ paddingLeft: '0.3em !important',
+ lineHeight: '1.6 !important'
+ }
+ }}
+ dangerouslySetInnerHTML={{ __html: cleanDescription }}
+ />
+ )}
+
+ </div>
+ </TabPanel>
+
+ {/* SPESIFIKASI */}
+ <TabPanel px={0} py={2}>
+ <Box
+ border="1px solid"
+ borderColor="gray.200"
+ borderRadius="sm"
+ overflowX="auto"
+ overflowY="auto"
+ maxHeight="500px"
+ css={isMobile ? cssScrollbarMobile : cssScrollbarDesktop}
+ >
+ {loadingSpecs ? (
+ <Center py={6}><Spinner color='red.500' /></Center>
+ ) : specsMatrix.length > 0 ? (
+ (() => {
+ const variantCount = sortedVariants.length;
+ const isSingleVariant = variantCount === 1;
+
+ // === LOGIC 1: SINGLE VARIANT (VERTICAL TABLE) ===
+ if (isSingleVariant) {
+ const singleVariantId = sortedVariants[0].id;
+ // Flatten data untuk list vertical
+ const rows: any[] = [];
+ specsMatrix.forEach(row => {
+ if (row.type === 'group') {
+ row.children.forEach((child: any) => rows.push(child));
+ } else {
+ rows.push(row);
+ }
+ });
+
+ return (
+ <Table variant="simple" size={isMobile ? "sm" : "md"}>
+ <Tbody>
+ {rows.map((row, idx) => (
+ <Tr key={idx} bg={idx % 2 === 0 ? 'white' : 'gray.50'}>
+ {/* Kolom Label (Kiri) */}
+ <Td
+ width="40%"
+ fontWeight="bold"
+ color="gray.600"
+ borderColor="gray.200"
+ verticalAlign="top"
+ py={3}
+ >
+ {row.label}
+ </Td>
+ {/* Kolom Value (Kanan) */}
+ <Td
+ color="gray.800"
+ borderColor="gray.200"
+ verticalAlign="top"
+ py={3}
+ >
+ {renderSpecValue(row.values[singleVariantId])}
+ </Td>
+ </Tr>
+ ))}
+ </Tbody>
+ </Table>
+ );
+ }
+
+ // === LOGIC 2: MULTIPLE VARIANTS (MATRIX TABLE HORIZONTAL) ===
+ const topHeaders: any[] = [];
+ const subHeaders: any[] = [];
+ const flatSpecs: any[] = [];
+
+ specsMatrix.forEach(row => {
+ if (row.type === 'group') {
+ topHeaders.push({
+ label: row.label,
+ type: 'group',
+ colSpan: row.children.length,
+ rowSpan: 1
+ });
+ row.children.forEach((child: any) => {
+ subHeaders.push(child);
+ flatSpecs.push(child);
+ });
+ } else {
+ topHeaders.push({
+ label: row.label,
+ type: 'single',
+ colSpan: 1,
+ rowSpan: 2
+ });
+ flatSpecs.push(row);
+ }
+ });
+
+ return (
+ <Table variant="simple" size={isMobile ? "sm" : "md"}>
+ <Thead bg="red.600" position="sticky" top={0} zIndex={3}>
+ <Tr>
+ {topHeaders.map((th, idx) => (
+ <Th
+ key={`top-${idx}`}
+ position={idx === 0 ? "sticky" : "static"}
+ left={idx === 0 ? 0 : undefined}
+ zIndex={idx === 0 ? 4 : 3}
+ boxShadow={idx === 0 ? "2px 0 5px -2px rgba(0,0,0,0.2)" : "none"}
+ bg="red.600"
+ colSpan={th.colSpan}
+ rowSpan={th.rowSpan}
+ color="white"
+ textAlign="center"
+ fontSize={isMobile ? "xs" : "sm"}
+ textTransform="none"
+ fontWeight="800"
+ letterSpacing="wide"
+ verticalAlign="middle"
+ borderBottom="none"
+ px={isMobile ? 2 : 4}
+ >
+ {th.label}
+ </Th>
+ ))}
+ </Tr>
+ <Tr>
+ {subHeaders.map((sub, idx) => {
+ const isFirstHeaderGroup = topHeaders[0]?.type === 'group';
+ const shouldSticky = idx === 0 && isFirstHeaderGroup;
+ return (
+ <Th
+ key={`sub-${idx}`}
+ position={shouldSticky ? "sticky" : "static"}
+ left={shouldSticky ? 0 : undefined}
+ zIndex={shouldSticky ? 4 : 1}
+ boxShadow={shouldSticky ? "2px 0 5px -2px rgba(0,0,0,0.2)" : "none"}
+ color="white"
+ textAlign="center"
+ fontSize="xs"
+ textTransform="none"
+ verticalAlign="middle"
+ whiteSpace="nowrap"
+ bg="red.600"
+ pt={1} pb={1}
+ px={isMobile ? 2 : 4}
+ >
+ {sub.label}
+ </Th>
+ );
+ })}
+ </Tr>
+ </Thead>
+
+ <Tbody>
+ {sortedVariants.map((v, vIdx) => (
+ <Tr key={v.id} bg={vIdx % 2 === 0 ? 'white' : 'gray.50'}>
+ {flatSpecs.map((spec, sIdx) => {
+ const rawValue = spec.values[v.id] || '-';
+ const isFirstCol = sIdx === 0;
+ return (
+ <Td
+ key={sIdx}
+ position={isFirstCol ? "sticky" : "static"}
+ left={isFirstCol ? 0 : undefined}
+ zIndex={isFirstCol ? 2 : 1}
+ bg={vIdx % 2 === 0 ? 'white' : 'gray.50'}
+ boxShadow={isFirstCol ? "2px 0 5px -2px rgba(0,0,0,0.1)" : "none"}
+ borderColor="gray.200"
+ textAlign="center"
+ fontSize={isMobile ? "xs" : "sm"}
+ verticalAlign="middle"
+ px={isMobile ? 1 : 2}
+ py={3}
+ minW={isMobile ? "100px" : "120px"}
+ maxW="200px"
+ whiteSpace="normal"
+ overflowWrap="break-word"
+ fontWeight={isFirstCol ? "bold" : "normal"}
+ >
+ {renderSpecValue(rawValue)}
+ </Td>
+ );
+ })}
+ </Tr>
+ ))}
+ </Tbody>
+ </Table>
+ );
+ })()
+ ) : (
+ <Box p={4} color="gray.500" fontSize="sm"><Text>Spesifikasi teknis belum tersedia.</Text></Box>
+ )}
+ </Box>
+ </TabPanel>
+ </TabPanels>
+ </Tabs>
</div>
</div>
</div>
{isDesktop && (
<div className='md:w-3/12'>
- <PriceAction product={product} />
- <div className='flex gap-x-5 items-center justify-center'>
- <Button
- as={Link}
- href={askAdminUrl}
- variant='link'
- target='_blank'
- colorScheme='gray'
- leftIcon={<MessageCircleIcon size={18} />}
- isDisabled={!hasPrice}
- >
- Ask Admin
- </Button>
-
+ {/* 4. INTEGRASI: PASSING HANDLER MODAL KE PRICE ACTION */}
+ <PriceAction
+ product={product}
+ onCompare={() => setCompareOpen(true)}
+ />
+
+ <div className='flex gap-x-5 items-center justify-center py-4'>
+ <Button as={Link} href={askAdminUrl} variant='link' target='_blank' colorScheme='gray' leftIcon={<MessageCircleIcon size={18} />} isDisabled={!hasPrice}>Ask Admin</Button>
<span>|</span>
-
- <div className={hasPrice ? '' : 'opacity-40 pointer-events-none'}>
- <AddToWishlist productId={product.id} />
- </div>
-
+ <div className={hasPrice ? '' : 'opacity-40 pointer-events-none'}><AddToWishlist productId={product.id} /></div>
<span>|</span>
-
- {canShare && (
- <RWebShare
- data={{
- text: 'Check out this product',
- title: `${product.name} - Indoteknik.com`,
- url:
- (process.env.NEXT_PUBLIC_SELF_HOST || '') +
- (router?.asPath || '/'),
- }}
- >
- <Button
- variant='link'
- colorScheme='gray'
- leftIcon={<Share2Icon size={18} />}
- isDisabled={!hasPrice}
- >
- Share
- </Button>
- </RWebShare>
- )}
+ {canShare && (<RWebShare data={{ text: 'Check out this product', title: `${product.name} - Indoteknik.com`, url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), }}><Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />} isDisabled={!hasPrice}>Share</Button></RWebShare>)}
</div>
<div className='h-6' />
<div className={style['heading']}>Produk Serupa</div>
-
<div className='h-4' />
-
- <SimilarSide product={product} />
+ <SimilarSide product={product} relatedIds={relatedIds} />
</div>
)}
<div className='md:w-full pt-4 md:py-10 px-4 md:px-0'>
<div className={style['heading']}>Kamu Mungkin Juga Suka</div>
-
<div className='h-6' />
-
<LazyLoadComponent>
- <SimilarBottom product={product} />
+ <SimilarBottom product={product} upsellIds={upsellIds} />
</LazyLoadComponent>
</div>
-
<div className='h-6 md:h-0' />
</div>
</>
);
};
-export default ProductDetail;
+export default ProductDetail; \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/SimilarBottom.tsx b/src-migrate/modules/product-detail/components/SimilarBottom.tsx
index 40d4dd82..d3957f4b 100644
--- a/src-migrate/modules/product-detail/components/SimilarBottom.tsx
+++ b/src-migrate/modules/product-detail/components/SimilarBottom.tsx
@@ -1,23 +1,58 @@
import { Skeleton } from '@chakra-ui/react'
-import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar'
+import { useQuery } from 'react-query'
import ProductSlider from '~/modules/product-slider'
+import { getProductSimilar, getProductsByIds } from '~/services/product'
import { IProductDetail } from '~/types/product'
type Props = {
- product: IProductDetail
+ product: IProductDetail;
+ upsellIds?: number[];
}
-const SimilarBottom = ({ product }: Props) => {
- const productSimilar = useProductSimilar({
- name: product.name,
- except: { productId: product.id }
- })
+const SimilarBottom = ({ product, upsellIds = [] }: Props) => {
+
+ const hasUpsell = upsellIds.length > 0;
- const products = productSimilar.data?.products || []
+ // Query 1: Upsell
+ const upsellQuery = useQuery({
+ queryKey: ['product-upsell', upsellIds],
+ queryFn: () => getProductsByIds({ ids: upsellIds }),
+ enabled: hasUpsell,
+ staleTime: 1000 * 60 * 5,
+ });
+
+ // Query 2: Similar Biasa
+ const similarQuery = useQuery({
+ queryKey: ['product-similar', product.name],
+ queryFn: () => getProductSimilar({
+ name: product.name,
+ except: { productId: product.id }
+ }),
+ enabled: !hasUpsell,
+ staleTime: 1000 * 60 * 5,
+ });
+
+ let products = [];
+ let isLoading = false;
+
+ // ==========================================
+ // PERBAIKAN DI SINI
+ // ==========================================
+ if (hasUpsell) {
+ // Salah: products = upsellQuery.data || [];
+ // Benar: Ambil properti .products di dalamnya
+ products = (upsellQuery.data as any)?.products || [];
+ isLoading = upsellQuery.isLoading;
+ } else {
+ products = similarQuery.data?.products || [];
+ isLoading = similarQuery.isLoading;
+ }
+
+ if (!isLoading && products.length === 0) return null;
return (
<Skeleton
- isLoaded={!productSimilar.isLoading}
+ isLoaded={!isLoading}
rounded='lg'
className='h-[350px]'
>
@@ -26,4 +61,4 @@ const SimilarBottom = ({ product }: Props) => {
);
}
-export default SimilarBottom \ No newline at end of file
+export default SimilarBottom; \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/SimilarSide.tsx b/src-migrate/modules/product-detail/components/SimilarSide.tsx
index d70a314d..51d9eff7 100644
--- a/src-migrate/modules/product-detail/components/SimilarSide.tsx
+++ b/src-migrate/modules/product-detail/components/SimilarSide.tsx
@@ -1,33 +1,75 @@
import { Skeleton } from '@chakra-ui/react'
+import { useQuery } from 'react-query'
import ProductCard from '~/modules/product-card'
-import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar'
-import { IProductDetail } from '~/types/product'
+// Import service
+import { getProductSimilar, getProductsByIds } from '~/services/product'
+// TAMBAHKAN 'IProduct' DISINI
+import { IProduct, IProductDetail } from '~/types/product'
type Props = {
product: IProductDetail
+ relatedIds?: number[]
}
-const SimilarSide = ({ product }: Props) => {
- const productSimilar = useProductSimilar({
- name: product.name,
- except: { productId: product.id, manufactureId: product.manufacture.id },
- })
+const SimilarSide = ({ product, relatedIds = [] }: Props) => {
+
+ const hasRelated = relatedIds.length > 0;
- const products = productSimilar.data?.products || []
+ // 1. Fetch Related by ID
+ const relatedQuery = useQuery({
+ queryKey: ['product-related', relatedIds],
+ queryFn: () => getProductsByIds({ ids: relatedIds }),
+ enabled: hasRelated,
+ staleTime: 1000 * 60 * 5,
+ });
+
+ // 2. Fetch Similar Biasa
+ const similarQuery = useQuery({
+ queryKey: ['product-similar-side', product.name],
+ queryFn: () => getProductSimilar({
+ name: product.name,
+ except: {
+ productId: product.id,
+ manufactureId: product.manufacture?.id
+ }
+ }),
+ enabled: !hasRelated,
+ staleTime: 1000 * 60 * 5,
+ });
+
+ // ============================================================
+ // PERBAIKAN: Definisikan tipe array secara eksplisit (IProduct[])
+ // ============================================================
+ let products: IProduct[] = [];
+ let isLoading = false;
+
+ if (hasRelated) {
+ // Cast ke any dulu jika tipe return service belum sempurna terdeteksi, lalu ambil products
+ // Atau jika getProductsByIds me-return { products: IProduct[] }, ambil .products
+ // Sesuai kode service terakhir, getProductsByIds me-return GetProductSimilarRes yg punya .products
+ products = (relatedQuery.data as any)?.products || [];
+ isLoading = relatedQuery.isLoading;
+ } else {
+ products = similarQuery.data?.products || [];
+ isLoading = similarQuery.isLoading;
+ }
+
+ if (!isLoading && products.length === 0) return null;
return (
<Skeleton
- isLoaded={!productSimilar.isLoading}
- className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg"
+ isLoaded={!isLoading}
+ className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg p-2"
rounded='lg'
>
- {products.map((product) => (
- <ProductCard
- key={product.id}
- product={product}
- layout='horizontal'
- />
+ {products.map((item) => (
+ <div key={item.id} className="pt-2 first:pt-0">
+ <ProductCard
+ product={item}
+ layout='horizontal'
+ />
+ </div>
))}
</Skeleton>
)