diff options
3 files changed, 211 insertions, 95 deletions
diff --git a/src-migrate/modules/product-detail/components/Breadcrumb.tsx b/src-migrate/modules/product-detail/components/Breadcrumb.tsx index 0e263fe9..a0f983d0 100644 --- a/src-migrate/modules/product-detail/components/Breadcrumb.tsx +++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx @@ -10,12 +10,13 @@ type Props = { id: number; name: string }; const Breadcrumb = ({ id, name }: Props) => { const { isDesktop, isMobile } = useDevice(); - const { data: breadcrumbs = [] } = useQuery({ + const { data } = useQuery({ queryKey: ['product-category-breadcrumb', id], queryFn: () => getProductCategoryBreadcrumb(id), refetchOnWindowFocus: false, }); + const breadcrumbs = data ?? []; const total = breadcrumbs.length; const lastCat = total ? breadcrumbs[total - 1] : null; const hasHidden = total > 1; diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 39011620..f32bb38e 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -1,18 +1,13 @@ import style from '../styles/product-detail.module.css'; + import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; -import dynamic from 'next/dynamic'; +import { useEffect, useRef, useState, UIEvent } from 'react'; import { Button } from '@chakra-ui/react'; import { MessageCircleIcon, Share2Icon } from 'lucide-react'; import { LazyLoadComponent } from 'react-lazy-load-image-component'; -const RWebShare = dynamic( - () => import('react-web-share').then(m => m.RWebShare), - { ssr: false } -); - import useDevice from '@/core/hooks/useDevice'; import { getAuth } from '~/libs/auth'; import { whatsappUrl } from '~/libs/whatsappUrl'; @@ -26,23 +21,34 @@ import Information from './Information'; import PriceAction from './PriceAction'; import SimilarBottom from './SimilarBottom'; import SimilarSide from './SimilarSide'; +import dynamic from 'next/dynamic'; + + import { gtagProductDetail } from '@/core/utils/googleTag'; -type Props = { product: IProductDetail }; +type Props = { + product: IProductDetail; +}; + +const RWebShare = dynamic( + () => import('react-web-share').then(m => m.RWebShare), + { ssr: false } +); -const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST || ''; +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); useEffect(() => { - try { - setAuth(getAuth() ?? null); - } catch {} + try { setAuth(getAuth() ?? null); } catch { } }, []); + const canShare = + typeof navigator !== 'undefined' && + typeof (navigator as any).share === 'function'; + const { setAskAdminUrl, askAdminUrl, @@ -50,51 +56,88 @@ const ProductDetail = ({ product }: Props) => { setIsApproval, isApproval, setSelectedVariant, + setSla, } = useProductDetail(); useEffect(() => { - try { - gtagProductDetail(product); - } catch {} + gtagProductDetail(product); }, [product]); - const currentPath = router?.asPath || '/'; useEffect(() => { const createdAskUrl = whatsappUrl({ template: 'product', payload: { - manufacture: product.manufacture?.name ?? '', - productName: product.name ?? '', - url: SELF_HOST + currentPath, + manufacture: product.manufacture.name, + productName: product.name, + url: process.env.NEXT_PUBLIC_SELF_HOST + router.asPath, }, - fallbackUrl: currentPath, + fallbackUrl: router.asPath, }); + setAskAdminUrl(createdAskUrl); - }, [currentPath, product.manufacture?.name, product.name, setAskAdminUrl]); + }, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl]); useEffect(() => { - if (auth && typeof auth === 'object') { - setIsApproval(!!auth?.feature?.soApproval); + if (typeof auth === 'object') { + setIsApproval(auth?.feature?.soApproval); } const selectedVariant = - product?.variants?.find(v => v.is_in_bu) || product?.variants?.[0] || null; + product?.variants?.find((variant) => variant.is_in_bu) || + product?.variants?.[0]; setSelectedVariant(selectedVariant); - }, [auth, product?.variants, setIsApproval, setSelectedVariant]); + }, []); + - const allImages = useMemo(() => { + const allImages = (() => { const arr: string[] = []; - if (Array.isArray(product?.image_carousel)) arr.push(...product.image_carousel); - if (product?.image) arr.unshift(product.image); + if (product?.image) arr.push(product.image); + if ( + Array.isArray(product?.image_carousel) && + product.image_carousel.length + ) { + + const set = new Set(arr); + for (const img of product.image_carousel) { + if (!set.has(img)) { + arr.push(img); + set.add(img); + } + } + } return arr; - }, [product?.image, product?.image_carousel]); + })(); const [mainImage, setMainImage] = useState(allImages[0] || ''); + useEffect(() => { - if (!allImages.includes(mainImage)) setMainImage(allImages[0] || ''); - }, [allImages, mainImage]); - const canShare = - typeof navigator !== 'undefined' && typeof (navigator as any).share === 'function'; + if (!allImages.includes(mainImage)) { + setMainImage(allImages[0] || ''); + } + }, [allImages]); + + + const sliderRef = useRef<HTMLDivElement | null>(null); + const [currentIdx, setCurrentIdx] = useState(0); + + const handleMobileScroll = (e: UIEvent<HTMLDivElement>) => { + const el = e.currentTarget; + if (!el) return; + const idx = Math.round(el.scrollLeft / el.clientWidth); + if (idx !== currentIdx) { + setCurrentIdx(idx); + setMainImage(allImages[idx] || ''); + } + }; + + const scrollToIndex = (i: number) => { + const el = sliderRef.current; + if (!el) return; + el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' }); + setCurrentIdx(i); + setMainImage(allImages[i] || ''); + }; + return ( <> @@ -105,38 +148,105 @@ const ProductDetail = ({ product }: Props) => { <div className='md:w-9/12 md:flex md:flex-col md:pr-4 md:pt-6'> <div className='md:flex md:flex-wrap'> + {/* ===== Kolom kiri: gambar ===== */} <div className='md:w-4/12'> - <ProductImage product={{ ...product, image: mainImage }} /> - - {allImages.length > 0 && ( - <div className='mt-4 overflow-x-auto'> - <div className='flex space-x-3 pb-3'> - {allImages.map((img, index) => ( - <div - key={index} - className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${ - mainImage === img - ? 'border-red-500 ring-2 ring-red-200' - : 'border-gray-200 hover:border-gray-300' - }`} - onClick={() => setMainImage(img)} - > + {/* === MOBILE: Slider swipeable, tanpa thumbnail carousel === */} + {isMobile ? ( + <div className='relative'> + <div + ref={sliderRef} + onScroll={handleMobileScroll} + className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar' + style={{ + scrollBehavior: 'smooth', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + }} + > + {allImages.length > 0 ? ( + allImages.map((img, i) => ( + + <div + key={i} + className='w-full flex-shrink-0 snap-center flex justify-center items-center' + > + {/* gambar diperkecil */} + <img + src={img} + alt={`Gambar ${i + 1}`} + className='w-[85%] aspect-square object-contain' + onError={(e) => { + (e.target as HTMLImageElement).src = + '/images/noimage.jpeg'; + }} + /> + </div> + )) + ) : ( + <div className='w-full flex-shrink-0 snap-center flex justify-center items-center'> <img - src={img} - alt={`Thumbnail ${index + 1}`} - className='w-full h-full object-cover rounded-sm' - loading='lazy' - onError={e => { - (e.target as HTMLImageElement).src = '/images/noimage.jpeg'; - }} + src={mainImage || '/images/noimage.jpeg'} + alt='Gambar produk' + className='w-[85%] aspect-square object-contain' /> </div> - ))} + )} </div> + + {/* Dots indicator */} + {allImages.length > 1 && ( + <div className='absolute bottom-2 left-0 right-0 flex justify-center gap-2'> + {allImages.map((_, i) => ( + <button + key={i} + aria-label={`Ke slide ${i + 1}`} + className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300' + }`} + onClick={() => scrollToIndex(i)} + /> + ))} + </div> + )} </div> + ) : ( + <> + {/* === DESKTOP: Tetap seperti sebelumnya === */} + <ProductImage product={{ ...product, image: mainImage }} /> + + {/* Carousel horizontal (thumbnail) – hanya desktop */} + {allImages.length > 0 && ( + <div className='mt-4 overflow-x-auto'> + <div className='flex space-x-3 pb-3'> + {allImages.map((img, index) => ( + <div + key={index} + className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${mainImage === img + ? 'border-red-500 ring-2 ring-red-200' + : 'border-gray-200 hover:border-gray-300' + }`} + onClick={() => setMainImage(img)} + > + <img + src={img} + alt={`Thumbnail ${index + 1}`} + className='w-full h-full object-cover rounded-sm' + loading='lazy' + onError={(e) => { + (e.target as HTMLImageElement).src = + '/images/noimage.jpeg'; + }} + /> + </div> + ))} + </div> + </div> + )} + </> )} </div> + {/* <<=== TUTUP kolom kiri */} + {/* ===== Kolom kanan: info ===== */} <div className='md:w-8/12 px-4 md:pl-6'> <div className='h-6 md:h-0' /> <h1 className={style['title']}>{product.name}</h1> @@ -155,7 +265,10 @@ const ProductDetail = ({ product }: Props) => { <div className='h-4 md:h-10' /> {!!activeVariantId && !isApproval && ( - <ProductPromoSection product={product} productId={activeVariantId} /> + <ProductPromoSection + product={product} + productId={activeVariantId} + /> )} <div className='h-0 md:h-6' /> @@ -168,7 +281,7 @@ const ProductDetail = ({ product }: Props) => { className={style['description']} dangerouslySetInnerHTML={{ __html: - !product.description || product.description === '<p><br></p>' + !product.description || product.description == '<p><br></p>' ? 'Belum ada deskripsi' : product.description, }} @@ -184,7 +297,7 @@ const ProductDetail = ({ product }: Props) => { <div className='flex gap-x-5 items-center justify-center'> <Button as={Link} - href={/* askAdminUrl sudah di-set di effect */ askAdminUrl} + href={askAdminUrl} variant='link' target='_blank' colorScheme='gray' @@ -204,7 +317,7 @@ const ProductDetail = ({ product }: Props) => { data={{ text: 'Check out this product', title: `${product.name} - Indoteknik.com`, - url: SELF_HOST + currentPath, + url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), }} > <Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />}> @@ -216,14 +329,18 @@ const ProductDetail = ({ product }: Props) => { <div className='h-6' /> <div className={style['heading']}>Produk Serupa</div> + <div className='h-4' /> + <SimilarSide product={product} /> </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} /> </LazyLoadComponent> @@ -233,6 +350,8 @@ const ProductDetail = ({ product }: Props) => { </div> </> ); + + }; export default ProductDetail; diff --git a/src-migrate/pages/shop/product/[slug].tsx b/src-migrate/pages/shop/product/[slug].tsx index fc72a6b0..90658544 100644 --- a/src-migrate/pages/shop/product/[slug].tsx +++ b/src-migrate/pages/shop/product/[slug].tsx @@ -18,34 +18,41 @@ type PageProps = { product: IProductDetail } -export const getServerSideProps: GetServerSideProps<PageProps> = (async (context) => { - const { slug } = context.query +export const getServerSideProps: GetServerSideProps<PageProps & { canonicalPath: string }> = async (context) => { + const { slug } = context.query; const cookieString = context.req.headers.cookie; const cookies = cookieString ? cookie.parse(cookieString) : {}; const auth = cookies?.auth ? JSON.parse(cookies.auth) : {}; - const tier = auth?.pricelist || '' + const tier = auth?.pricelist || ''; - const productId = getIdFromSlug(slug as string) + const productId = getIdFromSlug(slug as string); + const product = await getProductById(productId, tier); - const product = await getProductById(productId, tier) + // ❌ produk tidak ada → 404 + if (!product) return { notFound: true }; - if (!product) return { notFound: true } + // ❌ tidak ada variants atau tidak ada yang harga > 0 → 404 + const hasValidVariant = Array.isArray(product.variants) + && product.variants.some(v => (v?.price?.price ?? 0) > 0); + if (!hasValidVariant) return { notFound: true }; + + // Canonical path aman untuk SSR (hindari router.asPath di server) + const canonicalPath = context.resolvedUrl || `/product/${slug}`; + + return { props: { product, canonicalPath } }; +}; - return { - props: { product } - } -}) const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST -const ProductDetailPage: NextPage<PageProps> = ({ product }) => { +const ProductDetailPage: NextPage<PageProps & { canonicalPath: string }> = ({ product, canonicalPath }) => { const router = useRouter(); - const { setProduct } = useProductContext(); - useEffect(() => { - if (product) setProduct(product); - }, [product, setProduct]); + useEffect(() => { if (product) setProduct(product); }, [product, setProduct]); + + const origin = process.env.NEXT_PUBLIC_SELF_HOST || ''; + const url = origin + (canonicalPath?.startsWith('/') ? canonicalPath : `/${canonicalPath}`); return ( <BasicLayout> @@ -53,31 +60,20 @@ const ProductDetailPage: NextPage<PageProps> = ({ product }) => { title={`${product.name} - Indoteknik.com`} description='Temukan pilihan produk B2B Industri & Alat Teknik untuk Perusahaan, UMKM & Pemerintah dengan lengkap, mudah dan transparan.' openGraph={{ - url: SELF_HOST + router.asPath, - images: [ - { - url: product?.image, - width: 800, - height: 800, - alt: product?.name, - }, - ], + url, + images: [{ url: product?.image, width: 800, height: 800, alt: product?.name }], type: 'product', }} - additionalMetaTags={[ - { - name: 'keywords', - content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}`, - } - ]} - canonical={SELF_HOST + router.asPath} + additionalMetaTags={[{ name: 'keywords', content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}` }]} + canonical={url} /> <div className='md:container pt-4 md:pt-6'> <ProductDetail product={product} /> </div> </BasicLayout> - ) -} + ); +}; + export default ProductDetailPage
\ No newline at end of file |
