diff options
19 files changed, 932 insertions, 378 deletions
diff --git a/public/robots.txt b/public/robots.txt index 1520dbb4..08a35d98 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -23,9 +23,6 @@ User-agent: Adsbot-Google Allow: /my/* Allow: /shop/search/* -User-agent: * -Disallow: sentral.indoteknik.com/* -Disallow: erp.indoteknik.com/* Sitemap: https://indoteknik.com/sitemap/products.xml Sitemap: https://indoteknik.com/sitemap/brands.xml diff --git a/src-migrate/modules/product-detail/components/Image.tsx b/src-migrate/modules/product-detail/components/Image.tsx index 96ae2027..d99b683c 100644 --- a/src-migrate/modules/product-detail/components/Image.tsx +++ b/src-migrate/modules/product-detail/components/Image.tsx @@ -50,8 +50,7 @@ const Image = ({ product }: Props) => { <ImageUI src={image} alt={product.name} - width={256} - height={256} + fill className={style['image']} loading='eager' priority diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx index ffc9ba40..6cc2f0bf 100644 --- a/src-migrate/modules/product-detail/components/PriceAction.tsx +++ b/src-migrate/modules/product-detail/components/PriceAction.tsx @@ -66,17 +66,21 @@ const PriceAction = ({ product }: Props) => { setQuantityInput('1'); }, [selectedVariant]); - let voucherPastiHemat = 0; + const price = activePrice?.price_discount || activePrice?.price || 0; + const pricedigit = String(Math.floor(price)).length; + const fontSize = pricedigit >= 9 ? '20px' : undefined; - if ( - product?.voucher_pasti_hemat - ? product?.voucher_pasti_hemat.length - : voucherPastiHemat > 0 - ) { - const stringVoucher = product?.voucher_pasti_hemat[0]; - const validJsonString = stringVoucher.replace(/'/g, '"'); - voucherPastiHemat = JSON.parse(validJsonString); - } + // let voucherPastiHemat = 0; + + // if ( + // product?.voucher_pasti_hemat + // ? product?.voucher_pasti_hemat.length + // : voucherPastiHemat > 0 + // ) { + // const stringVoucher = product?.voucher_pasti_hemat[0]; + // const validJsonString = stringVoucher.replace(/'/g, '"'); + // voucherPastiHemat = JSON.parse(validJsonString); + // } return ( <div @@ -91,25 +95,40 @@ const PriceAction = ({ product }: Props) => { <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['disc-badge']}> + {Math.floor(activePrice.discount_percentage)}% + </div> )} - <div className={style['main-price']}> - Rp {formatCurrency(activePrice.price_discount || 0)} + <div + className={style['main-price']} + style={fontSize ? { fontSize } : undefined} + > + Rp{' '} + {formatCurrency( + activePrice.discount_percentage > 0 + ? activePrice.price_discount || 0 + : activePrice.price || 0 + )} </div> + {activePrice.discount_percentage > 0 && ( + <div className={style['disc-price']}> + Rp {formatCurrency(activePrice.price || 0)} + </div> + )} </div> <div className='h-1' /> <div className={style['secondary-text']}> Termasuk PPN: Rp{' '} - {formatCurrency(Math.round(activePrice.price_discount * PPN))} + {formatCurrency( + Math.round( + (activePrice.discount_percentage > 0 + ? activePrice.price_discount + : activePrice.price) * PPN + ) + )} </div> </DesktopView> + <MobileView> <div className='flex items-end gap-x-2'> {activePrice.discount_percentage > 0 ? ( @@ -159,7 +178,7 @@ const PriceAction = ({ product }: Props) => { <DesktopView> <div className='h-4' /> - <div className='flex gap-x-5 items-center'> + <div className='flex gap-x-4 items-center'> {/* Qty */} <div className='relative flex items-center'> <button @@ -191,20 +210,20 @@ const PriceAction = ({ product }: Props) => { </div> {/* Stok */} - <div> + <div className='min-w-[89px]'> <Skeleton isLoaded={sla} h='21px' className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} > - Stock : {sla?.qty}{' '} + Stock : {sla?.qty} </Skeleton> </div> {/* Pickup badge */} - <div> + <div className='shrink-0'> {qtyPickUp > 0 && ( - <div className='flex items-center gap-2'> + <div className='flex items-center'> <Link href='/panduan-pick-up-service' className='group'> <Image src='/images/PICKUP-NOW.png' @@ -218,7 +237,7 @@ const PriceAction = ({ product }: Props) => { )} </div> </div> - <span className='text-[12px] text-red-500 italic'> + <span className='block text-[12px] text-red-500 italic mt-1'> * {qtyPickUp} barang bisa di pickup </span> </DesktopView> diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index f32bb38e..51b080ef 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -22,16 +22,17 @@ import PriceAction from './PriceAction'; import SimilarBottom from './SimilarBottom'; import SimilarSide from './SimilarSide'; import dynamic from 'next/dynamic'; - +import { TicketIcon } from '@heroicons/react/24/solid'; import { gtagProductDetail } from '@/core/utils/googleTag'; +import currencyFormat from '@/core/utils/currencyFormat'; type Props = { product: IProductDetail; }; const RWebShare = dynamic( - () => import('react-web-share').then(m => m.RWebShare), + () => import('react-web-share').then((m) => m.RWebShare), { ssr: false } ); @@ -42,7 +43,9 @@ const ProductDetail = ({ product }: Props) => { const router = useRouter(); const [auth, setAuth] = useState<any>(null); useEffect(() => { - try { setAuth(getAuth() ?? null); } catch { } + try { + setAuth(getAuth() ?? null); + } catch {} }, []); const canShare = @@ -87,7 +90,6 @@ const ProductDetail = ({ product }: Props) => { setSelectedVariant(selectedVariant); }, []); - const allImages = (() => { const arr: string[] = []; if (product?.image) arr.push(product.image); @@ -95,7 +97,6 @@ const ProductDetail = ({ product }: Props) => { 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)) { @@ -108,15 +109,14 @@ const ProductDetail = ({ product }: Props) => { })(); const [mainImage, setMainImage] = useState(allImages[0] || ''); + const [discount, setDiscount] = useState(0); useEffect(() => { - if (!allImages.includes(mainImage)) { setMainImage(allImages[0] || ''); } }, [allImages]); - const sliderRef = useRef<HTMLDivElement | null>(null); const [currentIdx, setCurrentIdx] = useState(0); @@ -138,6 +138,47 @@ const ProductDetail = ({ product }: Props) => { setMainImage(allImages[i] || ''); }; + const voucherNew = Array.isArray(product?.new_voucher_pasti_hemat) + ? product.new_voucher_pasti_hemat[0] + : undefined; + + const voucher = voucherNew ?? null; + + const type = voucher?.discount_type ?? ''; // 'percentage' | 'percent' | 'fixed' + const amount = Number( + voucher?.discountAmount ?? voucher?.discount_amount ?? 0 + ); + const max = Number(voucher?.max_discount ?? 0); + const min = Number(voucher?.min_purchase ?? 0); + + const basePrice = + Number(product?.lowest_price?.price_discount ?? 0) || + Number(product?.lowest_price?.price ?? 0); + + function calcVoucherDiscount() { + if (!voucher || !basePrice) return 0; + if (min > 0 && basePrice < min) return 0; + + const percent = type.toLowerCase().startsWith('percent') + ? amount <= 1 + ? amount * 100 + : amount + : 0; + + let cut = 0; + if (type.toLowerCase().startsWith('percent')) { + cut = basePrice * (percent / 100); + } else { + cut = amount || 0; + } + + if (max > 0) cut = Math.min(cut, max); + return Math.max(0, cut); + } + + useEffect(() => { + setDiscount(calcVoucherDiscount()); + }, [product?.lowest_price]); return ( <> @@ -165,7 +206,6 @@ const ProductDetail = ({ product }: Props) => { > {allImages.length > 0 ? ( allImages.map((img, i) => ( - <div key={i} className='w-full flex-shrink-0 snap-center flex justify-center items-center' @@ -200,8 +240,9 @@ const ProductDetail = ({ product }: Props) => { <button key={i} aria-label={`Ke slide ${i + 1}`} - className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300' - }`} + className={`w-2 h-2 rounded-full ${ + currentIdx === i ? 'bg-gray-800' : 'bg-gray-300' + }`} onClick={() => scrollToIndex(i)} /> ))} @@ -220,10 +261,11 @@ const ProductDetail = ({ product }: Props) => { {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' - }`} + 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 @@ -249,6 +291,17 @@ const ProductDetail = ({ product }: Props) => { {/* ===== Kolom kanan: info ===== */} <div className='md:w-8/12 px-4 md:pl-6'> <div className='h-6 md:h-0' /> + {isMobile && ( + <div className='text text-sm font-medium'> + <TicketIcon className='inline text-yellow-300 w-5 h-5' />{' '} + Pakai{' '} + <span className='text-green-600 font-extrabold'> + {' '} + Voucher belanja hemat {currencyFormat(discount)} + </span>{' '} + saat checkout + </div> + )} <h1 className={style['title']}>{product.name}</h1> <div className='h-3 md:h-0' /> <Information product={product} /> @@ -281,7 +334,8 @@ 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, }} @@ -317,10 +371,16 @@ const ProductDetail = ({ product }: Props) => { data={{ text: 'Check out this product', title: `${product.name} - Indoteknik.com`, - url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), + url: + (process.env.NEXT_PUBLIC_SELF_HOST || '') + + (router?.asPath || '/'), }} > - <Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />}> + <Button + variant='link' + colorScheme='gray' + leftIcon={<Share2Icon size={18} />} + > Share </Button> </RWebShare> @@ -350,8 +410,6 @@ const ProductDetail = ({ product }: Props) => { </div> </> ); - - }; export default ProductDetail; diff --git a/src-migrate/modules/product-detail/styles/image.module.css b/src-migrate/modules/product-detail/styles/image.module.css index e472fe8d..f33a659b 100644 --- a/src-migrate/modules/product-detail/styles/image.module.css +++ b/src-migrate/modules/product-detail/styles/image.module.css @@ -1,9 +1,21 @@ -.wrapper { +/* .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; +} */ + +.wrapper { + @apply relative border border-gray-200 rounded-lg overflow-hidden; + + @apply w-full aspect-[1/1]; + + @apply mx-auto; +} + +.image { + @apply w-full h-full object-cover object-center block; } .absolute-info { diff --git a/src-migrate/modules/product-detail/styles/price-action.module.css b/src-migrate/modules/product-detail/styles/price-action.module.css index cea50bff..b94c33f7 100644 --- a/src-migrate/modules/product-detail/styles/price-action.module.css +++ b/src-migrate/modules/product-detail/styles/price-action.module.css @@ -2,16 +2,16 @@ @apply font-medium text-gray-500; } .main-price { - @apply font-medium text-danger-500 text-title-md; + @apply font-medium text-danger-500 text-title-sm; } .action-wrapper { @apply flex gap-x-2.5; } .quantity-input { - @apply w-24 h-10 text-center border border-gray-300 rounded focus:outline-none; - /* Padding di kiri dan kanan untuk memberi ruang bagi tombol */ - padding-left: 2rem; - padding-right: 2rem; + @apply w-24 h-10 text-center border border-gray-300 rounded focus:outline-none; + /* Padding di kiri dan kanan untuk memberi ruang bagi tombol */ + padding-left: 2rem; + padding-right: 2rem; } .contact-us { diff --git a/src-migrate/modules/promo/components/Voucher.tsx b/src-migrate/modules/promo/components/Voucher.tsx index 0c225c74..c2c65766 100644 --- a/src-migrate/modules/promo/components/Voucher.tsx +++ b/src-migrate/modules/promo/components/Voucher.tsx @@ -123,7 +123,7 @@ const VoucherComponent = () => { <> <h1 className={style['title']}>Pakai Voucher Belanja</h1> - <div className='h-6' /> + {/* <div className='h-6' /> */} {voucherQuery?.isLoading && ( <div className='grid grid-cols-3 gap-x-4 animate-pulse'> diff --git a/src-migrate/modules/promo/styles/voucher.module.css b/src-migrate/modules/promo/styles/voucher.module.css index 22d07f91..ab652a5f 100644 --- a/src-migrate/modules/promo/styles/voucher.module.css +++ b/src-migrate/modules/promo/styles/voucher.module.css @@ -2,42 +2,55 @@ @apply text-h-sm md:text-h-lg font-semibold; } +/* beri ruang dari footer */ .voucher-section { - @apply w-full; + @apply w-full pb-6 md:pb-8; +} + +/* biar card bisa full-width tanpa ada gutter ekstra */ +.voucher-section :global(.swiper-slide) { + @apply flex items-start px-0 md:px-0; } .voucher-card { - @apply w-full md:w-11/12 h-3/4 rounded-xl border items-center border-gray-200 shadow-md p-4 flex gap-x-4 ; + /* FULL width (hapus md:w-11/12), tetap horizontal & kompak */ + @apply w-full rounded-xl border border-gray-200 shadow-md + p-4 flex items-start gap-x-4 bg-white; } .voucher-image { - @apply bg-gray-100 rounded-lg w-4/12 h-fit object-contain object-center; + /* gambar kecil & stabil */ + @apply bg-gray-100 rounded-lg w-3/12 md:w-2/12 h-auto object-contain object-center; } .voucher-content { - @apply flex-1 flex flex-col; + /* penting: min-w-0 supaya teks bisa wrap/ellipsis dan card memanjang mulus */ + @apply flex-1 min-w-0 flex flex-col justify-between; } .voucher-title { - @apply font-medium text-body-1 leading-6 mb-1; + @apply font-medium text-body-1 leading-6 line-clamp-2 mb-1; } - .voucher-desc { - @apply text-gray-800 line-clamp-2 text-caption-1; + @apply text-gray-800 text-caption-1 line-clamp-1; } .voucher-bottom { - @apply flex justify-between mt-2; + /* biar rapih di semua ukuran layar */ + @apply flex flex-col sm:flex-row justify-between items-start sm:items-center + gap-2 sm:gap-3 mt-3; } .voucher-code-desc { @apply text-gray-500 text-caption-1; } - .voucher-code { @apply text-red-700 font-medium; } +/* tombol tetap kecil, tapi lebar penuh di mobile; tidak keluar dari card */ .voucher-copy { - @apply bg-gray-200 hover:bg-danger-500 text-danger-500 hover:text-white transition-colors rounded-lg flex items-center justify-center px-6; + @apply bg-gray-200 hover:bg-danger-500 text-danger-500 hover:text-white transition-colors + rounded-lg flex items-center justify-center px-5 py-1.5 text-sm whitespace-nowrap + w-full md:w-auto; } diff --git a/src-migrate/services/product.ts b/src-migrate/services/product.ts index 77b645f0..9ab7814e 100644 --- a/src-migrate/services/product.ts +++ b/src-migrate/services/product.ts @@ -2,6 +2,7 @@ import { IProduct, IProductDetail } from '~/types/product'; import snakeCase from 'snakecase-keys'; import odooApi from '~/libs/odooApi'; import { ICategoryBreadcrumb } from '~/types/category'; +import { json } from 'stream/consumers'; const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST; @@ -13,6 +14,10 @@ export const getProductById = async ( const params = new URLSearchParams({ id, auth: tier }); return await fetch(`${url}?${params.toString()}`) .then((res) => res.json()) + // .then((json) => { + // console.log('[Prod detail]: ', json); + // return json; + // }) .then((res) => { if (res.length > 0) return snakeCase(res[0]) as IProductDetail; return null; diff --git a/src-migrate/types/product.ts b/src-migrate/types/product.ts index 746cdd4a..7212d34d 100644 --- a/src-migrate/types/product.ts +++ b/src-migrate/types/product.ts @@ -35,7 +35,13 @@ export interface IProduct { name: string; logo: string; }; - voucher_pasti_hemat: any; + new_voucher_pasti_hemat: { + min_purchase: number; + discount_percentage: number; + max_discount: number; + discount_type: string; + }; + // voucher_pasti_hemat: any; } export interface IProductDetail extends IProduct { diff --git a/src/core/components/elements/Image/Image.jsx b/src/core/components/elements/Image/Image.jsx index d7b19821..ad565be6 100644 --- a/src/core/components/elements/Image/Image.jsx +++ b/src/core/components/elements/Image/Image.jsx @@ -1,5 +1,5 @@ -import { LazyLoadImage } from 'react-lazy-load-image-component' -import 'react-lazy-load-image-component/src/effects/opacity.css' +import { LazyLoadImage } from 'react-lazy-load-image-component'; +import 'react-lazy-load-image-component/src/effects/opacity.css'; /** * The `Image` component is used to display lazy-loaded images. @@ -17,10 +17,10 @@ const Image = ({ ...props }) => { placeholderSrc='/images/indoteknik-placeholder.png' effect='opacity' alt={props.src ? props.alt : 'Image Not Found - Indoteknik'} - wrapperClassName='bg-white' + // wrapperClassName='bg-white' loading='eager' /> - ) -} + ); +}; -export default Image +export default Image; diff --git a/src/lib/brand/components/MediaCard.jsx b/src/lib/brand/components/MediaCard.jsx index 4a298e15..e37aa76c 100644 --- a/src/lib/brand/components/MediaCard.jsx +++ b/src/lib/brand/components/MediaCard.jsx @@ -4,9 +4,7 @@ import useDevice from '@/core/hooks/useDevice'; import { createSlug } from '@/core/utils/slug'; const MediaCard = ({ media }) => { - const { isMobile } = useDevice(); - - console.log("Media logo:", media); + const { isMobile } = useDevice(); return ( <Link @@ -25,11 +23,11 @@ const MediaCard = ({ media }) => { width={500} height={500} quality={85} - className="h-full w-[122px] object-contain object-center" + className='h-full w-[122px] object-contain object-center' /> ) : ( <span - className="text-center" + className='text-center' style={{ fontSize: `${16 - media.name.length * 0.5}px` }} > {media.name} diff --git a/src/lib/product/components/Product/ProductDesktopVariant.jsx b/src/lib/product/components/Product/ProductDesktopVariant.jsx index 44ae04bd..bc07507b 100644 --- a/src/lib/product/components/Product/ProductDesktopVariant.jsx +++ b/src/lib/product/components/Product/ProductDesktopVariant.jsx @@ -1,3 +1,4 @@ +import { TicketIcon } from '@heroicons/react/24/solid'; import { Box, Button, Skeleton, Tooltip } from '@chakra-ui/react'; import { HeartIcon } from '@heroicons/react/24/outline'; import { Info, MessageCircleIcon, Share2Icon } from 'lucide-react'; @@ -46,6 +47,7 @@ const ProductDesktopVariant = ({ const [isLoadingSLA, setIsLoadingSLA] = useState(true); const [selectedVariant, setSelectedVariant] = useState(product.id); const { setRefreshCart } = useProductCartContext(); + const [discount, setDiscount] = useState(0); const [quantityInput, setQuantityInput] = useState(1); @@ -312,6 +314,75 @@ const ProductDesktopVariant = ({ fetchData(); }, [product]); + // voucher dari Solr: utamakan newVoucherPastiHemat, fallback voucherPastiHemat + let voucherPastiHemat = Array.isArray(product?.newVoucherPastiHemat) + ? product.newVoucherPastiHemat[0] + : product?.newVoucherPastiHemat; + + if (!voucherPastiHemat && Array.isArray(product?.voucherPastiHemat)) { + voucherPastiHemat = product.voucherPastiHemat[0]; + } + + const hitungDiscountVoucher = () => { + const basePrice = + Number(product?.price?.priceDiscount ?? 0) || + Number(product?.price?.price ?? 0); + + if (!voucherPastiHemat || !basePrice) { + setDiscount(0); + return; + } + + const type = String( + voucherPastiHemat.discountType ?? voucherPastiHemat.discount_type ?? '' + ).toLowerCase(); + const amount = Number( + voucherPastiHemat.discountAmount ?? voucherPastiHemat.discount_amount ?? 0 + ); + const max = Number( + voucherPastiHemat.maxDiscount ?? voucherPastiHemat.max_discount ?? 0 + ); + const min = Number( + voucherPastiHemat.minPurchase ?? voucherPastiHemat.min_purchase ?? 0 + ); + + if (min > 0 && basePrice < min) { + setDiscount(0); + return; + } + + let countDiscount = 0; + + if (type.startsWith('percent')) { + const pct = amount <= 1 ? amount * 100 : amount; + countDiscount = Math.floor(basePrice * (pct / 100)); + } else { + countDiscount = Math.floor(amount || 0); + } + + if (max > 0 && countDiscount > max) countDiscount = max; + + setDiscount(Math.max(0, countDiscount)); + // console.log('count disc', countDiscount, { + // basePrice, + // type, + // amount, + // max, + // min, + // }); + }; + + useEffect(() => { + hitungDiscountVoucher(); + }, [ + product?.price?.priceDiscount, + product?.price?.price, + product?.newVoucherPastiHemat, + product?.voucherPastiHemat, + ]); + + // console.log('product', product); + return ( <DesktopView> <div className='container mx-auto pt-10'> @@ -383,7 +454,10 @@ const ProductDesktopVariant = ({ </div> <div className='flex p-3 items-center '> <div className='w-4/12 text-gray_r-12/70'>Terjual</div> - <div className='w-8/12'>-</div> + <div className='w-8/12'> + {product.qtySold > 0 && <span>{product.qtySold}</span>} + {product.qtySold == 0 && <span>-</span>} + </div> </div> <div className='flex p-3 items-center bg-gray_r-4 '> @@ -605,6 +679,16 @@ const ProductDesktopVariant = ({ /> Penawaran Harga Instan </Button> + {discount > 0 && (product?.isFlashSale ?? 0) < 1 && ( + <div className='mt-3'> + <div className='inline-flex items-center border border-green-500 p-3 bg-green-50 rounded-md'> + <span className='text-sm font-semibold text-green-700'> + Pakai Voucher Belanja <b>{currencyFormat(discount)} </b> & + Potongan Ongkir hingga <b>Rp 20.000 </b> Saat Checkout + </span> + </div> + </div> + )} <div className='flex py-5'> <div className='flex gap-x-5 items-center justify-center'> <Button diff --git a/src/lib/product/components/Product/ProductMobileVariant.jsx b/src/lib/product/components/Product/ProductMobileVariant.jsx index 4cfc63ca..c44de561 100644 --- a/src/lib/product/components/Product/ProductMobileVariant.jsx +++ b/src/lib/product/components/Product/ProductMobileVariant.jsx @@ -19,6 +19,7 @@ import whatsappUrl from '@/core/utils/whatsappUrl'; import { getAuth } from '~/libs/auth'; import SimilarBottom from '~/modules/product-detail/components/SimilarBottom'; import ProductSimilar from '../ProductSimilar'; +import styles from '../../styles/desc_mobile_variant.module.css'; const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { const router = useRouter(); @@ -175,6 +176,8 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { }; fetchData(); }, [product]); + console.log(product); + // console.log(product.parent.description); const [fakeStock] = useState(() => { // inisialisasi sekali doang pas pertama kali komponen dibuat @@ -185,7 +188,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { <MobileView> {/* PRICE & ACTIONS: tetap punyamu, hanya hapus input number lama */} {/* ===== BAR BAWAH (fixed) ===== */} - <div className='px-4 fixed bottom-0 left-0 right-0 bg-white z-10 pb-6 pt-4 rounded-t-2xl shadow-[rgba(0,0,4,0.1)_0px_-4px_4px_0px]'> + <div className='p-3 fixed gap-x-2 bottom-0 left-0 right-0 bg-white z-10 pb-6 pt-4 rounded-t-2xl shadow-[rgba(0,0,4,0.1)_0px_-4px_4px_0px]'> {/* HARGA & PPN (logikamu tetap) */} {activeVariant.isFlashSale && activeVariant?.price?.discountPercentage > 0 ? ( @@ -201,7 +204,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { {currencyFormat(activeVariant?.price?.priceDiscount)} </div> </div> - <div className='text-gray_r-9 text-base font-normal mt-1'> + <div className='text-sm text-gray-400 mt-1'> Termasuk PPN:{' '} {currencyFormat( activeVariant?.price.priceDiscount * process.env.NEXT_PUBLIC_PPN @@ -209,11 +212,11 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { </div> </> ) : ( - <div className='text-danger-500 font-semibold mt-1 text-3xl'> + <div className='text-danger-500 font-semibold mt-1 text-title-sm'> {activeVariant?.price?.price > 0 ? ( <> {currencyFormat(activeVariant?.price?.price)} - <div className='text-gray_r-9 text-base font-normal mt-1'> + <div className='text-sm text-gray-500 mt-1'> Termasuk PPN:{' '} {currencyFormat( activeVariant?.price.price * process.env.NEXT_PUBLIC_PPN @@ -243,7 +246,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { )} {/* ⬇️ TAMBAHKAN BLOK INI DI DALAM BAR: STOK & STEPPER */} - <div className='grid grid-cols-12 items-center gap-3 mt-3'> + <div className='grid grid-cols-12 items-center gap-3'> <div className='col-span-7'> <div className={`text-[14px] ${ @@ -335,7 +338,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { colorScheme='red' isDisabled={product.stock === 0} > - Beli + Beli Sekarang </Button> </div> </div> @@ -348,7 +351,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { /> <div className='p-4'> - <div className='flex items-end mb-2'> + {/* <div className='flex items-end mb-2'> {product.manufacture?.name ? ( <Link href={createSlug( @@ -369,7 +372,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { <HeartIcon className='w-6' /> )} </button> - </div> + </div> */} <h1 className='font-medium text-h-lg leading-8 md:text-title-md md:leading-10 mb-3'> {activeVariant?.name} </h1> @@ -396,7 +399,59 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { active={informationTab == 'specification'} className='rounded border border-gray_r-6 divide-y divide-gray_r-6' > - <SpecificationContent label='Ketersediaan'> + {/* <SpecificationContent label='Nomor SKU'> + <span>SKU-{product?.id}</span> + </SpecificationContent> */} + <SpecificationContent label='Item Code'> + <span>{activeVariant?.code || '-'}</span> + </SpecificationContent> + <SpecificationContent label='Manufacture'> + <Link + href={createSlug( + '/shop/brands/', + product.manufacture.name, + product.manufacture.id.toString() + )} + > + {product?.manufacture.logo ? ( + <Image + width={55} + objectFit='contain' + src={product.manufacture.logo} + alt={product.manufacture.name} + /> + ) : ( + <p className='font-bold text-red-500'> + {product.manufacture.name} + </p> + )} + </Link> + </SpecificationContent>{' '} + <SpecificationContent label='Terjual'> + <span className='text-sm'>{product.qtySold || '-'}</span> + </SpecificationContent>{' '} + <SpecificationContent label='Berat Barang'> + {activeVariant?.weight > 0 && ( + <span>{activeVariant?.weight} KG</span> + )} + {activeVariant?.weight == 0 && ( + <a + href={whatsappUrl('productWeight', { + name: product.name, + url: createSlug( + '/shop/product/', + product.name, + product.id, + true + ), + })} + className='text-danger-500 font-medium' + > + Tanya Berat + </a> + )} + </SpecificationContent> + <SpecificationContent label='Persiapan Barang'> <span> {isLoadingSLA ? ( <Skeleton width='100px' height='full' /> @@ -440,68 +495,15 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { )} </span> </SpecificationContent> - <SpecificationContent label='Nomor SKU'> - <span>SKU-{product?.id}</span> - </SpecificationContent> - <SpecificationContent label='Part Number'> - <span>{activeVariant?.code || '-'}</span> - </SpecificationContent> - <SpecificationContent label='Stok'> - {activeVariant?.stock > 0 && ( - <span className='flex gap-x-1.5'> - <div className='badge-solid-red'>Ready Stock</div> - <div className='badge-gray'> - {activeVariant?.stock > 5 ? '> 5' : '< 5'} - </div> - </span> - )} - {activeVariant?.stock == 0 && ( - <a - href={whatsappUrl('product', { - name: product.name, - url: createSlug( - '/shop/product/', - product.name, - product.id, - true - ), - })} - className='text-danger-500 font-medium' - > - Tanya Stok - </a> - )} - </SpecificationContent> - <SpecificationContent label='Berat Barang'> - {activeVariant?.weight > 0 && ( - <span>{activeVariant?.weight} KG</span> - )} - {activeVariant?.weight == 0 && ( - <a - href={whatsappUrl('productWeight', { - name: product.name, - url: createSlug( - '/shop/product/', - product.name, - product.id, - true - ), - })} - className='text-danger-500 font-medium' - > - Tanya Berat - </a> - )} - </SpecificationContent> </TabContent> <TabContent active={informationTab == 'description'} - className='leading-6 text-gray_r-11' + className={`${styles.richtextHtml} leading-6 text-gray_r-11`} dangerouslySetInnerHTML={{ __html: - product.description != '' - ? product.description + product.parent.description != '' + ? product.parent.description : 'Belum ada deskripsi produk.', }} /> @@ -562,7 +564,7 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { const informationTabOptions = [ { value: 'specification', label: 'Spesifikasi' }, - // { value: 'description', label: 'Deskripsi' }, + { value: 'description', label: 'Deskripsi' }, // { value: 'information', label: 'Info Penting' } ]; @@ -581,11 +583,14 @@ const TabButton = ({ children, active, ...props }) => { ); }; -const TabContent = ({ children, active, className, ...props }) => ( - <div {...props} className={`${active ? 'block' : 'hidden'} ${className}`}> - {children} - </div> -); +const TabContent = ({ children, active, className = '', ...props }) => { + if (!active) return null; // <— jangan render kalau tidak aktif + return ( + <div {...props} className={className}> + {children} + </div> + ); +}; const SpecificationContent = ({ children, label }) => ( <div className='flex justify-between p-3'> diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx index f4f5882e..b6a3cfad 100644 --- a/src/lib/product/components/ProductCard.jsx +++ b/src/lib/product/components/ProductCard.jsx @@ -11,6 +11,10 @@ import { createSlug } from '@/core/utils/slug'; import whatsappUrl from '@/core/utils/whatsappUrl'; import useUtmSource from '~/hooks/useUtmSource'; import useDevice from '@/core/hooks/useDevice'; +import { BadgePercent, Tag } from 'lucide-react'; +import { TicketIcon } from '@heroicons/react/24/solid'; +import DesktopView from '@/core/components/views/DesktopView'; +import MobileView from '@/core/components/views/MobileView'; const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { const router = useRouter(); @@ -73,186 +77,461 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { if (variant == 'vertical') { return ( - <div className='rounded shadow-sm border border-gray_r-4 bg-white'> - <Link href={URL.product} className='border-b border-gray_r-4 relative' aria-label='Produk'> - <div className='relative'> - <Image - src={image} - alt={product?.name} - className='gambarA w-full object-contain object-center h-36 sm:h-48' - /> - <div className='absolute top-0 right-0 flex mt-3'> - <div className='gambarB '> - {product?.isSni && ( - <ImageNext - src='/images/sni-logo.png' - alt='SNI Logo' - className='w-4 h-5 object-contain object-top sm:h-6' - width={50} - height={50} - loading='eager' - /> - )} - </div> - <div className='gambarC '> - {product?.isTkdn && ( - <ImageNext - src='/images/TKDN.png' - alt='TKDN' - className='w-11 h-6 object-contain object-top ml-1 mr-1 sm:h-6' - width={50} - height={50} - loading='eager' - /> - )} - </div> - </div> - </div> - - {router.pathname != '/' && product?.flashSale?.id > 0 && ( - <div className='absolute bottom-0 w-full grid'> - <div className='absolute bottom-0 w-full h-full'> - <ImageNext - src='/images/BG-FLASH-SALE.jpg' - className='h-full' - width={1000} - height={100} - loading='eager' + <> + <DesktopView> + <div className='rounded shadow-sm'> + <Link href={URL.product} className='relative' aria-label='Produk'> + <div className='relative'> + <Image + src={image} + alt={product?.name} + className='gambarA w-full object-contain object-center h-36 sm:h-48' /> + + <div className='absolute top-0 right-0 flex mt-3 z-20'> + <div className='gambarB '> + {product?.isSni && ( + <ImageNext + src='/images/sni-logo.png' + alt='SNI Logo' + className='w-4 h-5 object-contain object-top sm:h-6' + width={50} + height={50} + loading='eager' + /> + )} + </div> + <div className='gambarC '> + {product?.isTkdn && ( + <ImageNext + src='/images/TKDN.png' + alt='TKDN' + className='w-11 h-6 object-contain object-top ml-1 mr-1 sm:h-6' + width={50} + height={50} + loading='eager' + /> + )} + </div> + </div> </div> - <div className='relative'> - <div className='flex gap-x-1 items-center p-2 justify-center'> - <div className='bg-yellow-400 rounded-lg p-1 h-6 w-19 flex items-center justify-center '> - <span className='text-sm font-bold text-black'> - {Math.floor(product?.lowestPrice.discountPercentage)}% - </span> + + {(product?.lowestPrice?.discountPercentage ?? 0) > 0 && ( + <div className='absolute right-0 top-1.5 '> + <div className='bg-red-600 text-white px-2 py-1 rounded-l-lg shadow-sm text-xs font-bold leading-none'> + {Math.floor(product.lowestPrice.discountPercentage)}% </div> - <div className='bg-red-600 border border-solid border-yellow-400 p-2 rounded-full h-6 flex w-fit items-center justify-center gap-x-2'> + </div> + )} + + {(product?.variantTotal > 1 || product?.isInBu) && ( + <div className='absolute bottom-1.5 left-1.5 z-30 flex items-center gap-2'> + {' '} + {product?.variantTotal > 1 && ( + <div className='badge-gray'> + {' '} + {product.variantTotal} Varian{' '} + </div> + )}{' '} + {product?.isInBu && ( + <Image + src='/images/PICKUP-NOW.png' + alt='Pick Up Now' + width={83} + height={24} + className='drop-shadow-sm' + loading='eager' + /> + )}{' '} + </div> + )} + {router.pathname != '/' && product?.flashSale?.id > 0 && ( + <div className='absolute bottom-0 w-full grid'> + <div className='absolute bottom-0 w-full h-full'> <ImageNext - src='/images/ICON_FLASH_SALE_WEBSITE_INDOTEKNIK.svg' - alt='flash sale' - width={13} - height={5} + src='/images/BG-FLASH-SALE.jpg' + className='h-full' + width={1000} + height={100} loading='eager' /> - <span className='text-white text-[9px] md:text-[10px] font-semibold'> - {product?.flashSale?.tag != 'false' || - product?.flashSale?.tag - ? product?.flashSale?.tag - : 'FLASH SALE'} + </div> + <div className='relative'> + {/* <div className='flex gap-x-1 items-center p-2 justify-center'> + <div className='bg-yellow-400 rounded-lg p-1 h-6 w-19 flex items-center justify-center '> + <span className='text-sm font-bold text-black'> + {Math.floor(product?.lowestPrice.discountPercentage)}% + </span> + </div> + <div className='bg-red-600 border border-solid border-yellow-400 p-2 rounded-full h-6 flex w-fit items-center justify-center gap-x-2'> + <ImageNext + src='/images/ICON_FLASH_SALE_WEBSITE_INDOTEKNIK.svg' + alt='flash sale' + width={13} + height={5} + loading='eager' + /> + <span className='text-white text-[9px] md:text-[10px] font-semibold'> + {product?.flashSale?.tag != 'false' || + product?.flashSale?.tag + ? product?.flashSale?.tag + : 'FLASH SALE'} + </span> + </div> + </div> */} + </div> + </div> + )} + {/* {product.variantTotal > 1 && ( + <div className='absolute badge-gray bottom-1.5 left-1.5'> + {product.variantTotal} Varian + </div> + )} */} + </Link> + + <div className='p-2 sm:p-3 pb-3 text-caption-2 sm:text-body-2 leading-5'> + <div className='flex justify-between '> + {product?.manufacture?.name ? ( + <Link + href={URL.manufacture} + className='mb-1 mt-1 truncate' + aria-label={product.manufacture.name} + > + {product.manufacture.name} + </Link> + ) : ( + <div>-</div> + )} + </div> + + {/* ⬇️ line clamp desktop dibiarkan seperti aslinya */} + <Link + href={URL.product} + aria-label={product?.name} + className={`mb-2 !text-gray_r-12 leading-6 block`} + style={{ + WebkitLineClamp: 2, + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }} + title={product?.name} + > + {product?.name} + </Link> + + {product?.flashSale?.id > 0 && + product?.lowestPrice.discountPercentage > 0 ? ( + <div className='mb-2'> + <div className='flex items-baseline gap-1 min-w-0'> + <span className='text-danger-500 text-sm font-semibold whitespace-nowrap'> + {product?.lowestPrice.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice.priceDiscount) + ) : ( + <a + rel='noopener noreferrer' + target='_blank' + href={callForPriceWhatsapp} + aria-label='Call for Inquiry' + > + Call for Inquiry + </a> + )} + </span> + <span + className='text-gray_r-11 line-through text-xs sm:text-caption-2 + whitespace-nowrap overflow-hidden text-ellipsis max-w-[40%]' + > + {currencyFormat(product.lowestPrice.price)} + </span> + </div> + </div> + ) : ( + <div className='text-danger-500 font-semibold mb-2'> + {product?.lowestPrice.price > 0 ? ( + <> + {currencyFormat(product?.lowestPrice.priceDiscount)} + <div className='text-gray_r-9 text-[10px] font-normal'> + Include PPN:{' '} + {currencyFormat( + product.lowestPrice.price * + process.env.NEXT_PUBLIC_PPN + )} + </div> + </> + ) : ( + <a + rel='noopener noreferrer' + target='_blank' + href={callForPriceWhatsapp} + aria-label='Call for Inquiry' + > + Call for Inquiry + </a> + )} + </div> + )} + + {discount > 0 && (product?.flashSale?.id ?? 0) < 1 && ( + <div className='mt-1 mb-1'> + <div className='flex items-center gap-2 text-green-600 min-w-0 mb-2 flex-nowrap'> + {/* label jangan pecah */} + <span className='text-xs font-medium shrink-0 whitespace-nowrap'> + Voucher + </span> + + {/* chip bisa mengecil & memotong teks di dalam */} + <span + className='flex items-center gap-1.5 rounded bg-green-50 px-2.5 py-0.5 ring-0 + min-w-0 max-w-full overflow-hidden' + > + <TicketIcon className='h-3.5 w-3.5 shrink-0' /> + {/* nominal: truncate */} + <span className='text-xs font-medium truncate whitespace-nowrap min-w-0'> + {currencyFormat(discount)} + </span> </span> </div> </div> + )} + + <div className='flex w-full items-center gap-x-1 '> + {(product?.stockTotal > 0 || product?.qtySold > 0) && ( + <div className='flex w-full items-center gap-x-2 flex-nowrap min-w-0'> + {product?.stockTotal > 0 && ( + <div className='badge-solid-red text-center shrink-0 whitespace-nowrap'> + Ready Stock + </div> + )} + + {product?.qtySold > 0 && ( + <div className='text-gray_r-9 text-xs flex-1 min-w-0 truncate'> + {sellingProductFormat(product?.qtySold)} Terjual + </div> + )} + </div> + )} </div> </div> - )} - {product.variantTotal > 1 && ( - <div className='absolute badge-gray bottom-1.5 left-1.5'> - {product.variantTotal} Varian - </div> - )} - </Link> - <div className='p-2 sm:p-3 pb-3 text-caption-2 sm:text-body-2 leading-5'> - <div className='flex justify-between '> - {product?.manufacture?.name ? ( - <Link href={URL.manufacture} className='mb-1 mt-1 truncate' aria-label={product.manufacture.name}> - {product.manufacture.name} - </Link> - ) : ( - <div>-</div> - )} - {product?.isInBu && ( - <Link href='/panduan-pick-up-service' className='group' aria-label='pickup now'> - <Image - src='/images/PICKUP-NOW.png' - className='group-hover:scale-105 transition-transform duration-200' - alt='pickup now' - width={90} - height={12} - loading='eager' - /> - </Link> - )} </div> - <Link - href={URL.product} - aria-label={product?.name} - className={`mb-2 !text-gray_r-12 leading-6 block line-clamp-3 h-[64px]`} - title={product?.name} - > - {product?.name} - </Link> - {product?.flashSale?.id > 0 && - product?.lowestPrice.discountPercentage > 0 ? ( - <> - <div className='flex gap-x-1 mb-1 items-center'> - <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> - {currencyFormat(product.lowestPrice.price)} + </DesktopView> + + <MobileView> + <div className='rounded shadow-sm'> + <Link href={URL.product} className='relative' aria-label='Produk'> + <div className='relative'> + <div + className='relative w-full overflow-hidden' + style={{ aspectRatio: '1 / 1' }} + > + <Image + src={image} + alt={product?.name} + fill + sizes='(max-width:640px) 100vw, 50vw' + className='object-contain object-center bg-white' + /> </div> - <div className='badge-solid-red'> - {Math.floor(product?.lowestPrice.discountPercentage)}% + + {/* SNI / TKDN (kanan-atas, tetap) */} + <div className='absolute top-0 right-0 flex mt-3 z-20'> + <div className='gambarB'> + {product?.isSni && ( + <ImageNext + src='/images/sni-logo.png' + alt='SNI Logo' + className='w-4 h-5 object-contain object-top sm:h-6' + width={50} + height={50} + loading='eager' + /> + )} + </div> + <div className='gambarC'> + {product?.isTkdn && ( + <ImageNext + src='/images/TKDN.png' + alt='TKDN' + className='w-11 h-6 object-contain object-top ml-1 mr-1 sm:h-6' + width={50} + height={50} + loading='eager' + /> + )} + </div> </div> + + {/* BADGE DISKON Kanan-ATAS */} + {(product?.lowestPrice?.discountPercentage ?? 0) > 0 && ( + <div className='absolute right-0 top-1.5 '> + <div className='bg-red-600 text-white px-2 py-1 rounded-l-lg shadow-sm text-xs font-bold leading-none'> + {Math.floor(product.lowestPrice.discountPercentage)}% + </div> + </div> + )} + + {/* BOTTOM-LEFT: Varian + PICK UP NOW */} + {(product?.variantTotal > 1 || product?.isInBu) && ( + <div className='absolute bottom-1.5 left-1.5 z-30 flex items-center gap-2'> + {product?.variantTotal > 1 && ( + <div className='badge-gray'> + {product.variantTotal} Varian + </div> + )} + {product?.isInBu && ( + <Image + src='/images/PICKUP-NOW.png' + alt='Pick Up Now' + width={83} + height={24} + className='drop-shadow-sm' + loading='eager' + /> + )} + </div> + )} </div> - <div className='text-danger-500 font-semibold mb-2'> - {product?.lowestPrice.priceDiscount > 0 ? ( - currencyFormat(product?.lowestPrice.priceDiscount) - ) : ( - <a - rel='noopener noreferrer' - target='_blank' - href={callForPriceWhatsapp} - aria-label='Call for Inquiry' + + {router.pathname != '/' && product?.flashSale?.id > 0 && ( + <div className='absolute bottom-0 w-full grid z-10'> + <div className='absolute bottom-0 w-full h-full'> + <ImageNext + src='/images/BG-FLASH-SALE.jpg' + className='h-full' + width={1000} + height={100} + loading='eager' + /> + </div> + </div> + )} + </Link> + + {/* ⬇️ konten bawah (tidak diubah) */} + <div className='p-2 sm:p-3 pb-3 text-caption-2 sm:text-body-2 leading-5 min-w-0'> + <div className='flex justify-between '> + {product?.manufacture?.name ? ( + <Link + href={URL.manufacture} + className='mt-1 truncate' + aria-label={product.manufacture.name} > - Call for Inquiry - </a> + {product.manufacture.name} + </Link> + ) : ( + <div>-</div> )} </div> - </> - ) : ( - <div className='text-danger-500 font-semibold mb-2 min-h-[40px]'> - {product?.lowestPrice.price > 0 ? ( - <> - {currencyFormat(product?.lowestPrice.price)} - <div className='text-gray_r-9 text-[10px] font-normal mt-2'> - Inc. PPN:{' '} - {currencyFormat( - product.lowestPrice.price * process.env.NEXT_PUBLIC_PPN - )} + + <Link + href={URL.product} + aria-label={product?.name} + className='block mb-1 leading-6 !text-gray_r-12 line-clamp-2' + title={product?.name} + style={{ + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }} + > + {product?.name} + </Link> + + {product?.flashSale?.id > 0 && + product?.lowestPrice.discountPercentage > 0 ? ( + <div className='mb-2'> + <div className='flex items-baseline gap-1 min-w-0'> + <span className='text-danger-500 text-sm font-semibold whitespace-nowrap'> + {product?.lowestPrice.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice.priceDiscount) + ) : ( + <a + rel='noopener noreferrer' + target='_blank' + href={callForPriceWhatsapp} + aria-label='Call for Inquiry' + > + Call for Inquiry + </a> + )} + </span> + <span + className='text-gray_r-11 line-through text-xs sm:text-caption-2 + whitespace-nowrap overflow-hidden text-ellipsis max-w-[40%]' + > + {currencyFormat(product.lowestPrice.price)} + </span> </div> - </> + </div> ) : ( - <a - rel='noopener noreferrer' - target='_blank' - href={callForPriceWhatsapp} - aria-label='Call for Inquiry' - > - Call for Inquiry - </a> + <div className='text-danger-500 font-semibold mb-2'> + {product?.lowestPrice.price > 0 ? ( + <> + {currencyFormat(product?.lowestPrice.priceDiscount)} + <div className='text-gray_r-9 text-[10px] font-normal'> + Include PPN:{' '} + {currencyFormat( + product.lowestPrice.price * + process.env.NEXT_PUBLIC_PPN + )} + </div> + </> + ) : ( + <a + rel='noopener noreferrer' + target='_blank' + href={callForPriceWhatsapp} + aria-label='Call for Inquiry' + > + Call for Inquiry + </a> + )} + </div> )} - </div> - )} - {discount > 0 && product?.flashSale?.id < 1 && ( - <div className='flex gap-x-1 mb-1 text-sm'> - <div className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20'> - Voucher : {currencyFormat(discount)} - </div> - </div> - )} - <div className='flex w-full items-center gap-x-1 '> - {product?.stockTotal > 0 && ( - <div className='badge-solid-red'>Ready Stock</div> - )} - {/* <div className='badge-gray'>{product?.stockTotal > 5 ? '> 5' : '< 5'}</div> */} - {product?.qtySold > 0 && ( - <div className='text-gray_r-9 text-[11px]'> - {sellingProductFormat(product?.qtySold) + ' Terjual'} - </div> - )} + {discount > 0 && (product?.flashSale?.id ?? 0) < 1 && ( + <div className='mt-1 mb-1'> + <div className='flex items-center gap-2 text-green-600 min-w-0 mb-2 flex-nowrap'> + {/* label jangan pecah */} + <span className='text-xs font-medium shrink-0 whitespace-nowrap'> + Voucher + </span> + + {/* chip bisa mengecil & memotong teks di dalam */} + <span + className='flex items-center gap-1.5 rounded bg-green-50 px-2.5 py-0.5 ring-0 + min-w-0 max-w-full overflow-hidden' + > + <TicketIcon className='h-3.5 w-3.5 shrink-0' /> + {/* nominal: truncate */} + <span className='text-xs font-medium truncate whitespace-nowrap min-w-0'> + {currencyFormat(discount)} + </span> + </span> + </div> + </div> + )} + + {(product?.stockTotal > 0 || product?.qtySold > 0) && ( + <div className='flex w-full items-center gap-x-2 flex-nowrap min-w-0'> + {product?.stockTotal > 0 && ( + <div className='badge-solid-red text-center shrink-0 whitespace-nowrap'> + Ready Stock + </div> + )} + + {product?.qtySold > 0 && ( + <div className='text-gray_r-9 text-xs flex-1 min-w-0 truncate'> + {sellingProductFormat(product?.qtySold)} Terjual + </div> + )} + </div> + )} + </div> </div> - </div> - </div> + </MobileView> + </> ); } @@ -260,7 +539,11 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { return ( <div className='flex bg-white'> <div className='w-4/12'> - <Link href={URL.product} className='relative' aria-label={product?.name}> + <Link + href={URL.product} + className='relative' + aria-label={product?.name} + > <div className='relative'> <Image src={image} @@ -321,7 +604,11 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { )} {product?.manufacture?.name ? ( <div className='flex justify-between'> - <Link href={URL.manufacture} className='mb-1' aria-label={product?.manufacture.name}> + <Link + href={URL.manufacture} + className='mb-1' + aria-label={product?.manufacture.name} + > {product.manufacture.name} </Link> {/* {product?.is_in_bu && ( @@ -338,46 +625,51 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { <Link href={URL.product} aria-label={product?.name} - className={`mb-3 !text-gray_r-12 leading-6 line-clamp-3`} + className={`mb-3 !text-gray_r-12 leading-6 `} + style={{ + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }} > {product?.name} </Link> + {product?.flashSale?.id > 0 && - product?.lowestPrice?.discountPercentage > 0 ? ( - <> - {product?.lowestPrice.discountPercentage > 0 && ( - <div className='flex gap-x-1 mb-1 items-center'> - <div className='badge-solid-red'> - {Math.floor(product?.lowestPrice?.discountPercentage)}% - </div> - <div className='text-gray_r-11 line-through text-caption-2'> - {currencyFormat(product?.lowestPrice?.price)} - </div> - </div> - )} + product?.lowestPrice.discountPercentage > 0 ? ( + <div className='mb-2'> + <div className='flex items-baseline gap-1'> + {/* harga sekarang (merah) */} + <span className='text-danger-500 font-semibold text-sm'> + {product?.lowestPrice.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice.priceDiscount) // ← perbaikan di sini + ) : ( + <a + rel='noopener noreferrer' + target='_blank' + href={callForPriceWhatsapp} + aria-label='Call for Inquiry' + > + Call for Inquiry + </a> + )} + </span> - <div className='text-danger-500 font-semibold mb-2'> - {product?.lowestPrice?.priceDiscount > 0 ? ( - currencyFormat(product?.lowestPrice?.priceDiscount) - ) : ( - <a - rel='noopener noreferrer' - target='_blank' - href={callForPriceWhatsapp} - aria-label='Call for Inquiry' - > - Call for Inquiry - </a> - )} + {/* harga lama (abu, dicoret) */} + <span className='text-gray_r-11 line-through text-xs sm:text-caption-2 '> + {currencyFormat(product.lowestPrice.price)} + </span> </div> - </> + </div> ) : ( - <div className='text-danger-500 font-semibold mb-2 min-h-[40px]'> + // === BLOK ELSE PUNYA KAMU, TIDAK DIUBAH === + <div className='text-danger-500 font-semibold mb-2'> {product?.lowestPrice.price > 0 ? ( <> - {currencyFormat(product?.lowestPrice.price)} - <div className='text-gray_r-9 text-[11px] sm:text-caption-2 font-normal mt-2'> - Inc. PPN:{' '} + {currencyFormat(product?.lowestPrice.priceDiscount)} + <div className='text-gray_r-9 text-[10px] font-normal'> + Include PPN:{' '} {currencyFormat( product.lowestPrice.price * process.env.NEXT_PUBLIC_PPN )} @@ -396,10 +688,17 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { </div> )} - {discount > 0 && product?.flashSale?.id < 1 && ( - <div className='flex gap-x-1 mb-1 text-sm'> - <div className='inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20'> - Voucher : {currencyFormat(discount)} + {discount > 0 && (product?.flashSale?.id ?? 0) < 1 && ( + <div className='mt-1 mb-1'> + {/* ⬇️ gunakan flex-wrap & min-w-0 */} + <div className='flex flex-wrap items-center gap-2 text-green-600 min-w-0 mb-2'> + <span className='text-xs font-medium'>Voucher</span> + <span className='inline-flex items-center gap-1.5 rounded bg-green-50 px-2.5 py-0.5 ring-0 max-w-full'> + <TicketIcon className='h-3.5 w-3.5 shrink-0' /> + <span className='text-xs font-medium break-all'> + {currencyFormat(discount)} + </span> + </span> </div> </div> )} diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index 850d00cc..0c106df7 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -6,7 +6,10 @@ import { HStack, Image, Tag, TagCloseButton, TagLabel } from '@chakra-ui/react'; import axios from 'axios'; import _ from 'lodash'; import { toQuery } from 'lodash-contrib'; -import { FunnelIcon, AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline'; +import { + FunnelIcon, + AdjustmentsHorizontalIcon, +} from '@heroicons/react/24/outline'; import odooApi from '@/core/api/odooApi'; import searchSpellApi from '@/core/api/searchSpellApi'; import Link from '@/core/components/elements/Link/Link'; @@ -57,7 +60,8 @@ const ProductSearch = ({ if (!router.isReady) return; const onBrandsPage = router.pathname.includes('brands'); - const hasOrder = typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; + const hasOrder = + typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; if (onBrandsPage && !hasOrder && !appliedDefaultBrandOrder.current) { let params = { @@ -67,10 +71,8 @@ const ProductSearch = ({ params = _.pickBy(params, _.identity); const qs = toQuery(params); - // ganti URL tanpa nambah history & tanpa full reload router.replace(`${prefixUrl}?${qs}`, undefined, { shallow: true }); - // sinkronkan state lokal setOrderBy('popular'); appliedDefaultBrandOrder.current = true; @@ -175,7 +177,11 @@ const ProductSearch = ({ }, [dataCategoriesProduct, dataLob]); useEffect(() => { - if (prefixUrl.includes('category') || prefixUrl.includes('lob') || router.asPath.includes('penawaran')) { + if ( + prefixUrl.includes('category') || + prefixUrl.includes('lob') || + router.asPath.includes('penawaran') + ) { setQueryFinal({ ...finalQuery, q, limit, orderBy }); } else { setQueryFinal({ ...query, q, limit, orderBy }); @@ -430,7 +436,9 @@ const ProductSearch = ({ <div className='p-4 pt-0'> {isNotReadyStockPage && isBrand && isBrand.logo && ( <div className='mb-3'> - <h1 className='mb-2 font-semibold text-h-sm'>Brand Pencarian {q}</h1> + <h1 className='mb-2 font-semibold text-h-sm'> + Brand Pencarian {q} + </h1> <Link href={createSlug('/shop/brands/', isBrand.name, isBrand.id)} className='inline' @@ -462,7 +470,8 @@ const ProductSearch = ({ {pageCount > 1 ? ( <> {productStart + 1}- - {parseInt(productStart) + parseInt(productRows) > productFound + {parseInt(productStart) + parseInt(productRows) > + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -474,7 +483,8 @@ const ProductSearch = ({ produk{' '} {query.q && ( <> - untuk pencarian <span className='font-semibold'>{query.q}</span> + untuk pencarian{' '} + <span className='font-semibold'>{query.q}</span> </> )} </> @@ -512,7 +522,9 @@ const ProductSearch = ({ </div> )} {!!dataLob?.length && <LobSectionCategory categories={dataLob} />} - {!!dataCategories?.length && <CategorySection categories={dataCategories} />} + {!!dataCategories?.length && ( + <CategorySection categories={dataCategories} /> + )} <div className='grid grid-cols-2 gap-3'> {products && products.map((product) => ( @@ -567,9 +579,7 @@ const ProductSearch = ({ prefixUrl={prefixUrl} defaultBrand={defaultBrand} /> - <div className='h-6' /> - <SideBanner query={search} /> </div> @@ -621,7 +631,7 @@ const ProductSearch = ({ <> {productStart + 1}- {parseInt(productStart) + parseInt(productRows) > - productFound + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -697,8 +707,8 @@ const ProductSearch = ({ href={ query?.q ? whatsappUrl('productSearch', { - name: query.q, - }) + name: query.q, + }) : whatsappUrl() } className='text-danger-500' @@ -783,9 +793,9 @@ const FilterChoicesComponent = ({ </Tag> )} {brandValues?.length > 0 || - categoryValues?.length > 0 || - priceFrom || - priceTo ? ( + categoryValues?.length > 0 || + priceFrom || + priceTo ? ( <span> <button className='btn-transparent py-2 px-5 h-[40px] text-red-700' diff --git a/src/lib/product/styles/desc_mobile_variant.module.css b/src/lib/product/styles/desc_mobile_variant.module.css new file mode 100644 index 00000000..d2c86d77 --- /dev/null +++ b/src/lib/product/styles/desc_mobile_variant.module.css @@ -0,0 +1,18 @@ +.richtextHtml { + line-height: 1.7; + /* word-break: break-word; */ + overflow-x: auto; +} +.richtextHtml h1 { + font-weight: 600; + margin: 0.5rem 0; + font-size: clamp(1.5rem, 2.5vw, 2rem); + line-height: 1.25; +} +.richtextHtml table { + width: 100%; + max-width: 100%; + border-collapse: collapse; + table-layout: auto; + margin: 12px 0; +} diff --git a/src/pages/shop/search.jsx b/src/pages/shop/search.jsx index db3b449a..157d2091 100644 --- a/src/pages/shop/search.jsx +++ b/src/pages/shop/search.jsx @@ -1,25 +1,33 @@ -import dynamic from 'next/dynamic' -import { useRouter } from 'next/router' -import _ from 'lodash-contrib' -import Seo from '@/core/components/Seo' -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react' -import Link from 'next/link' +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; +import _ from 'lodash-contrib'; +import Seo from '@/core/components/Seo'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react'; +import Link from 'next/link'; -const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) -const ProductSearch = dynamic(() => import('@/lib/product/components/ProductSearch')) +const BasicLayout = dynamic(() => + import('@/core/components/layouts/BasicLayout') +); +const ProductSearch = dynamic(() => + import('@/lib/product/components/ProductSearch') +); export default function Search() { - const router = useRouter() + const router = useRouter(); return ( <BasicLayout> <Seo title={`Cari produk ${router.query.q || ''} di Indoteknik.com`} /> - <div className='container mx-auto py-4 md:py-6'> + <div className='container mx-auto py-4 md:py-6 mb-3'> {router.query?.q && ( <Breadcrumb> <BreadcrumbItem> - <BreadcrumbLink as={Link} href='/' className='!text-danger-500 whitespace-nowrap'> + <BreadcrumbLink + as={Link} + href='/' + className='!text-danger-500 whitespace-nowrap' + > Home </BreadcrumbLink> </BreadcrumbItem> @@ -32,8 +40,9 @@ export default function Search() { </Breadcrumb> )} </div> - - {!_.isEmpty(router.query) && <ProductSearch query={router.query} prefixUrl='/shop/search' />} + {!_.isEmpty(router.query) && ( + <ProductSearch query={router.query} prefixUrl='/shop/search' /> + )} </BasicLayout> - ) + ); } diff --git a/src/utils/solrMapping.js b/src/utils/solrMapping.js index 33f0cbaf..b7733e92 100644 --- a/src/utils/solrMapping.js +++ b/src/utils/solrMapping.js @@ -121,7 +121,6 @@ export const variantsMappingSolr = (parent, products, pricelist) => { image: product.image_s || '', code: product.default_code_s || '', isFlashsale: flashsaleTime(product?.flashsale_end_date_s)?.isFlashSale, - isFlashsale: flashsaleTime(product?.flashsale_end_date_s)?.isFlashSale, name: product.display_name_s || '', price: { price, priceDiscount, discountPercentage }, variantTotal: product.variant_total_i || 0, @@ -140,12 +139,35 @@ export const variantsMappingSolr = (parent, products, pricelist) => { logo: parent[0]?.x_logo_manufacture_s, }; } + productMapped.parent = { id: parent[0]?.product_id_i || '', image: parent[0]?.image_s || '', name: parent[0]?.name_s || '', description: parent[0]?.description_t || '', }; + + productMapped.newVoucherPastiHemat = [ + { + min_purchase: + parent[0]?.voucher_min_purchase_f ?? + product?.voucher_min_purchase_f ?? + 0, + discount_type: + parent[0]?.voucher_discount_type_s ?? + product?.voucher_discount_type_s ?? + '', + discount_amount: + parent[0]?.voucher_discount_amount_f ?? + product?.voucher_discount_amount_f ?? + 0, + max_discount: + parent[0]?.voucher_max_discount_f ?? + product?.voucher_max_discount_f ?? + 0, + }, + ]; + return productMapped; }); }; |
