summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIT Fixcomart <it@fixcomart.co.id>2025-09-29 05:20:43 +0000
committerIT Fixcomart <it@fixcomart.co.id>2025-09-29 05:20:43 +0000
commit0c8f06679b1f6ba3f6c8a73fecf461646b842a38 (patch)
tree1599a22e609b194e00d2f6eeed72c244e65a6c66
parent3ccfa710d65ff607f21c8eec5d77e343b6ed800a (diff)
parent6174fcca46f50be04a48f02098cb049e94e455d3 (diff)
Merged in crawl-fix (pull request #467)
Crawl fix
-rw-r--r--src-migrate/modules/product-detail/components/Breadcrumb.tsx3
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx241
-rw-r--r--src-migrate/pages/shop/product/[slug].tsx62
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 &amp; Alat Teknik untuk Perusahaan, UMKM &amp; 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