diff options
Diffstat (limited to 'src-migrate/modules')
6 files changed, 805 insertions, 269 deletions
diff --git a/src-migrate/modules/cart/components/CartSummaryMobile.tsx b/src-migrate/modules/cart/components/CartSummaryMobile.tsx index 02258204..7a334fed 100644 --- a/src-migrate/modules/cart/components/CartSummaryMobile.tsx +++ b/src-migrate/modules/cart/components/CartSummaryMobile.tsx @@ -29,7 +29,7 @@ const CartSummaryMobile = ({ isLoaded = false, }: Props) => { const [showPopup, setShowPopup] = useState(false); - const PPN : number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; + const PPN: number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; return ( <> <BottomPopup @@ -88,22 +88,30 @@ const CartSummaryMobile = ({ </div> </BottomPopup> <div className='flex flex-col gap-y-3'> - <Skeleton isLoaded={isLoaded} className={style.line}> - <span className={clsxm(style.label, style.grandTotal)}> + <Skeleton + isLoaded={isLoaded} + className={clsxm(style.line, 'flex items-center justify-between !py-2')} + > + {/* Left: label */} + <span className={clsxm(style.label, style.grandTotal, 'leading-tight')}> Grand Total </span> - <button - onClick={() => setShowPopup(true)} - className='bg-gray-300 w-6 h-6 items-center justify-center cursor-pointer hover:bg-red-400 md:hidden ' - > - <ChevronDownIcon className='h-6 w-6 text-white' /> - </button> - </Skeleton> - <Skeleton isLoaded={isLoaded} className={style.line}> - <span className={style.value}> - Rp {formatCurrency(grandTotal || 0)} - </span> + + {/* Right: amount + chevron */} + <div className="flex items-center gap-2"> + <span className={clsxm(style.value, 'whitespace-nowrap tabular-nums leading-tight')}> + Rp {formatCurrency(grandTotal || 0)} + </span> + <button + onClick={() => setShowPopup(true)} + aria-label="Expand ringkasan" + className="md:hidden flex w-5 h-5 items-center justify-center rounded bg-gray-300 hover:bg-gray-400" + > + <ChevronDownIcon className="h-4 w-4 text-white" /> + </button> + </div> </Skeleton> + </div> </> ); diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx index 95bc1d88..147fd6d2 100644 --- a/src-migrate/modules/product-detail/components/AddToCart.tsx +++ b/src-migrate/modules/product-detail/components/AddToCart.tsx @@ -1,6 +1,6 @@ import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; import style from '../styles/price-action.module.css'; -import { Button, Link, useToast } from '@chakra-ui/react'; +import { Button, color, Link, useToast } from '@chakra-ui/react'; import product from 'next-seo/lib/jsonld/product'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -17,6 +17,10 @@ import formatCurrency from '~/libs/formatCurrency'; import { useProductDetail } from '../stores/useProductDetail'; import { gtagAddToCart } from '@/core/utils/googleTag'; import axios from 'axios'; +import useDevice from '@/core/hooks/useDevice'; +import MobileView from '@/core/components/views/MobileView'; +import DesktopView from '@/core/components/views/DesktopView'; +import ProductPromoSection from '~/modules/product-promo/components/Section'; type Props = { variantId: number | null; quantity?: number; @@ -39,6 +43,8 @@ const AddToCart = ({ isClosable: true, }); + const { isMobile, isDesktop } = useDevice(); + const { askAdminUrl } = useProductDetail(); const [product, setProducts] = useState(products); @@ -158,111 +164,266 @@ const AddToCart = ({ const btnConfig = { add_to_cart: { - colorScheme: 'yellow', + colorScheme: 'red', + variant: 'outline', text: 'Keranjang', }, buy: { colorScheme: 'red', - text: 'Beli', + variant: 'solid', + text: isDesktop ? 'Beli' : 'Beli Sekarang', }, }; return ( <div className='w-full'> - <Button - onClick={handleButton} - colorScheme={btnConfig[source].colorScheme} - className='w-full' - > - {btnConfig[source].text} - </Button> + <MobileView> + <Button + onClick={handleButton} + colorScheme={btnConfig[source].colorScheme} + variant={btnConfig[source].variant} + className='w-full' + > + {btnConfig[source].text} + </Button> + </MobileView> + + <DesktopView> + <Button + onClick={handleButton} + colorScheme={btnConfig[source].colorScheme} + variant={btnConfig[source].variant} + className='w-full' + > + {btnConfig[source].text} + </Button> + </DesktopView> + <BottomPopup className='!container' title='Berhasil Ditambahkan' active={addCartAlert} - close={() => { - setAddCartAlert(false); - }} + close={() => setAddCartAlert(false)} > - <div className='flex mt-4'> - <div className='w-[10%]'> - <ImageNext - src={product.image} - alt={product.name} - className='h-32 object-contain object-center w-full border border-gray_r-4' - width={80} - height={80} - /> - </div> - <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> - {!!product.manufacture.name ? ( - <Link - href={createSlug( - '/shop/brands/', - product.manufacture.name, - product.manufacture.id.toString() + {/* ===== MOBILE LAYOUT: konten scroll + footer fixed di dalam popup ===== */} + <div className='md:hidden flex flex-col max-h-[75vh]'> + {/* area scroll */} + <div className='flex-1 overflow-y-auto' style={{ scrollbarWidth: 'none' }}> + {/* HEADER ITEM */} + <div className='flex mt-4'> + <div className='w-[25%]'> + <ImageNext + src={product.image} + alt={product.name} + className='h-32 object-contain object-center w-full border border-gray_r-4' + width={80} + height={80} + /> + </div> + + <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> + {!!product.manufacture.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product.manufacture.name, + product.manufacture.id.toString() + )} + className=' hover:underline' + color={'red'} + > + {product.manufacture.name} + </Link> + ) : ( + '-' )} - className=' hover:underline' - color={'red'} - > - {product.manufacture.name} - </Link> - ) : ( - '-' - )} - <p className='text-ellipsis overflow-hidden'>{product.name}</p> - <p>{product.code}</p> - {!!product.lowest_price && product.lowest_price.price > 0 && ( - <> - <div className='flex items-end gap-x-2'> - {product.lowest_price.discount_percentage > 0 && ( - <> - <div className='badge-solid-red'> - {Math.floor(product.lowest_price.discount_percentage)}% - </div> - <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> - Rp {formatCurrency(product.lowest_price.price || 0)} + <p className='text-ellipsis overflow-hidden'>{product.name}</p> + <p>{product.code}</p> + + {!!product.lowest_price && product.lowest_price.price > 0 && ( + <> + <div className='flex items-end gap-x-2'> + {product.lowest_price.discount_percentage > 0 && ( + <> + <div className='badge-solid-red'> + {Math.floor( + product.lowest_price.discount_percentage + )} + % + </div> + <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + Rp {formatCurrency(product.lowest_price.price || 0)} + </div> + </> + )} + <div className='text-danger-500 font-semibold'> + Rp{' '} + {formatCurrency( + product.lowest_price.price_discount || 0 + )} </div> - </> - )} - <div className='text-danger-500 font-semibold'> - Rp{' '} - {formatCurrency(product.lowest_price.price_discount || 0)} - </div> - </div> - </> - )} + </div> + </> + )} - {!!product.lowest_price && product.lowest_price.price === 0 && ( - <span> - Hubungi kami untuk dapatkan harga terbaik,{' '} + {!!product.lowest_price && product.lowest_price.price === 0 && ( + <span> + Hubungi kami untuk dapatkan harga terbaik,{' '} + <Link + href={askAdminUrl} + target='_blank' + className='font-medium underline' + color={'red'} + > + klik disini + </Link> + </span> + )} + </div> + + {/* sembunyikan link header di mobile agar tidak dobel */} + <div className='ml-3 items-center font-normal hidden md:flex'> <Link - href={askAdminUrl} - target='_blank' - className='font-medium underline' - color={'red'} + href='/shop/cart' + className='flex-1 py-2 text-gray_r-12 btn-yellow' > - klik disini + Lihat Keranjang </Link> - </span> - )} + </div> + </div> + + {/* PROMO KHUSUS MOBILE */} + <div className='mt-6'> + <ProductPromoSection + product={product} + productId={Number(activeVariant?.id) || Number(variantId) || 0} + /> + </div> + + {/* PRODUCT SIMILAR */} + <div className='mt-8 mb-4'> + <div className='text-h-sm font-semibold mb-6'> + Kamu Mungkin Juga Suka + </div> + <DesktopView> + <LazyLoad> + <ProductSimilar query={productSimilarQuery} /> + </LazyLoad> + </DesktopView> + <MobileView> + <ProductSimilar query={productSimilarQuery} /> + </MobileView> + </div> </div> - <div className='ml-3 flex items-center font-normal'> + + {/* footer tombol: selalu terlihat di bawah popup mobile */} + <div className='border-t border-gray-200 bg-white px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+12px)]'> <Link href='/shop/cart' - className='flex-1 py-2 text-gray_r-12 btn-yellow' + className='w-full mb-2 block text-center btn-yellow py-3 rounded-xl font-semibold' > Lihat Keranjang </Link> </div> </div> - <div className='mt-8 mb-4'> - <div className='text-h-sm font-semibold mb-6'> - Kamu Mungkin Juga Suka + + {/* ===== DESKTOP LAYOUT: tetap seperti semula ===== */} + <div className='hidden md:block'> + {/* HEADER ITEM */} + <div className='flex mt-4'> + <div className='w-[10%]'> + <ImageNext + src={product.image} + alt={product.name} + className='h-32 object-contain object-center w-full border border-gray_r-4' + width={80} + height={80} + /> + </div> + + <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> + {!!product.manufacture.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product.manufacture.name, + product.manufacture.id.toString() + )} + className=' hover:underline' + color={'red'} + > + {product.manufacture.name} + </Link> + ) : ( + '-' + )} + <p className='text-ellipsis overflow-hidden'>{product.name}</p> + <p>{product.code}</p> + + {!!product.lowest_price && product.lowest_price.price > 0 && ( + <> + <div className='flex items-end gap-x-2'> + {product.lowest_price.discount_percentage > 0 && ( + <> + <div className='badge-solid-red'> + {Math.floor(product.lowest_price.discount_percentage)} + % + </div> + <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + Rp {formatCurrency(product.lowest_price.price || 0)} + </div> + </> + )} + <div className='text-danger-500 font-semibold'> + Rp{' '} + {formatCurrency(product.lowest_price.price_discount || 0)} + </div> + </div> + </> + )} + + {!!product.lowest_price && product.lowest_price.price === 0 && ( + <span> + Hubungi kami untuk dapatkan harga terbaik,{' '} + <Link + href={askAdminUrl} + target='_blank' + className='font-medium underline' + color={'red'} + > + klik disini + </Link> + </span> + )} + </div> + + <div className='ml-3 flex items-center font-normal'> + <Link + href='/shop/cart' + className='flex-1 py-2 text-gray_r-12 btn-yellow' + > + Lihat Keranjang + </Link> + </div> + </div> + + {/* PROMO (desktop biarkan sama posisinya) */} + <div className='mt-6'> + <ProductPromoSection + product={product} + productId={Number(activeVariant?.id) || Number(variantId) || 0} + /> + </div> + + {/* PRODUCT SIMILAR */} + <div className='mt-8 mb-4'> + <div className='text-h-sm font-semibold mb-6'> + Kamu Mungkin Juga Suka + </div> + <LazyLoad> + <ProductSimilar query={productSimilarQuery} /> + </LazyLoad> </div> - <LazyLoad> - <ProductSimilar query={productSimilarQuery} /> - </LazyLoad> </div> </BottomPopup> </div> diff --git a/src-migrate/modules/product-detail/components/AddToQuotation.tsx b/src-migrate/modules/product-detail/components/AddToQuotation.tsx index f9b6c2b3..ebfcef32 100644 --- a/src-migrate/modules/product-detail/components/AddToQuotation.tsx +++ b/src-migrate/modules/product-detail/components/AddToQuotation.tsx @@ -16,6 +16,7 @@ import { useProductCartContext } from '@/contexts/ProductCartContext'; import { createSlug } from '~/libs/slug'; import formatCurrency from '~/libs/formatCurrency'; import { useProductDetail } from '../stores/useProductDetail'; +import useDevice from '@/core/hooks/useDevice'; type Props = { variantId: number | null; @@ -40,6 +41,7 @@ const AddToQuotation = ({ }); const { askAdminUrl } = useProductDetail(); + const { isMobile, isDesktop } = useDevice(); const [product, setProducts] = useState(products); const [status, setStatus] = useState<Status>('idle'); @@ -104,12 +106,13 @@ const AddToQuotation = ({ const btnConfig = { add_to_cart: { - colorScheme: 'yellow', - + colorScheme: 'red', + variant: 'outline', text: 'Keranjang', }, buy: { colorScheme: 'red', + variant: 'solid', text: 'Beli', }, }; @@ -123,13 +126,13 @@ const AddToQuotation = ({ className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center' > <ImageNext - src='/images/writing.png' + src= {isDesktop ? '/images/doc_red.svg' : '/images/doc.svg'} alt='penawaran instan' className='' width={25} height={25} /> - Penawaran Harga Instan + {isDesktop ? 'Penawaran Harga Instan' : ''} </Button> <BottomPopup className='!container' diff --git a/src-migrate/modules/product-detail/components/Breadcrumb.tsx b/src-migrate/modules/product-detail/components/Breadcrumb.tsx index f41859a9..67aa12ab 100644 --- a/src-migrate/modules/product-detail/components/Breadcrumb.tsx +++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx @@ -1,31 +1,133 @@ -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' +import React, { Fragment } from 'react'; +import { useQuery } from 'react-query'; +import Link from 'next/link'; +import { getProductCategoryBreadcrumb } from '~/services/product'; +import { createSlug } from '~/libs/slug'; +import useDevice from '@/core/hooks/useDevice'; -type Props = { - id: number, - name: string -} +type Props = { id: number; name: string }; const Breadcrumb = ({ id, name }: Props) => { - const query = useQuery({ - queryKey: ['product-category-breadcrumb'], + const { isDesktop, isMobile } = useDevice(); + + const { data } = useQuery({ + queryKey: ['product-category-breadcrumb', id], queryFn: () => getProductCategoryBreadcrumb(id), - refetchOnWindowFocus: false - }) + refetchOnWindowFocus: false, + }); + + const breadcrumbs = data ?? []; + const total = breadcrumbs.length; + const lastCat = total ? breadcrumbs[total - 1] : null; + const prevCat = total > 1 ? breadcrumbs[total - 2] : null; + const hasHidden = total > 1; + const hiddenText = hasHidden + ? breadcrumbs + .slice(0, total - 1) + .map((c) => c.name) + .join(' / ') + : ''; + + if (isMobile) { + const crumbsMobile: React.ReactNode[] = []; + + crumbsMobile.push( + <Link href='/' className='text-danger-500 shrink-0' key='home'> + Home + </Link> + ); + + if (hasHidden && prevCat) { + // Jadikan ".." sebuah tautan ke kategori sebelumnya + crumbsMobile.push( + <Link + key='hidden' + href={createSlug('/shop/category/', prevCat.name, String(prevCat.id))} + className='text-danger-500 shrink-0' + title={hiddenText} + aria-label={`Kembali ke ${prevCat.name}`} + > + .. + </Link> + ); + } + + // Kategori terakhir + if (lastCat) { + crumbsMobile.push( + <Link + key={`cat-${lastCat.id}`} + href={createSlug('/shop/category/', lastCat.name, String(lastCat.id))} + className='text-danger-500 shrink-0' + > + {lastCat.name} + </Link> + ); + } + + // Nama produk (dipotong kalau gk muat) + crumbsMobile.push( + <span className='truncate min-w-0 flex-1' title={name} key='product'> + {name} + </span> + ); + + return ( + <div className='flex items-center whitespace-nowrap overflow-hidden text-caption-1 leading-7'> + {crumbsMobile.map((node, i) => ( + <Fragment key={i}> + {node} + {i < crumbsMobile.length - 1 && ( + <span className='mx-2 shrink-0'>/</span> + )} + </Fragment> + ))} + </div> + ); + } - const breadcrumbs = query.data || [] + // ===== DESKTOP ===== + if (isDesktop) { + 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> + ); + } return ( <div className='line-clamp-2 md:line-clamp-1 leading-7 text-caption-1'> - <Link href='/' className='text-danger-500'>Home</Link> + <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())} + href={createSlug( + '/shop/category/', + category.name, + category.id.toString() + )} className='text-danger-500' > {category.name} @@ -35,7 +137,7 @@ const Breadcrumb = ({ id, name }: Props) => { ))} <span>{name}</span> </div> - ) -} + ); +}; -export default Breadcrumb
\ No newline at end of file +export default Breadcrumb; diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx index 850c2d9d..ffc9ba40 100644 --- a/src-migrate/modules/product-detail/components/PriceAction.tsx +++ b/src-migrate/modules/product-detail/components/PriceAction.tsx @@ -12,12 +12,16 @@ import { getAuth } from '~/libs/auth'; import useDevice from '@/core/hooks/useDevice'; import odooApi from '~/libs/odooApi'; import { Button, Skeleton } from '@chakra-ui/react'; +import DesktopView from '@/core/components/views/DesktopView'; +import MobileView from '@/core/components/views/MobileView'; type Props = { product: IProductDetail; }; -const PPN : number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; +const PPN: number = process.env.NEXT_PUBLIC_PPN + ? parseFloat(process.env.NEXT_PUBLIC_PPN) + : 0; const PriceAction = ({ product }: Props) => { const { activePrice, @@ -84,26 +88,59 @@ const PriceAction = ({ product }: Props) => { > {!!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']}> + <DesktopView> + <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 * PPN))} + </div> + </DesktopView> + <MobileView> + <div className='flex items-end gap-x-2'> + {activePrice.discount_percentage > 0 ? ( + <> + <div className={style['disc-badge']}> + {Math.floor(activePrice.discount_percentage)}% + </div> + + {/* harga setelah diskon (main-price) di kiri */} + <div className={style['main-price']}> + Rp {formatCurrency(activePrice.price_discount || 0)} + </div> + + {/* harga coret di kanan */} + <div className={style['disc-price']}> + Rp {formatCurrency(activePrice.price || 0)} + </div> + </> + ) : ( + // kalau tidak ada diskon, tampilkan harga normal saja + <div className={style['main-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 * PPN))} - </div> + + <div className='text-md text-gray-500 shadow-0'> + Termasuk PPN: Rp{' '} + {formatCurrency(Math.round(activePrice.price_discount * PPN))} + </div> + </MobileView> </> )} @@ -120,92 +157,209 @@ const PriceAction = ({ product }: Props) => { </span> )} - <div className='h-4' /> - <div className='flex gap-x-5 items-center'> - <div className='relative flex items-center'> - <button - type='button' - className='absolute left-0 px-2 py-1 h-full text-gray-500' - onClick={() => - setQuantityInput(String(Math.max(1, Number(quantityInput) - 1))) - } - > - - - </button> - <input - type='number' - id='quantity' - min={1} - value={quantityInput} - onChange={(e) => setQuantityInput(e.target.value)} - className={style['quantity-input']} - /> - <button - type='button' - className='absolute right-0 px-2 py-1 h-full text-gray-500' - onClick={() => setQuantityInput(String(Number(quantityInput) + 1))} - > - + - </button> - </div> + <DesktopView> + <div className='h-4' /> + <div className='flex gap-x-5 items-center'> + {/* Qty */} + <div className='relative flex items-center'> + <button + type='button' + className='absolute left-0 px-2 py-1 h-full text-gray-500' + onClick={() => + setQuantityInput(String(Math.max(1, Number(quantityInput) - 1))) + } + > + - + </button> + <input + type='number' + id='quantity' + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className={style['quantity-input']} + /> + <button + type='button' + className='absolute right-0 px-2 py-1 h-full text-gray-500' + onClick={() => + setQuantityInput(String(Number(quantityInput) + 1)) + } + > + + + </button> + </div> - <div> - <Skeleton - isLoaded={sla} - h='21px' - // w={16} - className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} - > - Stock : {sla?.qty}{' '} - </Skeleton> - {/* <span className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''}> - {' '} - </span> */} - </div> - <div> - {qtyPickUp > 0 && ( - <Link href='/panduan-pick-up-service' className='group'> - <Image - src='/images/PICKUP-NOW.png' - className='group-hover:scale-105 transition-transform duration-200' - alt='pickup now' - width={100} - height={12} - /> - </Link> - )} + {/* Stok */} + <div> + <Skeleton + isLoaded={sla} + h='21px' + className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} + > + Stock : {sla?.qty}{' '} + </Skeleton> + </div> + + {/* Pickup badge */} + <div> + {qtyPickUp > 0 && ( + <div className='flex items-center gap-2'> + <Link href='/panduan-pick-up-service' className='group'> + <Image + src='/images/PICKUP-NOW.png' + className='group-hover:scale-105 transition-transform duration-200' + alt='pickup now' + width={100} + height={12} + /> + </Link> + </div> + )} + </div> </div> - </div> - {qtyPickUp > 0 && ( - <div className='text-[12px] mt-1 text-red-500 italic'> + <span className='text-[12px] text-red-500 italic'> * {qtyPickUp} barang bisa di pickup + </span> + </DesktopView> + + {/* ===== MOBILE: grid kiri-kanan, kanan hanya qty ===== */} + <MobileView> + <div className='grid grid-cols-12 items-start gap-3'> + {/* Kiri */} + <div className='col-span-8 mt-2'> + <div className='flex items-center gap-1'> + <Skeleton + isLoaded={sla} + h='21px' + w='auto' // ⬅️ penting: biar selebar konten + display='inline-block' // ⬅️ penting: jangan full width + className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} + > + Stock : {sla?.qty} + </Skeleton> + + {qtyPickUp > 0 && ( + <Link + href='/panduan-pick-up-service' + className='inline-block shrink-0' + > + <Image + src='/images/PICKUP-NOW.png' + className='align-middle' + alt='pickup now' + width={90} + height={12} + /> + </Link> + )} + </div> + + {qtyPickUp > 0 && ( + <div className='text-[12px] mt-1 text-red-500 italic'> + * {qtyPickUp} barang bisa di pickup + </div> + )} + </div> + + {/* Kanan: hanya qty, rata kanan */} + <div className='col-span-4 flex justify-end'> + <div className='inline-flex items-stretch border rounded-xl overflow-hidden'> + <button + type='button' + className='h-11 w-11 md:h-12 md:w-12 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95 select-none touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500' + onClick={() => + setQuantityInput( + String(Math.max(1, Number(quantityInput) - 1)) + ) + } + aria-label='Kurangi' + > + <span className='text-2xl leading-none'>–</span> + </button> + + <input + type='number' + id='quantity' + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className='h-11 md:h-12 w-16 md:w-20 text-center text-lg md:text-xl outline-none border-x + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none' + /> + + <button + type='button' + className='h-11 w-11 md:h-12 md:w-12 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95 select-none touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500' + onClick={() => + setQuantityInput(String(Number(quantityInput) + 1)) + } + aria-label='Tambah' + > + <span className='text-2xl leading-none'>+</span> + </button> + </div> + </div> </div> - )} + </MobileView> <div className='h-4' /> - <div className={`${style['action-wrapper']}`}> - <AddToCart - products={product} - variantId={activeVariantId} - quantity={Number(quantityInput)} - /> - {!isApproval && ( + <DesktopView> + <div className={`${style['action-wrapper']}`}> <AddToCart + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + {!isApproval && ( + <AddToCart + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + )} + </div> + <div className='mt-4'> + <AddToQuotation source='buy' products={product} variantId={activeVariantId} quantity={Number(quantityInput)} /> - )} - </div> - <div className='mt-4'> - <AddToQuotation - source='buy' - products={product} - variantId={activeVariantId} - quantity={Number(quantityInput)} - /> - </div> + </div> + </DesktopView> + <MobileView> + <div className='grid grid-cols-12 gap-2'> + <div className='col-span-2'> + <AddToQuotation + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + </div> + <div className='col-span-5'> + <AddToCart + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + </div> + <div className='col-span-5'> + {!isApproval && ( + <AddToCart + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + )} + </div> + </div> + </MobileView> </div> ); }; diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 192e1dc3..f32bb38e 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -2,12 +2,11 @@ import style from '../styles/product-detail.module.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +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'; -import { RWebShare } from 'react-web-share'; import useDevice from '@/core/hooks/useDevice'; import { getAuth } from '~/libs/auth'; @@ -22,6 +21,8 @@ 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'; @@ -29,12 +30,25 @@ 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 ProductDetail = ({ product }: Props) => { const { isDesktop, isMobile } = useDevice(); const router = useRouter(); - const auth = getAuth(); + const [auth, setAuth] = useState<any>(null); + useEffect(() => { + try { setAuth(getAuth() ?? null); } catch { } + }, []); + + const canShare = + typeof navigator !== 'undefined' && + typeof (navigator as any).share === 'function'; + const { setAskAdminUrl, askAdminUrl, @@ -71,70 +85,173 @@ const ProductDetail = ({ product }: Props) => { product?.variants?.find((variant) => variant.is_in_bu) || product?.variants?.[0]; setSelectedVariant(selectedVariant); - // setSelectedVariant(product?.variants[0]) }, []); - // Gabungkan semua gambar produk (utama + tambahan) - const allImages = product.image_carousel ? [...product.image_carousel] : []; - if (product.image) { - allImages.unshift(product.image); // Tambahkan gambar utama di awal array - } - console.log(product); + const allImages = (() => { + const arr: string[] = []; + 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; + })(); const [mainImage, setMainImage] = useState(allImages[0] || ''); + useEffect(() => { + + 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 ( <> <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'> + {/* ===== Kolom kiri: gambar ===== */} <div className='md:w-4/12'> - <ProductImage product={{ ...product, image: mainImage }} /> - - {/* Carousel horizontal */} - {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 = - '/path/to/fallback-image.jpg'; - }} + 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> - <div className='h-3 md:h-0' /> - <Information product={product} /> - <div className='h-6' /> </div> </div> @@ -154,14 +271,6 @@ const ProductDetail = ({ product }: Props) => { /> )} - {/* <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']}> @@ -172,8 +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, }} @@ -204,21 +312,19 @@ const ProductDetail = ({ product }: Props) => { <span>|</span> - <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} />} + {canShare && ( + <RWebShare + data={{ + text: 'Check out this product', + title: `${product.name} - Indoteknik.com`, + url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), + }} > - Share - </Button> - </RWebShare> + <Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />}> + Share + </Button> + </RWebShare> + )} </div> <div className='h-6' /> @@ -244,6 +350,8 @@ const ProductDetail = ({ product }: Props) => { </div> </> ); + + }; export default ProductDetail; |
