diff options
| author | trisusilo48 <tri.susilo@altama.co.id> | 2024-07-10 15:58:51 +0700 |
|---|---|---|
| committer | trisusilo48 <tri.susilo@altama.co.id> | 2024-07-10 15:58:51 +0700 |
| commit | 2e3c726bc8217f3960cfecec44b81303b03de72b (patch) | |
| tree | 1b85ced7f61f3e4c3f1f27b577b37aa161615065 /src-migrate/modules/product-detail | |
| parent | 2b3bd9c0a454dbad69ce29cee877bfb1fca5dfa6 (diff) | |
| parent | a99bf6480eea556e53b85e6db45f3b8c2361e693 (diff) | |
Merge branch 'release' into development
# Conflicts:
# src/pages/shop/product/variant/[slug].jsx
Diffstat (limited to 'src-migrate/modules/product-detail')
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; +} |
