diff options
42 files changed, 3032 insertions, 1917 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/cart/components/CartSummaryMobile.tsx b/src-migrate/modules/cart/components/CartSummaryMobile.tsx index 02258204..7a334fed 100644 --- a/src-migrate/modules/cart/components/CartSummaryMobile.tsx +++ b/src-migrate/modules/cart/components/CartSummaryMobile.tsx @@ -29,7 +29,7 @@ const CartSummaryMobile = ({ isLoaded = false, }: Props) => { const [showPopup, setShowPopup] = useState(false); - const PPN : number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; + const PPN: number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; return ( <> <BottomPopup @@ -88,22 +88,30 @@ const CartSummaryMobile = ({ </div> </BottomPopup> <div className='flex flex-col gap-y-3'> - <Skeleton isLoaded={isLoaded} className={style.line}> - <span className={clsxm(style.label, style.grandTotal)}> + <Skeleton + isLoaded={isLoaded} + className={clsxm(style.line, 'flex items-center justify-between !py-2')} + > + {/* Left: label */} + <span className={clsxm(style.label, style.grandTotal, 'leading-tight')}> Grand Total </span> - <button - onClick={() => setShowPopup(true)} - className='bg-gray-300 w-6 h-6 items-center justify-center cursor-pointer hover:bg-red-400 md:hidden ' - > - <ChevronDownIcon className='h-6 w-6 text-white' /> - </button> - </Skeleton> - <Skeleton isLoaded={isLoaded} className={style.line}> - <span className={style.value}> - Rp {formatCurrency(grandTotal || 0)} - </span> + + {/* Right: amount + chevron */} + <div className="flex items-center gap-2"> + <span className={clsxm(style.value, 'whitespace-nowrap tabular-nums leading-tight')}> + Rp {formatCurrency(grandTotal || 0)} + </span> + <button + onClick={() => setShowPopup(true)} + aria-label="Expand ringkasan" + className="md:hidden flex w-5 h-5 items-center justify-center rounded bg-gray-300 hover:bg-gray-400" + > + <ChevronDownIcon className="h-4 w-4 text-white" /> + </button> + </div> </Skeleton> + </div> </> ); diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx index 95bc1d88..147fd6d2 100644 --- a/src-migrate/modules/product-detail/components/AddToCart.tsx +++ b/src-migrate/modules/product-detail/components/AddToCart.tsx @@ -1,6 +1,6 @@ import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; import style from '../styles/price-action.module.css'; -import { Button, Link, useToast } from '@chakra-ui/react'; +import { Button, color, Link, useToast } from '@chakra-ui/react'; import product from 'next-seo/lib/jsonld/product'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; @@ -17,6 +17,10 @@ import formatCurrency from '~/libs/formatCurrency'; import { useProductDetail } from '../stores/useProductDetail'; import { gtagAddToCart } from '@/core/utils/googleTag'; import axios from 'axios'; +import useDevice from '@/core/hooks/useDevice'; +import MobileView from '@/core/components/views/MobileView'; +import DesktopView from '@/core/components/views/DesktopView'; +import ProductPromoSection from '~/modules/product-promo/components/Section'; type Props = { variantId: number | null; quantity?: number; @@ -39,6 +43,8 @@ const AddToCart = ({ isClosable: true, }); + const { isMobile, isDesktop } = useDevice(); + const { askAdminUrl } = useProductDetail(); const [product, setProducts] = useState(products); @@ -158,111 +164,266 @@ const AddToCart = ({ const btnConfig = { add_to_cart: { - colorScheme: 'yellow', + colorScheme: 'red', + variant: 'outline', text: 'Keranjang', }, buy: { colorScheme: 'red', - text: 'Beli', + variant: 'solid', + text: isDesktop ? 'Beli' : 'Beli Sekarang', }, }; return ( <div className='w-full'> - <Button - onClick={handleButton} - colorScheme={btnConfig[source].colorScheme} - className='w-full' - > - {btnConfig[source].text} - </Button> + <MobileView> + <Button + onClick={handleButton} + colorScheme={btnConfig[source].colorScheme} + variant={btnConfig[source].variant} + className='w-full' + > + {btnConfig[source].text} + </Button> + </MobileView> + + <DesktopView> + <Button + onClick={handleButton} + colorScheme={btnConfig[source].colorScheme} + variant={btnConfig[source].variant} + className='w-full' + > + {btnConfig[source].text} + </Button> + </DesktopView> + <BottomPopup className='!container' title='Berhasil Ditambahkan' active={addCartAlert} - close={() => { - setAddCartAlert(false); - }} + close={() => setAddCartAlert(false)} > - <div className='flex mt-4'> - <div className='w-[10%]'> - <ImageNext - src={product.image} - alt={product.name} - className='h-32 object-contain object-center w-full border border-gray_r-4' - width={80} - height={80} - /> - </div> - <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> - {!!product.manufacture.name ? ( - <Link - href={createSlug( - '/shop/brands/', - product.manufacture.name, - product.manufacture.id.toString() + {/* ===== MOBILE LAYOUT: konten scroll + footer fixed di dalam popup ===== */} + <div className='md:hidden flex flex-col max-h-[75vh]'> + {/* area scroll */} + <div className='flex-1 overflow-y-auto' style={{ scrollbarWidth: 'none' }}> + {/* HEADER ITEM */} + <div className='flex mt-4'> + <div className='w-[25%]'> + <ImageNext + src={product.image} + alt={product.name} + className='h-32 object-contain object-center w-full border border-gray_r-4' + width={80} + height={80} + /> + </div> + + <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> + {!!product.manufacture.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product.manufacture.name, + product.manufacture.id.toString() + )} + className=' hover:underline' + color={'red'} + > + {product.manufacture.name} + </Link> + ) : ( + '-' )} - className=' hover:underline' - color={'red'} - > - {product.manufacture.name} - </Link> - ) : ( - '-' - )} - <p className='text-ellipsis overflow-hidden'>{product.name}</p> - <p>{product.code}</p> - {!!product.lowest_price && product.lowest_price.price > 0 && ( - <> - <div className='flex items-end gap-x-2'> - {product.lowest_price.discount_percentage > 0 && ( - <> - <div className='badge-solid-red'> - {Math.floor(product.lowest_price.discount_percentage)}% - </div> - <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> - Rp {formatCurrency(product.lowest_price.price || 0)} + <p className='text-ellipsis overflow-hidden'>{product.name}</p> + <p>{product.code}</p> + + {!!product.lowest_price && product.lowest_price.price > 0 && ( + <> + <div className='flex items-end gap-x-2'> + {product.lowest_price.discount_percentage > 0 && ( + <> + <div className='badge-solid-red'> + {Math.floor( + product.lowest_price.discount_percentage + )} + % + </div> + <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + Rp {formatCurrency(product.lowest_price.price || 0)} + </div> + </> + )} + <div className='text-danger-500 font-semibold'> + Rp{' '} + {formatCurrency( + product.lowest_price.price_discount || 0 + )} </div> - </> - )} - <div className='text-danger-500 font-semibold'> - Rp{' '} - {formatCurrency(product.lowest_price.price_discount || 0)} - </div> - </div> - </> - )} + </div> + </> + )} - {!!product.lowest_price && product.lowest_price.price === 0 && ( - <span> - Hubungi kami untuk dapatkan harga terbaik,{' '} + {!!product.lowest_price && product.lowest_price.price === 0 && ( + <span> + Hubungi kami untuk dapatkan harga terbaik,{' '} + <Link + href={askAdminUrl} + target='_blank' + className='font-medium underline' + color={'red'} + > + klik disini + </Link> + </span> + )} + </div> + + {/* sembunyikan link header di mobile agar tidak dobel */} + <div className='ml-3 items-center font-normal hidden md:flex'> <Link - href={askAdminUrl} - target='_blank' - className='font-medium underline' - color={'red'} + href='/shop/cart' + className='flex-1 py-2 text-gray_r-12 btn-yellow' > - klik disini + Lihat Keranjang </Link> - </span> - )} + </div> + </div> + + {/* PROMO KHUSUS MOBILE */} + <div className='mt-6'> + <ProductPromoSection + product={product} + productId={Number(activeVariant?.id) || Number(variantId) || 0} + /> + </div> + + {/* PRODUCT SIMILAR */} + <div className='mt-8 mb-4'> + <div className='text-h-sm font-semibold mb-6'> + Kamu Mungkin Juga Suka + </div> + <DesktopView> + <LazyLoad> + <ProductSimilar query={productSimilarQuery} /> + </LazyLoad> + </DesktopView> + <MobileView> + <ProductSimilar query={productSimilarQuery} /> + </MobileView> + </div> </div> - <div className='ml-3 flex items-center font-normal'> + + {/* footer tombol: selalu terlihat di bawah popup mobile */} + <div className='border-t border-gray-200 bg-white px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+12px)]'> <Link href='/shop/cart' - className='flex-1 py-2 text-gray_r-12 btn-yellow' + className='w-full mb-2 block text-center btn-yellow py-3 rounded-xl font-semibold' > Lihat Keranjang </Link> </div> </div> - <div className='mt-8 mb-4'> - <div className='text-h-sm font-semibold mb-6'> - Kamu Mungkin Juga Suka + + {/* ===== DESKTOP LAYOUT: tetap seperti semula ===== */} + <div className='hidden md:block'> + {/* HEADER ITEM */} + <div className='flex mt-4'> + <div className='w-[10%]'> + <ImageNext + src={product.image} + alt={product.name} + className='h-32 object-contain object-center w-full border border-gray_r-4' + width={80} + height={80} + /> + </div> + + <div className='ml-3 flex flex-1 items-start font-medium justify-center flex-col gap-y-1'> + {!!product.manufacture.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product.manufacture.name, + product.manufacture.id.toString() + )} + className=' hover:underline' + color={'red'} + > + {product.manufacture.name} + </Link> + ) : ( + '-' + )} + <p className='text-ellipsis overflow-hidden'>{product.name}</p> + <p>{product.code}</p> + + {!!product.lowest_price && product.lowest_price.price > 0 && ( + <> + <div className='flex items-end gap-x-2'> + {product.lowest_price.discount_percentage > 0 && ( + <> + <div className='badge-solid-red'> + {Math.floor(product.lowest_price.discount_percentage)} + % + </div> + <div className='text-gray_r-11 line-through text-[11px] sm:text-caption-2'> + Rp {formatCurrency(product.lowest_price.price || 0)} + </div> + </> + )} + <div className='text-danger-500 font-semibold'> + Rp{' '} + {formatCurrency(product.lowest_price.price_discount || 0)} + </div> + </div> + </> + )} + + {!!product.lowest_price && product.lowest_price.price === 0 && ( + <span> + Hubungi kami untuk dapatkan harga terbaik,{' '} + <Link + href={askAdminUrl} + target='_blank' + className='font-medium underline' + color={'red'} + > + klik disini + </Link> + </span> + )} + </div> + + <div className='ml-3 flex items-center font-normal'> + <Link + href='/shop/cart' + className='flex-1 py-2 text-gray_r-12 btn-yellow' + > + Lihat Keranjang + </Link> + </div> + </div> + + {/* PROMO (desktop biarkan sama posisinya) */} + <div className='mt-6'> + <ProductPromoSection + product={product} + productId={Number(activeVariant?.id) || Number(variantId) || 0} + /> + </div> + + {/* PRODUCT SIMILAR */} + <div className='mt-8 mb-4'> + <div className='text-h-sm font-semibold mb-6'> + Kamu Mungkin Juga Suka + </div> + <LazyLoad> + <ProductSimilar query={productSimilarQuery} /> + </LazyLoad> </div> - <LazyLoad> - <ProductSimilar query={productSimilarQuery} /> - </LazyLoad> </div> </BottomPopup> </div> diff --git a/src-migrate/modules/product-detail/components/AddToQuotation.tsx b/src-migrate/modules/product-detail/components/AddToQuotation.tsx index f9b6c2b3..ebfcef32 100644 --- a/src-migrate/modules/product-detail/components/AddToQuotation.tsx +++ b/src-migrate/modules/product-detail/components/AddToQuotation.tsx @@ -16,6 +16,7 @@ import { useProductCartContext } from '@/contexts/ProductCartContext'; import { createSlug } from '~/libs/slug'; import formatCurrency from '~/libs/formatCurrency'; import { useProductDetail } from '../stores/useProductDetail'; +import useDevice from '@/core/hooks/useDevice'; type Props = { variantId: number | null; @@ -40,6 +41,7 @@ const AddToQuotation = ({ }); const { askAdminUrl } = useProductDetail(); + const { isMobile, isDesktop } = useDevice(); const [product, setProducts] = useState(products); const [status, setStatus] = useState<Status>('idle'); @@ -104,12 +106,13 @@ const AddToQuotation = ({ const btnConfig = { add_to_cart: { - colorScheme: 'yellow', - + colorScheme: 'red', + variant: 'outline', text: 'Keranjang', }, buy: { colorScheme: 'red', + variant: 'solid', text: 'Beli', }, }; @@ -123,13 +126,13 @@ const AddToQuotation = ({ className='w-full border-2 p-2 gap-1 hover:bg-slate-100 flex items-center' > <ImageNext - src='/images/writing.png' + src= {isDesktop ? '/images/doc_red.svg' : '/images/doc.svg'} alt='penawaran instan' className='' width={25} height={25} /> - Penawaran Harga Instan + {isDesktop ? 'Penawaran Harga Instan' : ''} </Button> <BottomPopup className='!container' diff --git a/src-migrate/modules/product-detail/components/Breadcrumb.tsx b/src-migrate/modules/product-detail/components/Breadcrumb.tsx index f41859a9..67aa12ab 100644 --- a/src-migrate/modules/product-detail/components/Breadcrumb.tsx +++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx @@ -1,31 +1,133 @@ -import React, { Fragment } from 'react' -import { useQuery } from 'react-query' -import { getProductCategoryBreadcrumb } from '~/services/product' -import Link from 'next/link' -import { createSlug } from '~/libs/slug' +import React, { Fragment } from 'react'; +import { useQuery } from 'react-query'; +import Link from 'next/link'; +import { getProductCategoryBreadcrumb } from '~/services/product'; +import { createSlug } from '~/libs/slug'; +import useDevice from '@/core/hooks/useDevice'; -type Props = { - id: number, - name: string -} +type Props = { id: number; name: string }; const Breadcrumb = ({ id, name }: Props) => { - const query = useQuery({ - queryKey: ['product-category-breadcrumb'], + const { isDesktop, isMobile } = useDevice(); + + const { data } = useQuery({ + queryKey: ['product-category-breadcrumb', id], queryFn: () => getProductCategoryBreadcrumb(id), - refetchOnWindowFocus: false - }) + refetchOnWindowFocus: false, + }); + + const breadcrumbs = data ?? []; + const total = breadcrumbs.length; + const lastCat = total ? breadcrumbs[total - 1] : null; + const prevCat = total > 1 ? breadcrumbs[total - 2] : null; + const hasHidden = total > 1; + const hiddenText = hasHidden + ? breadcrumbs + .slice(0, total - 1) + .map((c) => c.name) + .join(' / ') + : ''; + + if (isMobile) { + const crumbsMobile: React.ReactNode[] = []; + + crumbsMobile.push( + <Link href='/' className='text-danger-500 shrink-0' key='home'> + Home + </Link> + ); + + if (hasHidden && prevCat) { + // Jadikan ".." sebuah tautan ke kategori sebelumnya + crumbsMobile.push( + <Link + key='hidden' + href={createSlug('/shop/category/', prevCat.name, String(prevCat.id))} + className='text-danger-500 shrink-0' + title={hiddenText} + aria-label={`Kembali ke ${prevCat.name}`} + > + .. + </Link> + ); + } + + // Kategori terakhir + if (lastCat) { + crumbsMobile.push( + <Link + key={`cat-${lastCat.id}`} + href={createSlug('/shop/category/', lastCat.name, String(lastCat.id))} + className='text-danger-500 shrink-0' + > + {lastCat.name} + </Link> + ); + } + + // Nama produk (dipotong kalau gk muat) + crumbsMobile.push( + <span className='truncate min-w-0 flex-1' title={name} key='product'> + {name} + </span> + ); + + return ( + <div className='flex items-center whitespace-nowrap overflow-hidden text-caption-1 leading-7'> + {crumbsMobile.map((node, i) => ( + <Fragment key={i}> + {node} + {i < crumbsMobile.length - 1 && ( + <span className='mx-2 shrink-0'>/</span> + )} + </Fragment> + ))} + </div> + ); + } - const breadcrumbs = query.data || [] + // ===== DESKTOP ===== + if (isDesktop) { + return ( + <div className='line-clamp-2 md:line-clamp-1 leading-7 text-caption-1'> + <Link href='/' className='text-danger-500'> + Home + </Link> + <span className='mx-2'>/</span> + {breadcrumbs.map((category, index) => ( + <Fragment key={index}> + <Link + href={createSlug( + '/shop/category/', + category.name, + category.id.toString() + )} + className='text-danger-500' + > + {category.name} + </Link> + <span className='mx-2'>/</span> + </Fragment> + ))} + <span>{name}</span> + </div> + ); + } return ( <div className='line-clamp-2 md:line-clamp-1 leading-7 text-caption-1'> - <Link href='/' className='text-danger-500'>Home</Link> + <Link href='/' className='text-danger-500'> + Home + </Link> <span className='mx-2'>/</span> {breadcrumbs.map((category, index) => ( <Fragment key={index}> <Link - href={createSlug('/shop/category/', category.name, category.id.toString())} + href={createSlug( + '/shop/category/', + category.name, + category.id.toString() + )} className='text-danger-500' > {category.name} @@ -35,7 +137,7 @@ const Breadcrumb = ({ id, name }: Props) => { ))} <span>{name}</span> </div> - ) -} + ); +}; -export default Breadcrumb
\ No newline at end of file +export default Breadcrumb; diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx index 850c2d9d..ffc9ba40 100644 --- a/src-migrate/modules/product-detail/components/PriceAction.tsx +++ b/src-migrate/modules/product-detail/components/PriceAction.tsx @@ -12,12 +12,16 @@ import { getAuth } from '~/libs/auth'; import useDevice from '@/core/hooks/useDevice'; import odooApi from '~/libs/odooApi'; import { Button, Skeleton } from '@chakra-ui/react'; +import DesktopView from '@/core/components/views/DesktopView'; +import MobileView from '@/core/components/views/MobileView'; type Props = { product: IProductDetail; }; -const PPN : number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; +const PPN: number = process.env.NEXT_PUBLIC_PPN + ? parseFloat(process.env.NEXT_PUBLIC_PPN) + : 0; const PriceAction = ({ product }: Props) => { const { activePrice, @@ -84,26 +88,59 @@ const PriceAction = ({ product }: Props) => { > {!!activePrice && activePrice.price > 0 && ( <> - <div className='flex items-end gap-x-2'> - {activePrice.discount_percentage > 0 && ( - <> - <div className={style['disc-badge']}> - {Math.floor(activePrice.discount_percentage)}% - </div> - <div className={style['disc-price']}> + <DesktopView> + <div className='flex items-end gap-x-2'> + {activePrice.discount_percentage > 0 && ( + <> + <div className={style['disc-badge']}> + {Math.floor(activePrice.discount_percentage)}% + </div> + <div className={style['disc-price']}> + Rp {formatCurrency(activePrice.price || 0)} + </div> + </> + )} + <div className={style['main-price']}> + Rp {formatCurrency(activePrice.price_discount || 0)} + </div> + </div> + <div className='h-1' /> + <div className={style['secondary-text']}> + Termasuk PPN: Rp{' '} + {formatCurrency(Math.round(activePrice.price_discount * PPN))} + </div> + </DesktopView> + <MobileView> + <div className='flex items-end gap-x-2'> + {activePrice.discount_percentage > 0 ? ( + <> + <div className={style['disc-badge']}> + {Math.floor(activePrice.discount_percentage)}% + </div> + + {/* harga setelah diskon (main-price) di kiri */} + <div className={style['main-price']}> + Rp {formatCurrency(activePrice.price_discount || 0)} + </div> + + {/* harga coret di kanan */} + <div className={style['disc-price']}> + Rp {formatCurrency(activePrice.price || 0)} + </div> + </> + ) : ( + // kalau tidak ada diskon, tampilkan harga normal saja + <div className={style['main-price']}> Rp {formatCurrency(activePrice.price || 0)} </div> - </> - )} - <div className={style['main-price']}> - Rp {formatCurrency(activePrice.price_discount || 0)} + )} </div> - </div> - <div className='h-1' /> - <div className={style['secondary-text']}> - Termasuk PPN: Rp{' '} - {formatCurrency(Math.round(activePrice.price_discount * PPN))} - </div> + + <div className='text-md text-gray-500 shadow-0'> + Termasuk PPN: Rp{' '} + {formatCurrency(Math.round(activePrice.price_discount * PPN))} + </div> + </MobileView> </> )} @@ -120,92 +157,209 @@ const PriceAction = ({ product }: Props) => { </span> )} - <div className='h-4' /> - <div className='flex gap-x-5 items-center'> - <div className='relative flex items-center'> - <button - type='button' - className='absolute left-0 px-2 py-1 h-full text-gray-500' - onClick={() => - setQuantityInput(String(Math.max(1, Number(quantityInput) - 1))) - } - > - - - </button> - <input - type='number' - id='quantity' - min={1} - value={quantityInput} - onChange={(e) => setQuantityInput(e.target.value)} - className={style['quantity-input']} - /> - <button - type='button' - className='absolute right-0 px-2 py-1 h-full text-gray-500' - onClick={() => setQuantityInput(String(Number(quantityInput) + 1))} - > - + - </button> - </div> + <DesktopView> + <div className='h-4' /> + <div className='flex gap-x-5 items-center'> + {/* Qty */} + <div className='relative flex items-center'> + <button + type='button' + className='absolute left-0 px-2 py-1 h-full text-gray-500' + onClick={() => + setQuantityInput(String(Math.max(1, Number(quantityInput) - 1))) + } + > + - + </button> + <input + type='number' + id='quantity' + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className={style['quantity-input']} + /> + <button + type='button' + className='absolute right-0 px-2 py-1 h-full text-gray-500' + onClick={() => + setQuantityInput(String(Number(quantityInput) + 1)) + } + > + + + </button> + </div> - <div> - <Skeleton - isLoaded={sla} - h='21px' - // w={16} - className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} - > - Stock : {sla?.qty}{' '} - </Skeleton> - {/* <span className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''}> - {' '} - </span> */} - </div> - <div> - {qtyPickUp > 0 && ( - <Link href='/panduan-pick-up-service' className='group'> - <Image - src='/images/PICKUP-NOW.png' - className='group-hover:scale-105 transition-transform duration-200' - alt='pickup now' - width={100} - height={12} - /> - </Link> - )} + {/* Stok */} + <div> + <Skeleton + isLoaded={sla} + h='21px' + className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} + > + Stock : {sla?.qty}{' '} + </Skeleton> + </div> + + {/* Pickup badge */} + <div> + {qtyPickUp > 0 && ( + <div className='flex items-center gap-2'> + <Link href='/panduan-pick-up-service' className='group'> + <Image + src='/images/PICKUP-NOW.png' + className='group-hover:scale-105 transition-transform duration-200' + alt='pickup now' + width={100} + height={12} + /> + </Link> + </div> + )} + </div> </div> - </div> - {qtyPickUp > 0 && ( - <div className='text-[12px] mt-1 text-red-500 italic'> + <span className='text-[12px] text-red-500 italic'> * {qtyPickUp} barang bisa di pickup + </span> + </DesktopView> + + {/* ===== MOBILE: grid kiri-kanan, kanan hanya qty ===== */} + <MobileView> + <div className='grid grid-cols-12 items-start gap-3'> + {/* Kiri */} + <div className='col-span-8 mt-2'> + <div className='flex items-center gap-1'> + <Skeleton + isLoaded={sla} + h='21px' + w='auto' // ⬅️ penting: biar selebar konten + display='inline-block' // ⬅️ penting: jangan full width + className={sla?.qty < 10 ? 'text-red-600 font-medium' : ''} + > + Stock : {sla?.qty} + </Skeleton> + + {qtyPickUp > 0 && ( + <Link + href='/panduan-pick-up-service' + className='inline-block shrink-0' + > + <Image + src='/images/PICKUP-NOW.png' + className='align-middle' + alt='pickup now' + width={90} + height={12} + /> + </Link> + )} + </div> + + {qtyPickUp > 0 && ( + <div className='text-[12px] mt-1 text-red-500 italic'> + * {qtyPickUp} barang bisa di pickup + </div> + )} + </div> + + {/* Kanan: hanya qty, rata kanan */} + <div className='col-span-4 flex justify-end'> + <div className='inline-flex items-stretch border rounded-xl overflow-hidden'> + <button + type='button' + className='h-11 w-11 md:h-12 md:w-12 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95 select-none touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500' + onClick={() => + setQuantityInput( + String(Math.max(1, Number(quantityInput) - 1)) + ) + } + aria-label='Kurangi' + > + <span className='text-2xl leading-none'>–</span> + </button> + + <input + type='number' + id='quantity' + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className='h-11 md:h-12 w-16 md:w-20 text-center text-lg md:text-xl outline-none border-x + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none' + /> + + <button + type='button' + className='h-11 w-11 md:h-12 md:w-12 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95 select-none touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500' + onClick={() => + setQuantityInput(String(Number(quantityInput) + 1)) + } + aria-label='Tambah' + > + <span className='text-2xl leading-none'>+</span> + </button> + </div> + </div> </div> - )} + </MobileView> <div className='h-4' /> - <div className={`${style['action-wrapper']}`}> - <AddToCart - products={product} - variantId={activeVariantId} - quantity={Number(quantityInput)} - /> - {!isApproval && ( + <DesktopView> + <div className={`${style['action-wrapper']}`}> <AddToCart + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + {!isApproval && ( + <AddToCart + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + )} + </div> + <div className='mt-4'> + <AddToQuotation source='buy' products={product} variantId={activeVariantId} quantity={Number(quantityInput)} /> - )} - </div> - <div className='mt-4'> - <AddToQuotation - source='buy' - products={product} - variantId={activeVariantId} - quantity={Number(quantityInput)} - /> - </div> + </div> + </DesktopView> + <MobileView> + <div className='grid grid-cols-12 gap-2'> + <div className='col-span-2'> + <AddToQuotation + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + </div> + <div className='col-span-5'> + <AddToCart + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + </div> + <div className='col-span-5'> + {!isApproval && ( + <AddToCart + source='buy' + products={product} + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + )} + </div> + </div> + </MobileView> </div> ); }; diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 192e1dc3..f32bb38e 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -2,12 +2,11 @@ import style from '../styles/product-detail.module.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState, UIEvent } from 'react'; import { Button } from '@chakra-ui/react'; import { MessageCircleIcon, Share2Icon } from 'lucide-react'; import { LazyLoadComponent } from 'react-lazy-load-image-component'; -import { RWebShare } from 'react-web-share'; import useDevice from '@/core/hooks/useDevice'; import { getAuth } from '~/libs/auth'; @@ -22,6 +21,8 @@ import Information from './Information'; import PriceAction from './PriceAction'; import SimilarBottom from './SimilarBottom'; import SimilarSide from './SimilarSide'; +import dynamic from 'next/dynamic'; + import { gtagProductDetail } from '@/core/utils/googleTag'; @@ -29,12 +30,25 @@ type Props = { product: IProductDetail; }; +const RWebShare = dynamic( + () => import('react-web-share').then(m => m.RWebShare), + { ssr: false } +); + const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST; const ProductDetail = ({ product }: Props) => { const { isDesktop, isMobile } = useDevice(); const router = useRouter(); - const auth = getAuth(); + const [auth, setAuth] = useState<any>(null); + useEffect(() => { + try { setAuth(getAuth() ?? null); } catch { } + }, []); + + const canShare = + typeof navigator !== 'undefined' && + typeof (navigator as any).share === 'function'; + const { setAskAdminUrl, askAdminUrl, @@ -71,70 +85,173 @@ const ProductDetail = ({ product }: Props) => { product?.variants?.find((variant) => variant.is_in_bu) || product?.variants?.[0]; setSelectedVariant(selectedVariant); - // setSelectedVariant(product?.variants[0]) }, []); - // Gabungkan semua gambar produk (utama + tambahan) - const allImages = product.image_carousel ? [...product.image_carousel] : []; - if (product.image) { - allImages.unshift(product.image); // Tambahkan gambar utama di awal array - } - console.log(product); + const allImages = (() => { + const arr: string[] = []; + if (product?.image) arr.push(product.image); + if ( + Array.isArray(product?.image_carousel) && + product.image_carousel.length + ) { + + const set = new Set(arr); + for (const img of product.image_carousel) { + if (!set.has(img)) { + arr.push(img); + set.add(img); + } + } + } + return arr; + })(); const [mainImage, setMainImage] = useState(allImages[0] || ''); + useEffect(() => { + + if (!allImages.includes(mainImage)) { + setMainImage(allImages[0] || ''); + } + }, [allImages]); + + + const sliderRef = useRef<HTMLDivElement | null>(null); + const [currentIdx, setCurrentIdx] = useState(0); + + const handleMobileScroll = (e: UIEvent<HTMLDivElement>) => { + const el = e.currentTarget; + if (!el) return; + const idx = Math.round(el.scrollLeft / el.clientWidth); + if (idx !== currentIdx) { + setCurrentIdx(idx); + setMainImage(allImages[idx] || ''); + } + }; + + const scrollToIndex = (i: number) => { + const el = sliderRef.current; + if (!el) return; + el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' }); + setCurrentIdx(i); + setMainImage(allImages[i] || ''); + }; + + return ( <> <div className='md:flex md:flex-wrap'> <div className='w-full mb-4 md:mb-0 px-4 md:px-0'> <Breadcrumb id={product.id} name={product.name} /> </div> + <div className='md:w-9/12 md:flex md:flex-col md:pr-4 md:pt-6'> <div className='md:flex md:flex-wrap'> + {/* ===== Kolom kiri: gambar ===== */} <div className='md:w-4/12'> - <ProductImage product={{ ...product, image: mainImage }} /> - - {/* Carousel horizontal */} - {allImages.length > 0 && ( - <div className='mt-4 overflow-x-auto'> - <div className='flex space-x-3 pb-3'> - {allImages.map((img, index) => ( - <div - key={index} - className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${ - mainImage === img - ? 'border-red-500 ring-2 ring-red-200' - : 'border-gray-200 hover:border-gray-300' - }`} - onClick={() => setMainImage(img)} - > + {/* === MOBILE: Slider swipeable, tanpa thumbnail carousel === */} + {isMobile ? ( + <div className='relative'> + <div + ref={sliderRef} + onScroll={handleMobileScroll} + className='flex overflow-x-auto snap-x snap-mandatory scroll-smooth no-scrollbar' + style={{ + scrollBehavior: 'smooth', + msOverflowStyle: 'none', + scrollbarWidth: 'none', + }} + > + {allImages.length > 0 ? ( + allImages.map((img, i) => ( + + <div + key={i} + className='w-full flex-shrink-0 snap-center flex justify-center items-center' + > + {/* gambar diperkecil */} + <img + src={img} + alt={`Gambar ${i + 1}`} + className='w-[85%] aspect-square object-contain' + onError={(e) => { + (e.target as HTMLImageElement).src = + '/images/noimage.jpeg'; + }} + /> + </div> + )) + ) : ( + <div className='w-full flex-shrink-0 snap-center flex justify-center items-center'> <img - src={img} - alt={`Thumbnail ${index + 1}`} - className='w-full h-full object-cover rounded-sm' - loading='lazy' - onError={(e) => { - (e.target as HTMLImageElement).src = - '/path/to/fallback-image.jpg'; - }} + src={mainImage || '/images/noimage.jpeg'} + alt='Gambar produk' + className='w-[85%] aspect-square object-contain' /> </div> - ))} + )} </div> + + {/* Dots indicator */} + {allImages.length > 1 && ( + <div className='absolute bottom-2 left-0 right-0 flex justify-center gap-2'> + {allImages.map((_, i) => ( + <button + key={i} + aria-label={`Ke slide ${i + 1}`} + className={`w-2 h-2 rounded-full ${currentIdx === i ? 'bg-gray-800' : 'bg-gray-300' + }`} + onClick={() => scrollToIndex(i)} + /> + ))} + </div> + )} </div> + ) : ( + <> + {/* === DESKTOP: Tetap seperti sebelumnya === */} + <ProductImage product={{ ...product, image: mainImage }} /> + + {/* Carousel horizontal (thumbnail) – hanya desktop */} + {allImages.length > 0 && ( + <div className='mt-4 overflow-x-auto'> + <div className='flex space-x-3 pb-3'> + {allImages.map((img, index) => ( + <div + key={index} + className={`flex-shrink-0 w-16 h-16 cursor-pointer border-2 rounded-md transition-colors ${mainImage === img + ? 'border-red-500 ring-2 ring-red-200' + : 'border-gray-200 hover:border-gray-300' + }`} + onClick={() => setMainImage(img)} + > + <img + src={img} + alt={`Thumbnail ${index + 1}`} + className='w-full h-full object-cover rounded-sm' + loading='lazy' + onError={(e) => { + (e.target as HTMLImageElement).src = + '/images/noimage.jpeg'; + }} + /> + </div> + ))} + </div> + </div> + )} + </> )} </div> + {/* <<=== TUTUP kolom kiri */} + {/* ===== Kolom kanan: info ===== */} <div className='md:w-8/12 px-4 md:pl-6'> <div className='h-6 md:h-0' /> - <h1 className={style['title']}>{product.name}</h1> - <div className='h-3 md:h-0' /> - <Information product={product} /> - <div className='h-6' /> </div> </div> @@ -154,14 +271,6 @@ const ProductDetail = ({ product }: Props) => { /> )} - {/* <div className={style['section-card']}> - <h2 className={style['heading']}> - Variant ({product.variant_total}) - </h2> - <div className='h-4' /> - <VariantList variants={product.variants} /> - </div> */} - <div className='h-0 md:h-6' /> <div className={style['section-card']}> @@ -172,8 +281,7 @@ const ProductDetail = ({ product }: Props) => { className={style['description']} dangerouslySetInnerHTML={{ __html: - !product.description || - product.description == '<p><br></p>' + !product.description || product.description == '<p><br></p>' ? 'Belum ada deskripsi' : product.description, }} @@ -204,21 +312,19 @@ const ProductDetail = ({ product }: Props) => { <span>|</span> - <RWebShare - data={{ - text: 'Check out this product', - title: `${product.name} - Indoteknik.com`, - url: SELF_HOST + router.asPath, - }} - > - <Button - variant='link' - colorScheme='gray' - leftIcon={<Share2Icon size={18} />} + {canShare && ( + <RWebShare + data={{ + text: 'Check out this product', + title: `${product.name} - Indoteknik.com`, + url: (process.env.NEXT_PUBLIC_SELF_HOST || '') + (router?.asPath || '/'), + }} > - Share - </Button> - </RWebShare> + <Button variant='link' colorScheme='gray' leftIcon={<Share2Icon size={18} />}> + Share + </Button> + </RWebShare> + )} </div> <div className='h-6' /> @@ -244,6 +350,8 @@ const ProductDetail = ({ product }: Props) => { </div> </> ); + + }; export default ProductDetail; diff --git a/src-migrate/pages/shop/cart/cart.module.css b/src-migrate/pages/shop/cart/cart.module.css index b756fb15..af5a2abc 100644 --- a/src-migrate/pages/shop/cart/cart.module.css +++ b/src-migrate/pages/shop/cart/cart.module.css @@ -3,7 +3,7 @@ } .content { - @apply flex flex-wrap ; + @apply flex flex-wrap; } .item-wrapper { @@ -33,3 +33,15 @@ .summary-buttons-step-approval { @apply grid grid-cols-1 gap-y-3 mt-6; } + +@media (max-width: 768px) { + .item-wrapper { + /* adjust if your bar is taller/shorter */ + padding-bottom: calc(env(safe-area-inset-bottom) + 9rem); + } + + .summary-wrapper { + @apply fixed inset-x-0 bottom-0 z-50 md:sticky w-full; + } + +}
\ No newline at end of file diff --git a/src-migrate/pages/shop/cart/index.tsx b/src-migrate/pages/shop/cart/index.tsx index 795dfa72..031aa45b 100644 --- a/src-migrate/pages/shop/cart/index.tsx +++ b/src-migrate/pages/shop/cart/index.tsx @@ -301,9 +301,8 @@ const CartPage: React.FC = () => { <> {/* Sticky Header */} <div - className={`${ - isTop ? 'border-b-[0px]' : 'border-b-[1px]' - } sticky md:top-[157px] flex-col bg-white py-4 border-gray-300 z-50 sm:w-full md:w-3/4`} + className={`${isTop ? 'border-b-[0px]' : 'border-b-[1px]' + } sticky md:top-[157px] flex-col bg-white py-4 border-gray-300 z-50 sm:w-full md:w-3/4`} > <div className='flex items-center justify-between mb-2'> <h1 className={style.title}>Keranjang Belanja</h1> @@ -392,13 +391,17 @@ const CartPage: React.FC = () => { </div> </div> + <div + className="md:hidden" + style={{ height: 'calc(var(--mobile-actions-h) + var(--mobile-total-h))' }} + /> + {/* Cart Summary */} <div - className={`${style['summary-wrapper']} ${ - device.isMobile && (!cart || cart?.product_total === 0) + className={`${style['summary-wrapper']} ${device.isMobile && (!cart || cart?.product_total === 0) ? 'hidden' : '' - }`} + }`} > <div className={style.summary}> {device.isMobile ? ( diff --git a/src-migrate/pages/shop/product/[slug].tsx b/src-migrate/pages/shop/product/[slug].tsx index fc72a6b0..90658544 100644 --- a/src-migrate/pages/shop/product/[slug].tsx +++ b/src-migrate/pages/shop/product/[slug].tsx @@ -18,34 +18,41 @@ type PageProps = { product: IProductDetail } -export const getServerSideProps: GetServerSideProps<PageProps> = (async (context) => { - const { slug } = context.query +export const getServerSideProps: GetServerSideProps<PageProps & { canonicalPath: string }> = async (context) => { + const { slug } = context.query; const cookieString = context.req.headers.cookie; const cookies = cookieString ? cookie.parse(cookieString) : {}; const auth = cookies?.auth ? JSON.parse(cookies.auth) : {}; - const tier = auth?.pricelist || '' + const tier = auth?.pricelist || ''; - const productId = getIdFromSlug(slug as string) + const productId = getIdFromSlug(slug as string); + const product = await getProductById(productId, tier); - const product = await getProductById(productId, tier) + // ❌ produk tidak ada → 404 + if (!product) return { notFound: true }; - if (!product) return { notFound: true } + // ❌ tidak ada variants atau tidak ada yang harga > 0 → 404 + const hasValidVariant = Array.isArray(product.variants) + && product.variants.some(v => (v?.price?.price ?? 0) > 0); + if (!hasValidVariant) return { notFound: true }; + + // Canonical path aman untuk SSR (hindari router.asPath di server) + const canonicalPath = context.resolvedUrl || `/product/${slug}`; + + return { props: { product, canonicalPath } }; +}; - return { - props: { product } - } -}) const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST -const ProductDetailPage: NextPage<PageProps> = ({ product }) => { +const ProductDetailPage: NextPage<PageProps & { canonicalPath: string }> = ({ product, canonicalPath }) => { const router = useRouter(); - const { setProduct } = useProductContext(); - useEffect(() => { - if (product) setProduct(product); - }, [product, setProduct]); + useEffect(() => { if (product) setProduct(product); }, [product, setProduct]); + + const origin = process.env.NEXT_PUBLIC_SELF_HOST || ''; + const url = origin + (canonicalPath?.startsWith('/') ? canonicalPath : `/${canonicalPath}`); return ( <BasicLayout> @@ -53,31 +60,20 @@ const ProductDetailPage: NextPage<PageProps> = ({ product }) => { title={`${product.name} - Indoteknik.com`} description='Temukan pilihan produk B2B Industri & Alat Teknik untuk Perusahaan, UMKM & Pemerintah dengan lengkap, mudah dan transparan.' openGraph={{ - url: SELF_HOST + router.asPath, - images: [ - { - url: product?.image, - width: 800, - height: 800, - alt: product?.name, - }, - ], + url, + images: [{ url: product?.image, width: 800, height: 800, alt: product?.name }], type: 'product', }} - additionalMetaTags={[ - { - name: 'keywords', - content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}`, - } - ]} - canonical={SELF_HOST + router.asPath} + additionalMetaTags={[{ name: 'keywords', content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}` }]} + canonical={url} /> <div className='md:container pt-4 md:pt-6'> <ProductDetail product={product} /> </div> </BasicLayout> - ) -} + ); +}; + export default ProductDetailPage
\ No newline at end of file diff --git a/src/lib/address/components/CreateAddress.jsx b/src/lib/address/components/CreateAddress.jsx index 963a19aa..2879fb3d 100644 --- a/src/lib/address/components/CreateAddress.jsx +++ b/src/lib/address/components/CreateAddress.jsx @@ -48,19 +48,16 @@ const CreateAddress = () => { pinedMaps, setPinedMaps } = useMaps(); - useEffect(() => { - if (detailAddress) { - setValue('zip', detailAddress.postalCode); - const selectedState = states.find( - (state) => - detailAddress?.province.includes(state.label) || - state.label.includes(detailAddress?.province) - ); - setValue('state', selectedState?.value); - setValue('street', detailAddress?.street); - - } - }, [detailAddress, setValue]); + + const resetPin = useMaps((state) => state.resetPin); + const [showValidationPopup, setShowValidationPopup] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [selectedCityName, setSelectedCityName] = useState(""); + const [normalizedDistrict, setNormalizedDistrict] = useState(""); + + useEffect(() => { + resetPin(); + }, [resetPin]); useEffect(() => { const loadState = async () => { @@ -75,6 +72,20 @@ const CreateAddress = () => { setAddressMaps(''); }, []); + useEffect(() => { + if (detailAddress) { + setValue('zip', detailAddress.postalCode); + const selectedState = states.find( + (state) => + (detailAddress?.province || "").includes(state.label) || + state.label.includes(detailAddress?.province) + ); + setValue('state', selectedState?.value); + setValue('street', detailAddress?.street); + + } + }, [detailAddress, setValue]); + const watchState = watch('state'); useEffect(() => { setValue('city', ''); @@ -91,19 +102,6 @@ const CreateAddress = () => { } }, [watchState, setValue]); - useEffect(() => { - if (detailAddress && Object.keys(detailAddress).length > 0) { - const selectedCities = cities.find( - (city) => - city.label.toLowerCase() === detailAddress?.district.toLowerCase() - ) || cities.find( - (city) => - detailAddress?.district.toLowerCase().includes(city.label.toLowerCase()) || - city.label.toLowerCase().includes(detailAddress?.district.toLowerCase()) - ); - setValue('city', selectedCities?.value); - } - }, [cities, detailAddress, setValue]); useEffect(() => { if (addresses) { @@ -123,6 +121,29 @@ const CreateAddress = () => { } }, [auth]); + const normalizeName = (name = "") => { + return name + .toLowerCase() + .replace(/\bkabupaten\b/gi, "") + .replace(/\bkota\b/gi, "") + .trim(); + }; + + useEffect(() => { + if (detailAddress?.district) { + const normalizedDistrict = normalizeName(detailAddress.district); + + const selectedCity = cities.find((city) => { + const normalizedCity = normalizeName(city.label); + return normalizedCity === normalizedDistrict; + }); + + if (selectedCity) { + setValue("city", selectedCity.value); + } + } + }, [cities, detailAddress, setValue]); + const watchCity = watch('city'); useEffect(() => { setValue('district', ''); @@ -188,7 +209,23 @@ const CreateAddress = () => { } }, [subDistricts, detailAddress, setValue]); + + + // console.log(selectedCityName, '=', normalizedDistrict); const onSubmitHandler = async (values) => { + if (detailAddress) { + const cityName = normalizeName( + cities.find((c) => c.value === watch("city"))?.label || "" + ); + const districtName = normalizeName(detailAddress?.district || ""); + console.log(cityName, '=', districtName); + + if (cityName && cityName !== districtName) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + return; + } + } const data = { ...values, state_id: values.state, @@ -219,6 +256,24 @@ const CreateAddress = () => { <PinPointMap /> </div> </BottomPopup> + <BottomPopup + active={showValidationPopup} + close={() => setShowValidationPopup(false)} + > + <div className="leading-7 text-gray_r-12/80 text-center"> + {popupMessage} + </div> + + <div className="flex justify-center mt-6"> + <button + className="btn-solid-red w-full md:w-auto" + type="button" + onClick={() => setShowValidationPopup(false)} + > + OK + </button> + </div> + </BottomPopup> <div className='max-w-none md:container mx-auto flex p-0 md:py-10'> <div className='hidden md:block w-3/12 pr-4'> <Menu /> diff --git a/src/lib/address/components/EditAddress.jsx b/src/lib/address/components/EditAddress.jsx index deaa8a3e..75f1a89a 100644 --- a/src/lib/address/components/EditAddress.jsx +++ b/src/lib/address/components/EditAddress.jsx @@ -1,6 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import * as Yup from 'yup'; import cityApi from '../api/cityApi'; import { Controller, useForm } from 'react-hook-form'; @@ -44,22 +44,92 @@ const EditAddress = ({ id, defaultValues }) => { const [districts, setDistricts] = useState([]); const [subDistricts, setSubDistricts] = useState([]); const [tempAddress, setTempAddress] = useState(getValues('addressMap')); - const { addressMaps, + const resetPin = useMaps((state) => state.resetPin); + const setSelectedPosition = useMaps((state) => state.setSelectedPosition); + const setAddressMaps = useMaps((state) => state.setAddressMaps); + const setDetailAddress = useMaps((state) => state.setDetailAddress); + const [showValidationPopup, setShowValidationPopup] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [selectedCityName, setSelectedCityName] = useState(""); + const [normalizedDistrict, setNormalizedDistrict] = useState(""); + + const { + addressMaps, selectedPosition, detailAddress, pinedMaps, - setPinedMaps } = useMaps(); + setPinedMaps, + getDefaultCenter, // penting untuk deteksi default center + } = useMaps(); + + const normalizeName = (name = "") => { + return name + .toLowerCase() + .replace(/\bkabupaten\b/gi, "") + .replace(/\bkota\b/gi, "") + .trim(); + }; - + // Helper: cek apakah benar2 sudah PIN (bukan default center & ada addressMaps) + const isPinned = useMemo(() => { + if (!selectedPosition) return false; + + // pastikan selalu cast ke number + const lat = Number(selectedPosition.lat); + const lng = Number(selectedPosition.lng); + + // kalau hasil cast bukan angka valid + if (isNaN(lat) || isNaN(lng)) return false; + + const dc = + typeof getDefaultCenter === "function" + ? getDefaultCenter() + : { lat: -6.2, lng: 106.816666 }; + + const nearDefault = + Math.abs(lat - dc.lat) < 1e-4 && + Math.abs(lng - dc.lng) < 1e-4; + + return Boolean(addressMaps) && !nearDefault; + }, [selectedPosition, addressMaps, getDefaultCenter]); + + // Hanya isi addressMap & lat/lng di form kalau SUDAH PIN useEffect(() => { - if (addressMaps) { - setTempAddress(addressMaps); - setValue('addressMap', addressMaps); - setValue('longtitude', selectedPosition.lng); - setValue('latitude', selectedPosition.lat); + // cek kalau form punya koordinat lama + const lat = getValues("latitude"); + const lng = getValues("longtitude"); + const oldAddress = getValues("addressMap"); + + if (lat && lng) { + setTempAddress(oldAddress); + setValue("addressMap", oldAddress); + + // kalau store punya setter untuk koordinat/alamat: + if (typeof setSelectedPosition === "function") { + setSelectedPosition({ lat: Number(lat), lng: Number(lng) }); + } + if (typeof setAddressMaps === "function") { + setAddressMaps(oldAddress); + } } - }, [addressMaps, selectedPosition, setValue]); - + }, [setSelectedPosition, setAddressMaps, getValues, setValue]); + + useEffect(() => { + const addr = getValues("addressMap"); + + if (!addr || addr.trim() === "") { + resetPin(); + } else { + setAddressMaps(addr); + + const lat = getValues("latitude"); + const lng = getValues("longtitude"); + if (lat && lng) { + setSelectedPosition({ lat: Number(lat), lng: Number(lng) }); + } + } + }, [getValues, resetPin, setAddressMaps, setSelectedPosition]); + useEffect(() => { if (Object.keys(detailAddress).length > 0) { setValue('zip', detailAddress.postalCode); @@ -72,7 +142,7 @@ const EditAddress = ({ id, defaultValues }) => { setValue('street', detailAddress?.street); } }, [detailAddress, setValue]); - + useEffect(() => { const loadProfile = async () => { const dataProfile = await addressApi({ id: auth.partnerId }); @@ -83,13 +153,40 @@ const EditAddress = ({ id, defaultValues }) => { setValue('alamat_wajib_pajak', dataProfile.alamatWajibPajak); setValue('alamat_bisnis', dataProfile.alamatBisnis); setValue('business_name', dataProfile.name); - setValue('city', dataProfile.city?.id); - setValue('district', dataProfile.district?.id); - setValue('subDistrict', dataProfile.subDistrict?.id); }; if (auth) loadProfile(); - }, [auth?.parentId]); - + }, [auth?.parentId, setValue]); + + // Isi ZIP/Prov dari detailAddress (JANGAN isi street) +useEffect(() => { + const zip = getValues("zip"); + const province = getValues("state"); + const street = getValues("street"); + + // set zip dari DB kalau kosong + if (!zip && defaultValues?.zip) { + setValue("zip", defaultValues.zip); + } + + // set state dari DB kalau kosong + if (!province && defaultValues?.state) { + const selectedState = states.find( + (state) => + defaultValues.state.includes(state.label) || + state.label.includes(defaultValues.state) + ); + if (selectedState) { + setValue("state", selectedState.value); + } + } + + // set street dari DB kalau kosong + if (!street && defaultValues?.street) { + setValue("street", defaultValues.street); + } + }, [states, setValue, getValues, defaultValues]); + + useEffect(() => { const loadStates = async () => { let dataStates = await stateApi({ tempo: false }); @@ -101,7 +198,7 @@ const EditAddress = ({ id, defaultValues }) => { }; loadStates(); }, []); - + const watchState = watch('state'); useEffect(() => { setValue('city', ''); @@ -122,35 +219,46 @@ const EditAddress = ({ id, defaultValues }) => { loadCities(); } }, [watchState, setValue, getValues]); - + useEffect(() => { - if (Object.keys(detailAddress).length > 0) { - const selectedCities = cities.find( - (city) => - city.label.toLowerCase() === detailAddress?.district.toLowerCase() - ) || cities.find( + if (!isPinned) return; + + if (getValues("city")) return; + + if (Object.keys(detailAddress || {}).length > 0) { + const selectedCities = + cities.find( + (city) => + city.label.toLowerCase() === detailAddress?.district?.toLowerCase() + ) || + cities.find( (city) => - detailAddress?.district.toLowerCase().includes(city.label.toLowerCase()) || - city.label.toLowerCase().includes(detailAddress?.district.toLowerCase()) + detailAddress?.district + ?.toLowerCase() + .includes(city.label.toLowerCase()) || + city.label + .toLowerCase() + .includes(detailAddress?.district?.toLowerCase()) ); - setValue('city', selectedCities?.value); - } - }, [cities, detailAddress, setValue]); - - const watchCity = watch('city'); - useEffect(() => { - if (watchCity) { - // setValue('district', ''); - const loadDistricts = async () => { - let dataDistricts = await districtApi({ cityId: watchCity }); - dataDistricts = dataDistricts.map((district) => ({ - value: district.id, - label: district.name, + + if (selectedCities) { + setValue("city", selectedCities.value); + } + } + }, [cities, detailAddress, isPinned, getValues, setValue]); + + const watchCity = watch('city'); + useEffect(() => { + if (watchCity) { + const loadDistricts = async () => { + let dataDistricts = await districtApi({ cityId: watchCity }); + dataDistricts = dataDistricts.map((district) => ({ + value: district.id, + label: district.name, })); setDistricts(dataDistricts); let oldDistrict = getValues('oldDistrict'); if (oldDistrict) { - // setValue('district', oldDistrict); setValue('oldDistrict', ''); } }; @@ -159,6 +267,9 @@ const EditAddress = ({ id, defaultValues }) => { }, [watchCity, setValue, getValues]); useEffect(() => { + if (!isPinned) return; // skip kalau belum pin + if (getValues("district")) return; + if (Object.keys(detailAddress).length > 0) { const selectedDistrict = districts.find( (district) => @@ -173,12 +284,10 @@ const EditAddress = ({ id, defaultValues }) => { } }, [districts, detailAddress, setValue]); - - + const watchDistrict = watch('district'); useEffect(() => { if (watchDistrict) { - // setValue('subDistrict', ''); const loadSubDistricts = async () => { let dataSubDistricts = await subDistrictApi({ districtId: watchDistrict, @@ -199,8 +308,10 @@ const EditAddress = ({ id, defaultValues }) => { } }, [watchDistrict, setValue, getValues]); - useEffect(() => { + if (!isPinned) return; // skip kalau belum pin + if (getValues("subDistrict")) return; + if (Object.keys(detailAddress).length > 0) { const selectedSubDistrict = subDistricts.find( (district) => @@ -221,8 +332,39 @@ const EditAddress = ({ id, defaultValues }) => { setValue('id', id); } }, [id, setValue]); - + const onSubmitHandler = async (values) => { + if (addressMaps) { + if (!detailAddress){ + if (defaultValues?.oldCity !== values.city) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + // console.log(detailAddress) + return; + } + } + if (detailAddress) { + const cityName = normalizeName( + cities.find((c) => c.value === watch("city"))?.label || "" + ); + const districtName = normalizeName(detailAddress?.district || ""); + // console.log(cityName, '=', districtName); + + if (cityName && cityName !== districtName) { + setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih"); + setShowValidationPopup(true); + return; + } + } + } + // if(!addressMaps && detailAddress){ + // if (selectedCityName && selectedCityName !== detailAddress?.district?.toLowerCase()) { + // setPopupMessage("Titik Koordinat tidak sesuai dengan Kota yang dipilih 3"); + // setShowValidationPopup(true); + // return; + // } + // } + const data = { ...values, phone: values.mobile, @@ -230,20 +372,32 @@ const EditAddress = ({ id, defaultValues }) => { city_id: parseInt(values.city, 10), district_id: parseInt(values.district, 10), sub_district_id: parseInt(values.subDistrict, 10), - longtitude: selectedPosition?.lng, - latitude: selectedPosition?.lat, - address_map: addressMaps, }; + + + if (isPinned) { + data.longtitude = selectedPosition?.lng; + data.latitude = selectedPosition?.lat; + data.address_map = addressMaps || values.addressMap; + data.use_pin = true; + } else { + data.use_pin = false; + // pastikan tidak ada nilai default center yang ikut terkirim + delete data.longtitude; + delete data.latitude; + delete data.address_map; + } + if (!auth.company) { data.alamat_lengkap_text = values.street; } + try { const address = await editAddressApi({ id, data }); console.log('Response address:', address); let isUpdated = null; - // Jika company dan partnerId sama dengan id, maka update data alamat wajib pajak const isCompanyEditingSelf = auth.company && auth.partnerId == id; if (isCompanyEditingSelf) { @@ -260,15 +414,13 @@ const EditAddress = ({ id, defaultValues }) => { mobile: values.mobile, }; - const isUpdated = await editPartnerApi({ + const isUpdatedRes = await editPartnerApi({ id: auth.partnerId, data: dataAlamat, }); - - console.log('Response isUpdated:', isUpdated); + console.log('Response isUpdated:', isUpdatedRes); } - // Validasi kondisi sukses const isSuccess = !!address?.id; if (isSuccess) { @@ -286,21 +438,10 @@ const EditAddress = ({ id, defaultValues }) => { toast.error(error?.message || 'Terjadi kesalahan tidak terduga.'); } - const dataProfile = await addressApi({ id: auth.partnerId }); - console.log('ini adalah', dataProfile); - - - // if (isUpdated?.id) { - // if (address?.id && auth.company ? isUpdated?.id : true) { - // toast.success('Berhasil mengubah alamat'); - // router.back(); - // } else { - // toast.error('Terjadi kesalahan internal'); - // router.back(); - // } + // console.log('ini adalah', dataProfile); }; - + // console.log('ini adalah', detailAddress); return ( <> <BottomPopup @@ -310,12 +451,29 @@ const EditAddress = ({ id, defaultValues }) => { close={() => setPinedMaps(false)} > <div className='flex mt-4'> - <PinPointMap - initialLatitude={selectedPosition?.lat} - initialLongitude={selectedPosition?.lng} - initialAddress={tempAddress} - /> + <PinPointMap + initialLatitude={getValues('latitude')} + initialLongitude={getValues('longtitude')} + initialAddress={getValues('addressMap')} + /> + </div> + </BottomPopup> + <BottomPopup + active={showValidationPopup} + close={() => setShowValidationPopup(false)} + > + <div className="leading-7 text-gray_r-12/80 text-center"> + {popupMessage} + </div> + <div className="flex justify-center mt-6"> + <button + className="btn-solid-red w-full md:w-auto" + type="button" + onClick={() => setShowValidationPopup(false)} + > + OK + </button> </div> </BottomPopup> <div className='max-w-none md:container mx-auto flex p-0 md:py-10'> @@ -332,17 +490,30 @@ const EditAddress = ({ id, defaultValues }) => { <form onSubmit={handleSubmit(onSubmitHandler)}> <div className='mb-4 items-start'> <label className='form-label mb-2'>Koordinat Alamat</label> - {tempAddress ? ( + {addressMaps ? ( <div className='flex gap-x-2 items-center'> - <button type='button' className="flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition"> - <MapPinIcon class='h-6 w-6' onClick={() => setPinedMaps(true)} />{' '} - </button> - <span> {tempAddress} </span> + <button + type='button' + className='flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition' + > + <MapPinIcon + className='h-6 w-6' + onClick={() => setPinedMaps(true)} + /> + </button> + <span> {addressMaps} </span> </div> ) : ( - <Button variant='plain' style={{ padding: 0 }} onClick={() => setPinedMaps(true)}> - <button type='button' className="flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition"> - <MapPinIcon className="h-6 w-6" /> + <Button + variant='plain' + style={{ padding: 0 }} + onClick={() => setPinedMaps(true)} + > + <button + type='button' + className='flex items-center justify-center me-3 p-2 badge-solid-red text-white rounded-full hover:bg-red-500 transition' + > + <MapPinIcon className='h-6 w-6' /> </button> Pin Koordinat Alamat </Button> @@ -530,4 +701,4 @@ const types = [ { value: 'other', label: 'Other Address' }, ]; -export default EditAddress; +export default EditAddress;
\ No newline at end of file diff --git a/src/lib/auth/api/checkParentStatusApi.js b/src/lib/auth/api/checkParentStatusApi.js new file mode 100644 index 00000000..aa2eb1b6 --- /dev/null +++ b/src/lib/auth/api/checkParentStatusApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi'; +import { getAuth } from '@/core/utils/auth'; + +const checkParentStatusApi = async () => { + const auth = getAuth(); + const checkParentStatus = await odooApi( + 'GET', + `/api/v1/user/${auth.partnerId}/parent_status` + ); + return checkParentStatus; +}; + +export default checkParentStatusApi;
\ No newline at end of file diff --git a/src/lib/auth/api/editPersonalProfileApi.js b/src/lib/auth/api/editPersonalProfileApi.js index 39cd44c1..90bda114 100644 --- a/src/lib/auth/api/editPersonalProfileApi.js +++ b/src/lib/auth/api/editPersonalProfileApi.js @@ -3,7 +3,7 @@ import { getAuth } from '@/core/utils/auth' const editPersonalProfileApi = async ({ data }) => { const auth = getAuth() - const dataProfile = await odooApi('PUT', `/api/v1/user/${auth.id}`, data) + const dataProfile = await odooApi('POST', `/api/v1/user/${auth.id}`, data) return dataProfile } diff --git a/src/lib/auth/api/switchAccountApi.js b/src/lib/auth/api/switchAccountApi.js index 79ca2553..f62693f6 100644 --- a/src/lib/auth/api/switchAccountApi.js +++ b/src/lib/auth/api/switchAccountApi.js @@ -4,7 +4,7 @@ import { getAuth } from '@/core/utils/auth'; const switchAccountApi = async ({ data }) => { const auth = getAuth(); const switchAccount = await odooApi( - 'PUT', + 'POST', `/api/v1/user/${auth.partnerId}/switch`, data ); diff --git a/src/lib/auth/components/SwitchAccount.jsx b/src/lib/auth/components/SwitchAccount.jsx index 46e57348..840758c9 100644 --- a/src/lib/auth/components/SwitchAccount.jsx +++ b/src/lib/auth/components/SwitchAccount.jsx @@ -13,9 +13,10 @@ import { useRegisterStore } from '~/modules/register/stores/useRegisterStore.ts' import { registerUser } from '~/services/auth'; import { useMutation } from 'react-query'; import { isValid } from 'zod'; +import Spinner from "@/core/components/elements/Spinner/LogoSpinner"; import useDevice from '@/core/hooks/useDevice'; import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; -const SwitchAccount = ({ company_type }) => { +const SwitchAccount = ({ company_type, setIsAprove, setUbahAkun }) => { const { isDesktop, isMobile } = useDevice(); const auth = useAuth(); const [isOpen, setIsOpen] = useState(true); @@ -27,6 +28,8 @@ const SwitchAccount = ({ company_type }) => { const [selectedValue, setSelectedValue] = useState('PKP'); const [buttonSubmitClick, setButtonSubmitClick] = useState(false); const [changeConfirmation, setChangeConfirmation] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingPopup, setIsLoadingPopup] = useState(false); const { register, setValue, handleSubmit } = useForm({ defaultValues: { email: '', @@ -131,35 +134,46 @@ const SwitchAccount = ({ company_type }) => { setIsPKP(false); } }; - const onSubmitHandler = async (values) => { - toast.loading('Mengubah status akun...'); + const onSubmitHandler = async () => { + setChangeConfirmation(false); // Tutup popup konfirmasi + setIsLoadingPopup(true); // Munculkan popup loading + updateForm('parent_id', `${auth.parentId}`); - setChangeConfirmation(false); - // let data = { ...form, id: `${auth.partnerId}` }; + const data = form; if (!isFormValid) { setNotValid(true); setButtonSubmitClick(!buttonSubmitClick); + toast.error('Form belum valid. Mohon periksa kembali input Anda.'); + setIsLoadingPopup(false); return; - } else { - setButtonSubmitClick(!buttonSubmitClick); - setNotValid(false); } - // if (!values.password) delete data.password; - const isUpdated = await switchAccountApi({ data }); - if (isUpdated?.switch === 'Pending') { - // setAuth(isUpdated.user); - // setValue('password', ''); - toast.success('Berhasil mengubah akun', { duration: 1500 }); - setTimeout(() => { - window.location.reload(); - }, 1500); - return; + try { + const isUpdated = await switchAccountApi({ data }); + + if (isUpdated?.switch === 'pending') { + toast.success('Berhasil mengajukan ubah akun', { duration: 1500 }); + if (typeof window !== 'undefined') { + localStorage.setItem('autoCheckProfile', 'true'); + } + setTimeout(() => { + setIsAprove('pending'); + setUbahAkun('pending'); + window.location.reload(); + }, 1500); + } else { + toast.error('Gagal mengubah akun. Silakan coba lagi nanti atau hubungi admin jika masalah tetap terjadi.'); + setIsLoadingPopup(false); + } + } catch (error) { + console.error(error); + toast.error('Terjadi kesalahan saat menghubungi server, silahkan cek internet Anda atau hubungi admin Indoteknik.'); + setIsLoadingPopup(false); } - toast.error('Terjadi kesalahan internal'); }; + const onSubmitHandlerCancel = async (values) => { window.location.reload(); }; @@ -172,26 +186,37 @@ const SwitchAccount = ({ company_type }) => { title='Ubah profil Bisnis' > <div className='leading-7 text-gray_r-12/80'> - Anda yakin akan merubah profil bisnis anda dari INDIVIDU menjadi{' '} - {selectedValue}? + Anda yakin akan merubah profil bisnis anda dari INDIVIDU menjadi {selectedValue}? </div> <div className='flex mt-6 gap-x-4 md:justify-end'> <button - className='btn-solid-red flex-1 md:flex-none' + className='btn-solid-red flex-1 md:flex-none flex items-center justify-center' type='button' onClick={onSubmitHandler} + disabled={isLoading} > - Ya, Ubah + {isLoading && <Spinner className="w-4 h-4 mr-2 text-white" />} + {isLoading ? 'Menyimpan...' : 'Ya, Ubah'} </button> <button className='btn-light flex-1 md:flex-none' type='button' onClick={() => setChangeConfirmation(false)} + disabled={isLoading} > Batal </button> </div> </BottomPopup> + <BottomPopup active={isLoadingPopup} close=""> + <div className="leading-7 text-gray_r-12/80 flex justify-center"> + Mengubah status akun... + </div> + <div className="container flex justify-center my-4"> + <Spinner width={48} height={48} /> + </div> + </BottomPopup> + {/* <div type='button' className='ml-4 flex items-center text-left w-full'> <div className={`flex ${ diff --git a/src/lib/category/components/Breadcrumb.jsx b/src/lib/category/components/Breadcrumb.jsx index 127904ee..50557c3e 100644 --- a/src/lib/category/components/Breadcrumb.jsx +++ b/src/lib/category/components/Breadcrumb.jsx @@ -1,56 +1,188 @@ -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' - -/** - * Render a breadcrumb component. - * - * @param {object} categoryId - The ID of the category. - * @return {JSX.Element} The breadcrumb component. - */ + Skeleton, +} from '@chakra-ui/react'; +import Link from 'next/link'; +import React from 'react'; +import { useQuery } from 'react-query'; +import useDevice from '@/core/hooks/useDevice'; + const Breadcrumb = ({ categoryId }) => { const breadcrumbs = useQuery( - `category-breadcrumbs/${categoryId}`, - async () => await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) - ) - - return ( - <div className='container mx-auto py-4 md:py-6'> - <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-2/3'> - <ChakraBreadcrumb> - <BreadcrumbItem> - <BreadcrumbLink as={Link} href='/' className='!text-danger-500 whitespace-nowrap'> - 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> - ) : ( + ['category-breadcrumbs', categoryId], + async () => + await odooApi('GET', `/api/v1/category/${categoryId}/category-breadcrumb`) + ); + const { isDesktop, isMobile } = useDevice(); + + const items = breadcrumbs.data ?? []; + const lastIdx = items.length - 1; + + if (isDesktop) { + return ( + <div className='container mx-auto py-4 md:py-6'> + <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-2/3'> + <ChakraBreadcrumb + spacing='8px' + sx={{ + '& ol': { + display: 'flex', + flexWrap: { base: 'wrap', md: 'nowrap' }, + alignItems: 'center', + }, + '& li': { display: 'inline-flex', alignItems: 'center' }, + '& li:not(:last-of-type)': { + flex: '0 0 auto', + whiteSpace: 'nowrap', + }, + '& li:last-of-type': { + flex: '1 1 auto', + minWidth: 0, + }, + }} + > + {/* Home */} + <BreadcrumbItem> + <BreadcrumbLink as={Link} href='/' className='!text-danger-500'> + Home + </BreadcrumbLink> + </BreadcrumbItem> + + {/* Categories */} + {items.map((category, index) => { + const isLast = index === lastIdx; + return ( + <BreadcrumbItem key={index} isCurrentPage={isLast}> + {isLast ? ( + <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> + ); + } + + if (isMobile) { + const items = breadcrumbs.data ?? []; + const n = items.length; + const lastCat = n >= 1 ? items[n - 1] : null; // terakhir (current) + const secondLast = n >= 2 ? items[n - 2] : null; // sebelum current + const beforeSecond = n >= 3 ? items[n - 3] : null; // sebelum secondLast + const hiddenText = + n >= 3 + ? items + .slice(0, n - 2) + .map((c) => c.name) + .join(' / ') + : ''; + + return ( + <div className='container mx-auto py-2 mt-2'> + <Skeleton isLoaded={!breadcrumbs.isLoading} className='w-full'> + <ChakraBreadcrumb + separator={<span className='mx-1'>/</span>} // lebih rapat + spacing='4px' + sx={{ + '& ol': { + display: 'flex', + alignItems: 'center', + overflow: 'hidden', // untuk ellipsis + whiteSpace: 'nowrap', // untuk ellipsis + gap: '0', // no extra gap + }, + '& li': { display: 'inline-flex', alignItems: 'center' }, + '& li:not(:last-of-type)': { + flex: '0 0 auto', + whiteSpace: 'nowrap', + }, + '& li:last-of-type': { + flex: '0 1 auto', // jangan ambil full space biar gak keliatan “space kosong” + minWidth: 0, + }, + }} + className='text-caption-2 p-0' + > + {/* Home */} + <BreadcrumbItem> + <BreadcrumbLink as={Link} href='/' className='!text-danger-500'> + Home + </BreadcrumbLink> + </BreadcrumbItem> + + {/* Jika ada kategori sebelum secondLast, tampilkan '..' (link ke beforeSecond) */} + {beforeSecond && ( + <BreadcrumbItem> <BreadcrumbLink as={Link} - href={createSlug('/shop/category/', category.name, category.id)} - className='!text-danger-500 whitespace-nowrap' + href={createSlug( + '/shop/category/', + beforeSecond.name, + beforeSecond.id + )} + title={hiddenText} + aria-label={`Kembali ke ${beforeSecond.name}`} + className='!text-danger-500' > - {category.name} + .. </BreadcrumbLink> - )} - </BreadcrumbItem> - ))} - </ChakraBreadcrumb> - </Skeleton> - </div> - ) -} - -export default Breadcrumb + </BreadcrumbItem> + )} + + {/* secondLast sebagai link (kalau ada) */} + {secondLast && ( + <BreadcrumbItem> + <BreadcrumbLink + as={Link} + href={createSlug( + '/shop/category/', + secondLast.name, + secondLast.id + )} + className='!text-danger-500' + > + {secondLast.name} + </BreadcrumbLink> + </BreadcrumbItem> + )} + + {/* lastCat (current) dengan truncate & lebar dibatasi */} + {lastCat && ( + <BreadcrumbItem isCurrentPage> + <span + className='inline-block truncate align-bottom' + style={{ maxWidth: '60vw' }} // batasi lebar supaya gak “makan” baris & keliatan space kosong + title={lastCat.name} + > + {lastCat.name} + </span> + </BreadcrumbItem> + )} + </ChakraBreadcrumb> + </Skeleton> + </div> + ); + } +}; + +export default Breadcrumb; diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js index c30d9631..11c7d4c2 100644 --- a/src/lib/checkout/api/checkoutApi.js +++ b/src/lib/checkout/api/checkoutApi.js @@ -11,6 +11,16 @@ export const checkoutApi = async ({ data }) => { return dataCheckout; }; +export const checkoutQuotation = async (data) => { + const auth = getAuth(); + const qs = new URLSearchParams({ context: 'quotation' }).toString(); + return odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/checkout?${qs}`, + data + ); +}; + export const getProductsCheckout = async (query) => { const queryParam = new URLSearchParams(query); const userId = getAuth()?.id; diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx index d8ede118..6cda069c 100644 --- a/src/lib/checkout/components/Checkout.jsx +++ b/src/lib/checkout/components/Checkout.jsx @@ -55,7 +55,9 @@ function convertToInternational(number) { } const Checkout = () => { - const PPN = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; + const PPN = process.env.NEXT_PUBLIC_PPN + ? parseFloat(process.env.NEXT_PUBLIC_PPN) + : 0; const router = useRouter(); const query = router.query.source ?? null; const qVoucher = router.query.voucher ?? null; @@ -172,7 +174,7 @@ const Checkout = () => { selectedService, listExpedisi, setExpedisi, - productSla + productSla, } = useCheckout(); const expedisiValidation = useRef(null); @@ -184,7 +186,7 @@ const Checkout = () => { let dataVoucher = await getVoucher(auth?.id, { source: query, type: 'all,brand', - partner_id : auth?.partnerId, + partner_id: auth?.partnerId, }); SetListVoucher(dataVoucher); @@ -210,7 +212,6 @@ const Checkout = () => { return; } - dataVoucher.forEach((addNewLine) => { if (addNewLine.applyType !== 'shipping') { // Mencari voucher dalam listVouchers @@ -374,17 +375,21 @@ const Checkout = () => { } return; } - if (selectedCourierId !== SELF_PICKUP_ID) { // Menggunakan selectedCourierId karena lebih spesifik dan numerik - if (!selectedService) { // Jika kurir bukan Self Pickup, maka harus ada layanan yang dipilih - toast.error('Harap pilih tipe layanan pengiriman'); - return; - } - // Validasi biaya kirim hanya untuk kurir selain Self Pickup (dan ID kurir 1 jika itu kasus khusus) - // Jika selectedCourierId adalah 1 (misalnya kurir internal yang bisa gratis), lewati validasi biayaKirim 0 - if (selectedCourierId !== 1 && biayaKirim === 0) { - toast.error('Maaf, layanan tidak tersedia untuk ekspedisi ini. Mohon pilih ekspedisi lain atau layanan lain.'); - return; - } + if (selectedCourierId !== SELF_PICKUP_ID) { + // Menggunakan selectedCourierId karena lebih spesifik dan numerik + if (!selectedService) { + // Jika kurir bukan Self Pickup, maka harus ada layanan yang dipilih + toast.error('Harap pilih tipe layanan pengiriman'); + return; + } + // Validasi biaya kirim hanya untuk kurir selain Self Pickup (dan ID kurir 1 jika itu kasus khusus) + // Jika selectedCourierId adalah 1 (misalnya kurir internal yang bisa gratis), lewati validasi biayaKirim 0 + if (selectedCourierId !== 1 && biayaKirim === 0) { + toast.error( + 'Maaf, layanan tidak tersedia untuk ekspedisi ini. Mohon pilih ekspedisi lain atau layanan lain.' + ); + return; + } } setIsLoading(true); const productOrder = products.map((product) => ({ @@ -415,7 +420,8 @@ const Checkout = () => { order_line: JSON.stringify(productOrder), delivery_amount: biayaKirim, carrier_id: selectedCourierId, - estimated_arrival_days_start : parseInt(eta_courier_start) + parseInt(productSla), + estimated_arrival_days_start: + parseInt(eta_courier_start) + parseInt(productSla), estimated_arrival_days: parseInt(eta_courier) + parseInt(productSla), delivery_service_type: selectedService?.service_type, flash_sale: hasFlashSale, // dibuat negasi untuk ngetest kebalikan nilai false @@ -430,8 +436,8 @@ const Checkout = () => { if (typeof file == 'undefined') { toast.error( 'Nomor PO ' + - poNumber.current.value + - ' telah dimasukkan, Harap upload file PO yang dimaksud' + poNumber.current.value + + ' telah dimasukkan, Harap upload file PO yang dimaksud' ); setIsLoading(false); return; @@ -1019,10 +1025,10 @@ const Checkout = () => { </div> )} - {selectedCarrierId == SELF_PICKUP_ID && ( + {selectedCourierId == SELF_PICKUP_ID && ( <PickupAddress label='Alamat Pickup' /> )} - {selectedCarrierId != SELF_PICKUP_ID && ( + {selectedCourierId != SELF_PICKUP_ID && ( <Skeleton isLoaded={!!selectedAddress.invoicing && !!selectedAddress.shipping} minHeight={320} @@ -1137,7 +1143,9 @@ const Checkout = () => { <div>{currencyFormat(cartCheckout?.subtotal)}</div> </div> <div className='flex gap-x-2 justify-between'> - <div className='text-gray_r-11'>PPN {((PPN - 1) * 100).toFixed(0)}%</div> + <div className='text-gray_r-11'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </div> <div>{currencyFormat(cartCheckout?.tax)}</div> </div> <div className='flex gap-x-2 justify-between'> @@ -1269,10 +1277,7 @@ const Checkout = () => { className='flex-1 btn-yellow' onClick={checkout} disabled={ - !products || - products?.length == 0 || - priceCheck || - hasNoPrice + !products || products?.length == 0 || priceCheck || hasNoPrice || isLoading } > {isLoading ? 'Loading...' : 'Lanjut Pembayaran'} @@ -1317,16 +1322,11 @@ const Checkout = () => { <div className='flex'> {' '} <div className='w-3/4 border border-gray_r-6 rounded bg-white'> - {selectedCarrierId == SELF_PICKUP_ID && ( + {selectedCourierId == SELF_PICKUP_ID && ( <PickupAddress label='Alamat Pickup' /> )} - {selectedCarrierId != SELF_PICKUP_ID && ( - <Skeleton - isLoaded={ - !!selectedAddress.invoicing && !!selectedAddress.shipping - } - minHeight={290} - > + {selectedCourierId != SELF_PICKUP_ID && ( + <Skeleton isLoaded minHeight={290}> <SectionAddress address={selectedAddress.shipping} label='Alamat Pengiriman' @@ -1444,7 +1444,9 @@ const Checkout = () => { <div>{currencyFormat(cartCheckout?.subtotal)}</div> </div> <div className='flex gap-x-2 justify-between'> - <div className='text-gray_r-11'>PPN {((PPN - 1) * 100).toFixed(0)}%</div> + <div className='text-gray_r-11'> + PPN {((PPN - 1) * 100).toFixed(0)}% + </div> <div>{currencyFormat(cartCheckout?.tax)}</div> </div> <div className='flex gap-x-2 justify-between'> @@ -1577,7 +1579,8 @@ const Checkout = () => { !products || products?.length == 0 || priceCheck || - hasNoPrice + hasNoPrice || + isLoading } > {isLoading ? 'Loading...' : 'Lanjut Pembayaran'} @@ -1633,8 +1636,14 @@ const SectionAddress = ({ address, label, url }) => ( {address.addressMap ? ( <label>Sudah Pinpoint</label> ) : ( - <Link href={'/my/address/' + address.id + '/edit'} target='_blank' className='cursor-pointer'> - <label className='text-red-500 cursor-pointer '>Belum Pinpoint</label> + <Link + href={'/my/address/' + address.id + '/edit'} + target='_blank' + className='cursor-pointer' + > + <label className='text-red-500 cursor-pointer '> + Belum Pinpoint + </label> </Link> )} </div> @@ -1644,7 +1653,7 @@ const SectionAddress = ({ address, label, url }) => ( ); const SectionValidation = ({ address }) => - address?.stateId == 0 && ( + address?.stateId === 0 && ( <BottomPopup active={true} title='Update Alamat'> <div className='leading-7 text-gray_r-12/80'> Mohon untuk memperbarui alamat Anda dengan mengklik tombol di bawah ini.{' '} @@ -1661,14 +1670,14 @@ const SectionValidation = ({ address }) => ); const SectionExpedisi = ({ - address, - listExpedisi, - setSelectedExpedisi, - checkWeigth, - checkoutValidation, - expedisiValidation, - loadingRajaOngkir, - }) => + address, + listExpedisi, + setSelectedExpedisi, + checkWeigth, + checkoutValidation, + expedisiValidation, + loadingRajaOngkir, +}) => address?.rajaongkirCityId > 0 && ( <div className='p-4' ref={expedisiValidation}> <div className='flex justify-between items-center'> @@ -1720,9 +1729,9 @@ const SectionExpedisi = ({ )} </div> <style jsx>{` - .shake { - animation: shake 0.4s ease-in-out; - } + .shake { + animation: shake 0.4s ease-in-out; + } `}</style> </div> {checkWeigth == true && ( diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx index d533325e..51837a59 100644 --- a/src/lib/checkout/components/FinishCheckout.jsx +++ b/src/lib/checkout/components/FinishCheckout.jsx @@ -55,12 +55,21 @@ const FinishCheckout = ({ query }) => { <div className='text-title-sm md:text-title-lg text-center font-semibold'> Terima Kasih atas Pembelian di Indoteknik.com </div> - <p className='text-title-sm md:text-title-lg font-semibold my-2'>No. Transaksi: <span className='text-red-500'>{query?.order_id?.replaceAll('-', '/')}</span></p> + <p className='text-title-sm md:text-title-lg font-semibold my-2'> + No. Transaksi:{' '} + <span className='text-red-500'> + {query?.order_id?.replaceAll('-', '/')} + </span> + </p> <div className='flex flex-col justify-center items-center text-body-2 md:text-body-1 text-center mt-3 px-24 md:px-36 py-4 border-2 gap-y-2 rounded'> - <p className="text-title-sm md:text-title-xl text-gray-500 mt-1">Estimasi Barang Siap pada Tanggal</p> - <p className="text-title-sm md:text-title-xl text-red-500 font-semibold my-2">{data?.expectedReadyToShip}</p> + <p className='text-title-sm md:text-title-xl text-gray-500 mt-1'> + Estimasi Barang Siap pada Tanggal + </p> + <p className='text-title-sm md:text-title-xl text-red-500 font-semibold my-2'> + {data?.expectedReadyToShip} + </p> <Link - href={`/my/quotations/${data?.id}`} + href={`/my/transactions/${data?.id}`} className='btn-solid-red rounded-md text-base' > Cek Detail Transaksi @@ -74,7 +83,7 @@ const FinishCheckout = ({ query }) => { </a>{' '} atau{' '} <span onClick={sendEmail} className='text-red-500 cursor-pointer'> - kirim rincian pesanan ulang + Kirim ulang rincian pesanan ke Email anda. </span> . </div> diff --git a/src/lib/checkout/components/SectionExpedition.jsx b/src/lib/checkout/components/SectionExpedition.jsx index 2e92ffbc..66182589 100644 --- a/src/lib/checkout/components/SectionExpedition.jsx +++ b/src/lib/checkout/components/SectionExpedition.jsx @@ -18,7 +18,7 @@ import { getProductsSla } from '../api/checkoutApi'; function mappingItems(products) { return products?.map((item) => ({ // name: item.parent.name || item?.name || 'Unknown Product', - name: item?.name, + name: item?.name, description: `${item.code} - ${item.name}`, value: item.price.priceDiscount, weight: item.weight * 1000, @@ -27,61 +27,71 @@ function mappingItems(products) { } function reverseMappingCourier(couriersOdoo, couriers, includeInstant = false) { - // Buat peta courier berdasarkan nama courier dari couriers + // Bangun peta dari hasil Biteship (pakai dua key: code & name) const courierMap = couriers.reduce((acc, item) => { - const { courier_name, courier_code, courier_service_code } = item; - const key = courier_code.toLowerCase(); - - if ( - !includeInstant && (['hours'].includes(item.shipment_duration_unit.toLowerCase()) || item.service_type == 'same_day') - - ) { - return acc; - } + const codeKey = (item.courier_code || '').toLowerCase(); + const nameKey = (item.courier_name || '').toLowerCase(); + + const isInstant = + (item.shipment_duration_unit || '').toLowerCase() === 'hours' || + (item.service_type || '').toLowerCase() === 'same_day'; + if (!includeInstant && isInstant) return acc; + + const ensureEntry = (key) => { + if (!key) return; + if (!acc[key]) { + acc[key] = { + courier_name: item.courier_name, + courier_code: item.courier_code, + service_type: {}, + }; + } + }; - if (!acc[key]) { - acc[key] = { - courier_name: item.courier_name, - courier_code: courier_code, - service_type: {}, - }; - } + ensureEntry(codeKey); + ensureEntry(nameKey); - acc[key].service_type[courier_service_code] = { + const svc = { service_name: item.courier_service_name, duration: item.duration, shipment_range: item.shipment_duration_range, shipment_unit: item.shipment_duration_unit, price: item.price, - service_type: courier_service_code, + service_type: item.courier_service_code, description: item.description, }; + if (codeKey && acc[codeKey]) { + acc[codeKey].service_type[item.courier_service_code] = svc; + } + if (nameKey && acc[nameKey]) { + acc[nameKey].service_type[item.courier_service_code] = svc; + } return acc; }, {}); - // Iterasi berdasarkan couriersOdoo - return couriersOdoo.map((courierOdoo) => { - const courierNameKey = courierOdoo.label.toLowerCase(); - const carrierId = courierOdoo.carrierId; + // Petakan Odoo ke map Biteship dan FILTER hanya yg punya layanan + return couriersOdoo + .map((courierOdoo) => { + const key = (courierOdoo.label || '').toLowerCase(); + const matched = courierMap[key] || null; - const mappedCourier = courierMap[courierNameKey] || false; + if (!matched) return { ...courierOdoo, courier: false }; - if (!mappedCourier) { return { ...courierOdoo, - courier: false, + courier: { + ...matched, + courier_id_odoo: courierOdoo.carrierId, // penting: simpan id Odoo di sini + }, }; - } - - return { - ...courierOdoo, - courier: { - ...mappedCourier, - courier_id_odoo: carrierId, - }, - }; - }); + }) + .filter( + (x) => + x.courier && + x.courier.service_type && + Object.keys(x.courier.service_type).length > 0 + ); } function mappingCourier(couriersOdoo, couriers, notIncludeInstant = false) { @@ -214,7 +224,7 @@ export default function SectionExpedition({ products }) { let data = { products: JSON.stringify(productsMapped), - } + }; const res = await odooApi('POST', `/api/v1/product/variants/sla`, data); setSlaProducts(res); } catch (error) { @@ -328,13 +338,13 @@ export default function SectionExpedition({ products }) { }; useEffect(() => { - if (serviceOptions.length > 0) { - setSavedServiceOptions(serviceOptions); - } -}, [serviceOptions]); + if (serviceOptions.length > 0) { + setSavedServiceOptions(serviceOptions); + } + }, [serviceOptions]); return ( - <form > + <form> <div className='px-4 py-2'> <div className='flex justify-between items-center'> <div className='font-medium'>Pilih Ekspedisi: </div> @@ -371,27 +381,40 @@ export default function SectionExpedition({ products }) { <p className='font-semibold'>SELF PICKUP</p> </div> </div> - {couriers?.map((courier) => ( - <div - key={courier?.courier?.courier_code} - onClick={() => onCourierChange(courier)} - className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' - > - <div> - <p className='font-semibold'> - {courier?.label} - </p> + {couriers + ?.map((c) => c) // sudah ter-filter di reverseMappingCourier, tapi aman buat double-check + .filter( + (c) => + c.courier && + Object.keys(c.courier.service_type).length > 0 + ) + .map((courier) => ( + <div + key={ + courier?.courier?.courier_code || + courier?.label + } + onClick={() => onCourierChange(courier)} + className='flex justify-between p-2 items-center hover:bg-gray-100 cursor-pointer' + > + <div> + <p className='font-semibold'> + {courier?.label} + </p> + </div> + <span className='font-semibold'> + <Image + src={courier?.logo} + alt={ + courier?.courier?.courier_name || + courier?.label + } + width={50} + height={50} + /> + </span> </div> - <span className='font-semibold'> - <Image - src={courier?.logo} - alt={courier?.courier?.courier_name} - width={50} - height={50} - /> - </span> - </div> - ))} + ))} </> ) : ( <> @@ -432,8 +455,7 @@ export default function SectionExpedition({ products }) { )} </div> - {(serviceOptions.length > 0 || - selectedService )&& + {(serviceOptions.length > 0 || selectedService) && selectedCourier && selectedCourier !== 32 && selectedCourier !== 0 && ( diff --git a/src/lib/checkout/components/SectionQuotationExpedition.jsx b/src/lib/checkout/components/SectionQuotationExpedition.jsx index b8ea04ef..817cd21b 100644 --- a/src/lib/checkout/components/SectionQuotationExpedition.jsx +++ b/src/lib/checkout/components/SectionQuotationExpedition.jsx @@ -239,7 +239,7 @@ export default function SectionExpeditionQuotation({ products }) { <div className='px-4 py-2'> <div className='flex justify-between items-center'> <div className='font-medium'>Pilih Ekspedisi: </div> - <div className='w-[350px]'> + <div className='relative w-[350px]'> <div className='w-full p-2 border rounded-lg bg-white cursor-pointer' onClick={() => setOnFocuseSelectedCourier(!onFocusSelectedCourier)} @@ -253,7 +253,10 @@ export default function SectionExpeditionQuotation({ products }) { )} </div> {onFocusSelectedCourier && ( - <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 max-h-[200px] overflow-y-auto w-[350px]'> + <div + className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50 + max-h-[200px] overflow-y-auto w-full sm:w-[350px]' + > {!isLoading ? ( <> <div @@ -297,8 +300,8 @@ export default function SectionExpeditionQuotation({ products }) { {checkWeigth && ( <p className='mt-4 text-gray-600'> - Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada barang - yang belum memiliki berat. Silakan hubungi admin via{' '} + Mohon maaf, pengiriman hanya tersedia untuk self pickup karena ada + barang yang belum memiliki berat. Silakan hubungi admin via{' '} <a className='text-blue-600 underline' href='https://api.whatsapp.com/send?phone=6281717181922' @@ -316,7 +319,7 @@ export default function SectionExpeditionQuotation({ products }) { selectedCourier !== 0 && ( <div className='mt-4 flex justify-between'> <div className='font-medium mb-2'>Tipe Layanan Ekspedisi:</div> - <div className='relative w-[350px]'> + <div className='relative w-full sm:w-[350px]'> <div className='p-2 border rounded-lg bg-white cursor-pointer' onClick={() => setIsOpen(!isOpen)} @@ -331,11 +334,13 @@ export default function SectionExpeditionQuotation({ products }) { </span> </div> ) : ( - <span className='text-gray-500'>Pilih layanan pengiriman</span> + <span className='text-gray-500'> + Pilih layanan pengiriman + </span> )} </div> {isOpen && ( - <div className='absolute bg-white border rounded-lg mt-1 shadow-lg z-10 w-full'> + <div className='absolute left-0 top-full mt-1 bg-white border rounded-lg shadow-lg z-50 w-full'> {serviceOptions.map((service) => ( <div key={service.service_type} diff --git a/src/lib/form/components/Merchant.jsx b/src/lib/form/components/Merchant.jsx index 03b8fc84..b65449a8 100644 --- a/src/lib/form/components/Merchant.jsx +++ b/src/lib/form/components/Merchant.jsx @@ -147,7 +147,7 @@ const CreateMerchant = () => { <p> Penjualan online adalah hal yang HARUS dilakukan mulai sekarang. Perubahan dalam banyak industri dan pola pembelian. Gabung dengan - platform kami dan mulai penjualan lansung di ribuan perusahaan d + platform kami dan mulai penjualan langsung di ribuan perusahaan di seluruh Indonesia.{' '} </p> </div> diff --git a/src/lib/maps/components/PinPointMap.jsx b/src/lib/maps/components/PinPointMap.jsx index c46d838a..a9ead055 100644 --- a/src/lib/maps/components/PinPointMap.jsx +++ b/src/lib/maps/components/PinPointMap.jsx @@ -14,37 +14,54 @@ const containerStyle = { height: '400px', }; -const defaultCenter = { - lat: -6.2, - lng: 106.816666, -}; - -const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) => { +const PinpointLocation = ({ + initialLatitude, + initialLongitude, + initialAddress, +}) => { const { isLoaded } = useJsApiLoader({ googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY, libraries: ['places'], }); const { - addressMaps, setAddressMaps, selectedPosition, setSelectedPosition, setDetailAddress, setPinedMaps, + getDefaultCenter, // ✅ ambil default center dari store } = useMaps(); + + const [tempAddress, setTempAddress] = useState(initialAddress || ''); const [tempPosition, setTempPosition] = useState( - initialLatitude && initialLongitude + initialLatitude && initialLongitude ? { lat: parseFloat(initialLatitude), lng: parseFloat(initialLongitude) } - : selectedPosition.lat && selectedPosition.lng - ? selectedPosition - : defaultCenter + : selectedPosition?.lat && selectedPosition?.lng + ? selectedPosition + : getDefaultCenter() // ✅ fallback aman untuk view ); - const [markerIcon, setMarkerIcon] = useState(null); + useEffect(() => { + if (initialLatitude && initialLongitude) { + const lat = parseFloat(initialLatitude); + const lng = parseFloat(initialLongitude); + + if (!isNaN(lat) && !isNaN(lng)) { + setTempPosition({ lat, lng }); + // kalau address belum ada, reverse geocode + if (!initialAddress) { + getAddress(lat, lng); + } else { + setTempAddress(initialAddress); + } + } + } + }, [initialLatitude, initialLongitude, initialAddress]); + const [markerIcon, setMarkerIcon] = useState(null); const autocompleteRef = useRef(null); useEffect(() => { @@ -55,7 +72,7 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) }); } - // If we have initial coordinates but no address, fetch the address + // Jika ada koordinat awal tapi belum ada address → reverse geocode if (initialLatitude && initialLongitude && !initialAddress) { getAddress(parseFloat(initialLatitude), parseFloat(initialLongitude)); } @@ -66,10 +83,11 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) return component ? component.long_name : ''; }; + // fill from pin point const getAddress = async (lat, lng) => { try { const response = await fetch( - `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.NEXT_PUBLIC_GOOGLE_API_KEY}` + `https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${process.env.NEXT_PUBLIC_GOOGLE_API_KEY}&language=id` ); const data = await response.json(); @@ -78,14 +96,31 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) const formattedAddress = data.results[0].formatted_address; const details = { - street: - getAddressComponent(addressComponents, 'route') + - ' ' + + street: [ + getAddressComponent(addressComponents, 'route'), getAddressComponent(addressComponents, 'street_number'), - province: getAddressComponent(addressComponents, 'administrative_area_level_1'), - district: getAddressComponent(addressComponents, 'administrative_area_level_2'), - subDistrict: getAddressComponent(addressComponents, 'administrative_area_level_3'), - village: getAddressComponent(addressComponents, 'administrative_area_level_4'), + getAddressComponent(addressComponents, 'administrative_area_level_4'), + getAddressComponent(addressComponents, 'administrative_area_level_3'), + getAddressComponent(addressComponents, 'administrative_area_level_2'), + getAddressComponent(addressComponents, 'administrative_area_level_1'), + getAddressComponent(addressComponents, 'postal_code'), + ].filter(Boolean).join(', '), + province: getAddressComponent( + addressComponents, + 'administrative_area_level_1' + ), + district: getAddressComponent( + addressComponents, + 'administrative_area_level_2' + ), + subDistrict: getAddressComponent( + addressComponents, + 'administrative_area_level_3' + ), + village: getAddressComponent( + addressComponents, + 'administrative_area_level_4' + ), postalCode: getAddressComponent(addressComponents, 'postal_code'), }; @@ -136,8 +171,15 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) const handleSavePinpoint = (event) => { event.preventDefault(); - if (tempAddress === '') { - alert('Silahkan pilih lokasi terlebih dahulu'); + + // ✅ cegah save jika masih di default center (user belum benar2 pilih lokasi) + const dc = getDefaultCenter(); + const isDefault = + Math.abs(tempPosition.lat - dc.lat) < 1e-6 && + Math.abs(tempPosition.lng - dc.lng) < 1e-6; + + if (!tempAddress || isDefault) { + alert('Silahkan pilih lokasi di peta atau autocomplete terlebih dahulu'); return; } @@ -173,13 +215,13 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) {isLoaded ? ( <GoogleMap mapContainerStyle={containerStyle} - center={tempPosition} + center={tempPosition || getDefaultCenter()} // ✅ aman jika null zoom={15} onClick={onMapClick} > {markerIcon && ( <Marker - position={tempPosition} + position={tempPosition || getDefaultCenter()} // ✅ selalu ada posisi draggable={true} onDragEnd={(e) => { const lat = e.latLng.lat(); @@ -199,7 +241,8 @@ const PinpointLocation = ({ initialLatitude, initialLongitude, initialAddress }) <div style={{ marginTop: '20px' }}> <Button variant='solid' onClick={handleUseCurrentLocation}> - <LocateFixed className='h-6 w-6 text-gray-500 mr-2' /> Gunakan Lokasi Saat Ini + <LocateFixed className='h-6 w-6 text-gray-500 mr-2' /> Gunakan Lokasi + Saat Ini </Button> </div> diff --git a/src/lib/maps/stores/useMaps.js b/src/lib/maps/stores/useMaps.js index c57a05ad..b02c2ae3 100644 --- a/src/lib/maps/stores/useMaps.js +++ b/src/lib/maps/stores/useMaps.js @@ -1,32 +1,57 @@ import { create } from "zustand"; -const center = { - lat: -6.200000, // Default latitude (Jakarta) - lng: 106.816666, // Default longitude (Jakarta) -}; - -export const useMaps = create((set) => ({ - // State existing - selectedPosition: center, +const DEFAULT_CENTER = { lat: -6.2, lng: 106.816666 }; + +export const useMaps = create((set, get) => ({ + // ==== STATE ==== + selectedPosition: null, addressMaps: '', detailAddress: {}, pinedMaps: false, - // State tambahan untuk penyimpanan posisi sementara + // posisi sementara (create/edit) tempPositionCreate: null, tempPositionEdit: null, - // Setter existing + // ==== SETTERS ==== setSelectedPosition: (position) => set({ selectedPosition: position }), setAddressMaps: (addressMaps) => set({ addressMaps }), setDetailAddress: (detailAddress) => set({ detailAddress }), setPinedMaps: (pinedMaps) => set({ pinedMaps }), - // Setter tambahan untuk posisi sementara setTempPositionCreate: (position) => set({ tempPositionCreate: position }), setTempPositionEdit: (position) => set({ tempPositionEdit: position }), - // Opsional: Reset jika ingin clear saat keluar halaman resetTempPositionCreate: () => set({ tempPositionCreate: null }), resetTempPositionEdit: () => set({ tempPositionEdit: null }), + + getDefaultCenter: () => DEFAULT_CENTER, + + isPinned: () => { + const p = get().selectedPosition; + if (!p) return false; + + // parse string -> number + const lat = parseFloat(p.lat); + const lng = parseFloat(p.lng); + + // cek kalau bukan angka valid + if (isNaN(lat) || isNaN(lng)) return false; + + // cek apakah sama dengan default + const isDefault = + Math.abs(lat - DEFAULT_CENTER.lat) < 1e-6 && + Math.abs(lng - DEFAULT_CENTER.lng) < 1e-6; + + return !isDefault; + }, + + resetPin: () => set({ + selectedPosition: null, + addressMaps: '', + detailAddress: {}, + pinedMaps: false, + tempPositionCreate: null, + tempPositionEdit: null, + }), })); diff --git a/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js b/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js index af1d6c3a..173287de 100644 --- a/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js +++ b/src/lib/pengajuan-tempo/api/createPengajuanTempoApi.js @@ -11,4 +11,4 @@ const createPengajuanTempoApi = async (data) => { return dataPengajuanTempo; }; -export default createPengajuanTempoApi; +export default createPengajuanTempoApi;
\ No newline at end of file diff --git a/src/lib/pengajuan-tempo/component/FinishTempo.jsx b/src/lib/pengajuan-tempo/component/FinishTempo.jsx index aacb9ef3..abf218d9 100644 --- a/src/lib/pengajuan-tempo/component/FinishTempo.jsx +++ b/src/lib/pengajuan-tempo/component/FinishTempo.jsx @@ -8,13 +8,19 @@ import useAuth from '@/core/hooks/useAuth'; import axios from 'axios'; import { toast } from 'react-hot-toast'; import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline'; +import { useRouter } from 'next/router'; +import switchAccountProgresApi from '@/lib/auth/api/switchAccountProgresApi.js'; const FinishTempo = ({ query }) => { const [data, setData] = useState(); + const [switchStatus, setSwitchStatus] = useState(null); + const [loadingStatus, setLoadingStatus] = useState(true); const [transactionData, setTransactionData] = useState(); const { isDesktop, isMobile } = useDevice(); const auth = useAuth(); const so_order = query?.order_id?.replaceAll('-', '/'); + const router = useRouter(); + useEffect(() => { const fetchData = async () => { const fetchedData = await odooApi( @@ -26,8 +32,46 @@ const FinishTempo = ({ query }) => { fetchData(); }, [query]); + useEffect(() => { + const fetchSwitchStatus = async () => { + try { + const progres = await switchAccountProgresApi(); + setSwitchStatus(progres?.status); + } catch (err) { + console.error('Gagal cek progres switch account:', err); + } finally { + setLoadingStatus(false); + } + }; + fetchSwitchStatus(); + }, []); + + // Handler khusus untuk tombol Ubah Akun + const handleSwitchAccountClick = () => { + if (switchStatus === 'pending') { + toast.info('Akun sedang menunggu verifikasi. Tidak dapat mengubah akun saat ini.', { duration: 2500 }); + return; + } + localStorage.setItem('autoCheckProfile', 'true'); + router.push('/my/profile'); + }; + + if (loadingStatus) { + return ( + <div className="container flex flex-col items-center gap-4"> + <div className="py-20 text-gray-500">Memuat data...</div> + </div> + ); + } + return ( - <div className='container flex flex-col items-center gap-4'> + <div + className={`container flex flex-col items-center gap-4 pb-20 ${ + switchStatus === 'pending' + ? 'min-h-[calc(100vh-80px)] md:min-h-[calc(100vh-100px)] lg:min-h-[calc(100vh-120px)]' + : 'min-h-screen' + }`} + > <div className={`flex ${ isMobile ? 'w-full' : 'w-2/3' @@ -40,8 +84,11 @@ const FinishTempo = ({ query }) => { > {query?.status == 'finish' && 'Form Pengajuan Tempo kamu Telah Berhasil Didaftarkan Mohon menunggu hingga Proses Verifikasi Selesai Dilakukan'} - {query?.status == 'switch-account' && - 'Form Pengajuan Tempo Kamu Gagal Dilakukan'} + {switchStatus === 'pending' + ? 'Form Pengajuan Tempo Kamu Belum Dapat Dilakukan' + : query?.status == 'switch-account' && + 'Form Pengajuan Tempo Kamu Gagal Dilakukan' + } {query?.status == 'review' && 'Pengajuan Tempo dalam Proses Verifikasi'} {query?.status == 'approve' && 'Pengajuan Tempo Berhasil'} @@ -85,8 +132,11 @@ const FinishTempo = ({ query }) => { isMobile ? 'w-full text-sm' : 'w-4/5 text-base' }`} > - {query?.status == 'switch-account' && - 'Terima kasih atas minat anda untuk mendaftar Tempo, namun sayangnya akun anda bukan merupakan akun bisnis. Segera ubah akun anda menjadi Bisnis untuk menggunakan fitur ini'} + {switchStatus === 'pending' + ? 'Proses perubahan ke akun bisnis sedang kami review, mohon menunggu hingga 2x24 jam' + : query?.status == 'switch-account' && + 'Terima kasih atas minat anda untuk mendaftar Tempo, namun sayangnya akun anda bukan merupakan akun bisnis. Segera ubah akun anda menjadi Bisnis untuk menggunakan fitur ini' + } {query?.status == 'finish' && 'Mohon menunggu untuk verifikasi dokumen dan kelengkapan data yang telah anda berikan. Proses approval pembayaran tempo kamu berhasil atau tidak akan diinfokan melalui email perusahaan / email yang mendaftar'} {query?.status == 'review' && @@ -94,23 +144,59 @@ const FinishTempo = ({ query }) => { {query?.status == 'approve' && 'Proses pengajuan tempo anda sudah berhasil terdaftar di indoteknik.com. Nikmati pembelian anda di website indoteknik dengan menggunakan pembayaran tempo'} </div> - <Link - href={ - query?.status === 'switch-account' - ? '/my/profile' - : query?.status === 'approve' - ? '/my/tempo/' - : '/' - } - className='btn-solid-red rounded-md text-base flex flex-row items-center justify-center' - > - {query?.status === 'switch-account' - ? 'Ubah Akun' - : query?.status === 'approve' - ? 'Lihat Detail Tempo' - : 'Kembali Ke Beranda'} - <ChevronRightIcon className='w-5' /> - </Link> + + {switchStatus !== 'pending' && ( + <hr className="border-gray-300 w-full" /> + )} + + {/* Video panduan khusus tampil saat status switch-account */} + {query?.status === 'switch-account' && switchStatus !== 'pending' && ( + <div className="w-full max-w-3xl mx-auto px-4 text-center text-gray-700 mb-6"> + <div className="mb-3 font-medium"> + <h1 + className={`text-red-500 text-center font-semibold ${ + isMobile ? 'text-lg' : 'text-3xl' + }`} + // Mengganti py-4 dengan my-6 supaya jarak vertikalnya sama dengan hr + style={{ marginTop: 24, marginBottom: 24 }} + > + Video Panduan Pengajuan Tempo + </h1> + </div> + <div className="relative" style={{ paddingTop: '56.25%' /* 16:9 aspect ratio */ }}> + <iframe + src="https://www.youtube.com/embed/m15f8-eLqUc?si=frNbGnJu1zjINnDT" + title="YouTube video player" + frameBorder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + allowFullScreen + referrerPolicy="strict-origin-when-cross-origin" + className="absolute top-0 left-0 w-full h-full rounded-md shadow-lg" + ></iframe> + </div> + </div> + )} + + {/* Tombol dengan behavior berbeda jika status switch-account */} + {query?.status === 'switch-account' && switchStatus !== 'pending' ? ( + <button + onClick={handleSwitchAccountClick} + className="btn-solid-red rounded-md text-base flex flex-row items-center justify-center mb-10" + > + Ubah Akun + <ChevronRightIcon className="w-5" /> + </button> + ) : query?.status !== 'switch-account' && ( + <Link + href={query?.status === 'approve' ? '/my/tempo/' : '/'} + className="btn-solid-red rounded-md text-base flex flex-row items-center justify-center mb-10" + > + {query?.status === 'approve' + ? 'Lihat Detail Tempo' + : 'Kembali Ke Beranda'} + <ChevronRightIcon className="w-5" /> + </Link> + )} </div> ); }; diff --git a/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx b/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx index ae3d97fd..096fe1ed 100644 --- a/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx +++ b/src/lib/pengajuan-tempo/component/PengajuanTempo.jsx @@ -40,6 +40,7 @@ const PengajuanTempo = () => { const { form, errors, validate, updateForm } = usePengajuanTempoStore(); const { control, watch, setValue, setError } = useForm(); const auth = useAuth(); + console.log('auth', auth); const router = useRouter(); const [BannerTempo, setBannerTempo] = useState(); const { formDokumen, errorsDokumen, validateDokumen, updateFormDokumen } = diff --git a/src/lib/product/components/Product/ProductDesktopVariant.jsx b/src/lib/product/components/Product/ProductDesktopVariant.jsx index de88e5bb..59fa2032 100644 --- a/src/lib/product/components/Product/ProductDesktopVariant.jsx +++ b/src/lib/product/components/Product/ProductDesktopVariant.jsx @@ -103,15 +103,6 @@ const ProductDesktopVariant = ({ variantQuantityRefs.current[variantId] = element; }; - const validQuantity = (quantity) => { - let isValid = true; - if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { - toast.error('Jumlah barang minimal 1'); - isValid = false; - } - return isValid; - }; - const handleAddToCart = (variant) => { if (!auth) { router.push(`/login?next=/shop/product/${slug}?srsltid=${srsltid}`); @@ -132,29 +123,36 @@ const ProductDesktopVariant = ({ setAddCartAlert(true); }; + const toInt = (v) => { + const n = parseInt(String(v ?? '').trim(), 10); + return Number.isFinite(n) ? n : 0; + }; + + const validQuantity = (q) => { + if (!Number.isInteger(q) || q < 1) { + toast.error('Jumlah barang minimal 1'); + return false; + } + return true; + }; + const handleBuy = async (variant) => { - const quantity = variantQuantityRefs?.current[product.id]?.value; - let isLoggedIn = typeof auth === 'object'; + const quantity = Math.max(1, toInt(quantityInput)); // clamp min 1 + let isLoggedIn = typeof auth === 'object'; if (!isLoggedIn) { const currentUrl = encodeURIComponent(router.asPath); await router.push(`/login?next=${currentUrl}`); - // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. - const authCheckInterval = setInterval(() => { - const newAuth = getAuth(); - if (typeof newAuth === 'object') { - isLoggedIn = true; - auth = newAuth; // Update nilai auth setelah login - clearInterval(authCheckInterval); - } - }, 500); // Periksa status login setiap 500ms - + // tunggu sampai auth ada await new Promise((resolve) => { - const checkLogin = setInterval(() => { - if (isLoggedIn) { - clearInterval(checkLogin); - resolve(null); + const t = setInterval(() => { + const newAuth = getAuth(); + if (typeof newAuth === 'object') { + isLoggedIn = true; + auth = newAuth; + clearInterval(t); + resolve(); } }, 500); }); @@ -162,16 +160,57 @@ const ProductDesktopVariant = ({ if (!validQuantity(quantity)) return; - updateItemCart({ + await updateItemCart({ productId: variant, quantity, programLineId: null, selected: true, source: 'buy', }); - router.push(`/shop/checkout?source=buy`); + + router.push('/shop/checkout?source=buy'); }; + // const handleBuy = async (variant) => { + // const quantity = variantQuantityRefs?.current[product.id]?.value; + // let isLoggedIn = typeof auth === 'object'; + + // if (!isLoggedIn) { + // const currentUrl = encodeURIComponent(router.asPath); + // await router.push(`/login?next=${currentUrl}`); + + // // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. + // const authCheckInterval = setInterval(() => { + // const newAuth = getAuth(); + // if (typeof newAuth === 'object') { + // isLoggedIn = true; + // auth = newAuth; // Update nilai auth setelah login + // clearInterval(authCheckInterval); + // } + // }, 500); // Periksa status login setiap 500ms + + // await new Promise((resolve) => { + // const checkLogin = setInterval(() => { + // if (isLoggedIn) { + // clearInterval(checkLogin); + // resolve(null); + // } + // }, 500); + // }); + // } + + // if (!validQuantity(quantity)) return; + + // updateItemCart({ + // productId: variant, + // quantity, + // programLineId: null, + // selected: true, + // source: 'buy', + // }); + // router.push(`/shop/checkout?source=buy`); + // }; + const handleButton = async (variant) => { const quantity = quantityInput; let isLoggedIn = typeof auth === 'object'; @@ -443,37 +482,53 @@ const ProductDesktopVariant = ({ )} </h3> )} - <div className='flex justify-between items-center py-5 px-3'> + <div className='flex gap-x-5 items-center py-5'> <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)) - ) - } + onClick={() => { + const n = parseInt(String(quantityInput), 10); + const next = Number.isFinite(n) ? Math.max(1, n - 1) : 1; + setQuantityInput(next); + }} > - </button> + <input type='number' id='quantity' min={1} + step={1} + inputMode='numeric' + pattern='[0-9]*' value={quantityInput} - onChange={(e) => setQuantityInput(e.target.value)} - className=' w-24 h-10 text-center border border-gray-300 rounded focus:outline-none' + onChange={(e) => { + const raw = e.target.value.trim(); + const n = parseInt(raw, 10); + setQuantityInput(Number.isFinite(n) && n > 0 ? n : 1); + }} + onKeyDown={(e) => { + if (['e', 'E', '+', '-', '.'].includes(e.key)) + e.preventDefault(); + }} + className='w-24 h-10 text-center border border-gray-300 rounded focus:outline-none' /> + <button type='button' className='absolute right-0 px-2 py-1 h-full text-gray-500' - onClick={() => - setQuantityInput(String(Number(quantityInput) + 1)) - } + onClick={() => { + const n = parseInt(String(quantityInput), 10); + const next = (Number.isFinite(n) ? n : 0) + 1; + setQuantityInput(next); + }} > + </button> </div> + <div> <Skeleton isLoaded={!isLoadingSLA} @@ -510,7 +565,8 @@ const ProductDesktopVariant = ({ <Button onClick={() => handleAddToCart(product.id)} className='w-full' - colorScheme='yellow' + colorScheme='red' + variant={'outline'} > Keranjang </Button> @@ -529,7 +585,7 @@ const ProductDesktopVariant = ({ className='w-full border-2 p-2 gap-1 mt-2 hover:bg-slate-100 flex items-center' > <ImageNext - src='/images/writing.png' + src='/images/doc_red.svg' alt='penawaran instan' className='' width={25} diff --git a/src/lib/product/components/Product/ProductMobileVariant.jsx b/src/lib/product/components/Product/ProductMobileVariant.jsx index de5c3f10..cab8e9be 100644 --- a/src/lib/product/components/Product/ProductMobileVariant.jsx +++ b/src/lib/product/components/Product/ProductMobileVariant.jsx @@ -27,6 +27,8 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { let auth = getAuth(); const [quantity, setQuantity] = useState('1'); const [selectedVariant, setSelectedVariant] = useState(product.id); + const [quantityInput, setQuantityInput] = useState(String(1)); + const [qtyPickUp, setQtyPickUp] = useState(0); const [informationTab, setInformationTab] = useState( informationTabOptions[0].value ); @@ -63,30 +65,33 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { } }, [selectedVariant, product]); - const validAction = () => { - let isValid = true; + const validAction = (q) => { if (!selectedVariant) { toast.error('Pilih varian terlebih dahulu'); - isValid = false; + return false; } - if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { + if (!Number.isInteger(q) || q < 1) { toast.error('Jumlah barang minimal 1'); - isValid = false; + return false; } - return isValid; + return true; }; + const getQty = () => Math.max(1, toInt(quantityInput)); + const handleClickCart = async () => { + const q = getQty(); + if (!auth) { router.push(`/login?next=/shop/product/${slug}?srsltid=${srsltid}`); return; } + if (!validAction(q)) return; - if (!validAction()) return; - gtagAddToCart(activeVariant, quantity); + gtagAddToCart(activeVariant, q); updateItemCart({ productId: product.id, - quantity, + quantity: q, programLineId: null, selected: true, source: null, @@ -95,37 +100,29 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { }; const handleClickBuy = async () => { - let isLoggedIn = typeof auth === 'object'; + const q = getQty(); + let isLoggedIn = typeof auth === 'object'; if (!isLoggedIn) { const currentUrl = encodeURIComponent(router.asPath); await router.push(`/login?next=${currentUrl}`); - - // Tunggu login berhasil, misalnya dengan memantau perubahan status auth. - const authCheckInterval = setInterval(() => { - const newAuth = getAuth(); - if (typeof newAuth === 'object') { - isLoggedIn = true; - auth = newAuth; // Update nilai auth setelah login - clearInterval(authCheckInterval); - } - }, 500); // Periksa status login setiap 500ms - await new Promise((resolve) => { - const checkLogin = setInterval(() => { - if (isLoggedIn) { - clearInterval(checkLogin); + const t = setInterval(() => { + const newAuth = getAuth(); + if (typeof newAuth === 'object') { + auth = newAuth; + clearInterval(t); resolve(null); } }, 500); }); } - if (!validAction()) return; + if (!validAction(q)) return; updateItemCart({ productId: product.id, - quantity, + quantity: q, programLineId: null, selected: true, source: 'buy', @@ -133,8 +130,21 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { router.push(`/shop/checkout?source=buy`); }; + const toInt = (v) => { + const n = parseInt(String(v ?? '').trim(), 10); + return Number.isFinite(n) ? n : 0; + }; + + const validQuantity = (q) => { + if (!Number.isInteger(q) || q < 1) { + toast.error('Jumlah barang minimal 1'); + return false; + } + return true; + }; + const handleButton = (variant) => { - const quantity = quantityInput; + const quantity = Math.max(1, toInt(quantityInput)); // clamp min 1 if (!validQuantity(quantity)) return; updateItemCart({ @@ -168,9 +178,10 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { return ( <MobileView> - <div - className={`px-4 block md:sticky md:top-[150px] md:py-6 fixed bottom-0 left-0 right-0 bg-white p-2 z-10 pb-6 pt-6 rounded-lg shadow-[rgba(0,0,4,0.1)_0px_-4px_4px_0px] `} - > + {/* 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]'> + {/* HARGA & PPN (logikamu tetap) */} {activeVariant.isFlashSale && activeVariant?.price?.discountPercentage > 0 ? ( <> @@ -225,50 +236,105 @@ const ProductMobileVariant = ({ product, wishlist, toggleWishlist }) => { )} </div> )} - <div className=''> - <div className='mt-4 mb-2'>Jumlah</div> - <div className='flex gap-x-3'> - <div className='w-2/12'> + + {/* ⬇️ TAMBAHKAN BLOK INI DI DALAM BAR: STOK & STEPPER */} + <div className='grid grid-cols-12 items-center gap-3 mt-3'> + <div className='col-span-7'> + <div + className={`text-[14px] ${ + product?.sla?.qty < 10 ? 'text-red-600 font-medium' : '' + }`} + > + Stock : {activeVariant?.stock ?? 0} + </div> + {qtyPickUp > 0 && ( + <div className='text-[16px] mt-0.5 text-red-500 italic'> + * {qtyPickUp} barang bisa di pickup + </div> + )} + </div> + <div className='col-span-5 flex justify-end'> + <div className='inline-flex items-stretch border rounded-xl overflow-hidden'> + <button + type='button' + className='h-10 w-10 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95' + onClick={() => + setQuantityInput( + String(Math.max(1, Number(quantityInput || 1) - 1)) + ) + } + aria-label='Kurangi' + > + <span className='text-2xl leading-none'>–</span> + </button> <input - name='quantity' type='number' - className='form-input' - value={quantity} - onChange={(e) => setQuantity(e.target.value)} + min={1} + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className='h-10 w-16 text-center text-lg outline-none border-x + [appearance:textfield] + [&::-webkit-outer-spin-button]:appearance-none + [&::-webkit-inner-spin-button]:appearance-none' /> + <button + type='button' + className='h-10 w-10 grid place-items-center text-gray-700 hover:bg-gray-100 active:scale-95' + onClick={() => + setQuantityInput(String(Number(quantityInput || 1) + 1)) + } + aria-label='Tambah' + > + <span className='text-2xl leading-none'>+</span> + </button> </div> - <button - type='button' - className='btn-yellow flex-1' - onClick={handleClickCart} - > - Keranjang - </button> - <button - type='button' - className='btn-solid-red flex-1' - onClick={handleClickBuy} - > - Beli - </button> </div> + </div> + + <div className='h4'/> + {/* TOMBOL AKSI */} + <div className='flex gap-2 mt-3'> + {/* Tombol Dokumen */} <Button onClick={() => handleButton(product.id)} - color={'red'} - colorScheme='white' - className='w-full border-2 p-2 gap-1 mt-2 hover:bg-slate-100 flex items-center' + className='flex items-center justify-center p-2 border-2 hover:bg-slate-100' + variant='outline' + title='Lihat Dokumen' > <ImageNext - src='/images/writing.png' - alt='penawaran instan' - className='' - width={25} - height={25} + src='/images/doc.svg' + width={18} + height={18} + alt='Dokumen' /> - Penawaran Harga Instan </Button> + {/* Container untuk tombol aksi utama */} + <div className='flex-1 flex gap-2'> + <Button + onClick={() => + handleClickCart(product.id, Number(quantityInput || 1)) + } + className='flex-1' + colorScheme='red' + variant='outline' + isDisabled={product.stock === 0} + > + Keranjang + </Button> + <Button + onClick={() => + handleClickBuy(product.id, Number(quantityInput || 1)) + } + className='flex-1' + colorScheme='red' + isDisabled={product.stock === 0} + > + Beli + </Button> + </div> </div> </div> + <Image src={product.image + '?variant=True'} alt={product.name} diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx index a8ed90a4..f4f5882e 100644 --- a/src/lib/product/components/ProductCard.jsx +++ b/src/lib/product/components/ProductCard.jsx @@ -73,7 +73,7 @@ const ProductCard = ({ product, simpleTitle, variant = 'vertical' }) => { if (variant == 'vertical') { return ( - <div className='rounded shadow-sm border border-gray_r-4 bg-white h-[330px] md:h-[380px]'> + <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 diff --git a/src/lib/product/components/ProductFilterDesktop.jsx b/src/lib/product/components/ProductFilterDesktop.jsx index d2ecb4d9..440e1795 100644 --- a/src/lib/product/components/ProductFilterDesktop.jsx +++ b/src/lib/product/components/ProductFilterDesktop.jsx @@ -1,7 +1,6 @@ import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import _ from 'lodash'; -import { toQuery } from 'lodash-contrib'; import { Accordion, AccordionButton, @@ -9,7 +8,6 @@ import { AccordionItem, AccordionPanel, Box, - Button, Checkbox, Input, InputGroup, @@ -17,136 +15,200 @@ import { Stack, VStack, } from '@chakra-ui/react'; -import Image from '@/core/components/elements/Image/Image'; import { formatCurrency } from '@/core/utils/formatValue'; const ProductFilterDesktop = ({ - brands, - categories, + brands, // bisa [{id,name,qty}] atau [{brand,qty}] + categories, // [{name, qty}] prefixUrl, - defaultBrand = null, }) => { const router = useRouter(); - const { query } = router; - const [order, setOrder] = useState(query?.orderBy); - const [brandValues, setBrand] = useState(query?.brand?.split(',') || []); + + const [order, setOrder] = useState(router.query?.orderBy); + const [brandValues, setBrand] = useState( + typeof router.query?.brand === 'string' && router.query.brand + ? router.query.brand.split(',').filter(Boolean) + : [] + ); const [categoryValues, setCategory] = useState( - query?.category?.split(',') || [] + typeof router.query?.category === 'string' && router.query.category + ? router.query.category.split(',').filter(Boolean) + : [] ); - const [priceFrom, setPriceFrom] = useState(query?.priceFrom); - const [priceTo, setPriceTo] = useState(query?.priceTo); - const [stock, setStock] = useState(query?.stock); + const [priceFrom, setPriceFrom] = useState(router.query?.priceFrom ?? ''); + const [priceTo, setPriceTo] = useState(router.query?.priceTo ?? ''); + const [stock, setStock] = useState(router.query?.stock ?? null); const [activeRange, setActiveRange] = useState(null); - const [activeIndeces, setActiveIndeces] = useState([]); + + const handlePriceKeyDown = (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + // keluar dari preset kalau user input manual + setActiveRange(null); + + // pakai state terkini untuk apply + const fromVal = priceFrom === '' ? '' : String(priceFrom); + const toVal = priceTo === '' ? '' : String(priceTo); + + applyFilters({ priceFrom: fromVal, priceTo: toVal }); + }; + + // --- normalisasi data brand agar tahan banting --- + const normBrands = useMemo(() => { + return (brands ?? []) + .map((b, i) => ({ + id: String(b.id ?? b.val ?? b.brand ?? i), + name: String(b.name ?? b.brand ?? b.label ?? b.val ?? '').trim(), + qty: b.qty ?? b.count, + })) + .filter((b) => b.name); + }, [brands]); const priceRange = [ - { - priceFrom: 100000, - priceTo: 200000, - }, - { - priceFrom: 200000, - priceTo: 300000, - }, - { - priceFrom: 300000, - priceTo: 400000, - }, - { - priceFrom: 400000, - priceTo: 500000, - }, + { priceFrom: 100000, priceTo: 200000 }, + { priceFrom: 200000, priceTo: 300000 }, + { priceFrom: 300000, priceTo: 400000 }, + { priceFrom: 400000, priceTo: 500000 }, ]; - const indexRange = priceRange.findIndex((range) => { - return ( - range.priceFrom === parseInt(priceFrom) && - range.priceTo == parseInt(priceTo) - ); - }); - - const handleCategoriesChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setCategory([...categoryValues, value]); - } else { - setCategory(categoryValues.filter((val) => val !== value)); - } - }; - const handleBrandsChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setBrand([...brandValues, value]); - } else { - setBrand(brandValues.filter((val) => val !== value)); + const indexRange = priceRange.findIndex( + (r) => r.priceFrom === parseInt(priceFrom) && r.priceTo == parseInt(priceTo) + ); + + const applyFilters = (changes = {}) => { + const params = new URLSearchParams(); + + // 1) salin SEMUA param yang ada sekarang (jangan hilangkan apapun) + Object.entries(router.query).forEach(([k, v]) => { + if (v == null) return; + if (Array.isArray(v)) { + // penting: fq bisa multi-value; gunakan append, bukan join + v.forEach((item) => params.append(k, String(item))); + } else { + params.set(k, String(v)); + } + }); + + // 2) baca nilai dasar langsung dari URL (hindari state stale) + const arr = (val) => + typeof val === 'string' && val ? val.split(',').filter(Boolean) : []; + + const nextBrand = + 'brandValues' in changes ? changes.brandValues : arr(router.query.brand); + const nextCategory = + 'categoryValues' in changes + ? changes.categoryValues + : arr(router.query.category); + const nextPriceFrom = + 'priceFrom' in changes ? changes.priceFrom : router.query.priceFrom ?? ''; + const nextPriceTo = + 'priceTo' in changes ? changes.priceTo : router.query.priceTo ?? ''; + const nextStock = + 'stock' in changes ? changes.stock : router.query.stock ?? null; + const nextOrder = + 'order' in changes ? changes.order : router.query.orderBy ?? ''; + + const setOrDel = (key, val) => { + const empty = + val == null || val === '' || (Array.isArray(val) && val.length === 0); + if (empty) params.delete(key); + else params.set(key, Array.isArray(val) ? val.join(',') : String(val)); + }; + + setOrDel('brand', nextBrand); + setOrDel('category', nextCategory); + setOrDel('priceFrom', nextPriceFrom); + setOrDel('priceTo', nextPriceTo); + setOrDel('stock', nextStock); + setOrDel('orderBy', nextOrder); + + // 3) kalau ada perubahan filter utama → reset page ke 1 + const changedKeys = Object.keys(changes); + const touched = [ + 'brandValues', + 'categoryValues', + 'priceFrom', + 'priceTo', + 'stock', + 'order', + ]; + if (changedKeys.some((k) => touched.includes(k))) { + params.set('page', '1'); } + + // 4) shallow replace (tanpa reload penuh) + const base = router.asPath.split('?')[0]; + router.replace(`${base}?${params.toString()}`, undefined, { + shallow: true, + scroll: false, + }); }; - const handleReadyStockChange = (event) => { - const value = event.target.value; - const isChecked = event.target.checked; - if (isChecked) { - setStock(value); - } else { - setStock(null); - } + // debounce untuk input harga (biar nggak spam) + const debouncedApply = useMemo(() => _.debounce(applyFilters, 350), []); // eslint-disable-line + useEffect(() => () => debouncedApply.cancel(), [debouncedApply]); + + // === handlers === + const handleCategoriesChange = (e) => { + const { value, checked } = e.target; + const next = checked + ? [...categoryValues, value] + : categoryValues.filter((v) => v !== value); + setCategory(next); + applyFilters({ categoryValues: next }); }; - const handlePriceFromChange = async (priceFromr, priceTor, index) => { - await setPriceFrom(priceFromr); - await setPriceTo(priceTor); - setActiveRange(index); + const handleBrandsChange = (e) => { + const { value, checked } = e.target; // value = brand ID/name (string) + const next = checked + ? [...brandValues, value] + : brandValues.filter((v) => v !== value); + setBrand(next); + applyFilters({ brandValues: next }); }; - const handleSubmit = () => { - let params = { - penawaran: router.query.penawaran, - q: router.query.q, - orderBy: order, - brand: brandValues.join(','), - category: categoryValues.join(','), - priceFrom, - priceTo, - stock: stock, - }; - params = _.pickBy(params, _.identity); - params = toQuery(params); + const handleReadyStockChange = (e) => { + const { checked, value } = e.target; + const next = checked ? value : null; + setStock(next); + applyFilters({ stock: next }); + }; - const slug = Array.isArray(router.query.slug) - ? router.query.slug[0] - : router.query.slug; + const handlePriceRangeClick = async (pf, pt, idx) => { + await setPriceFrom(pf); + await setPriceTo(pt); + setActiveRange(idx); + applyFilters({ priceFrom: pf, priceTo: pt }); + }; - if (slug) { - if (prefixUrl.includes('category') || prefixUrl.includes('lob')) { - router.push(`${prefixUrl}?${params}`); - } else { - router.push(`${prefixUrl}/${slug}?${params}`); - } - } else { - router.push(`${prefixUrl}?${params}`); - } + const onPriceFromInput = (e) => { + setPriceFrom(e.target.value); }; - /*const handleIndexAccordion = async () => { - if (brandValues) { - await setActiveIndeces([...activeIndeces, 0]) - } - if (categoryValues) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 1 : 0]) - } - if (priceRange) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 2 : 1]) - } - if (stock) { - await setActiveIndeces([...activeIndeces, !router.pathname.includes('brands') ? 3 : 2]) - } - }*/ + const onPriceToInput = (e) => { + setPriceTo(e.target.value); + }; useEffect(() => { setActiveRange(indexRange); - }, []); + }, []); // init active range + + useEffect(() => { + setBrand( + router.query?.brand + ? String(router.query.brand).split(',').filter(Boolean) + : [] + ); + setCategory( + router.query?.category + ? String(router.query.category).split(',').filter(Boolean) + : [] + ); + setPriceFrom(router.query?.priceFrom ?? ''); + setPriceTo(router.query?.priceTo ?? ''); + setStock(router.query?.stock ?? null); + setOrder(router.query?.orderBy ?? ''); + }, [router.query]); return ( <> @@ -159,23 +221,24 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {brands && brands.length > 0 ? ( - brands.map((brand, index) => ( - <div className='flex items-center gap-2 ' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {normBrands.length > 0 ? ( + normBrands.map((b) => ( + <div className='flex items-center gap-2' key={b.id}> <Checkbox - isChecked={brandValues.includes(brand.brand)} + isChecked={brandValues.includes(String(b.id))} onChange={handleBrandsChange} - value={brand.brand} + value={String(b.id)} // idealnya ID brand size='md' > <div className='flex items-center gap-2'> - <span>{brand.brand} </span> - <span className='text-sm text-gray-600'> - ({brand.qty}) - </span> + <span>{b.name}</span> + {b.qty !== undefined && ( + <span className='text-sm text-gray-600'> + ({b.qty}) + </span> + )} </div> </Checkbox> </div> @@ -197,23 +260,20 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel> - <Stack gap={3} direction='column' maxH={'240px'} overflow='auto'> - {categories && categories.length > 0 ? ( - categories.map((category, index) => ( - <div className='flex items-center gap-2' key={index}> + <Stack gap={3} direction='column' maxH='240px' overflow='auto'> + {(categories ?? []).length > 0 ? ( + categories.map((c, i) => ( + <div className='flex items-center gap-2' key={i}> <Checkbox - isChecked={categoryValues.includes(category.name)} + isChecked={categoryValues.includes(c.name)} onChange={handleCategoriesChange} - value={category.name} + value={c.name} size='md' > <div className='flex items-center gap-2'> - <span>{category.name} </span> - <span className='text-sm text-gray-600'> - ({category.qty}) - </span> + <span>{c.name}</span> + <span className='text-sm text-gray-600'>({c.qty})</span> </div> </Checkbox> </div> @@ -234,7 +294,6 @@ const ProductFilterDesktop = ({ </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <VStack gap={4}> <InputGroup> @@ -243,32 +302,34 @@ const ProductFilterDesktop = ({ type='number' placeholder='Harga minimum' value={priceFrom} - onChange={(e) => setPriceFrom(e.target.value)} + onChange={onPriceFromInput} + onKeyDown={handlePriceKeyDown} // âźµ apply saat Enter /> </InputGroup> + <InputGroup> <InputLeftAddon>Rp</InputLeftAddon> <Input type='number' - placeholder='Harga maximum' + placeholder='Harga maksimum' value={priceTo} - onChange={(e) => setPriceTo(e.target.value)} + onChange={onPriceToInput} + onKeyDown={handlePriceKeyDown} // âźµ apply saat Enter /> </InputGroup> + <div className='grid grid-cols-2 gap-x-3 gap-y-2'> - {priceRange.map((price, i) => ( + {priceRange.map((p, i) => ( <button key={i} onClick={() => - handlePriceFromChange(price.priceFrom, price.priceTo, i) + handlePriceRangeClick(p.priceFrom, p.priceTo, i) } className={`w-full border ${ i === activeRange ? 'border-red-600' : 'border-gray-400' - } - py-2 p-3 rounded-full text-sm whitespace-nowrap`} + } py-2 p-3 rounded-full text-sm whitespace-nowrap`} > - {formatCurrency(price.priceFrom)} -{' '} - {formatCurrency(price.priceTo)} + {formatCurrency(p.priceFrom)} - {formatCurrency(p.priceTo)} </button> ))} </div> @@ -279,27 +340,22 @@ const ProductFilterDesktop = ({ {/* <AccordionItem> <AccordionButton padding={[2, 4]}> <Box as='span' flex='1' textAlign='left' fontWeight='semibold'> - Ketersedian Stok + Ketersediaan Stok </Box> <AccordionIcon /> </AccordionButton> - <AccordionPanel paddingY={4}> <Checkbox isChecked={stock === 'ready stock'} onChange={handleReadyStockChange} - value={'ready stock'} + value='ready stock' size='md' > - Ketersedian Stock + Ready Stock </Checkbox> </AccordionPanel> </AccordionItem> */} </Accordion> - - <Button className='w-full mt-6' colorScheme='red' onClick={handleSubmit}> - Terapkan - </Button> </> ); }; diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx index 2fb3138a..850d00cc 100644 --- a/src/lib/product/components/ProductSearch.jsx +++ b/src/lib/product/components/ProductSearch.jsx @@ -1,12 +1,12 @@ import NextImage from 'next/image'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useRef } from 'react'; 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 odooApi from '@/core/api/odooApi'; import searchSpellApi from '@/core/api/searchSpellApi'; import Link from '@/core/components/elements/Link/Link'; @@ -50,7 +50,33 @@ const ProductSearch = ({ const categoryId = getIdFromSlug(prefixUrl); const [data, setData] = useState([]); const [dataLob, setDataLob] = useState([]); + const appliedDefaultBrandOrder = useRef(false); + if (defaultBrand) query.brand = defaultBrand.toLowerCase(); + useEffect(() => { + if (!router.isReady) return; + + const onBrandsPage = router.pathname.includes('brands'); + const hasOrder = typeof router.query?.orderBy === 'string' && router.query.orderBy !== ''; + + if (onBrandsPage && !hasOrder && !appliedDefaultBrandOrder.current) { + let params = { + ...router.query, + orderBy: 'popular', + }; + 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; + } + }, [router.isReady, router.pathname, router.query?.orderBy, prefixUrl]); + const dataIdCategories = []; useEffect(() => { if (prefixUrl.includes('category')) { @@ -84,7 +110,7 @@ const ProductSearch = ({ if (router.asPath.includes('penawaran')) { query = { ...query, - fq:`flashsale_id_i:${router.query.penawaran} AND flashsale_price_f:[1 TO *]`, + fq: `flashsale_id_i:${router.query.penawaran} AND flashsale_price_f:[1 TO *]`, orderBy: 'flashsale-discount-desc', }; setFinalQuery(query); @@ -404,9 +430,7 @@ 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' @@ -419,7 +443,9 @@ const ProductSearch = ({ </Link> </div> )} + <h1 className='mb-2 font-semibold text-h-sm'>Produk</h1> + <FilterChoicesComponent brandValues={brandValues} categoryValues={categoryValues} @@ -428,6 +454,7 @@ const ProductSearch = ({ handleDeleteFilter={handleDeleteFilter} /> + {/* info jumlah hasil */} <div className='mb-2 leading-6 text-gray_r-11'> {!spellings ? ( <> @@ -435,8 +462,7 @@ const ProductSearch = ({ {pageCount > 1 ? ( <> {productStart + 1}- - {parseInt(productStart) + parseInt(productRows) > - productFound + {parseInt(productStart) + parseInt(productRows) > productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -448,8 +474,7 @@ const ProductSearch = ({ produk{' '} {query.q && ( <> - untuk pencarian{' '} - <span className='font-semibold'>{query.q}</span> + untuk pencarian <span className='font-semibold'>{query.q}</span> </> )} </> @@ -457,37 +482,37 @@ const ProductSearch = ({ SpellingComponent )} </div> - <LobSectionCategory categories={dataLob} /> - <CategorySection categories={dataCategories} /> {productFound > 0 && ( - <div className='flex items-center gap-x-2 mb-5 justify-between'> + <div className='flex items-center gap-x-2 mt-2 mb-3 justify-end'> <div> <button - className='btn-light py-2 px-5 h-[40px]' + aria-label='Filter' + title='Filter' onClick={popup.activate} + className='btn-light w-fit flex items-center justify-center rounded-md' > - Filter + <AdjustmentsHorizontalIcon className='w-5 h-5' /> </button> </div> - <div className=''> + <div> <select name='limit' - className='form-input w-24' + className='form-input w-20' value={router.query?.limit || ''} onChange={(e) => handleLimit(e)} > {numRows.map((option, index) => ( <option key={index} value={option}> - {' '} - {option}{' '} + {option} </option> ))} </select> </div> </div> )} - + {!!dataLob?.length && <LobSectionCategory categories={dataLob} />} + {!!dataCategories?.length && <CategorySection categories={dataCategories} />} <div className='grid grid-cols-2 gap-3'> {products && products.map((product) => ( @@ -499,7 +524,6 @@ const ProductSearch = ({ pageCount={pageCount} currentPage={parseInt(page)} url={`${prefixUrl}?${toQuery(_.omit(query, ['page', 'fq']))}`} - // url={prefixUrl.includes('category') || prefixUrl.includes('lob')? `${prefixUrl}?${toQuery(_.omit(finalQuery, ['page']))}` : `${prefixUrl}?${toQuery(_.omit(query, ['page']))}`} className='mt-6 mb-2' /> @@ -597,7 +621,7 @@ const ProductSearch = ({ <> {productStart + 1}- {parseInt(productStart) + parseInt(productRows) > - productFound + productFound ? productFound : parseInt(productStart) + parseInt(productRows)} dari @@ -673,8 +697,8 @@ const ProductSearch = ({ href={ query?.q ? whatsappUrl('productSearch', { - name: query.q, - }) + name: query.q, + }) : whatsappUrl() } className='text-danger-500' @@ -759,9 +783,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/quotation/components/Quotation.jsx b/src/lib/quotation/components/Quotation.jsx index f0791512..c7e5b16a 100644 --- a/src/lib/quotation/components/Quotation.jsx +++ b/src/lib/quotation/components/Quotation.jsx @@ -33,7 +33,7 @@ import ExpedisiList from '../../checkout/api/ExpedisiList'; import SectionQuotationExpedition from '@/lib/checkout/components/SectionQuotationExpedition'; import { useQuotation } from '@/lib/checkout/stores/stateQuotation'; -const { checkoutApi } = require('@/lib/checkout/api/checkoutApi'); +const { checkoutApi, checkoutQuotation } = require('@/lib/checkout/api/checkoutApi'); const { getProductsCheckout } = require('@/lib/checkout/api/checkoutApi'); const Quotation = () => { @@ -243,8 +243,8 @@ const Quotation = () => { note_website: note_websiteText, }; - const isSuccess = await checkoutApi({ data }); - + const isSuccess = await checkoutQuotation(data); + if (isSuccess?.id) { for (const product of products) { deleteItemCart({ productId: product.id }); diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx index 77e60dc1..96c89aec 100644 --- a/src/lib/transaction/components/Transaction.jsx +++ b/src/lib/transaction/components/Transaction.jsx @@ -43,13 +43,14 @@ import { gtagPurchase } from '@/core/utils/googleTag'; import { deleteItemCart } from '@/core/utils/cart'; import { downloadInvoice, - downloadTaxInvoice, + // downloadTaxInvoice, // (unused) } from '@/lib/invoice/utils/invoices'; import { Download } from 'lucide-react'; import axios from 'axios'; import InformationSection from '../../treckingAwb/component/InformationSection'; -import { Button } from '@chakra-ui/react'; -import { div } from 'lodash-contrib'; +// import { Button } from '@chakra-ui/react'; // (unused) +// import { div } from 'lodash-contrib'; // (unused) + const Transaction = ({ id }) => { const PPN = process.env.NEXT_PUBLIC_PPN; const router = useRouter(); @@ -73,6 +74,7 @@ const Transaction = ({ id }) => { const [toOthers, setToOthers] = useState(null); const [totalAmount, setTotalAmount] = useState(0); const [totalDiscountAmount, setTotalDiscountAmount] = useState(0); + const [contLoading, setContLoading] = useState(false); useEffect(() => { if (transaction?.data?.products) { @@ -88,6 +90,7 @@ const Transaction = ({ id }) => { setTotalDiscountAmount(calculateTotalDiscountAmount); } }, [transaction.data, transaction.isLoading]); + const submitUploadPo = async () => { const file = poFile.current.files[0]; const name = poNumber.current.value; @@ -127,10 +130,6 @@ const Transaction = ({ id }) => { } } }; - // const ContinueTransaction = () => { - // setContinueNoPo(true); - // checkoutNoPO(); - // }; const closeCancelTransaction = () => setCancelTransaction(false); const closeContinueTransaction = () => setContinueTransaction(false); @@ -138,6 +137,7 @@ const Transaction = ({ id }) => { const openRejectTransaction = () => setRejectTransaction(true); const closeRejectTransaction = () => setRejectTransaction(false); + const submitCancelTransaction = async () => { const isCancelled = await cancelTransactionApi({ transaction: transaction.data, @@ -148,6 +148,7 @@ const Transaction = ({ id }) => { } closeCancelTransaction(); }; + const checkout = async () => { if (!transaction.data?.purchaseOrderFile) { toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan'); @@ -194,25 +195,6 @@ const Transaction = ({ id }) => { } toast.success('Berhasil melanjutkan pesanan'); transaction.refetch(); - // console.log(transaction); - - /* const midtrans = async () => { - for (const product of products) deleteItemCart({ productId: product.id }); - if (grandTotal > 0) { - const payment = await axios.post( - `${process.env.NEXT_PUBLIC_SELF_HOST}/api/shop/midtrans-payment?transactionId=${isCheckouted.id}` - ); - setIsLoading(false); - window.location.href = payment.data.redirectUrl; - } else { - window.location.href = `${ - process.env.NEXT_PUBLIC_SELF_HOST - }/shop/checkout/success?order_id=${isCheckouted.name.replace( - /\//g, - '-' - )}`; - } - };*/ }; const handleApproval = async () => { @@ -227,6 +209,73 @@ const Transaction = ({ id }) => { transaction.refetch(); }; + // ===== Bayar Sekarang (pakai link dari backend; fallback generate via Next API) ===== + const handlePayNow = async () => { + try { + setContLoading(true); + + const base = (process.env.NEXT_PUBLIC_ODOO_API_HOST || '').replace( + /\/$/, + '' + ); + const token = auth?.token; + const partnerId = auth?.partnerId; + + // 1) Minta Odoo ensure payment link + const { data: resp } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${transaction.data.id}`, + { + params: { ensure_payment_link: 1, ts: Date.now() }, + headers: { Token: token }, + } + ); + + // console.log('API Response:', resp); // Debug + + // 2) Akses semua kemungkinan path + let url = + resp?.result?.payment_summary?.redirect_url || + resp?.data?.result?.payment_summary?.redirect_url || + resp?.payment_summary?.redirect_url || + resp?.paymentSummary?.redirectUrl || + ''; + + // console.log('Extracted URL:', url); // Debug + + if (url) { + window.location.href = url; + return; + } + + // 3) Fallback + await transaction.refetch(); + // console.log('Transaction data:', transaction.data); // Debug + + url = + transaction?.data?.result?.payment_summary?.redirect_url || + transaction?.data?.paymentSummary?.redirectUrl || + transaction?.data?.payment_summary?.redirect_url || + ''; + + // console.log('Fallback URL:', url); // Debug + + if (url) { + window.location.href = url; + return; + } + + throw new Error('Link pembayaran belum tersedia.'); + } catch (e) { + toast.error( + e?.response?.data?.description || + e?.message || + 'Gagal membuka pembayaran' + ); + } finally { + setContLoading(false); + } + }; + const memoizeVariantGroupCard = useMemo( () => ( <div className='p-4 pt-0 flex flex-col gap-y-3'> @@ -314,7 +363,7 @@ const Transaction = ({ id }) => { navigator.clipboard.writeText(textToCopy); setCopied(true); toast.success('No Resi Berhasil di Copy'); - setTimeout(() => setCopied(false), 2000); // Reset copied state after 2 seconds + setTimeout(() => setCopied(false), 2000); }; const formatDate = (dateString) => { @@ -336,7 +385,7 @@ const Transaction = ({ id }) => { const [day, month, year] = dateString.split('/'); return `${day} ${months[parseInt(month, 10) - 1]} ${year}`; }; - // console.log(transaction); + return ( transaction.data?.name && ( <> @@ -366,6 +415,7 @@ const Transaction = ({ id }) => { </button> </div> </BottomPopup> + <BottomPopup active={cancelTransaction} close={closeCancelTransaction} @@ -452,48 +502,53 @@ const Transaction = ({ id }) => { active={toOthers} close={() => setToOthers(null)} > - <div className='flex flex-col gap-y-4 mt-2'> + {transaction.data?.status === 'draft' && ( + <> <button className='text-left disabled:opacity-60' - disabled={!toOthers?.purchaseOrderFile} + disabled={toOthers?.status != 'draft'} onClick={() => { - downloadPurchaseOrder(toOthers); + downloadQuotation(toOthers); setToOthers(null); }} > - Download PO + Download Quotation </button> <button className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'draft'} + disabled={toOthers?.status != 'waiting'} onClick={() => { - downloadQuotation(toOthers); + setToCancel(toOthers); setToOthers(null); }} > - Download Quotation + Batalkan Transaksi </button> + </> + )} + <div className='flex flex-col gap-y-4 mt-2'> <button className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'waiting'} + disabled={!toOthers?.purchaseOrderFile} onClick={() => { - setToCancel(toOthers); + downloadPurchaseOrder(toOthers); setToOthers(null); }} > - Batalkan Transaksi + Download PO </button> </div> </BottomPopup> <Manifest idAWB={idAWB} closePopup={closePopup}></Manifest> + {/* ============ MOBILE ============ */} <MobileView> <div className='px-4'> <div className='flex flex-row w-full justify-between items-center py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50 gap-2'> - <div class='flex items-center w-full ' role='alert'> + <div className='flex items-center w-full ' role='alert'> <svg - class='flex-shrink-0 inline w-4 h-4 mr-2' + className='flex-shrink-0 inline w-4 h-4 mr-2' aria-hidden='true' fill='currentColor' viewBox='0 0 20 20' @@ -514,6 +569,7 @@ const Transaction = ({ id }) => { </span> </div> </div> + {auth?.feature?.soApproval && ( <div className='p-4'> <StepApproval @@ -559,7 +615,7 @@ const Transaction = ({ id }) => { <Divider /> <div className='flex flex-col gap-y-4 p-4'> - <h4 className="font-semibold">Detail Order</h4> + <h4 className='font-semibold'>Detail Order</h4> <DescriptionRow label='No Transaksi'> <p className='font-semibold'>{transaction.data?.name}</p> </DescriptionRow> @@ -579,9 +635,11 @@ const Transaction = ({ id }) => { <Divider /> <div className='flex flex-col gap-y-4 p-4'> - <h4 className="font-semibold">Alamat Pengiriman</h4> + <h4 className='font-semibold'>Alamat Pengiriman</h4> <DescriptionRow label='Nama Penerima'> - <p className='font-semibold'>{transaction?.data?.address?.customer?.name}</p> + <p className='font-semibold'> + {transaction?.data?.address?.customer?.name} + </p> </DescriptionRow> <DescriptionRow label='No. Telp'> {transaction?.data?.address?.customer?.phone @@ -602,9 +660,7 @@ const Transaction = ({ id }) => { <div className='p-4'> <div className='font-medium mb-4'>Info Pengiriman</div> {transaction?.data?.pickings.length == 0 && ( - <div className='badge-red text-sm'> - Belum ada pengiriman - </div> + <div className='badge-red text-sm'>Belum ada pengiriman</div> )} {transaction?.data?.pickings?.map((airway) => ( <div @@ -627,28 +683,7 @@ const Transaction = ({ id }) => { </button> </div> </div> - // <button - // className='shadow rounded-md p-3 text-gray_r-12 font-normal flex justify-between items-center text-left h-20' - // key={airway?.id} - // onClick={() => setIdAWB(airway?.id)} - // > - // <div> - // <span className='text-sm text-gray_r-11'> - // No Resi : {airway?.trackingNumber || '-'}{' '} - // </span> - // <p className='mt-1 font-medium'>{airway?.name}</p> - // </div> - // <div className='flex gap-x-2'> - // <div className='text-sm text-gray-600 badge-green leading-[1.5] mt-1'> - // {airway?.delivered - // ? 'Pesanan Tiba' - // : 'Sedang Dikirim'} - // </div> - // <ChevronRightIcon className='w-5 stroke-2' /> - // </div> - // </button> ))} - </div> <Divider /> @@ -717,17 +752,20 @@ const Transaction = ({ id }) => { <div className='font-medium p-4'>Detail Produk</div> {transaction?.data?.products.length > 0 ? ( <div className='p-4 pt-0 flex flex-col gap-y-3'> - <VariantGroupCard variants={transaction.data?.products}/> + <VariantGroupCard variants={transaction.data?.products} /> <div className='font-medium'>Rincian Pembayaran</div> <div className='flex justify-between mt-1'> <p className='text-gray_r-12/70'>Metode Pembayaran</p> - <p> - {transaction.data?.paymentTerm || '-'} - </p> + <p>{transaction.data?.paymentTerm || '-'}</p> </div> <div className='flex justify-between mt-1'> <p className='text-gray_r-12/70'>Berat Barang</p> - <p>{(transaction.data?.products?.reduce((total, item) => total + (item.weight || 0), 0)) + ' Kg'}</p> + <p> + {transaction.data?.products?.reduce( + (total, item) => total + (item.weight || 0), + 0 + ) + ' Kg'} + </p> </div> <hr className='mt-1 border border-gray-100' /> <div className='flex justify-between mt-1'> @@ -774,12 +812,7 @@ const Transaction = ({ id }) => { </div> )} - {/* <Divider /> */} - - {/* <SectionAddress address={transaction.data?.address} /> */} - - {/* <Divider /> */} - + {/* Tombol aksi (Mobile) */} {transaction.data?.status === 'draft' && ( <div className='p-4 pt-0'> <button @@ -796,18 +829,33 @@ const Transaction = ({ id }) => { Batalkan Transaksi </button> {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( - <button - className='btn-yellow w-full mt-4' - onClick={openContinueTransaction} - > - Lanjutkan Transaksi - </button> - )} + transaction?.data?.purchaseOrderFile && ( + <button + className='btn-yellow w-full mt-4' + onClick={openContinueTransaction} + > + Lanjutkan Transaksi + </button> + )} + </div> + )} + + {/* Bayar Sekarang (Mobile) — tampil jika eligible */} + {transaction.data?.eligibleContinue && ( + <div className='p-4 pt-0'> + <button + type='button' + disabled={contLoading} + onClick={handlePayNow} + className='w-full py-2 text-center rounded-md border border-red-500 text-red-500 bg-white disabled:opacity-60' + > + {contLoading ? 'Memproses…' : 'Bayar Sekarang'} + </button> </div> )} </MobileView> + {/* ============ DESKTOP ============ */} <DesktopView> <div className='container mx-auto flex py-10'> <div className='w-3/12 pr-4'> @@ -827,44 +875,36 @@ const Transaction = ({ id }) => { )} </div> - {/*new-release*/} - {/*<div className='flex items-center justify-between mb-3'>*/} - {/* <div className='flex items-center gap-x-2'>*/} - {/* <span className='text-h-sm font-medium'>*/} - {/* {transaction?.data?.name}*/} - {/* </span>*/} - {/* <TransactionStatusBadge status={transaction?.data?.status} />*/} - {/* </div>*/} - {/* <div className='text-h-sm'>*/} - {/* Estimasi Barang Siap:{' '}*/} - {/* <span className='text-red-500 font-semibold'>*/} - {/* {transaction?.data?.expectedReadyToShip}*/} - {/* </span>*/} - {/* </div>*/} - <div className='flex items-center gap-x-2 mb-3'> - <span className='text-h-sm font-medium'> - {transaction?.data?.name} - </span> - <TransactionStatusBadge status={transaction?.data?.status} /> - {transaction.data?.status === 'draft' && ( - <div className='flex items-center justify-between w-full'> - <button - type='button' - className='btn-light px-3 py-2' - onClick={() => downloadQuotation(transaction.data)} - > - <Download size={12} /> - </button> + {/* HEADER (Desktop) — sejajarkan kiri & kanan */} + <div className='flex items-center justify-between gap-3 mb-3'> + {/* Kiri: SO + badge */} + <div className='flex items-center gap-x-2 min-w-0'> + <span className='text-h-sm font-medium truncate'> + {transaction?.data?.name} + </span> + <TransactionStatusBadge status={transaction?.data?.status} /> + </div> + + {/* Kanan: aksi */} + <div className='flex items-center gap-3'> + {transaction.data?.status === 'draft' && ( + <> + <button + type='button' + className='btn-light px-3 py-2' + onClick={() => downloadQuotation(transaction.data)} + > + <Download size={12} /> + </button> - <div className="flex gap-x-4"> <button className='btn-solid-red' onClick={openCancelTransaction} > Batalkan Transaksi </button> - {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( + + {transaction?.data?.purchaseOrderFile && ( <button className='btn-yellow' onClick={openContinueTransaction} @@ -872,37 +912,20 @@ const Transaction = ({ id }) => { Lanjutkan Transaksi </button> )} - </div> - </div> - )} - </div> - {/* {transaction.data?.status === 'draft' && ( - <div className='flex gap-x-4'> - <button - type='button' - className='btn-light px-3 py-2 mr-auto' - onClick={() => downloadQuotation(transaction.data)} - > - <Download size={12} /> - </button> - <button - className='btn-solid-red' - onClick={openCancelTransaction} - > - Batalkan Transaksi - </button> - - {transaction.data?.status == 'draft' && - transaction?.data?.purchaseOrderFile && ( - <button - className='btn-yellow' - onClick={openContinueTransaction} - > - Lanjutkan Transaksi - </button> - )} + </> + )} + + {transaction.data?.eligibleContinue && ( + <button + className='px-4 py-2 rounded-md border border-red-500 text-red-500 bg-white disabled:opacity-60 mb-3' + disabled={contLoading} + onClick={handlePayNow} + > + {contLoading ? 'Memproses…' : 'Bayar Sekarang'} + </button> + )} </div> - )} */} + </div> <div className='grid grid-cols-2 gap-x-6 mt-4'> <div className='grid grid-cols-[35%_65%] gap-y-4'> @@ -967,29 +990,13 @@ const Transaction = ({ id }) => { key={index} > {invoice?.name} - {/* <div className='shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between'> - <div> - <p className='mb-1'>{invoice?.name}</p> - <div className='flex items-center gap-x-1'> - {invoice.amountResidual > 0 ? ( - <div className='badge-red'>Belum Lunas</div> - ) : ( - <div className='badge-green'>Lunas</div> - )} - <p className='text-caption-2 text-gray_r-11'> - {currencyFormat(invoice.amountTotal)} - </p> - </div> - </div> - <ChevronRightIcon className='w-5 stroke-2' /> - </div> */} </Link> ))} </div> </div> </div> <hr className='mt-4 mb-4 border border-gray-100' /> - {/* <div className='grid grid-cols-2 gap-x-6'> */} + <div className='flex flex-row justify-between items-start w-full h-fit '> <div className='flex flex-col w-1/2 justify-start items-start'> <span className='text-h-sm font-medium mb-2'> @@ -1039,17 +1046,17 @@ const Transaction = ({ id }) => { ) : ( '-' )} - {transaction?.data?.carrierId !== 32 &&( - <> - <div>Jenis Service</div> - <div>: </div> - <div> - {' '} - {transaction?.data?.serviceType - ? transaction?.data?.serviceType - : '-'} - </div> - </> + {transaction?.data?.carrierId !== 32 && ( + <> + <div>Jenis Service</div> + <div>: </div> + <div> + {' '} + {transaction?.data?.serviceType + ? transaction?.data?.serviceType + : '-'} + </div> + </> )} <div>Estimasi Tanggal Kirim</div> @@ -1059,41 +1066,42 @@ const Transaction = ({ id }) => { ? transaction?.data?.expectedReadyToShip : '-'} </div> - {transaction?.data?.carrierId !== 32 &&( - <> - <div>Estimasi Tiba</div> - <div>: </div> - <div className=''> - {transaction?.data?.etaDateStart && transaction?.data?.etaDateEnd ? ( - `${transaction.data.etaDateStart} - ${transaction.data.etaDateEnd}` - ) : ( - '-' - )} - </div> - </> + {transaction?.data?.carrierId !== 32 && ( + <> + <div>Estimasi Tiba</div> + <div>: </div> + <div className=''> + {transaction?.data?.etaDateStart && + transaction?.data?.etaDateEnd + ? `${transaction.data.etaDateStart} - ${transaction.data.etaDateEnd}` + : '-'} + </div> + </> )} - {transaction?.data?.pickings[0] && transaction?.data?.carrierId !== 32 && ( - <div className='w-full bagian-informasi col-span-3'> - <div - class='flex items-center w-fit py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' - role='alert' - > - <svg - class='flex-shrink-0 inline w-4 h-4 mr-2' - aria-hidden='true' - fill='currentColor' - viewBox='0 0 20 20' + {transaction?.data?.pickings[0] && + transaction?.data?.carrierId !== 32 && ( + <div className='w-full bagian-informasi col-span-3'> + <div + className='flex items-center w-fit py-2 px-3 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' + role='alert' > - <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> - </svg> - <div className='text-justify flex flex-col gap-1'> - <span className='text-black text-xs'> - Pesanan anda mungkin mengalami keterlambatan tiba - </span> + <svg + className='flex-shrink-0 inline w-4 h-4 mr-2' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 20 20' + > + <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> + </svg> + <div className='text-justify flex flex-col gap-1'> + <span className='text-black text-xs'> + Pesanan anda mungkin mengalami keterlambatan + tiba + </span> + </div> </div> </div> - </div> - )} + )} </div> </div> </div> @@ -1103,9 +1111,7 @@ const Transaction = ({ id }) => { </div> <div className='grid grid-cols-1 md:grid-cols-2 gap-3'> {transaction?.data?.pickings.length == 0 && ( - <div className='badge-red text-sm'> - Belum ada pengiriman - </div> + <div className='badge-red text-sm'>Belum ada pengiriman</div> )} {transaction?.data?.pickings?.map((airway) => ( <div @@ -1129,39 +1135,9 @@ const Transaction = ({ id }) => { </div> </div> ))} - {/* </div> */} - </div> <div className='flex '> - {/*New release*/} - {/* <div className='grid grid-cols-1 gap-1 w-2/3'>*/} - {/* {transaction?.data?.pickings?.map((airway) => (*/} - {/* <button*/} - {/* className='shadow rounded-md p-3 text-gray_r-12 font-normal flex justify-between items-center text-left h-20'*/} - {/* key={airway?.id}*/} - {/* onClick={() => setIdAWB(airway?.id)}*/} - {/* >*/} - {/* <div>*/} - {/* <p className='text-sm text-gray_r-11'>*/} - {/* {airway?.name}*/} - {/* </p>*/} - {/* <span className='text-md text-bold mt-1'>*/} - {/* No Resi : {airway?.trackingNumber || '-'}{' '}*/} - {/* </span>*/} - {/* </div>*/} - {/* <div className='flex gap-x-2'>*/} - {/* <div className='text-sm text-gray-600 badge-green leading-[1.5] mt-1 text-center'>*/} - {/* {airway?.delivered*/} - {/* ? 'Pesanan Tiba'*/} - {/* : 'Sedang Dikirim'}*/} - {/* </div>*/} - {/* <ChevronRightIcon className='w-5 stroke-2' />*/} - {/* </div>*/} - {/* </button>*/} - {/* ))}*/} - {/* </div>*/} - {/*</div>*/} <div className='invoice w-1/2 '> <div className='text-h-sm font-semibold mt-10 mb-4 '> Invoice @@ -1202,7 +1178,6 @@ const Transaction = ({ id }) => { <thead> <tr> <th>Nama Produk</th> - {/* <th>Diskon</th> */} <th>Jumlah</th> <th>Harga</th> <th>Subtotal</th> @@ -1280,24 +1255,13 @@ const Transaction = ({ id }) => { )} </div> </td> - {/* <td> - {product.price.discountPercentage > 0 - ? `${product.price.discountPercentage}%` - : ''} - </td> */} <td>{product.quantity}</td> <td> - {/* {product.price.discountPercentage > 0 && ( - <div className='line-through mb-1 text-caption-1 text-gray_r-12/70'> - {currencyFormat(product.price.price)} - </div> - )} */} <div> {currencyFormat(product.price.priceDiscount)} </div> </td> <td>{currencyFormat(product.price.subtotal)}</td> - {/* {auth?.feature.soApproval && (auth.webRole == 2 || auth.webRole == 3) && (transaction.data.isReaject == false) && ( */} {auth?.feature.soApproval && (auth.webRole == 2 || auth.webRole == 3) && router.asPath.includes('/my/quotations/') && @@ -1354,34 +1318,6 @@ const Transaction = ({ id }) => { )} {transaction?.data?.products?.length > 0 && ( - // <div className='flex justify-end mt-4'> - // <div className='w-1/4 grid grid-cols-2 gap-y-3 text-gray_r-12/80'> - // <div className='text-right'>Subtotal</div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.amountUntaxed)} - // </div> - - // <div className='text-right'> - // PPN {((PPN - 1) * 100).toFixed(0)}% - // </div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.amountTax)} - // </div> - - // <div className='text-right whitespace-nowrap'> - // Biaya Pengiriman - // </div> - // <div className='text-right font-medium'> - // {currencyFormat(transaction.data?.deliveryAmount)} - // </div> - - // <div className='text-right'>Grand Total</div> - // <div className='text-right font-medium text-gray_r-12'> - // {currencyFormat(transaction.data?.amountTotal)} - // </div> - // </div> - // </div> - <div className='flex justify-end mt-4 flex-col items-end'> <div className='w-1/4 grid grid-cols-2 gap-y-3 text-gray_r-12/80'> <div className='text-right'>Total Belanja</div> @@ -1437,7 +1373,6 @@ const Transaction = ({ id }) => { <thead> <tr> <th>Nama Produk</th> - {/* <th>Diskon</th> */} <th>Jumlah</th> <th>Harga</th> <th>Subtotal</th> @@ -1480,18 +1415,8 @@ const Transaction = ({ id }) => { </div> </div> </td> - {/* <td> - {product.price.discountPercentage > 0 - ? `${product.price.discountPercentage}%` - : ''} - </td> */} <td>{product.quantity}</td> <td> - {/* {product.price.discountPercentage > 0 && ( - <div className='line-through mb-1 text-caption-1 text-gray_r-12/70'> - {currencyFormat(product.price.price)} - </div> - )} */} <div> {currencyFormat(product.price.priceDiscount)} </div> @@ -1512,58 +1437,6 @@ const Transaction = ({ id }) => { </div> </div> </DesktopView> - - {/* {queryAirwayBill.data?.airways?.map((airway) => ( - <BottomPopup - key={airway.waybillNumber} - title='Detail Pengiriman' - active={airwayBillPopup == airway.waybillNumber} - close={() => setAirwayBillPopup(null)} - > - <div className='flex flex-col gap-y-4 my-4'> - <div className='flex justify-between'> - <div className='text-gray_r-11'>No Pengiriman</div> - <div>{airway?.deliveryOrder?.name}</div> - </div> - <div className='flex justify-between'> - <div className='text-gray_r-11'>Kurir</div> - <div>{airway?.deliveryOrder?.carrier}</div> - </div> - <div className='flex justify-between'> - <div className='text-gray_r-11'>No Resi</div> - <div>{airway?.waybillNumber}</div> - </div> - </div> - - <div className='pt-4'> - <div className='font-semibold text-body-1 mb-4'>Status Pengiriman</div> - <ol class='relative border-l border-gray_r-7'> - {airway?.manifests?.map((manifest, index) => ( - <li class='mb-6 ml-4' key={index}> - <div - class={`absolute w-3 h-3 rounded-full mt-1.5 -left-1.5 border ${ - index == 0 ? 'bg-red-600 border-red-600' : 'bg-gray_r-7 border-white' - }`} - /> - <time class='text-sm leading-none text-gray-400'> - {manifest.datetime} - </time> - <p - class={`leading-6 font-medium text-body-2 mt-2 ${ - index == 0 ? 'text-red-600' : 'text-gray_r-11' - }`} - > - {manifest.description} - </p> - </li> - ))} - {(!airway?.manifests || airway?.manifests?.length == 0) && ( - <div className='badge-red text-sm'>Belum ada pengiriman</div> - )} - </ol> - </div> - </BottomPopup> - ))} */} </> ) ); @@ -1589,24 +1462,7 @@ const SectionAddress = ({ address }) => { {section.customer && <SectionContent address={address?.customer} />} - {/* <Divider /> - - <SectionButton - label='Detail Pengiriman' - active={section.shipping} - toggle={() => toggleSection('shipping')} - /> - - {section.shipping && <SectionContent address={address?.shipping} />} - - <Divider /> - - <SectionButton - label='Detail Penagihan' - active={section.invoice} - toggle={() => toggleSection('invoice')} - /> - {section.invoice && <SectionContent address={address?.invoice} />} */} + {/* Bagian shipping/invoice disembunyikan */} </> ); }; diff --git a/src/lib/transaction/components/TransactionStatusBadge.jsx b/src/lib/transaction/components/TransactionStatusBadge.jsx index cb8cbcd9..d23b17cd 100644 --- a/src/lib/transaction/components/TransactionStatusBadge.jsx +++ b/src/lib/transaction/components/TransactionStatusBadge.jsx @@ -4,6 +4,10 @@ const TransactionStatusBadge = ({ status }) => { text: '' } switch (status) { + case 'belum_bayar': + badgeProps.className.push('badge-solid-red') + badgeProps.text = 'Belum Bayar' + break case 'cancel': badgeProps.className.push('badge-solid-red') badgeProps.text = 'Pesanan Batal' diff --git a/src/lib/transaction/components/Transactions.jsx b/src/lib/transaction/components/Transactions.jsx index 5e37be50..600518fa 100644 --- a/src/lib/transaction/components/Transactions.jsx +++ b/src/lib/transaction/components/Transactions.jsx @@ -1,11 +1,11 @@ +import axios from 'axios'; import { useRouter } from 'next/router'; -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { toast } from 'react-hot-toast'; import { EllipsisVerticalIcon, MagnifyingGlassIcon, ChevronDownIcon, - ChevronUpIcon, } from '@heroicons/react/24/outline'; import useAuth from '@/core/hooks/useAuth'; import { @@ -38,17 +38,13 @@ import { Navigation } from 'swiper'; import 'swiper/css'; import 'swiper/css/navigation'; import { Calendar } from 'lucide-react'; -import DatePicker from 'react-datepicker'; -import 'react-datepicker/dist/react-datepicker.css'; import { DateRangePicker } from 'react-date-range'; -import { addDays } from 'date-fns'; -import 'react-date-range/dist/styles.css'; // main style file -import 'react-date-range/dist/theme/default.css'; // theme css file -import { Popover } from '@headlessui/react'; +import 'react-date-range/dist/styles.css'; +import 'react-date-range/dist/theme/default.css'; + const Transactions = ({ context = '' }) => { const auth = useAuth(); const router = useRouter(); - const swiperRef = useRef(null); const { q = '', page = 1, @@ -59,15 +55,11 @@ const Transactions = ({ context = '' }) => { startDate = null, endDate = new Date(), } = router.query; - const { - productCart, - setRefreshCart, - setProductCart, - refreshCart, - isLoading, - setIsloading, - } = useProductCartContext(); + const { setRefreshCart } = useProductCartContext(); + const [inputQuery, setInputQuery] = useState(q); + const [cachedAllData, setCachedAllData] = useState(null); // Simpan data "All" + const [currentData, setCurrentData] = useState([]); // Data yang ditampilkan const [toOthers, setToOthers] = useState(null); const [toCancel, setToCancel] = useState(null); const [listSites, setListSites] = useState([]); @@ -75,17 +67,16 @@ const Transactions = ({ context = '' }) => { const [siteFilter, setSiteFilter] = useState(site); const [pageNew, setPageNew] = useState(page); const [limitNew, setLimitNew] = useState(limit); - // const [status, setStatus] = useState('idle'); const [statusNew, setStatusNew] = useState(status); const [sortNew, setSortNew] = useState(sort); const [contextNew, setcontextNew] = useState(router.query.context || context); - const [dateRange, setDateRange] = useState([null, null]); - // const [startDate, endDate] = dateRange; const [isOpenCalender, setIsOpenCalender] = useState(false); - const [cachedAllData, setCachedAllData] = useState(null); // Simpan data "All" - const [currentData, setCurrentData] = useState([]); // Data yang ditampilkan const calendarRef = useRef(null); - const [isDateSelected, setIsDateSelected] = useState(false); + const isUnpaid = (s) => + ['belum_bayar'].includes(String(s || '').toLowerCase()); + + // loading id utk tombol lanjutkan transaksi + const [contLoadingId, setContLoadingId] = useState(null); const parseDate = (date) => { if (!date || date === 'null') return null; @@ -96,7 +87,7 @@ const Transactions = ({ context = '' }) => { const [state, setState] = useState([ { - startDate: startDate != null || 'null' ? parseDate(startDate) : null, // Gunakan `parseDate` + startDate: startDate != null || 'null' ? parseDate(startDate) : null, endDate: startDate == null ? endDate : parseDate(endDate), key: 'selection', }, @@ -116,9 +107,11 @@ const Transactions = ({ context = '' }) => { site: siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), }; + const statuses = [ { id: 'all', label: 'Semua' }, { id: 'quotation', label: 'Pending Quotation' }, + { id: 'belum_bayar', label: 'Belum Bayar' }, { id: 'diproses', label: 'Pesanan Diproses' }, { id: 'dikemas', label: 'Pesanan Dikemas' }, { id: 'partial', label: 'Dikirim Sebagian' }, @@ -135,6 +128,7 @@ const Transactions = ({ context = '' }) => { shipping: 'Pesanan Dikirim', done: 'Pesanan Selesai', cancel: 'Pesanan Dibatalkan', + belum_bayar: 'Belum Bayar', }; const sortes = [ @@ -142,16 +136,16 @@ const Transactions = ({ context = '' }) => { { id: 'asc', label: 'dari yang terkecil' }, { id: 'desc', label: 'dari yang terbesar' }, ]; + const { transactions } = useTransactions({ query }); + const fetchSite = async () => { const site = await getSite(); setListSites(site.sites); }; const submitCancelTransaction = async () => { - const isCancelled = await cancelTransactionApi({ - transaction: toCancel, - }); + const isCancelled = await cancelTransactionApi({ transaction: toCancel }); if (isCancelled) { toast.success('Berhasil batalkan transaksi'); transactions.refetch(); @@ -159,23 +153,16 @@ const Transactions = ({ context = '' }) => { setToCancel(null); }; - const pageCount = Math.ceil(transactions?.data?.saleOrderTotal / limitNew); - let pageQuery = _.omit(query, ['limit', 'offset', 'context']); - pageQuery = _.pickBy( - pageQuery, - (value, key) => value !== '' && !(key === 'page' && value === '1') + const pageCount = Math.ceil( + (transactions?.data?.saleOrderTotal || 0) / (limitNew || 1) ); - pageQuery = toQuery(pageQuery); const handleSubmit = (e) => { e.preventDefault(); const queryParams = {}; if (inputQuery) queryParams.q = inputQuery; if (siteFilter) queryParams.site = siteFilter; - router.push({ - pathname: router.pathname, - query: queryParams, - }); + router.push({ pathname: router.pathname, query: queryParams }); }; const handleSiteFilterChange = (e) => { @@ -183,10 +170,7 @@ const Transactions = ({ context = '' }) => { const queryParams = {}; if (inputQuery) queryParams.q = inputQuery; if (e.target.value) queryParams.site = e.target.value; - router.push({ - pathname: router.pathname, - query: queryParams, - }); + router.push({ pathname: router.pathname, query: queryParams }); }; const exportToExcel = (data, siteFilter) => { @@ -201,19 +185,17 @@ const Transactions = ({ context = '' }) => { ]; const rowsToExport = []; - data.forEach((saleOrder) => { + (data || []).forEach((saleOrder) => { const row = { 'No. Transaksi': saleOrder.name, 'No. PO': saleOrder.purchaseOrderName || '-', Tanggal: saleOrder.dateOrder || '-', - 'Created By': saleOrder.address.customer?.name || '-', + 'Created By': saleOrder.address?.customer?.name || '-', Salesperson: saleOrder.sales, Total: currencyFormat(saleOrder.amountTotal), Status: contextLabelMap[saleOrder.status] || saleOrder.status, }; - if (siteFilter) { - row['Site'] = siteFilter; - } + if (siteFilter) row['Site'] = siteFilter; rowsToExport.push(row); }); @@ -226,13 +208,30 @@ const Transactions = ({ context = '' }) => { XLSX.writeFile(workbook, 'transactions.xlsx'); }; + const getAllData = async () => { + const qobj = { + name: q, + offset: (pageNew - 1) * limitNew, + limit: limitNew, + context: contextNew[statusNew] || 'all', + sort: sortNew, + startDate: state[0]?.startDate + ? state[0].startDate.toLocaleDateString('id-ID') + : null, + endDate: state[0]?.endDate + ? state[0].endDate.toLocaleDateString('id-ID') + : null, + site: + siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), + }; + const queryString = toQuery(qobj); + const data = await transactionsApi({ query: queryString }); + return data; + }; + const handleExportCSV = async () => { const dataToExport = await getAllData(); - exportToCSV(dataToExport?.saleOrders, siteFilter); - }; - - const exportToCSV = (data, siteFilter) => { const fieldsToExport = [ 'No. Transaksi', 'No. PO', @@ -242,28 +241,23 @@ const Transactions = ({ context = '' }) => { 'Total', 'Status', ]; - - if (siteFilter) { - fieldsToExport.push('Site'); - } - - const rowsToExport = data.map((saleOrder) => { - const row = [ - saleOrder.name, - saleOrder.purchaseOrderName || '-', - saleOrder.dateOrder || '-', - saleOrder.address.customer?.name || '-', - saleOrder.sales, - currencyFormat(saleOrder.amountTotal), - contextLabelMap[saleOrder.status] || saleOrder.status, - ]; - - if (siteFilter) { - row.push(siteFilter); - } - - return row.join(','); - }); + const rowsToExport = + dataToExport?.saleOrders?.map((saleOrder) => { + const row = [ + saleOrder.name, + saleOrder.purchaseOrderName || '-', + saleOrder.dateOrder || '-', + saleOrder.address?.customer?.name || '-', + saleOrder.sales, + currencyFormat(saleOrder.amountTotal), + (contextLabelMap[saleOrder.status] || saleOrder.status || '').replace( + /,/g, + ' ' + ), + ]; + if (siteFilter) row.push((siteFilter || '').replace(/,/g, ' ')); + return row.join(','); + }) || []; const csvContent = 'data:text/csv;charset=utf-8,' + @@ -278,66 +272,31 @@ const Transactions = ({ context = '' }) => { document.body.removeChild(link); }; - const getAllData = async () => { - const query = { - name: q, - offset: (pageNew - 1) * limitNew, - limit: limitNew, - context: contextNew[statusNew] || 'all', - sort: sortNew, - startDate: state[0]?.startDate - ? state[0].startDate.toLocaleDateString('id-ID') - : null, - endDate: state[0]?.endDate - ? state[0].endDate.toLocaleDateString('id-ID') - : null, - site: - siteFilter || (auth?.webRole === null && auth?.site ? auth.site : null), - }; - const queryString = toQuery(query); - const data = await transactionsApi({ query: queryString }); - return data; - }; - const handleExportExcel = async () => { const dataToExport = await getAllData(); - exportToExcel(dataToExport?.saleOrders, siteFilter); }; const handleDownload = (format) => { - handleExport(format); + if (format === 'csv') handleExportCSV(); + else if (format === 'xlsx') handleExportExcel(); setIsOpen(false); }; - const handleExport = (format) => { - if (format === 'csv') { - handleExportCSV(); - } else if (format === 'xlsx') { - handleExportExcel(); - } - }; - useEffect(() => { const handleClickOutside = (event) => { - if ( - calendarRef.current && - !calendarRef.current.contains(event.target) - ) { + if (calendarRef.current && !calendarRef.current.contains(event.target)) { setIsOpenCalender(false); } }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const startItem = 1 + (pageNew - 1) * limitNew; const endItem = Math.min( limitNew * pageNew, - transactions?.data?.saleOrderTotal + transactions?.data?.saleOrderTotal || 0 ); useEffect(() => { @@ -346,11 +305,8 @@ const Transactions = ({ context = '' }) => { const handleBuyBack = async (products) => { try { - // setStatus('loading'); - console.log("Products to add:", products); - const results = await Promise.all( - products.map((product) => + (products || []).map((product) => upsertUserCart({ userId: auth.id, type: 'product', @@ -359,51 +315,42 @@ const Transactions = ({ context = '' }) => { selected: true, source: 'add_to_cart', qtyAppend: true, - }).catch(error => { - return { error, product }; - }) + }).catch((error) => ({ error, product })) ) ); - const failedOperations = results.filter(result => result && result.error); - // console.log(results); - - if (failedOperations.length > 0) { - console.error('Some products failed to add to cart:', failedOperations); - toast.error(`${failedOperations.length} produk gagal ditambahkan ke keranjang`); - - // You might want to proceed with the successful ones or handle differently - if (failedOperations.length < products.length) { - toast.success(`${products.length - failedOperations.length} produk berhasil ditambahkan ke keranjang`); + const failed = results.filter((r) => r && r.error); + if (failed.length > 0) { + toast.error(`${failed.length} produk gagal ditambahkan ke keranjang`); + if (failed.length < (products || []).length) { + toast.success( + `${ + (products || []).length - failed.length + } produk berhasil ditambahkan` + ); setRefreshCart(true); router.push('/shop/cart'); } return; } - - // All operations succeeded setRefreshCart(true); - toast.success('Semua produk berhasil ditambahkan ke keranjang belanja'); + toast.success('Semua produk berhasil ditambahkan ke keranjang'); router.push('/shop/cart'); - - } catch (error) { - console.error('Gagal menambahkan produk ke keranjang:', error); + } catch { toast.error('Terjadi kesalahan saat menambahkan produk ke keranjang'); - // setStatus('error'); } }; - const handleStatusChange = async (status) => { setStatusNew(status); setPageNew(1); if (status === 'all' && cachedAllData) { - setCurrentData(cachedAllData); - return; + setCurrentData(cachedAllData); + return; } const data = await fetchSite(status, 1); - + if (status === 'all') { setCachedAllData(data); } @@ -411,19 +358,8 @@ const Transactions = ({ context = '' }) => { setCurrentData(data); }; - useEffect(() => { - setCachedAllData([]); - }, []); - - const handleReset = () => { - setState([ - { - startDate: null, - endDate: new Date(), - key: 'selection', - }, - ]); + setState([{ startDate: null, endDate: new Date(), key: 'selection' }]); setIsOpenCalender(false); router.push(`${router.pathname}`); }; @@ -443,20 +379,95 @@ const Transactions = ({ context = '' }) => { 'November', 'Desember', ]; - - const [day, month, year] = dateString.split('/'); + const [day, month, year] = (dateString || '').split('/'); + if (!day || !month || !year) return dateString || '-'; return `${day} ${months[parseInt(month, 10) - 1]} ${year}`; }; + // ==== Lanjutkan Transaksi (tanpa endpoint baru) ==== + const handleContinuePayment = async (saleOrder) => { + try { + setContLoadingId(saleOrder.id); + + const base = (process.env.NEXT_PUBLIC_ODOO_API_HOST || '').replace( + /\/$/, + '' + ); + const token = auth?.token; + const partnerId = auth?.partnerId; + + // 1. TRIGGER GENERATE + GET URL + const { data: response } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${saleOrder.id}`, + { + params: { ensure_payment_link: 1, ts: Date.now() }, + headers: { Token: token, 'Cache-Control': 'no-cache' }, + timeout: 10000, + } + ); + + // 2. EKSTRAK URL + let paymentUrl = + response?.result?.payment_summary?.redirect_url || + response?.data?.result?.payment_summary?.redirect_url; + + // 3. JIKA DAPAT URL, BUKA + if (paymentUrl) { + window.location.href = paymentUrl; + toast.success('Membuka halaman pembayaran…'); + return; + } + + // 4. FALLBACK: COBA TANPA ensure_payment_link + try { + const { data: fallbackResponse } = await axios.get( + `${base}/api/v1/partner/${partnerId}/sale_order/${saleOrder.id}`, + { headers: { Token: token }, timeout: 5000 } + ); + + const fallbackUrl = + fallbackResponse?.result?.payment_summary?.redirect_url || + fallbackResponse?.data?.result?.payment_summary?.redirect_url; + + if (fallbackUrl) { + window.location.href = fallbackUrl; + toast.success('Membuka halaman pembayaran…'); + return; + } + } catch (fallbackError) { + // Continue to next fallback + } + + // 5. ULTIMATE FALLBACK: PAKAI URL DARI DATA LAMA + const existingUrl = + saleOrder?.paymentSummary?.redirectUrl || + saleOrder?.payment_summary?.redirect_url; + + if (existingUrl) { + window.open(existingUrl, '_blank', 'noopener,noreferrer'); + toast.success('Membuka halaman pembayaran…'); + } else { + toast.error('Link pembayaran tidak ditemukan. Silakan coba lagi.'); + } + } catch (error) { + toast.error( + error.response?.data?.description || 'Gagal memproses pembayaran' + ); + } finally { + setContLoadingId(null); + } + }; + return ( <> + {/* ===== MOBILE ===== */} <MobileView> - <div className='p-4 flex flex-col gap-y-4'> - <div className='grid grid-cols-[30%_30%_40%] justify-between items-center gap-2 w-full '> + <div className=' flex flex-col gap-y-4'> + <div className='grid grid-cols-[40%_40%_15%] justify-between items-center gap-2 w-full '> <select value={statusNew} onChange={(e) => handleStatusChange(e.target.value)} - className='border border-gray-300 rounded-lg px-2 py-1 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' + className='border border-gray-300 rounded-lg px-2 py-2 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' > {statuses.map((status) => ( <option key={status.id} value={status.id}> @@ -467,7 +478,7 @@ const Transactions = ({ context = '' }) => { <select value={sortNew} onChange={(e) => setSortNew(e.target.value)} - className='border border-gray-300 rounded-lg px-2 py-1 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' + className='border border-gray-300 rounded-lg px-2 py-2 text-xs bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500' > {sortes.map((status) => ( <option key={status.id} value={status.id}> @@ -475,23 +486,22 @@ const Transactions = ({ context = '' }) => { </option> ))} </select> - <div ref={calendarRef} className="relative inline-block"> - <button - type='button' - className='p-2 w-auto h-auto cursor-pointer hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' - onClick={() => setIsOpenCalender((prev) => !prev)} - > - <span className='text-nowrap px-1 truncate flex items-center gap-1'> - {state[0]?.startDate ? ( - `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` - ) : ( - <Calendar size={16} className="text-gray-500" /> - )} - </span> - </button> - {isOpenCalender && ( + <div ref={calendarRef} className='relative inline-block'> + <button + type='button' + className='p-1 w-full h-auto cursor-pointer border hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' + onClick={() => setIsOpenCalender((prev) => !prev)} + > + <span className='text-nowrap px-1 truncate flex items-center gap-1'> + {state[0]?.startDate ? ( + `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` + ) : ( + <Calendar size={20} className='text-gray-500' /> + )} + </span> + </button> + {isOpenCalender && ( <div className='absolute right-1 mt-2 bg-white p-4 rounded shadow-lg z-50'> - {/* Tombol silang di sudut kanan atas */} <button onClick={() => setIsOpenCalender(false)} className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' @@ -508,37 +518,13 @@ const Transactions = ({ context = '' }) => { className='w-full' /> <style>{` - /* Atur container agar menjadi column */ - .rdrCalendarWrapper { - display: flex; - flex-direction: column; - } - .rdrDateRangePickerWrapper { - display: flex; - flex-direction: column; - } - - /* Pindahkan rdrStaticRanges ke atas */ - .rdrDefinedRangesWrapper { - order: -1; - width: fit-content; - } - .rdrStaticRanges { - flex-direction: row; - margin-right: 2px; - } - - /* Sembunyikan bagian input manual */ - .rdrInputRanges { - display: none !important; - } - - .rdrStaticRangeLabel { - padding: 10px 10px; - } - .rdrMonth { - width: -moz-available; - } + .rdrCalendarWrapper{display:flex;flex-direction:column;} + .rdrDateRangePickerWrapper{display:flex;flex-direction:column;} + .rdrDefinedRangesWrapper{order:-1;width:fit-content;} + .rdrStaticRanges{flex-direction:row;margin-right:2px;} + .rdrInputRanges{display:none !important;} + .rdrStaticRangeLabel{padding:10px 10px;} + .rdrMonth{width:-moz-available;} `}</style> <div className='flex flex-row justify-end gap-3 mt-2'> <button @@ -549,68 +535,7 @@ const Transactions = ({ context = '' }) => { </button> </div> </div> - )} - </div> - {/* <div className='border border-gray-300 rounded-lg px-1 py-1 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500 text-xs'> - <DatePicker - closeOnScroll={(e) => e.target === document} - selectsRange={true} - startDate={startDate} - endDate={endDate} - dateFormat='dd/MM' - className='w-full' - maxDate={new Date()} - placeholderText='Semua Tanggal' - onChange={(update) => { - setDateRange(update); - }} - withPortal - isClearable={true} - /> - </div> */} - </div> - <div className='flex flex-row justify-between items-center gap-2'> - <form className='flex' onSubmit={handleSubmit}> - <button - className='btn-light border-r-0 rounded-r-none bg-transparent px-3' - type='submit' - > - <MagnifyingGlassIcon className='w-6' /> - </button> - <input - type='text' - className='form-input border-l-0 rounded-l-none text-xs' - placeholder='Cari Transaksi...' - value={inputQuery} - onChange={(e) => setInputQuery(e.target.value)} - /> - </form> - <div className='flex flex-row gap-2 items-center justify-center text-nowrap'> - <p className='text-xs'> - Menampilkan {startItem}- - {endItem - ? endItem - : transactions?.data?.saleOrderTotal - ? transactions?.data?.saleOrderTotal - : limitNew * pageNew}{' '} - dari{' '} - {transactions?.data?.saleOrderTotal - ? transactions?.data?.saleOrderTotal - : limitNew * pageNew} - </p> - <select - id='limitSelect' - value={limitNew} - onChange={(e) => { - setLimitNew(Number(e.target.value)); - setPageNew(1); - }} - className='border p-2 text-xs' - > - <option value={10}>10</option> - <option value={15}>15</option> - <option value={20}>20</option> - </select> + )} </div> </div> @@ -634,7 +559,9 @@ const Transactions = ({ context = '' }) => { > <div className='flex flex-row justify-between items-start'> <Link href={`${router.pathname}/${saleOrder.id}`}> - <h2 className='text-danger-500'>{saleOrder.name}</h2> + <h2 className='text-danger-500 text-base'> + {saleOrder.name} + </h2> <span className='font-medium text-black opacity-75'> {formatDate(saleOrder.dateOrder.split(' ')[0]) || '-'} </span> @@ -662,8 +589,8 @@ const Transactions = ({ context = '' }) => { </div> <div className='flex w-4/5 flex-col gap-2 justify-start'> <p className='flex flex-row gap-2'> - <span className=' text-black'>Nomor PO:</span> - <span className=' text-red-500 font-semibold'> + <span className=' text-black text-sm'>Nomor PO:</span> + <span className=' text-red-500 font-semibold text-sm'> {saleOrder.purchaseOrderName || '-'} </span> </p> @@ -681,29 +608,30 @@ const Transactions = ({ context = '' }) => { <div className='flex flex-row gap-1 justify-start items-center'> {saleOrder.products .slice(1, 4) - .map((product, index) => ( + .map((product, idx) => ( <Image - key={index} // Tambahkan key untuk setiap elemen dalam map() + key={idx} src={product?.parent?.image} alt={product?.name} className='object-contain object-center border border-gray_r-6 h-8 w-8 rounded-md' /> ))} {saleOrder.products.length > 4 ? ( - <Link - href={`${router.pathname}/${saleOrder?.id}`} - className='text-red-500 text-nowrap' - > - +{saleOrder.products.length - 4} lihat semua produk - </Link> - ) : ( - <Link - href={`${router.pathname}/${saleOrder?.id}`} - className='text-red-500 text-nowrap' - > - Lihat semua produk - </Link> - )} + <Link + href={`${router.pathname}/${saleOrder?.id}`} + className='text-red-500 text-nowrap' + > + +{saleOrder.products.length - 4} lihat semua + produk + </Link> + ) : ( + <Link + href={`${router.pathname}/${saleOrder?.id}`} + className='text-red-500 text-nowrap' + > + Lihat semua produk + </Link> + )} </div> )} </div> @@ -716,58 +644,50 @@ const Transactions = ({ context = '' }) => { </div> </div> <div className='col-span-2 h-[1px] w-full bg-gray-300'></div> - <div className='flex flex-row gap-3 justify-between items-center text-sm'> - <div className='flex flex-col text-black text-xs'> + + <div className='flex flex-col gap-3 text-sm'> + <div className='flex flex-col text-black'> <p className='font-extralight'>Total Harga</p> - <p className='font-semibold'> + <p className='font-semibold text-lg'> {currencyFormat(saleOrder.amountTotal)} </p> </div> - <div> - <button - type='button' - onClick={() => handleBuyBack(saleOrder.products)} - className='flex-1 py-2 btn-solid-red text-nowrap' - > - Beli Lagi - </button> - </div> - </div> - {/* <div className='grid grid-cols-2 mt-3'> - <div> - <span className='text-caption-2 text-gray_r-11'> - No. Purchase Order - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.purchaseOrderName || '-'} - </p> - </div> - <div className='text-right'> - <span className='text-caption-2 text-gray_r-11'> - Total Invoice - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.invoiceCount} Invoice - </p> - </div> - </div> */} - {/* <div className='grid grid-cols-2 mt-3'> - <div> - <span className='text-caption-2 text-gray_r-11'>Sales</span> - <p className='mt-1 font-medium text-gray_r-12'> - {saleOrder.sales} - </p> - </div> - <div className='text-right'> - <span className='text-caption-2 text-gray_r-11'> - Total Harga - </span> - <p className='mt-1 font-medium text-gray_r-12'> - {currencyFormat(saleOrder.amountTotal)} - </p> + <div className='flex flex-col gap-2 w-full'> + {/* Beli Lagi hanya muncul jika status bukan unpaid */} + {!isUnpaid(saleOrder.status) && ( + <button + type='button' + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleBuyBack(saleOrder.products); + }} + className='w-full py-2 btn-solid-red text-center rounded-md' + > + Beli Lagi + </button> + )} + + {/* Bayar Sekarang hanya kalau eligible */} + {saleOrder?.eligibleContinue && ( + <button + type='button' + disabled={contLoadingId === saleOrder.id} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContinuePayment(saleOrder); + }} + className='w-full py-2 text-center rounded-md border border-red-300 text-red-500 bg-white disabled:opacity-60' + > + {contLoadingId === saleOrder.id + ? 'Memproses…' + : 'Bayar Sekarang'} + </button> + )} </div> - </div> */} + </div> </Link> </div> ))} @@ -775,7 +695,6 @@ const Transactions = ({ context = '' }) => { <Pagination pageCount={pageCount} currentPage={parseInt(pageNew)} - // url={router.pathname + pageQuery} url={`${router.pathname}?${toQuery(_.omit(query, ['page']))}`} className='mt-2 mb-2' /> @@ -785,6 +704,30 @@ const Transactions = ({ context = '' }) => { active={toOthers} close={() => setToOthers(null)} > + {transactions.data?.status === 'draft' && ( + <> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'draft'} + onClick={() => { + downloadQuotation(toOthers); + setToOthers(null); + }} + > + Download Quotation + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'waiting'} + onClick={() => { + setToCancel(toOthers); + setToOthers(null); + }} + > + Batalkan Transaksi + </button> + </> + )} <div className='flex flex-col gap-y-4 mt-2'> <button className='text-left disabled:opacity-60' @@ -796,26 +739,6 @@ const Transactions = ({ context = '' }) => { > Download PO </button> - <button - className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'draft'} - onClick={() => { - downloadQuotation(toOthers); - setToOthers(null); - }} - > - Download Quotation - </button> - <button - className='text-left disabled:opacity-60' - disabled={toOthers?.status != 'waiting'} - onClick={() => { - setToCancel(toOthers); - setToOthers(null); - }} - > - Batalkan Transaksi - </button> </div> </BottomPopup> @@ -848,6 +771,7 @@ const Transactions = ({ context = '' }) => { </div> </MobileView> + {/* ===== DESKTOP ===== */} <DesktopView> <div className='container mx-auto flex py-10'> <div className='w-3/12 pr-4'> @@ -900,51 +824,36 @@ const Transactions = ({ context = '' }) => { )} </div> </div> - <div className=''> - <div - class='flex items-center p-4 mb-4 text-sm border border-yellow-500 text-yellow-800 rounded-lg bg-yellow-50' - role='alert' - > - <svg - class='flex-shrink-0 inline w-5 h-5 mr-2' - aria-hidden='true' - fill='currentColor' - viewBox='0 0 20 20' - > - <path d='M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' /> - </svg> - <span class='sr-only'>Info</span> - <div className='text-justify flex flex-col gap-1'> - <p className='font-bold text-black'>Info Transaksi</p> - <span className='text-black'> - Gunakan filter status untuk mempermudah pencarian transaksi anda di Daftar Transaksi - </span> - </div> - </div> - </div> + <div className='flex flex-col gap-y-2 border rounded-lg mb-2 w-full'> <div className='p-2'> <div className='flex items-center space-x-3'> <span className='text-base font-semibold text-gray-600'> Status </span> - <div className="relative w-full overflow-hidden"> - {/* Container flex: tombol prev - swiper - tombol next */} - <div className="flex items-center space-x-2"> - - {/* Prev */} - <button className="custom-prev w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-1"> - <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> + <div className='relative w-full overflow-hidden'> + <div className='flex items-center space-x-2'> + <button className='custom-prev w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-1'> + <svg + className='w-4 h-4 text-gray-500' + fill='none' + stroke='currentColor' + viewBox='0 0 24 24' + > + <path + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={2} + d='M15 19l-7-7 7-7' + /> </svg> </button> - {/* Swiper container scrollable */} - <div className="w-full overflow-hidden"> + <div className='w-full overflow-hidden'> <Swiper spaceBetween={10} - slidesPerView="auto" - className="status-swiper" + slidesPerView='auto' + className='status-swiper' modules={[Navigation]} navigation={{ nextEl: '.custom-next', @@ -952,12 +861,13 @@ const Transactions = ({ context = '' }) => { }} > {statuses.map((status) => ( - <SwiperSlide key={status.id} className="!w-auto"> + <SwiperSlide key={status.id} className='!w-auto'> <button className={`px-4 py-1 text-sm font-medium border rounded-lg transition whitespace-nowrap - ${statusNew === status.id - ? 'border-red-500 text-red-500 bg-white' - : 'border-gray-300 text-gray-400 bg-gray-100 hover:bg-gray-200' + ${ + statusNew === status.id + ? 'border-red-500 text-red-500 bg-white' + : 'border-gray-300 text-gray-400 bg-gray-100 hover:bg-gray-200' }`} onClick={() => handleStatusChange(status.id)} > @@ -968,16 +878,26 @@ const Transactions = ({ context = '' }) => { </Swiper> </div> - {/* Next */} - <button className="custom-next w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-10"> - <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> + <button className='custom-next w-8 h-8 flex-shrink-0 flex items-center justify-center bg-white border rounded-full shadow z-10'> + <svg + className='w-4 h-4 text-gray-500' + fill='none' + stroke='currentColor' + viewBox='0 0 24 24' + > + <path + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={2} + d='M9 5l7 7-7 7' + /> </svg> </button> </div> </div> </div> </div> + <div className='flex flex-row items-center justify-between mb-2 p-2'> <div className='flex flex-col gap-2 pb-2'> {listSites?.length > 0 ? ( @@ -1013,6 +933,7 @@ const Transactions = ({ context = '' }) => { </button> </form> </div> + <div className='flex flex-row gap-4 items-center justify-center'> <p> Menampilkan {startItem}- @@ -1038,87 +959,66 @@ const Transactions = ({ context = '' }) => { <option value={10}>10</option> <option value={15}>15</option> <option value={20}>20</option> - <option value={transactions?.data?.saleOrderTotal}>Semua</option> + <option value={transactions?.data?.saleOrderTotal}> + Semua + </option> </select> - <div ref={calendarRef} className="relative inline-block"> + + <div ref={calendarRef} className='relative inline-block'> <button type='button' className='p-2 w-auto h-auto cursor-pointer border hover:bg-gray-100 rounded transition duration-150 ease-in-out flex items-center justify-center' onClick={() => setIsOpenCalender((prev) => !prev)} > <span className='text-nowrap px-1 truncate flex items-center gap-1'> - {state[0]?.startDate ? ( - `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` - ) : ( - <Calendar size={16} className="text-gray-500" /> - )} + {state[0]?.startDate ? ( + `${state[0].startDate.toLocaleDateString()} - ${state[0].endDate.toLocaleDateString()}` + ) : ( + <Calendar size={16} className='text-gray-500' /> + )} </span> </button> {isOpenCalender && ( - <div className='absolute right-10 mt-2 bg-white p-4 rounded shadow-lg z-50'> - {/* Tombol silang di sudut kanan atas */} - <button - onClick={() => setIsOpenCalender(false)} - className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' - > - × - </button> - <DateRangePicker - onChange={(item) => setState([item.selection])} - showSelectionPreview={false} - maxDate={new Date()} - moveRangeOnFirstSelection={false} - months={1} - ranges={state} - className='w-full' - /> - <style>{` - /* Atur container agar menjadi column */ - .rdrCalendarWrapper { - display: flex; - flex-direction: column; - } - .rdrDateRangePickerWrapper { - display: flex; - flex-direction: column; - } - - /* Pindahkan rdrStaticRanges ke atas */ - .rdrDefinedRangesWrapper { - order: -1; - width: fit-content; - } - .rdrStaticRanges { - flex-direction: row; - margin-right: 2px; - } - - /* Sembunyikan bagian input manual */ - .rdrInputRanges { - display: none !important; - } - - .rdrStaticRangeLabel { - padding: 10px 10px; - } - .rdrMonth { - width: -moz-available; - } - `}</style> - <div className='flex flex-row justify-end gap-3 mt-2'> + <div className='absolute right-10 mt-2 bg-white p-4 rounded shadow-lg z-50'> <button - className='px-4 py-1 bg-red-500 text-white rounded' - onClick={handleReset} + onClick={() => setIsOpenCalender(false)} + className='absolute top-2 right-2 text-gray-600 hover:text-black text-xl font-bold' > - Reset + × </button> + <DateRangePicker + onChange={(item) => setState([item.selection])} + showSelectionPreview={false} + maxDate={new Date()} + moveRangeOnFirstSelection={false} + months={1} + ranges={state} + className='w-full' + /> + <style>{` + .rdrCalendarWrapper{display:flex;flex-direction:column;} + .rdrDateRangePickerWrapper{display:flex;flex-direction:column;} + .rdrDefinedRangesWrapper{order:-1;width:fit-content;} + .rdrStaticRanges{flex-direction:row;margin-right:2px;} + .rdrInputRanges{display:none !important;} + .rdrStaticRangeLabel{padding:10px 10px;} + .rdrMonth{width:-moz-available;} + `}</style> + <div className='flex flex-row justify-end gap-3 mt-2'> + <button + className='px-4 py-1 bg-red-500 text-white rounded' + onClick={handleReset} + > + Reset + </button> + </div> </div> - </div> )} </div> </div> </div> </div> + <div className='flex justify-center items-center'> {!transactions.isLoading && transactions?.data?.saleOrders?.length == 0 && ( @@ -1156,11 +1056,9 @@ const Transactions = ({ context = '' }) => { <p className='text-red-500'>{saleOrder.name}</p> <p className='text-black'> Salesperson:{' '} - { - <span className='font-semibold'> - {saleOrder.sales} - </span> - } + <span className='font-semibold'> + {saleOrder.sales} + </span> </p> </div> <div className='text-black'> @@ -1172,7 +1070,9 @@ const Transactions = ({ context = '' }) => { </span> </div> </div> + <hr className='mt-3 mb-3 border border-gray-100' /> + <div className='flex flex-row gap-2 justify-between items-center '> <div className='flex justify-start w-3/4 flex-col gap-2'> <div className='flex gap-2'> @@ -1207,9 +1107,9 @@ const Transactions = ({ context = '' }) => { <div className='flex flex-row gap-1 justify-start items-center'> {saleOrder.products .slice(1, 4) - .map((product, index) => ( + .map((product, idx) => ( <Image - key={index} // Tambahkan key untuk setiap elemen dalam map() + key={idx} src={product?.parent?.image} alt={product?.name} className='object-contain object-center border border-gray_r-6 h-16 w-16 rounded-md' @@ -1220,7 +1120,8 @@ const Transactions = ({ context = '' }) => { href={`${router.pathname}/${saleOrder?.id}`} className='text-red-500 text-nowrap' > - +{saleOrder.products.length - 4} lihat semua produk + +{saleOrder.products.length - 4}{' '} + lihat semua produk </Link> ) : ( <Link @@ -1244,25 +1145,48 @@ const Transactions = ({ context = '' }) => { </p> </div> </div> + <div className='w-[1px] h-24 bg-gray-300'></div> - <div className='w-1/4 flex flex-row gap-3 justify-center items-center'> + + <div className='w-1/4 flex flex-col gap-2 items-center justify-center text-center pl-5'> <div className='flex flex-col text-black'> - <p>Total Harga</p> - <p className='font-bold'> + <p className='text-sm'>Total Harga</p> + <p className='font-bold text-lg'> {currencyFormat(saleOrder.amountTotal)} </p> </div> - <div> + + {!isUnpaid(saleOrder.status) && ( <button type='button' - onClick={() => - handleBuyBack(saleOrder.products) - } - className='flex-1 py-2 btn-solid-red text-nowrap' + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleBuyBack(saleOrder.products); + }} + className='w-full py-2 btn-solid-red text-nowrap rounded-md' > Beli Lagi </button> - </div> + )} + + {/* Bayar Sekarang: hanya kalau eligible */} + {saleOrder?.eligibleContinue && ( + <button + type='button' + disabled={contLoadingId === saleOrder.id} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleContinuePayment(saleOrder); + }} + className='w-full py-2 text-nowrap border border-red-500 text-red-500 rounded-md disabled:opacity-60' + > + {contLoadingId === saleOrder.id + ? 'Memproses…' + : 'Bayar Sekarang'} + </button> + )} </div> </div> </Link> @@ -1271,70 +1195,10 @@ const Transactions = ({ context = '' }) => { </div> )} </div> - {/* <table className='table-data'> - <thead> - <tr> - <th>No. Transaksi</th> - <th>No. PO</th> - <th>Tanggal</th> - <th>Created By</th> - {auth?.feature?.soApproval && <th>Site</th>} - <th className='!text-left'>Salesperson</th> - <th className='!text-left'>Total</th> - <th>Status</th> - </tr> - </thead> - <tbody> - {transactions.isLoading && ( - <tr> - <td colSpan={7}> - <div className='flex justify-center my-2'> - <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> - </div> - </td> - </tr> - )} - {!transactions.isLoading && - (!transactions?.data?.saleOrders || - transactions?.data?.saleOrders?.length == 0) && ( - <tr> - <td colSpan={7}>Tidak ada transaksi</td> - </tr> - )} - {transactions.data?.saleOrders?.map((saleOrder) => ( - <tr key={saleOrder.id}> - <td> - <Link - className='whitespace-nowrap' - href={`${router.pathname}/${saleOrder.id}`} - > - {saleOrder.name} - </Link> - </td> - <td>{saleOrder.purchaseOrderName || '-'}</td> - <td>{saleOrder.dateOrder || '-'}</td> - <td>{saleOrder.address.customer?.name || '-'}</td> - {auth?.feature?.soApproval && ( - <td>{saleOrder.sitePartner || '-'}</td> - )} - <td className='!text-left'>{saleOrder.sales}</td> - <td className='!text-left'> - {currencyFormat(saleOrder.amountTotal)} - </td> - <td> - <div className='flex justify-center'> - <TransactionStatusBadge status={saleOrder.status} /> - </div> - </td> - </tr> - ))} - </tbody> - </table> */} <Pagination pageCount={pageCount} currentPage={parseInt(pageNew)} - // url={router.pathname + (pageQuery ? `?${pageQuery}` : '')} url={`${router.pathname}?${toQuery(_.omit(query, ['page']))}`} className='mt-2 mb-2' /> diff --git a/src/lib/treckingAwb/component/Manifest.jsx b/src/lib/treckingAwb/component/Manifest.jsx index 6eb0b0ac..d2896567 100644 --- a/src/lib/treckingAwb/component/Manifest.jsx +++ b/src/lib/treckingAwb/component/Manifest.jsx @@ -9,17 +9,24 @@ import { list } from 'postcss'; import InformationSection from './InformationSection'; import Link from 'next/link'; -function capitalizeFirstLetter(str) { - return str.charAt(0).toUpperCase() + str.slice(1); -} +// function capitalizeFirstLetter(str) { +// return str.charAt(0).toUpperCase() + str.slice(1); +// } function capitalizeWords(str) { + if (!str || typeof str !== 'string') { + return ''; +} return str .split(' ') .map((word) => capitalizeFirstLetter(word)) .join(' '); } +function capitalizeFirstLetter(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); +} + function mappingLiveTracking(kurir, resi){ let url = null switch (kurir){ diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index e14b0ca2..8c9782cb 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -19,7 +19,6 @@ export default async function handler(req, res) { source = '', } = req.query; - let { stock = '' } = req.query; let paramOrderBy = ''; switch (orderBy) { @@ -73,7 +72,7 @@ export default async function handler(req, res) { const formattedQuery = `(${newQ .split(' ') - .map((term) => (term.length < 2 ? term : `${term}*`)) // Tambahkan '*' hanya jika panjang kata >= 2 + .map((term) => (term.length < 2 ? term : `${term}*`)) .join(' ')})`; const mm = @@ -90,7 +89,7 @@ export default async function handler(req, res) { ]; if (orderBy === 'stock') { - filterQueries.push('stock_total_f:{0 TO *}'); + filterQueries.push('stock_total_f:[1 TO *]'); } if (fq && source != 'similar' && typeof fq != 'string') { @@ -112,8 +111,9 @@ export default async function handler(req, res) { let offset = (page - 1) * limit; let parameter = [ - 'facet.field=manufacture_name_s', - 'facet.field=category_name', + // === Facet disjunctive: exclude filter brand/cat saat hitung facet === + 'facet.field={!ex=brand}manufacture_name_s', + 'facet.field={!ex=cat}category_name', 'facet=true', 'indent=true', `facet.query=${escapeSolrQuery(q)}`, @@ -142,25 +142,29 @@ export default async function handler(req, res) { if (auth.feature.onlyReadyStock) stock = true; } - if (brand) - parameter.push( - `fq=${brand - .split(',') - .map( - (manufacturer) => - `manufacture_name:"${encodeURIComponent(manufacturer)}"` - ) - .join(' OR ')}` - ); - if (category) - parameter.push( - `fq=${category - .split(',') - .map((cat) => `category_name:"${encodeURIComponent(cat)}"`) - .join(' OR ')}` - ); + if (brand) { + // bentuk ekspresi sama seperti versi kamu, tapi dibungkus tag brand + const brandExpr = brand + .split(',') + .map( + (manufacturer) => + `manufacture_name:"${encodeURIComponent(manufacturer)}"` + ) + .join(' OR '); + parameter.push(`fq={!tag=brand}(${brandExpr})`); + } + + if (category) { + // sama: tag kategori + const catExpr = category + .split(',') + .map((cat) => `category_name:"${encodeURIComponent(cat)}"`) + .join(' OR '); + parameter.push(`fq={!tag=cat}(${catExpr})`); + } + // if (category) parameter.push(`fq=category_name:${capitalizeFirstLetter(category.replace(/,/g, ' OR '))}`) - if (stock) parameter.push(`fq=stock_total_f:{1 TO *}`); + if (stock) parameter.push(`fq=stock_total_f:(1 TO *)`); // Single fq in url params if (typeof fq === 'string') parameter.push(`fq=${encodeURIComponent(fq)}`); @@ -169,6 +173,7 @@ export default async function handler(req, res) { parameter = parameter.concat( fq.map((val) => `fq=${encodeURIComponent(val)}`) ); + let result = await axios( process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&') ); diff --git a/src/pages/my/menu.jsx b/src/pages/my/menu.jsx index 2a46d866..fa656ed4 100644 --- a/src/pages/my/menu.jsx +++ b/src/pages/my/menu.jsx @@ -48,17 +48,6 @@ export default function Menu() { <MenuHeader>Aktivitas Pembelian</MenuHeader> <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> - <LinkItem href='/my/quotations'> - {' '} - <div className='flex gap-x-3 items-center'> - <ImageNext - src='/images/icon/icon_daftar_quotation.svg' - width={18} - height={20} - /> - <p>Daftar Quotation</p> - </div> - </LinkItem> <LinkItem href='/my/transactions'> <div className='flex gap-x-3 items-center'> <ImageNext diff --git a/src/pages/my/profile.jsx b/src/pages/my/profile.jsx index 859b6960..92e3956c 100644 --- a/src/pages/my/profile.jsx +++ b/src/pages/my/profile.jsx @@ -14,19 +14,27 @@ import { Checkbox } from '@chakra-ui/react'; import { useState, useEffect } from 'react'; import switchAccountProgresApi from '@/lib/auth/api/switchAccountProgresApi.js'; import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; +import { useRouter } from 'next/router'; + export default function Profile() { const auth = useAuth(); + // console.log('auth', auth); const [isChecked, setIsChecked] = useState(false); const [ubahAkun, setUbahAkun] = useState(false); const [isAprove, setIsAprove] = useState(); const [changeConfirmation, setChangeConfirmation] = useState(false); - const handleChange = async () => { + const router = useRouter(); + + // Handle checkbox toggle + const handleChange = () => { if (isChecked) { - setIsChecked(!isChecked); + setIsChecked(false); } else { setChangeConfirmation(true); } }; + + // Load status dan cek localStorage "autoCheckProfile" useEffect(() => { const loadPromo = async () => { const progresSwitchAccount = await switchAccountProgresApi(); @@ -36,7 +44,28 @@ export default function Profile() { } }; loadPromo(); - }, []); + + if (typeof window !== 'undefined') { + const autoCheck = localStorage.getItem('autoCheckProfile'); + if (autoCheck === 'true') { + setIsChecked(true); + localStorage.removeItem('autoCheckProfile'); + + // Hapus query param supaya URL bersih + if (router.query.autoCheck) { + const cleanQuery = { ...router.query }; + delete cleanQuery.autoCheck; + router.replace( + { pathname: router.pathname, query: cleanQuery }, + undefined, + { shallow: true } + ); + } + } + } + }, [router]); + + // Confirm checkbox change from popup const handleConfirmSubmit = () => { setChangeConfirmation(false); setIsChecked(true); @@ -45,23 +74,23 @@ export default function Profile() { <> <BottomPopup active={changeConfirmation} - close={() => setChangeConfirmation(false)} // Menutup popup - title='Ubah type akun' + close={() => setChangeConfirmation(false)} //Menutup popup + title="Ubah type akun" > - <div className='leading-7 text-gray_r-12/80'> + <div className="leading-7 text-gray_r-12/80"> Anda akan mengubah type akun anda? </div> - <div className='flex mt-6 gap-x-4 md:justify-end'> + <div className="flex mt-6 gap-x-4 md:justify-end"> <button - className='btn-solid-red flex-1 md:flex-none' - type='button' + className="btn-solid-red flex-1 md:flex-none" + type="button" onClick={handleConfirmSubmit} > Yakin </button> <button - className='btn-light flex-1 md:flex-none' - type='button' + className="btn-light flex-1 md:flex-none" + type="button" onClick={() => setChangeConfirmation(false)} > Batal @@ -70,23 +99,22 @@ export default function Profile() { </BottomPopup> <IsAuth> <MobileView> - <AppLayout title='Akun Saya'> - {auth?.company || - (!ubahAkun && ( - <div className='text-sm p-4 flex items-center'> + <AppLayout title="Akun Saya"> + {auth?.company || ((isChecked || (!ubahAkun && ubahAkun !== 'pending')) && ( + <div className="text-sm p-4 flex items-center"> <Checkbox - borderColor='gray.600' - colorScheme='red' - size='lg' + borderColor="gray.600" + colorScheme="red" + size="lg" isChecked={isChecked} onChange={handleChange} /> - <p className='ml-2'>Ubah ke akun bisnis</p> + <p className="ml-2">Ubah ke akun bisnis</p> </div> ))} - {isChecked && ( + {isChecked && ubahAkun !== 'pending' && ( <div> - <SwitchAccount company_type='nonpkp' /> + <SwitchAccount company_type="nonpkp" setIsAprove={setIsAprove} setUbahAkun={setUbahAkun}/> <Divider /> </div> )} @@ -104,27 +132,26 @@ export default function Profile() { <DesktopView> <BasicLayout> - <div className='container mx-auto flex py-10'> - <div className='w-3/12 pr-4'> + <div className="container mx-auto flex py-10"> + <div className="w-3/12 pr-4"> <Menu /> </div> - <div className='w-9/12 bg-white border border-gray_r-6 rounded'> - {auth?.company || - (!ubahAkun && ( - <div className='text-sm p-4 flex items-center'> + <div className="w-9/12 bg-white border border-gray_r-6 rounded"> + {auth?.company || ((isChecked || (!ubahAkun && ubahAkun !== 'pending')) && ( + <div className="text-sm p-4 flex items-center"> <Checkbox - borderColor='gray.600' - colorScheme='red' - size='lg' + borderColor="gray.600" + colorScheme="red" + size="lg" isChecked={isChecked} onChange={handleChange} /> - <p className='ml-2'>Ubah ke akun bisnis</p> + <p className="ml-2">Ubah ke akun bisnis</p> </div> ))} - {isChecked && ( + {isChecked && ubahAkun !== 'pending' && ( <div> - <SwitchAccount company_type='nonpkp' /> + <SwitchAccount company_type="nonpkp" setIsAprove={setIsAprove} setUbahAkun={setUbahAkun}/> <Divider /> </div> )} |
