diff options
| author | IT Fixcomart <it@fixcomart.co.id> | 2025-08-26 10:33:43 +0000 |
|---|---|---|
| committer | IT Fixcomart <it@fixcomart.co.id> | 2025-08-26 10:33:43 +0000 |
| commit | 0862e3a45411e5033b768ca43a56c8f27dee3b9b (patch) | |
| tree | 11d0d0a4b808bf938dfcc7e8c2d8fef21d052a9f | |
| parent | ae2827dbaf8b02480d997b7fd323159a57143af5 (diff) | |
| parent | 37bda78dec58cb8c218849a77620d95682b201b9 (diff) | |
Merged in cr/prod-detail (pull request #449)
Cr/prod detail
| -rw-r--r-- | public/images/doc.svg | 3 | ||||
| -rw-r--r-- | public/images/doc_red.svg | 5 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/AddToCart.tsx | 26 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/AddToQuotation.tsx | 11 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/Breadcrumb.tsx | 136 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/PriceAction.tsx | 338 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductDetail.tsx | 363 | ||||
| -rw-r--r-- | src/lib/category/components/Breadcrumb.jsx | 103 |
8 files changed, 699 insertions, 286 deletions
diff --git a/public/images/doc.svg b/public/images/doc.svg new file mode 100644 index 00000000..5e811ab2 --- /dev/null +++ b/public/images/doc.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.50009 1.25C6.83704 1.25 6.20116 1.51339 5.73232 1.98223C5.26348 2.45107 5.00009 3.08696 5.00009 3.75V6.25H6.25009V3.75C6.25009 3.41848 6.38178 3.10054 6.6162 2.86612C6.85062 2.6317 7.16857 2.5 7.50009 2.5H11.2501V5.625C11.2501 6.12228 11.4476 6.59919 11.7993 6.95083C12.1509 7.30246 12.6278 7.5 13.1251 7.5H16.2501V16.25C16.2501 16.5815 16.1184 16.8995 15.884 17.1339C15.6495 17.3683 15.3316 17.5 15.0001 17.5H7.50009C7.44576 17.5002 7.39148 17.4969 7.33759 17.49L6.31009 18.4487C6.66384 18.6413 7.06884 18.75 7.50009 18.75H15.0001C15.6631 18.75 16.299 18.4866 16.7679 18.0178C17.2367 17.5489 17.5001 16.913 17.5001 16.25V6.7675C17.5 6.27057 17.3026 5.794 16.9513 5.4425L13.3088 1.79875C12.9573 1.44748 12.4808 1.25011 11.9838 1.25H7.50009ZM15.9913 6.25H13.1263C12.9606 6.25 12.8016 6.18415 12.6844 6.06694C12.5672 5.94973 12.5013 5.79076 12.5013 5.625V2.75875L15.9913 6.25ZM1.87884 15H4.06634L3.32884 17.95C3.17759 18.5525 3.90634 18.9825 4.36134 18.5575L10.4513 12.8725C10.5887 12.7441 10.6843 12.5774 10.7257 12.394C10.7672 12.2106 10.7525 12.019 10.6836 11.8441C10.6147 11.6691 10.4948 11.5189 10.3395 11.413C10.1842 11.3071 10.0006 11.2503 9.81259 11.25H8.44009L9.41634 8.3225C9.44758 8.22869 9.45612 8.12881 9.44126 8.03106C9.4264 7.93331 9.38856 7.84048 9.33085 7.76019C9.27314 7.67991 9.1972 7.61446 9.10928 7.56923C9.02136 7.524 8.92396 7.50027 8.82509 7.5H4.48259C4.35905 7.49992 4.23827 7.53645 4.13548 7.60497C4.0327 7.67349 3.95252 7.77094 3.90509 7.885L1.30134 14.135C1.26186 14.2299 1.24641 14.3331 1.25634 14.4354C1.26627 14.5377 1.30128 14.636 1.35827 14.7215C1.41526 14.8071 1.49246 14.8772 1.58305 14.9258C1.67363 14.9744 1.7748 14.9999 1.87759 15" fill="#9CA3AF"/> +</svg> diff --git a/public/images/doc_red.svg b/public/images/doc_red.svg new file mode 100644 index 00000000..7816ac92 --- /dev/null +++ b/public/images/doc_red.svg @@ -0,0 +1,5 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path + d="M7.50009 1.25C6.83704 1.25 6.20116 1.51339 5.73232 1.98223C5.26348 2.45107 5.00009 3.08696 5.00009 3.75V6.25H6.25009V3.75C6.25009 3.41848 6.38178 3.10054 6.6162 2.86612C6.85062 2.6317 7.16857 2.5 7.50009 2.5H11.2501V5.625C11.2501 6.12228 11.4476 6.59919 11.7993 6.95083C12.1509 7.30246 12.6278 7.5 13.1251 7.5H16.2501V16.25C16.2501 16.5815 16.1184 16.8995 15.884 17.1339C15.6495 17.3683 15.3316 17.5 15.0001 17.5H7.50009C7.44576 17.5002 7.39148 17.4969 7.33759 17.49L6.31009 18.4487C6.66384 18.6413 7.06884 18.75 7.50009 18.75H15.0001C15.6631 18.75 16.299 18.4866 16.7679 18.0178C17.2367 17.5489 17.5001 16.913 17.5001 16.25V6.7675C17.5 6.27057 17.3026 5.794 16.9513 5.4425L13.3088 1.79875C12.9573 1.44748 12.4808 1.25011 11.9838 1.25H7.50009ZM15.9913 6.25H13.1263C12.9606 6.25 12.8016 6.18415 12.6844 6.06694C12.5672 5.94973 12.5013 5.79076 12.5013 5.625V2.75875L15.9913 6.25ZM1.87884 15H4.06634L3.32884 17.95C3.17759 18.5525 3.90634 18.9825 4.36134 18.5575L10.4513 12.8725C10.5887 12.7441 10.6843 12.5774 10.7257 12.394C10.7672 12.2106 10.7525 12.019 10.6836 11.8441C10.6147 11.6691 10.4948 11.5189 10.3395 11.413C10.1842 11.3071 10.0006 11.2503 9.81259 11.25H8.44009L9.41634 8.3225C9.44758 8.22869 9.45612 8.12881 9.44126 8.03106C9.4264 7.93331 9.38856 7.84048 9.33085 7.76019C9.27314 7.67991 9.1972 7.61446 9.10928 7.56923C9.02136 7.524 8.92396 7.50027 8.82509 7.5H4.48259C4.35905 7.49992 4.23827 7.53645 4.13548 7.60497C4.0327 7.67349 3.95252 7.77094 3.90509 7.885L1.30134 14.135C1.26186 14.2299 1.24641 14.3331 1.25634 14.4354C1.26627 14.5377 1.30128 14.636 1.35827 14.7215C1.41526 14.8071 1.49246 14.8772 1.58305 14.9258C1.67363 14.9744 1.7748 14.9999 1.87759 15" + fill="#CC2020" /> +</svg>
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx index 95bc1d88..1cb58a75 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,9 @@ 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'; type Props = { variantId: number | null; quantity?: number; @@ -39,6 +42,8 @@ const AddToCart = ({ isClosable: true, }); + const { isMobile, isDesktop } = useDevice(); + const { askAdminUrl } = useProductDetail(); const [product, setProducts] = useState(products); @@ -158,24 +163,39 @@ 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'> + <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' 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..0e263fe9 100644 --- a/src-migrate/modules/product-detail/components/Breadcrumb.tsx +++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx @@ -1,31 +1,129 @@ -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: breadcrumbs = [] } = useQuery({ + queryKey: ['product-category-breadcrumb', id], queryFn: () => getProductCategoryBreadcrumb(id), - refetchOnWindowFocus: false - }) + refetchOnWindowFocus: false, + }); + + const total = breadcrumbs.length; + const lastCat = total ? breadcrumbs[total - 1] : 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) { + crumbsMobile.push( + <span + className='text-danger-500 shrink-0' + title={hiddenText} + aria-label='Kategori tersembunyi' + key='hidden' + > + .. + </span> + ); + } + + // 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 +133,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..79921e22 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -2,7 +2,7 @@ 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'; @@ -35,6 +35,7 @@ const ProductDetail = ({ product }: Props) => { const { isDesktop, isMobile } = useDevice(); const router = useRouter(); const auth = getAuth(); + const { setAskAdminUrl, askAdminUrl, @@ -71,179 +72,277 @@ 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); // selalu masukkan utama, baik mobile maupun desktop + if ( + Array.isArray(product?.image_carousel) && + product.image_carousel.length + ) { + // hindari duplikat jika image utama juga ada di carousel + 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] || ''); - return ( - <> - <div className='md:flex md:flex-wrap'> - <div className='w-full mb-4 md:mb-0 px-4 md:px-0'> - <Breadcrumb id={product.id} name={product.name} /> - </div> - <div className='md:w-9/12 md:flex md:flex-col md:pr-4 md:pt-6'> - <div className='md:flex md:flex-wrap'> - <div className='md:w-4/12'> - <ProductImage product={{ ...product, 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) => ( + useEffect(() => { + // update mainImage jika sumber gambar berubah dan mainImage tidak ada di daftar + if (!allImages.includes(mainImage)) { + setMainImage(allImages[0] || ''); + } + }, [allImages]); // eslint-disable-line react-hooks/exhaustive-deps + + // ===== Slider mobile (tanpa dependency) ===== + 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'> + {/* === 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) => ( + // slide tetap selebar viewport untuk snap mulus <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)} + key={i} + className='w-full flex-shrink-0 snap-center flex justify-center items-center' > + {/* gambar diperkecil */} <img src={img} - alt={`Thumbnail ${index + 1}`} - className='w-full h-full object-cover rounded-sm' - loading='lazy' + alt={`Gambar ${i + 1}`} + className='w-[85%] aspect-square object-contain' onError={(e) => { (e.target as HTMLImageElement).src = '/path/to/fallback-image.jpg'; }} /> </div> - ))} - </div> + )) + ) : ( + <div className='w-full flex-shrink-0 snap-center flex justify-center items-center'> + <img + src={mainImage || '/path/to/fallback-image.jpg'} + alt='Gambar produk' + className='w-[85%] aspect-square object-contain' + /> + </div> + )} </div> - )} - </div> - - <div className='md:w-8/12 px-4 md:pl-6'> - <div className='h-6 md:h-0' /> - - <h1 className={style['title']}>{product.name}</h1> - - <div className='h-3 md:h-0' /> - - <Information product={product} /> - - <div className='h-6' /> - </div> - </div> - <div className='h-full'> - {isMobile && ( - <div className='px-4 pt-6'> - <PriceAction product={product} /> + {/* 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 = + '/path/to/fallback-image.jpg'; + }} + /> + </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> - <div className='h-4 md:h-10' /> - {!!activeVariantId && !isApproval && ( - <ProductPromoSection - product={product} - productId={activeVariantId} - /> - )} + <div className='h-full'> + {isMobile && ( + <div className='px-4 pt-6'> + <PriceAction product={product} /> + </div> + )} - {/* <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-4 md:h-10' /> + {!!activeVariantId && !isApproval && ( + <ProductPromoSection + product={product} + productId={activeVariantId} + /> + )} - <div className='h-0 md:h-6' /> + <div className='h-0 md:h-6' /> - <div className={style['section-card']}> - <h2 className={style['heading']}>Informasi Produk</h2> - <div className='h-4' /> - <div className='overflow-x-auto'> - <div - className={style['description']} - dangerouslySetInnerHTML={{ - __html: - !product.description || - product.description == '<p><br></p>' - ? 'Belum ada deskripsi' - : product.description, - }} - /> - </div> + <div className={style['section-card']}> + <h2 className={style['heading']}>Informasi Produk</h2> + <div className='h-4' /> + <div className='overflow-x-auto'> + <div + className={style['description']} + dangerouslySetInnerHTML={{ + __html: + !product.description || product.description == '<p><br></p>' + ? 'Belum ada deskripsi' + : product.description, + }} + /> </div> </div> </div> + </div> - {isDesktop && ( - <div className='md:w-3/12'> - <PriceAction product={product} /> - <div className='flex gap-x-5 items-center justify-center'> + {isDesktop && ( + <div className='md:w-3/12'> + <PriceAction product={product} /> + <div className='flex gap-x-5 items-center justify-center'> + <Button + as={Link} + href={askAdminUrl} + variant='link' + target='_blank' + colorScheme='gray' + leftIcon={<MessageCircleIcon size={18} />} + > + Ask Admin + </Button> + + <span>|</span> + + <AddToWishlist productId={product.id} /> + + <span>|</span> + + <RWebShare + data={{ + text: 'Check out this product', + title: `${product.name} - Indoteknik.com`, + url: SELF_HOST + router.asPath, + }} + > <Button - as={Link} - href={askAdminUrl} variant='link' - target='_blank' colorScheme='gray' - leftIcon={<MessageCircleIcon size={18} />} + leftIcon={<Share2Icon size={18} />} > - Ask Admin + Share </Button> + </RWebShare> + </div> - <span>|</span> - - <AddToWishlist productId={product.id} /> - - <span>|</span> + <div className='h-6' /> + <div className={style['heading']}>Produk Serupa</div> - <RWebShare - data={{ - text: 'Check out this product', - title: `${product.name} - Indoteknik.com`, - url: SELF_HOST + router.asPath, - }} - > - <Button - variant='link' - colorScheme='gray' - leftIcon={<Share2Icon size={18} />} - > - Share - </Button> - </RWebShare> - </div> + <div className='h-4' /> - <div className='h-6' /> - <div className={style['heading']}>Produk Serupa</div> + <SimilarSide product={product} /> + </div> + )} - <div className='h-4' /> + <div className='md:w-full pt-4 md:py-10 px-4 md:px-0'> + <div className={style['heading']}>Kamu Mungkin Juga Suka</div> - <SimilarSide product={product} /> - </div> - )} + <div className='h-6' /> - <div className='md:w-full pt-4 md:py-10 px-4 md:px-0'> - <div className={style['heading']}>Kamu Mungkin Juga Suka</div> + <LazyLoadComponent> + <SimilarBottom product={product} /> + </LazyLoadComponent> + </div> - <div className='h-6' /> + <div className='h-6 md:h-0' /> + </div> + </> +); - <LazyLoadComponent> - <SimilarBottom product={product} /> - </LazyLoadComponent> - </div> - <div className='h-6 md:h-0' /> - </div> - </> - ); }; export default ProductDetail; diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx index 127904ee..e691e379 100644 --- a/src/lib/category/components/Breadcrumb.jsx +++ b/src/lib/category/components/Breadcrumb.jsx @@ -1,56 +1,87 @@ -import odooApi from '@/core/api/odooApi' -import { createSlug } from '@/core/utils/slug' +import odooApi from '@/core/api/odooApi'; +import { createSlug } from '@/core/utils/slug'; import { Breadcrumb as ChakraBreadcrumb, BreadcrumbItem, BreadcrumbLink, - Skeleton -} from '@chakra-ui/react' -import Link from 'next/link' -import React from 'react' -import { useQuery } from 'react-query' + Skeleton, +} from '@chakra-ui/react'; +import Link from 'next/link'; +import React from 'react'; +import { useQuery } from 'react-query'; -/** - * Render a breadcrumb component. - * - * @param {object} categoryId - The ID of the category. - * @return {JSX.Element} The breadcrumb component. - */ const Breadcrumb = ({ categoryId }) => { const breadcrumbs = useQuery( - `category-breadcrumbs/${categoryId}`, - async () => await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) - ) + ['category-breadcrumbs', categoryId], + async () => + await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) + ); + + const items = breadcrumbs.data ?? []; + const lastIdx = items.length - 1; return ( <div className='container mx-auto py-4 md:py-6'> <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-2/3'> - <ChakraBreadcrumb> + <ChakraBreadcrumb + spacing='8px' + sx={{ + // mobile boleh wrap; desktop tetap 1 baris + '& ol': { + display: 'flex', + flexWrap: { base: 'wrap', md: 'nowrap' }, + alignItems: 'center', + }, + '& li': { display: 'inline-flex', alignItems: 'center' }, + // semua item sebelum terakhir: jangan pernah wrap (tetap di baris atas) + '& li:not(:last-of-type)': { + flex: '0 0 auto', + whiteSpace: 'nowrap', + }, + // item terakhir: boleh ambil sisa lebar & wrap jika perlu + '& li:last-of-type': { + flex: '1 1 auto', + minWidth: 0, + }, + }} + > + {/* Home */} <BreadcrumbItem> - <BreadcrumbLink as={Link} href='/' className='!text-danger-500 whitespace-nowrap'> + <BreadcrumbLink as={Link} href='/' className='!text-danger-500'> Home </BreadcrumbLink> </BreadcrumbItem> - {breadcrumbs.data?.map((category, index) => ( - <BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.data.length - 1}> - {index === breadcrumbs.data.length - 1 ? ( - <BreadcrumbLink className='whitespace-nowrap'>{category.name}</BreadcrumbLink> - ) : ( - <BreadcrumbLink - as={Link} - href={createSlug('/shop/category/', category.name, category.id)} - className='!text-danger-500 whitespace-nowrap' - > - {category.name} - </BreadcrumbLink> - )} - </BreadcrumbItem> - ))} + {/* Categories */} + {items.map((category, index) => { + const isLast = index === lastIdx; + return ( + <BreadcrumbItem key={index} isCurrentPage={isLast}> + {isLast ? ( + // HANYA yang terakhir boleh turun/wrap di mobile + <BreadcrumbLink className='block whitespace-normal break-words md:whitespace-nowrap'> + {category.name} + </BreadcrumbLink> + ) : ( + <BreadcrumbLink + as={Link} + href={createSlug( + '/shop/category/', + category.name, + category.id + )} + className='!text-danger-500' + > + {category.name} + </BreadcrumbLink> + )} + </BreadcrumbItem> + ); + })} </ChakraBreadcrumb> </Skeleton> </div> - ) -} + ); +}; -export default Breadcrumb +export default Breadcrumb; |
