summaryrefslogtreecommitdiff
path: root/src-migrate/modules/product-detail
diff options
context:
space:
mode:
authortrisusilo48 <tri.susilo@altama.co.id>2024-07-10 15:58:51 +0700
committertrisusilo48 <tri.susilo@altama.co.id>2024-07-10 15:58:51 +0700
commit2e3c726bc8217f3960cfecec44b81303b03de72b (patch)
tree1b85ced7f61f3e4c3f1f27b577b37aa161615065 /src-migrate/modules/product-detail
parent2b3bd9c0a454dbad69ce29cee877bfb1fca5dfa6 (diff)
parenta99bf6480eea556e53b85e6db45f3b8c2361e693 (diff)
Merge branch 'release' into development
# Conflicts: # src/pages/shop/product/variant/[slug].jsx
Diffstat (limited to 'src-migrate/modules/product-detail')
-rw-r--r--src-migrate/modules/product-detail/components/AddToCart.tsx78
-rw-r--r--src-migrate/modules/product-detail/components/AddToWishlist.tsx61
-rw-r--r--src-migrate/modules/product-detail/components/Breadcrumb.tsx41
-rw-r--r--src-migrate/modules/product-detail/components/Image.tsx133
-rw-r--r--src-migrate/modules/product-detail/components/Information.tsx56
-rw-r--r--src-migrate/modules/product-detail/components/PriceAction.tsx115
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx191
-rw-r--r--src-migrate/modules/product-detail/components/SimilarBottom.tsx29
-rw-r--r--src-migrate/modules/product-detail/components/SimilarSide.tsx36
-rw-r--r--src-migrate/modules/product-detail/components/VariantList.tsx117
-rw-r--r--src-migrate/modules/product-detail/index.ts3
-rw-r--r--src-migrate/modules/product-detail/stores/useProductDetail.ts37
-rw-r--r--src-migrate/modules/product-detail/styles/image.module.css35
-rw-r--r--src-migrate/modules/product-detail/styles/information.module.css19
-rw-r--r--src-migrate/modules/product-detail/styles/price-action.module.css24
-rw-r--r--src-migrate/modules/product-detail/styles/product-detail.module.css15
-rw-r--r--src-migrate/modules/product-detail/styles/variant-list.module.css35
17 files changed, 1025 insertions, 0 deletions
diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx
new file mode 100644
index 00000000..097db98a
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/AddToCart.tsx
@@ -0,0 +1,78 @@
+import { Button, useToast } from '@chakra-ui/react'
+import { useRouter } from 'next/router'
+
+import { getAuth } from '~/libs/auth'
+import { upsertUserCart } from '~/services/cart'
+
+type Props = {
+ variantId: number | null,
+ quantity?: number;
+ source?: 'buy' | 'add_to_cart';
+}
+
+const AddToCart = ({
+ variantId,
+ quantity = 1,
+ source = 'add_to_cart'
+}: Props) => {
+ const auth = getAuth()
+ const router = useRouter()
+ const toast = useToast({
+ position: 'top',
+ isClosable: true
+ })
+
+ const handleClick = async () => {
+ if (typeof auth !== 'object') {
+ const currentUrl = encodeURIComponent(router.asPath)
+ router.push(`/login?next=${currentUrl}`)
+ return;
+ }
+
+ if (
+ !variantId ||
+ isNaN(quantity) ||
+ typeof auth !== 'object'
+ ) return;
+
+ toast.promise(
+ upsertUserCart({
+ userId: auth.id,
+ type: 'product',
+ id: variantId,
+ qty: quantity,
+ selected: true,
+ source: source,
+ qtyAppend: true
+ }),
+ {
+ loading: { title: 'Menambahkan ke keranjang', description: 'Mohon tunggu...' },
+ success: { title: 'Menambahkan ke keranjang', description: 'Berhasil menambahkan ke keranjang belanja' },
+ error: { title: 'Menambahkan ke keranjang', description: 'Gagal menambahkan ke keranjang belanja' },
+ }
+ )
+
+ if (source === 'buy') {
+ router.push('/shop/checkout?source=buy')
+ }
+ }
+
+ const btnConfig = {
+ 'add_to_cart': {
+ colorScheme: 'yellow',
+ text: 'Keranjang'
+ },
+ 'buy': {
+ colorScheme: 'red',
+ text: 'Beli'
+ }
+ }
+
+ return (
+ <Button onClick={handleClick} colorScheme={btnConfig[source].colorScheme} className='w-full'>
+ {btnConfig[source].text}
+ </Button>
+ )
+}
+
+export default AddToCart \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/AddToWishlist.tsx b/src-migrate/modules/product-detail/components/AddToWishlist.tsx
new file mode 100644
index 00000000..697b2d5c
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/AddToWishlist.tsx
@@ -0,0 +1,61 @@
+import { Button, useToast } from '@chakra-ui/react'
+import { HeartIcon } from 'lucide-react'
+import React from 'react'
+import { useQuery } from 'react-query'
+import { getAuth } from '~/libs/auth'
+import clsxm from '~/libs/clsxm'
+import { getUserWishlist, upsertUserWishlist } from '~/services/wishlist'
+
+type Props = {
+ productId: number
+}
+
+const AddToWishlist = ({ productId }: Props) => {
+ const auth = getAuth()
+ const toast = useToast({
+ position: 'top',
+ isClosable: true
+ })
+
+ const searchParams = { product_id: productId.toString() }
+ const query = useQuery({
+ queryKey: ['wishlist', searchParams, auth],
+ queryFn: () => {
+ if (typeof auth !== 'object') return null;
+ return getUserWishlist(auth.id, searchParams)
+ },
+ refetchOnWindowFocus: false
+ })
+
+ const isAdded = query.data?.product_total ? query.data.product_total > 0 : false;
+
+ const toggleWishlist = async () => {
+ if (typeof auth !== 'object') return;
+ await upsertUserWishlist(auth.id, productId)
+ await query.refetch()
+ }
+
+ const handleClick = async () => {
+ toast.promise(toggleWishlist(), {
+ loading: { title: 'Update Wishlist', description: 'Mohon tunggu...' },
+ success: { title: 'Update Wishlist', description: 'Berhasil update wishlist' },
+ error: { title: 'Update Wishlist', description: 'Gagal update wishlist' },
+ })
+ }
+
+ return (
+ <Button
+ variant='link'
+ colorScheme='gray'
+ onClick={handleClick}
+ leftIcon={<HeartIcon size={18} className={clsxm('transition-colors', {
+ 'text-danger-500 fill-danger-500': isAdded,
+ 'fill-transparent': !isAdded
+ })} />}
+ >
+ Wishlist
+ </Button>
+ )
+}
+
+export default AddToWishlist \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/Breadcrumb.tsx b/src-migrate/modules/product-detail/components/Breadcrumb.tsx
new file mode 100644
index 00000000..f41859a9
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx
@@ -0,0 +1,41 @@
+import React, { Fragment } from 'react'
+import { useQuery } from 'react-query'
+import { getProductCategoryBreadcrumb } from '~/services/product'
+import Link from 'next/link'
+import { createSlug } from '~/libs/slug'
+
+type Props = {
+ id: number,
+ name: string
+}
+
+const Breadcrumb = ({ id, name }: Props) => {
+ const query = useQuery({
+ queryKey: ['product-category-breadcrumb'],
+ queryFn: () => getProductCategoryBreadcrumb(id),
+ refetchOnWindowFocus: false
+ })
+
+ const breadcrumbs = query.data || []
+
+ return (
+ <div className='line-clamp-2 md:line-clamp-1 leading-7 text-caption-1'>
+ <Link href='/' className='text-danger-500'>Home</Link>
+ <span className='mx-2'>/</span>
+ {breadcrumbs.map((category, index) => (
+ <Fragment key={index}>
+ <Link
+ href={createSlug('/shop/category/', category.name, category.id.toString())}
+ className='text-danger-500'
+ >
+ {category.name}
+ </Link>
+ <span className='mx-2'>/</span>
+ </Fragment>
+ ))}
+ <span>{name}</span>
+ </div>
+ )
+}
+
+export default Breadcrumb \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/Image.tsx b/src-migrate/modules/product-detail/components/Image.tsx
new file mode 100644
index 00000000..30ca0d34
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/Image.tsx
@@ -0,0 +1,133 @@
+import style from '../styles/image.module.css';
+import ImageNext from 'next/image';
+import React, { useEffect, useMemo, useState } from 'react'
+import { InfoIcon } from 'lucide-react'
+import { Tooltip } from '@chakra-ui/react'
+
+import { IProductDetail } from '~/types/product'
+import ImageUI from '~/components/ui/image'
+import moment from 'moment';
+
+type Props = {
+ product: IProductDetail
+}
+
+const Image = ({ product }: Props) => {
+ const flashSale = product.flash_sale
+ const [count, setCount] = useState(flashSale?.remaining_time || 0);
+
+
+
+ useEffect(() => {
+ let interval: NodeJS.Timeout;
+
+ if (flashSale?.remaining_time && flashSale.remaining_time > 0) {
+ setCount(flashSale.remaining_time);
+
+ interval = setInterval(() => {
+ setCount((prevCount) => prevCount - 1);
+ }, 1000);
+ }
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [flashSale?.remaining_time]);
+
+ const duration = moment.duration(count, 'seconds')
+
+ const image = useMemo(() => {
+ if (product.image) return product.image + '?ratio=square'
+ return '/images/noimage.jpeg'
+ }, [product.image])
+
+ return (
+ <div className={style['wrapper']}>
+ {/* <div className="relative"> */}
+ <ImageUI
+ src={image}
+ alt={product.name}
+ width={256}
+ height={256}
+ className={style['image']}
+ loading='eager'
+ priority
+ />
+ <div className="absolute top-4 right-10 flex ">
+ <div className="gambarB ">
+ {product.isSni && (
+ <ImageNext
+ src="/images/sni-logo.png"
+ alt="SNI Logo"
+ className="w-12 h-8 object-contain object-top sm:h-6"
+ width={50}
+ height={50}
+ />
+ )}
+ </div>
+ <div className="gambarC ">
+ {product.isTkdn && (
+ <ImageNext
+ src="/images/TKDN.png"
+ alt="TKDN"
+ className="w-16 h-8 object-contain object-top ml-1 mr-1 sm:h-6"
+ width={50}
+ height={50}
+ />
+ )}
+ </div>
+ </div>
+ {/* </div> */}
+
+
+
+ <div className={style['absolute-info']}>
+ <Tooltip
+ placement='bottom-end'
+ label='Gambar atau foto berperan sebagai ilustrasi produk. Kadang tidak sesuai dengan kondisi terbaru dengan berbagai perubahan dan perbaikan. Hubungi admin kami untuk informasi yang lebih baik perihal gambar.'
+ >
+ <div className="text-gray-600">
+ <InfoIcon size={20} />
+ </div>
+ </Tooltip>
+ </div>
+
+ {flashSale.remaining_time > 0 && (
+ <div className='absolute bottom-0 w-full h-14'>
+ <div className="relative w-full h-full">
+ <ImageUI
+ src='/images/BG-FLASH-SALE.jpg'
+ alt='Flash Sale Indoteknik'
+ width={200}
+ height={100}
+ className={style['flashsale-bg']}
+ />
+
+ <div className={style['flashsale']}>
+ <div className='flex items-center gap-x-3'>
+ <div className={style['disc-badge']}>{Math.floor(product.lowest_price.discount_percentage)}%</div>
+ <div className={style['flashsale-text']}>
+ <ImageUI
+ src='/images/ICON_FLASH_SALE_WEBSITE_INDOTEKNIK.svg'
+ alt='Icon Flash Sale'
+ width={20}
+ height={20}
+ />
+ {product.flash_sale.tag}
+ </div>
+ </div>
+ <div className={style['countdown']}>
+ <span>{duration.hours().toString().padStart(2, '0')}</span>
+ <span>{duration.minutes().toString().padStart(2, '0')}</span>
+ <span>{duration.seconds().toString().padStart(2, '0')}</span>
+ </div>
+ </div>
+
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
+
+export default Image \ 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
new file mode 100644
index 00000000..52eb6b88
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/Information.tsx
@@ -0,0 +1,56 @@
+import style from '../styles/information.module.css'
+
+import React from 'react'
+import dynamic from 'next/dynamic'
+import Link from 'next/link'
+import { useQuery } from 'react-query'
+
+import { IProductDetail } from '~/types/product'
+import { IProductVariantSLA } from '~/types/productVariant'
+import { createSlug } from '~/libs/slug'
+import { getVariantSLA } from '~/services/productVariant'
+import { formatToShortText } from '~/libs/formatNumber'
+
+const Skeleton = dynamic(() => import('@chakra-ui/react').then((mod) => mod.Skeleton))
+
+type Props = {
+ product: IProductDetail
+}
+
+const Information = ({ product }: Props) => {
+ const querySLA = useQuery<IProductVariantSLA>({
+ queryKey: ['variant-sla', product.variants[0].id],
+ queryFn: () => getVariantSLA(product.variants[0].id),
+ enabled: product.variant_total === 1
+ })
+
+ const sla = querySLA?.data
+
+ return (
+ <div className={style['wrapper']}>
+ <div className={style['row']}>
+ <div className={style['label']}>SKU Number</div>
+ <div className={style['value']}>SKU-{product.id}</div>
+ </div>
+ <div className={style['row']}>
+ <div className={style['label']}>Manufacture</div>
+ <div className={style['value']}>
+ {!!product.manufacture.name ? (
+ <Link
+ href={createSlug('/shop/brands/', product.manufacture.name, product.manufacture.id.toString())}
+ className='text-danger-500 hover:underline'
+ >
+ {product.manufacture.name}
+ </Link>
+ ) : '-'}
+ </div>
+ </div>
+ <div className={style['row']}>
+ <div className={style['label']}>Terjual</div>
+ <div className={style['value']}>{product.qty_sold > 0 ? formatToShortText(product.qty_sold) : '-'}</div>
+ </div>
+ </div>
+ )
+}
+
+export default Information \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx
new file mode 100644
index 00000000..81271f6e
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/PriceAction.tsx
@@ -0,0 +1,115 @@
+import style from '../styles/price-action.module.css';
+
+import React, { useEffect } from 'react';
+import formatCurrency from '~/libs/formatCurrency';
+import { IProductDetail } from '~/types/product';
+import { useProductDetail } from '../stores/useProductDetail';
+import AddToCart from './AddToCart';
+import Link from 'next/link';
+import { getAuth } from '~/libs/auth';
+
+type Props = {
+ product: IProductDetail;
+};
+
+const PriceAction = ({ product }: Props) => {
+ const {
+ activePrice,
+ setActive,
+ activeVariantId,
+ quantityInput,
+ setQuantityInput,
+ askAdminUrl,
+ isApproval,
+ setIsApproval,
+ } = useProductDetail();
+
+ useEffect(() => {
+ setActive(product.variants[0])
+ if(product.variants.length > 2 && product.variants[0].price.price === 0){
+ const variants = product.variants
+ for (let i = 0; i < variants.length; i++) {
+ if(variants[i].price.price > 0){
+ setActive(variants[i])
+ break;
+ }
+ }
+ }
+
+ }, [product, setActive]);
+
+
+
+ return (
+ <div
+ className='block md:sticky top-[150px] bg-white py-0 md:py-6 z-10'
+ id='price-section'
+ >
+ {!!activePrice && activePrice.price > 0 && (
+ <>
+ <div className='flex items-end gap-x-2'>
+ {activePrice.discount_percentage > 0 && (
+ <>
+ <div className={style['disc-badge']}>
+ {Math.floor(activePrice.discount_percentage)}%
+ </div>
+ <div className={style['disc-price']}>
+ Rp {formatCurrency(activePrice.price || 0)}
+ </div>
+ </>
+ )}
+ <div className={style['main-price']}>
+ Rp {formatCurrency(activePrice.price_discount || 0)}
+ </div>
+ </div>
+ <div className='h-1' />
+ <div className={style['secondary-text']}>
+ Termasuk PPN: Rp{' '}
+ {formatCurrency(Math.round(activePrice.price_discount * 1.11))}
+ </div>
+ </>
+ )}
+
+ {!!activePrice && activePrice.price === 0 && (
+ <span>
+ Hubungi kami untuk dapatkan harga terbaik,{' '}
+ <Link
+ href={askAdminUrl}
+ target='_blank'
+ className={style['contact-us']}
+ >
+ klik disini
+ </Link>
+ </span>
+ )}
+
+ <div className='h-4' />
+
+ <div className={style['action-wrapper']}>
+ <label htmlFor='quantity' className='hidden'>
+ Quantity
+ </label>
+ <input
+ type='number'
+ id='quantity'
+ value={quantityInput}
+ onChange={(e) => setQuantityInput(e.target.value)}
+ className={style['quantity-input']}
+ />
+ <AddToCart
+ variantId={activeVariantId}
+ quantity={Number(quantityInput)}
+ />
+ {!isApproval && (
+ <AddToCart
+ source='buy'
+ variantId={activeVariantId}
+ quantity={Number(quantityInput)}
+ />
+ )}
+ </div>
+ </div>
+ );
+};
+
+export default PriceAction;
diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx
new file mode 100644
index 00000000..fad35a7d
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx
@@ -0,0 +1,191 @@
+import style from '../styles/product-detail.module.css'
+
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
+
+import { Button } from '@chakra-ui/react'
+import { MessageCircleIcon, Share2Icon } from 'lucide-react'
+import { LazyLoadComponent } from 'react-lazy-load-image-component'
+import { RWebShare } from 'react-web-share'
+
+import useDevice from '@/core/hooks/useDevice'
+import { whatsappUrl } from '~/libs/whatsappUrl'
+import ProductPromoSection from '~/modules/product-promo/components/Section'
+import { IProductDetail } from '~/types/product'
+import { useProductDetail } from '../stores/useProductDetail'
+import AddToWishlist from './AddToWishlist'
+import Breadcrumb from './Breadcrumb'
+import ProductImage from './Image'
+import Information from './Information'
+import PriceAction from './PriceAction'
+import SimilarBottom from './SimilarBottom'
+import SimilarSide from './SimilarSide'
+import VariantList from './VariantList'
+import { getAuth } from '~/libs/auth'
+
+import { gtagProductDetail } from '@/core/utils/googleTag'
+
+type Props = {
+ product: IProductDetail
+}
+
+const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST
+
+const ProductDetail = ({ product }: Props) => {
+ const { isDesktop, isMobile } = useDevice()
+ const router = useRouter()
+ const auth = getAuth()
+ const { setAskAdminUrl, askAdminUrl, activeVariantId, setIsApproval, isApproval } = useProductDetail()
+
+ useEffect(() => {
+ gtagProductDetail(product);
+ },[product])
+
+ useEffect(() => {
+ const createdAskUrl = whatsappUrl({
+ template: 'product',
+ payload: {
+ manufacture: product.manufacture.name,
+ productName: product.name,
+ url: process.env.NEXT_PUBLIC_SELF_HOST + router.asPath
+ },
+ fallbackUrl: router.asPath
+ })
+
+ setAskAdminUrl(createdAskUrl)
+ }, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl])
+
+ useEffect(() => {
+ if (typeof auth === 'object') {
+ setIsApproval(auth?.feature?.soApproval);
+ }
+ }, []);
+
+ return (
+ <>
+ <div className='md:flex md:flex-wrap'>
+ <div className="w-full mb-4 md:mb-0 px-4 md:px-0">
+ <Breadcrumb id={product.id} name={product.name} />
+ </div>
+ <div className='md:w-9/12 md:flex md:flex-col md:pr-4 md:pt-6'>
+ <div className='md:flex md:flex-wrap'>
+ <div className="md:w-4/12">
+ <ProductImage product={product} />
+ </div>
+
+ <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>
+
+ <div className='h-6 md:h-8' />
+
+ <Information product={product} />
+
+ <div className='h-6' />
+
+ <div className="flex gap-x-5">
+ <Button
+ as={Link}
+ href={askAdminUrl}
+ variant='link'
+ target='_blank'
+ colorScheme='gray'
+ leftIcon={<MessageCircleIcon size={18} />}
+ >
+ Ask Admin
+ </Button>
+
+ <AddToWishlist productId={product.id} />
+
+ <RWebShare
+ data={{
+ text: 'Check out this product',
+ title: `${product.name} - Indoteknik.com`,
+ url: SELF_HOST + router.asPath
+ }}
+ >
+ <Button
+ variant='link'
+ colorScheme='gray'
+ leftIcon={<Share2Icon size={18} />}
+ >
+ Share
+ </Button>
+ </RWebShare>
+ </div>
+
+ </div>
+ </div>
+
+ <div className='h-full'>
+ {isMobile && (
+ <div className='px-4 pt-6'>
+ <PriceAction product={product} />
+ </div>
+ )}
+
+ <div className='h-4 md:h-10' />
+ {!!activeVariantId && !isApproval && <ProductPromoSection productId={activeVariantId} />}
+
+ <div className={style['section-card']}>
+ <h2 className={style['heading']}>
+ Variant ({product.variant_total})
+ </h2>
+ <div className='h-4' />
+ <VariantList variants={product.variants} />
+ </div>
+
+ <div className='h-0 md:h-6' />
+
+ <div className={style['section-card']}>
+ <h2 className={style['heading']}>
+ Informasi Produk
+ </h2>
+ <div className='h-4' />
+ <div
+ className={style['description']}
+ dangerouslySetInnerHTML={{ __html: !product.description || product.description == '<p><br></p>' ? 'Belum ada deskripsi' : product.description }}
+ />
+ </div>
+ </div>
+ </div>
+
+ {isDesktop && (
+ <div className="md:w-3/12">
+ <PriceAction product={product} />
+
+ <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>
+ </div>
+
+ <div className='h-6 md:h-0' />
+ </div>
+ </>
+ )
+}
+
+export default ProductDetail \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/SimilarBottom.tsx b/src-migrate/modules/product-detail/components/SimilarBottom.tsx
new file mode 100644
index 00000000..40d4dd82
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/SimilarBottom.tsx
@@ -0,0 +1,29 @@
+import { Skeleton } from '@chakra-ui/react'
+import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar'
+import ProductSlider from '~/modules/product-slider'
+import { IProductDetail } from '~/types/product'
+
+type Props = {
+ product: IProductDetail
+}
+
+const SimilarBottom = ({ product }: Props) => {
+ const productSimilar = useProductSimilar({
+ name: product.name,
+ except: { productId: product.id }
+ })
+
+ const products = productSimilar.data?.products || []
+
+ return (
+ <Skeleton
+ isLoaded={!productSimilar.isLoading}
+ rounded='lg'
+ className='h-[350px]'
+ >
+ <ProductSlider products={products} productLayout='vertical' />
+ </Skeleton>
+ );
+}
+
+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
new file mode 100644
index 00000000..d70a314d
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/SimilarSide.tsx
@@ -0,0 +1,36 @@
+import { Skeleton } from '@chakra-ui/react'
+
+import ProductCard from '~/modules/product-card'
+import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar'
+import { IProductDetail } from '~/types/product'
+
+type Props = {
+ product: IProductDetail
+}
+
+const SimilarSide = ({ product }: Props) => {
+ const productSimilar = useProductSimilar({
+ name: product.name,
+ except: { productId: product.id, manufactureId: product.manufacture.id },
+ })
+
+ const products = productSimilar.data?.products || []
+
+ 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"
+ rounded='lg'
+ >
+ {products.map((product) => (
+ <ProductCard
+ key={product.id}
+ product={product}
+ layout='horizontal'
+ />
+ ))}
+ </Skeleton>
+ )
+}
+
+export default SimilarSide \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/components/VariantList.tsx b/src-migrate/modules/product-detail/components/VariantList.tsx
new file mode 100644
index 00000000..3d5b9b74
--- /dev/null
+++ b/src-migrate/modules/product-detail/components/VariantList.tsx
@@ -0,0 +1,117 @@
+import style from '../styles/variant-list.module.css'
+
+import React from 'react'
+import { Button, Skeleton } from '@chakra-ui/react'
+
+import formatCurrency from '~/libs/formatCurrency'
+import clsxm from '~/libs/clsxm'
+import { IProductVariantDetail, IProductVariantSLA } from '~/types/productVariant'
+import { useProductDetail } from '../stores/useProductDetail'
+import { LazyLoadComponent } from 'react-lazy-load-image-component';
+import { getVariantSLA } from '~/services/productVariant'
+import { useQuery } from 'react-query'
+import useDevice from '@/core/hooks/useDevice'
+
+type Props = {
+ variants: IProductVariantDetail[]
+}
+
+const VariantList = ({ variants }: Props) => {
+ return (
+ <div className='overflow-auto'>
+ <div className={style['wrapper']}>
+ <div className={style['header']}>
+ <div className="w-2/12">Part Number</div>
+ <div className="w-2/12">Variant</div>
+ <div className="w-1/12">Stock</div>
+ <div className="w-2/12">Masa Persiapan</div>
+ <div className="w-1/12">Berat</div>
+ <div className="w-3/12">Harga</div>
+ <div className='w-1/12 sticky right-0 bg-gray-200'></div>
+ </div>
+ {variants.map((variant) => (
+ <LazyLoadComponent key={variant.id}>
+ <Row variant={variant} />
+ </LazyLoadComponent>
+ ))}
+ </div>
+ </div>
+ )
+}
+
+const Row = ({ variant }: { variant: IProductVariantDetail }) => {
+ const { isMobile } = useDevice()
+
+ const { activeVariantId, setActive } = useProductDetail()
+ const querySLA = useQuery<IProductVariantSLA>({
+ queryKey: ['variant-sla', variant.id],
+ queryFn: () => getVariantSLA(variant.id),
+ refetchOnWindowFocus: false,
+ })
+
+ const sla = querySLA?.data
+
+ const handleSelect = (variant: IProductVariantDetail) => {
+ const priceSectionEl = document.getElementById('price-section')
+ if (isMobile && priceSectionEl) {
+ window.scrollTo({
+ top: priceSectionEl.offsetTop - 120,
+ behavior: 'smooth'
+ })
+ }
+ setActive(variant)
+ }
+
+ return (
+ <div className={style['row']}>
+ <div className='w-2/12'>{variant.code}</div>
+ <div className='w-2/12'>{variant.attributes.join(', ') || '-'}</div>
+ <div className='w-1/12'>
+ <Skeleton isLoaded={querySLA.isSuccess} h='21px' w={16}>
+ {sla?.qty !== undefined && (
+ <div className={clsxm('text-center rounded-md', {
+ [style['stock-empty']]: sla.qty == 0,
+ [style['stock-ready']]: sla.qty > 0,
+ })}
+ >
+ {sla.qty > 0 && sla.qty}
+ {sla.qty == 0 && '-'}
+ </div>
+ )}
+ </Skeleton>
+ </div>
+ <div className='w-2/12'>
+ <Skeleton isLoaded={querySLA.isSuccess} h='21px' w={16}>
+ {sla?.sla_date}
+ </Skeleton>
+ </div>
+ <div className='w-1/12'>
+ {variant.weight > 0 ? `${variant.weight} Kg` : '-'}
+ </div>
+ <div className='w-3/12'>
+ {variant.price.discount_percentage > 0 && (
+ <div className='flex items-center gap-x-1'>
+ <div className={style['disc-badge']}>{Math.floor(variant.price.discount_percentage)}%</div>
+ <div className={style['disc-price']}>Rp {formatCurrency(variant.price.price)}</div>
+ </div>
+ )}
+ {variant.price.price_discount > 0 && `Rp ${formatCurrency(variant.price.price_discount)}`}
+ {variant.price.price_discount === 0 && '-'}
+ </div>
+ <div className='w-1/12 sticky right-0 bg-white md:bg-transparent'>
+ <Button
+ onClick={() => handleSelect(variant)}
+ size='sm'
+ w='100%'
+ className={clsxm(style['select-btn'], {
+ [style['select-btn--active']]: variant.id === activeVariantId
+ })}
+ >
+ Pilih
+ </Button>
+ </div>
+ </div>
+ )
+}
+
+export default VariantList \ No newline at end of file
diff --git a/src-migrate/modules/product-detail/index.ts b/src-migrate/modules/product-detail/index.ts
new file mode 100644
index 00000000..246bc06a
--- /dev/null
+++ b/src-migrate/modules/product-detail/index.ts
@@ -0,0 +1,3 @@
+import ProductDetail from './components/ProductDetail';
+
+export default ProductDetail;
diff --git a/src-migrate/modules/product-detail/stores/useProductDetail.ts b/src-migrate/modules/product-detail/stores/useProductDetail.ts
new file mode 100644
index 00000000..2da8835d
--- /dev/null
+++ b/src-migrate/modules/product-detail/stores/useProductDetail.ts
@@ -0,0 +1,37 @@
+import { create } from 'zustand';
+import { IProductVariantDetail } from '~/types/productVariant';
+
+type State = {
+ activeVariantId: number | null;
+ activePrice: IProductVariantDetail['price'] | null;
+ quantityInput: string;
+ askAdminUrl: string;
+ isApproval : boolean;
+};
+
+type Action = {
+ setActive: (variant: IProductVariantDetail) => void;
+ setQuantityInput: (value: string) => void;
+ setAskAdminUrl: (url: string) => void;
+ setIsApproval : (value : boolean) => void;
+};
+
+export const useProductDetail = create<State & Action>((set, get) => ({
+ activeVariantId: null,
+ activePrice: null,
+ quantityInput: '1',
+ askAdminUrl: '',
+ isApproval : false,
+ setActive: (variant) => {
+ set({ activeVariantId: variant.id, activePrice: variant.price });
+ },
+ setQuantityInput: (value: string) => {
+ set({ quantityInput: value });
+ },
+ setAskAdminUrl: (url: string) => {
+ set({ askAdminUrl: url });
+ },
+ setIsApproval : (value : boolean) => {
+ set({ isApproval : value })
+ }
+}));
diff --git a/src-migrate/modules/product-detail/styles/image.module.css b/src-migrate/modules/product-detail/styles/image.module.css
new file mode 100644
index 00000000..e472fe8d
--- /dev/null
+++ b/src-migrate/modules/product-detail/styles/image.module.css
@@ -0,0 +1,35 @@
+.wrapper {
+ @apply h-[250px] md:h-[340px] flex items-center justify-center border border-gray-200 rounded-lg p-4 relative;
+}
+
+.image {
+ @apply object-contain object-center h-full w-full;
+}
+
+.absolute-info {
+ @apply absolute hidden md:block top-4 right-4;
+}
+
+.disc-badge {
+ @apply bg-warning-500 py-1 px-3 w-fit font-semibold rounded-full;
+}
+
+.countdown {
+ @apply flex gap-x-1;
+}
+
+.countdown span {
+ @apply py-0.5 w-8 bg-warning-500 rounded-md text-center;
+}
+
+.flashsale-text {
+ @apply flex items-center gap-x-2 text-white font-medium text-caption-1;
+}
+
+.flashsale-bg {
+ @apply absolute top-0 w-full h-full object-cover object-center z-10;
+}
+
+.flashsale {
+ @apply absolute top-0 w-full h-full z-20 flex items-center justify-between px-3;
+}
diff --git a/src-migrate/modules/product-detail/styles/information.module.css b/src-migrate/modules/product-detail/styles/information.module.css
new file mode 100644
index 00000000..c9b29020
--- /dev/null
+++ b/src-migrate/modules/product-detail/styles/information.module.css
@@ -0,0 +1,19 @@
+.wrapper {
+ @apply grid grid-cols-1;
+}
+
+.row {
+ @apply flex p-3 rounded;
+}
+
+.row:nth-child(odd) {
+ @apply bg-gray-100;
+}
+
+.label {
+ @apply w-1/2 md:w-1/3 font-medium text-gray-500;
+}
+
+.value {
+ @apply w-1/2 md:w-3/4 text-gray-950;
+}
diff --git a/src-migrate/modules/product-detail/styles/price-action.module.css b/src-migrate/modules/product-detail/styles/price-action.module.css
new file mode 100644
index 00000000..651de958
--- /dev/null
+++ b/src-migrate/modules/product-detail/styles/price-action.module.css
@@ -0,0 +1,24 @@
+.secondary-text {
+ @apply font-medium text-gray-500;
+}
+.main-price {
+ @apply font-medium text-danger-500 text-title-md;
+}
+.action-wrapper {
+ @apply flex gap-x-2.5;
+}
+.quantity-input {
+ @apply px-2 rounded text-center border border-gray-300 w-14 h-10 focus:outline-none;
+}
+
+.contact-us {
+ @apply text-danger-500 font-medium underline;
+}
+
+.disc-badge {
+ @apply bg-danger-500 px-2 py-1.5 rounded text-white text-caption-2;
+}
+
+.disc-price {
+ @apply line-through text-gray-600 text-caption-2;
+}
diff --git a/src-migrate/modules/product-detail/styles/product-detail.module.css b/src-migrate/modules/product-detail/styles/product-detail.module.css
new file mode 100644
index 00000000..c668167c
--- /dev/null
+++ b/src-migrate/modules/product-detail/styles/product-detail.module.css
@@ -0,0 +1,15 @@
+.title {
+ @apply font-medium text-h-lg leading-8 md:text-title-md md:leading-10;
+}
+
+.section-card {
+ @apply p-4 md:p-6 md:bg-gray-50 rounded-xl;
+}
+
+.heading {
+ @apply text-h-md md:text-h-lg font-medium;
+}
+
+.description {
+ @apply leading-relaxed text-gray-700;
+}
diff --git a/src-migrate/modules/product-detail/styles/variant-list.module.css b/src-migrate/modules/product-detail/styles/variant-list.module.css
new file mode 100644
index 00000000..6d46df84
--- /dev/null
+++ b/src-migrate/modules/product-detail/styles/variant-list.module.css
@@ -0,0 +1,35 @@
+.wrapper {
+ @apply grid grid-cols-1 w-[200%] md:w-full;
+}
+
+.header {
+ @apply flex py-2.5 pl-4 font-medium bg-gray-200 rounded-md;
+}
+
+.row {
+ @apply flex items-center py-2.5 pl-4 text-gray-800;
+}
+
+.select-btn {
+ @apply !bg-gray-200 hover:!bg-danger-500 hover:!text-white;
+}
+
+.select-btn--active {
+ @apply !text-white !bg-danger-500 hover:!text-white;
+}
+
+.stock-empty {
+ @apply bg-red-50 border border-red-500 text-red-800;
+}
+
+.stock-ready {
+ @apply bg-green-50 border border-green-500 text-green-800;
+}
+
+.disc-badge {
+ @apply bg-danger-500 p-1 rounded text-white text-caption-2;
+}
+
+.disc-price {
+ @apply text-caption-2 line-through text-gray-600;
+}