diff options
Diffstat (limited to 'src-migrate/modules/product-detail/components/ProductDetail.tsx')
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductDetail.tsx | 696 |
1 files changed, 638 insertions, 58 deletions
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> </> |
