diff options
Diffstat (limited to 'src-migrate/modules/product-detail/components')
8 files changed, 2235 insertions, 226 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..236a03af 100644 --- a/src-migrate/modules/product-detail/components/Information.tsx +++ b/src-migrate/modules/product-detail/components/Information.tsx @@ -9,19 +9,35 @@ 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) + import('@chakra-ui/react').then((mod) => mod.Skeleton), ); type Props = { @@ -37,14 +53,46 @@ const Information = ({ product }: Props) => { 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; @@ -52,7 +100,7 @@ const Information = ({ product }: Props) => { selectedVariant.code + (selectedVariant.attributes?.[0] ? ` - ${selectedVariant.attributes[0]}` - : '') + : ''), ); }, [selectedVariant]); @@ -72,14 +120,19 @@ const Information = ({ product }: Props) => { /* ====================== * Handlers * ====================== */ - const handleOnChange = (value: string) => { + const handleOnChange = (vals: any) => { setDisableFilter(true); + let code = vals.replace(/\s-\s.*$/, '').trim(); + let variant = product?.variants.find((item) => item.code === code); - const variant = variantOptions.find((item) => String(item.id) === value); - - if (!variant) return; - - setSelectedVariant(variant); + if (variant) { + setSelectedVariant(variant); + setInputValue( + variant?.code + + (variant?.attributes[0] ? ' - ' + variant?.attributes[0] : ''), + ); + setVariantOptions(product?.variants); + } }; const handleOnKeyUp = (e: any) => { @@ -87,6 +140,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,70 +181,133 @@ const Information = ({ product }: Props) => { </InputGroup> <AutoCompleteList> - {variantOptions.map((option) => ( - <AutoCompleteItem - key={option.id} - value={String(option.id)} - _selected={ - option.id === selectedVariant?.id - ? { bg: 'gray.300' } - : undefined - } - > - <div className='flex gap-x-2 w-full justify-between px-3 items-center p-2'> - <div className='text-small'> - {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='badge-solid-red text-xs'> - {Math.floor(option.price.discount_percentage)}% - </div> - <div className='min-w-16 sm:min-w-24 text-gray_r-11 line-through text-[11px] sm:text-caption-2'> - {currencyFormat(option.price.price)} - </div> - </> - )} - <div className='min-w-20 sm:min-w-28 text-danger-500 font-semibold'> - {currencyFormat(option.price.price_discount)} + {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={ + option.code + + (option.attributes?.[0] ? ` - ${option.attributes[0]}` : '') + } + _selected={ + option.id === selectedVariant?.id + ? { bg: 'gray.300' } + : undefined + } + > + <div className='flex gap-x-2 w-full justify-between px-3 items-center p-2'> + <div className='text-small'> + {option.code} + {option.attributes?.[0] + ? ` - ${option.attributes[0]}` + : ''} + </div> + <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)}% + </div> + <div className='min-w-16 sm:min-w-24 text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + {currencyFormat(option.price.price)} + </div> + </> + )} + <div className='min-w-20 sm:min-w-28 text-danger-500 font-semibold'> + {currencyFormat(option.price.price_discount)} + </div> </div> </div> - </div> - </AutoCompleteItem> - ))} + </AutoCompleteItem> + ))} </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 href={createSlug( '/shop/brands/', product.manufacture.name, - product.manufacture.id.toString() + product.manufacture.id.toString(), )} > - {product.manufacture.logo ? ( - <Image - height={50} + {product?.manufacture.logo ? ( + <ImageNext + height={100} width={100} src={product.manufacture.logo} alt={product.manufacture.name} @@ -201,29 +325,143 @@ 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> ); diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx index d73ab5f6..ea65b3d1 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} /> @@ -322,24 +302,26 @@ const PriceAction = ({ product }: Props) => { <div className={`${style['action-wrapper']}`}> <AddToCart products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> {!isApproval && ( <AddToCart source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> )} </div> <div className='mt-4'> + {/* 2. TERUSKAN onCompare KE SINI */} <AddToQuotation source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} + onCompare={onCompare} /> </div> </DesktopView> @@ -349,14 +331,14 @@ const PriceAction = ({ product }: Props) => { <AddToQuotation source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> </div> <div className='col-span-5'> <AddToCart products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> </div> @@ -365,7 +347,7 @@ const PriceAction = ({ product }: Props) => { <AddToCart source='buy' products={product} - variantId={activeVariantId} + variantId={selectedVariant?.id ?? null} quantity={Number(quantityInput)} /> )} @@ -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..5dd3f175 --- /dev/null +++ b/src-migrate/modules/product-detail/components/ProductComparisonModal.tsx @@ -0,0 +1,1084 @@ +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="sm" + _focus={{ + fontSize: {base: '16px', md: 'sm'}, + borderColor: 'red.500', + }}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" + fontSize="16px" + sx={{ + base: { + transform: "scale(0.875)", // Kecilkan visual jadi setara 14px (sm) + transformOrigin: "left center", + width: "114.29%", // Kompensasi lebar + marginBottom: "-2px", + marginRight: "-14.29%" + }, + md: { + fontSize: "sm", // Di Desktop pakai ukuran asli (14px) + transform: "none", + width: "100%", + marginBottom: "0", + marginRight: "0" + } + }} + 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" + color="gray.800" + w="100%" + textAlign="center" + sx={{ + 'ul, ol': { + textAlign: 'left', + listStylePosition: 'outside !important', + paddingLeft: '1.2em !important', + marginLeft: '0 !important', + marginTop: '0.5em !important', + marginBottom: '1em !important' + }, + 'ul': { listStyleType: 'disc !important' }, + 'ol': { listStyleType: 'decimal !important' }, + + // 2. ITEM LIST (LI) + 'li': { + textAlign: 'left', + marginBottom: '4px !important', + paddingLeft: '0.2em !important', + lineHeight: '1.5 !important', + fontWeight: 'normal !important' + }, + + 'strong, b': { + display: 'block !important', + fontWeight: '700 !important', + marginBottom: '0px !important', + marginTop: '4px !important', + color: '#1a202c', + textAlign: 'left' + }, + + 'p': { + margin: '0 !important', + padding: '0 !important' + } + }} + 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: 'left', // List Wajib Rata Kiri + listStylePosition: 'outside !important', + paddingLeft: '1.2em !important', + marginLeft: '0 !important', + marginTop: '0.5em !important', + marginBottom: '1em !important' + }, + 'ul': { listStyleType: 'disc !important' }, + 'ol': { listStyleType: 'decimal !important' }, + '& li': { + textAlign: 'left', + marginBottom: '4px !important', + paddingLeft: '0.2em !important', + lineHeight: '1.5 !important', + fontWeight: 'normal !important' + }, + '& strong': { + display: 'block !important', + fontWeight: '700 !important', + marginBottom: '0px !important', + marginTop: '4px !important', + color: '#1a202c', + textAlign: 'left' + }, + '& p': { + margin: '0 !important', + padding: '0 !important' + } + }} + 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..de205c41 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 { useEffect, useRef, useState, UIEvent, useMemo } from 'react'; -import { Button } from '@chakra-ui/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; @@ -36,15 +62,66 @@ type Props = { const RWebShare = dynamic( () => import('react-web-share').then((m) => m.RWebShare), - { ssr: false } + { 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 +138,8 @@ const ProductDetail = ({ product }: Props) => { activeVariantId, setIsApproval, isApproval, + selectedVariant, setSelectedVariant, - setSla, } = useProductDetail(); useEffect(() => { @@ -95,15 +172,178 @@ 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[] = []; @@ -153,8 +393,62 @@ const ProductDetail = ({ product }: Props) => { 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,7 +482,7 @@ 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 @@ -207,7 +501,6 @@ const ProductDetail = ({ product }: Props) => { key={i} className='w-full flex-shrink-0 snap-center flex justify-center items-center' > - {/* gambar diperkecil */} <img src={img} alt={`Gambar ${i + 1}`} @@ -229,17 +522,13 @@ const ProductDetail = ({ product }: Props) => { </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' - }`} + className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300'}`} onClick={() => scrollToIndex(i)} /> ))} @@ -248,21 +537,14 @@ const ProductDetail = ({ product }: Props) => { </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' - }`} + 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 @@ -283,7 +565,6 @@ const ProductDetail = ({ product }: Props) => { </> )} </div> - {/* <<=== TUTUP kolom kiri */} {/* ===== Kolom kanan: info ===== */} {isDesktop && ( @@ -294,9 +575,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 +595,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,29 +625,337 @@ 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'> + {/* 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} @@ -378,15 +967,11 @@ const ProductDetail = ({ product }: Props) => { > Ask Admin </Button> - <span>|</span> - <div className={hasPrice ? '' : 'opacity-40 pointer-events-none'}> <AddToWishlist productId={product.id} /> </div> - <span>|</span> - {canShare && ( <RWebShare data={{ @@ -411,23 +996,18 @@ const ProductDetail = ({ product }: Props) => { <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> </> 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> ) |
