diff options
Diffstat (limited to 'src')
231 files changed, 6992 insertions, 5798 deletions
diff --git a/src/components/auth/WithAuth.js b/src/components/auth/WithAuth.js deleted file mode 100644 index ef975873..00000000 --- a/src/components/auth/WithAuth.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getAuth } from "@/core/utils/auth"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; - -const WithAuth = ({ children }) => { - const router = useRouter(); - const [response, setResponse] = useState(<></>); - - useEffect(() => { - if (!getAuth()) { - router.replace('/login'); - } else { - setResponse(children); - } - }, [children, router]); - - return response; -} - -export default WithAuth;
\ No newline at end of file diff --git a/src/components/elements/Alert.js b/src/components/elements/Alert.js deleted file mode 100644 index 914d1590..00000000 --- a/src/components/elements/Alert.js +++ /dev/null @@ -1,19 +0,0 @@ -const Alert = ({ children, className, type }) => { - let typeClass = ''; - switch (type) { - case 'info': - typeClass = ' bg-blue-100 text-blue-900 border-blue-400 ' - break; - case 'success': - typeClass = ' bg-green-100 text-green-900 border-green-400 ' - break; - case 'warning': - typeClass = ' bg-yellow-100 text-yellow-900 border-yellow-400 ' - break; - } - return ( - <div className={"rounded-md w-full text-medium p-3 border" + typeClass + className}>{children}</div> - ); -} - -export default Alert;
\ No newline at end of file diff --git a/src/components/elements/BottomPopup.js b/src/components/elements/BottomPopup.js deleted file mode 100644 index c1a56e10..00000000 --- a/src/components/elements/BottomPopup.js +++ /dev/null @@ -1,25 +0,0 @@ -import CloseIcon from "@/icons/close.svg"; - -const BottomPopup = ({ - active = false, - title, - children, - closePopup = () => {} -}) => { - return ( - <> - <div className={"menu-overlay " + (active ? 'block' : 'hidden')} onClick={closePopup} /> - <div className={`fixed w-full z-[60] py-6 px-4 bg-white rounded-t-3xl idt-transition bottom-0 ${active ? 'block' : 'hidden'}`}> - <div className="flex justify-between items-center mb-5"> - <h2 className="text-xl font-semibold">{ title }</h2> - <button onClick={closePopup}> - <CloseIcon className="w-7" /> - </button> - </div> - { children } - </div> - </> - ); -}; - -export default BottomPopup;
\ No newline at end of file diff --git a/src/components/elements/ConfirmAlert.js b/src/components/elements/ConfirmAlert.js deleted file mode 100644 index d33abb89..00000000 --- a/src/components/elements/ConfirmAlert.js +++ /dev/null @@ -1,27 +0,0 @@ -const ConfirmAlert = ({ - title, - caption, - show, - onClose, - onSubmit, - closeText, - submitText -}) => { - return ( - <> - {show && ( - <div className="menu-overlay" onClick={onClose}></div> - )} - <div className={"p-4 rounded border bg-white border-gray_r-6 fixed top-[50%] left-[50%] translate-x-[-50%] z-[70] w-[90%] translate-y-[-50%] " + (show ? "block" : "hidden")}> - <p className="h2 mb-2">{title}</p> - <p className="text-gray_r-11 mb-6">{caption}</p> - <div className="flex gap-x-2"> - <button className="flex-1 btn-yellow" onClick={onClose}>{closeText}</button> - <button className="flex-1 btn-solid-red" onClick={onSubmit}>{submitText}</button> - </div> - </div> - </> - ); -}; - -export default ConfirmAlert;
\ No newline at end of file diff --git a/src/components/elements/DescriptionRow.js b/src/components/elements/DescriptionRow.js deleted file mode 100644 index 7fe9e3a1..00000000 --- a/src/components/elements/DescriptionRow.js +++ /dev/null @@ -1,10 +0,0 @@ -const DescriptionRow = ({ label, children }) => ( - <div className="grid grid-cols-2"> - <p className="leading-normal text-gray_r-11">{ label }</p> - <div className="text-right leading-normal"> - { children } - </div> - </div> -); - -export default DescriptionRow;
\ No newline at end of file diff --git a/src/components/elements/Disclosure.js b/src/components/elements/Disclosure.js deleted file mode 100644 index 1f334be3..00000000 --- a/src/components/elements/Disclosure.js +++ /dev/null @@ -1,14 +0,0 @@ -const { ChevronUpIcon, ChevronDownIcon } = require("@heroicons/react/24/outline"); - -const Disclosure = ({ label, active, onClick }) => ( - <div className={`flex justify-between p-4 ` + (active && 'bg-yellow_r-2')} onClick={onClick}> - <p className="font-medium leading-normal">{ label }</p> - { onClick && ( active ? ( - <ChevronUpIcon className="w-5 h-5 stroke-2" /> - ) : ( - <ChevronDownIcon className="w-5 h-5 stroke-2" /> - ) ) } - </div> -); - -export default Disclosure;
\ No newline at end of file diff --git a/src/components/elements/Fields.js b/src/components/elements/Fields.js deleted file mode 100644 index 586a6a22..00000000 --- a/src/components/elements/Fields.js +++ /dev/null @@ -1,21 +0,0 @@ -import ReactSelect from "react-select"; - -const Select = ({ - field, - ...props -}) => ( - <> - <ReactSelect - classNamePrefix="form-select" - ref={field.ref} - onChange={(option) => field.onChange(option.value)} - value={field.value ? props.options.find(option => option.value === field.value) : ''} - isDisabled={props.disabled} - {...props} - /> - </> -); - -export { - Select -};
\ No newline at end of file diff --git a/src/components/elements/Filter.js b/src/components/elements/Filter.js deleted file mode 100644 index f2051ba8..00000000 --- a/src/components/elements/Filter.js +++ /dev/null @@ -1,176 +0,0 @@ -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import BottomPopup from "./BottomPopup"; - -const Filter = ({ - isActive, - closeFilter, - defaultRoute, - defaultPriceFrom, - defaultPriceTo, - defaultCategory, - defaultBrand, - defaultOrderBy, - searchResults, - disableFilter = [] -}) => { - const router = useRouter(); - - const [priceFrom, setPriceFrom] = useState(defaultPriceFrom); - const [priceTo, setPriceTo] = useState(defaultPriceTo); - const [orderBy, setOrderBy] = useState(defaultOrderBy); - const [selectedCategory, setSelectedCategory] = useState(defaultCategory); - const [selectedBrand, setSelectedBrand] = useState(defaultBrand); - const [categories, setCategories] = useState([]); - const [brands, setBrands] = useState([]); - - const filterRoute = () => { - let filterRoute = []; - let filterRoutePrefix = '?'; - if (selectedBrand) filterRoute.push(`brand=${selectedBrand}`); - if (selectedCategory) filterRoute.push(`category=${selectedCategory}`); - if (priceFrom) filterRoute.push(`price_from=${priceFrom}`); - if (priceTo) filterRoute.push(`price_to=${priceTo}`); - if (orderBy) filterRoute.push(`order_by=${orderBy}`); - - if (defaultRoute.includes('?')) filterRoutePrefix = '&'; - if (filterRoute.length > 0) { - filterRoute = filterRoutePrefix + filterRoute.join('&'); - } else { - filterRoute = ''; - } - - return defaultRoute + filterRoute; - } - - useEffect(() => { - const filterCategory = searchResults.facet_counts.facet_fields.category_name_str.filter((category, index) => { - if (index % 2 == 0) { - const productCountInCategory = searchResults.facet_counts.facet_fields.category_name_str[index + 1]; - if (productCountInCategory > 0) return category; - } - }); - setCategories(filterCategory); - - const filterBrand = searchResults.facet_counts.facet_fields.brand_str.filter((brand, index) => { - if (index % 2 == 0) { - const productCountInBrand = searchResults.facet_counts.facet_fields.brand_str[index + 1]; - if (productCountInBrand > 0) return brand; - } - }); - setBrands(filterBrand); - }, [searchResults]); - - const submit = (e) => { - e.preventDefault(); - closeFilter(); - router.push(filterRoute(), undefined, { scroll: false }); - } - - const reset = () => { - setSelectedBrand(''); - setSelectedCategory(''); - setPriceFrom(''); - setPriceTo(''); - setOrderBy(''); - } - - const changeOrderBy = (value) => { - if (orderBy == value) { - setOrderBy(''); - } else { - setOrderBy(value); - } - } - - const sortOptions = [ - { - name: 'Harga Terendah', - value: 'price-asc', - }, - { - name: 'Harga Tertinggi', - value: 'price-desc', - }, - { - name: 'Populer', - value: 'popular', - }, - { - name: 'Ready Stock', - value: 'stock', - }, - ]; - - return ( - <> - <BottomPopup active={isActive} closePopup={closeFilter} title="Filter Produk"> - <form className="flex flex-col gap-y-4" onSubmit={submit}> - {(selectedBrand || selectedCategory || priceFrom || priceTo || orderBy) && ( - <button type="button" className="text-yellow_r-11 font-semibold ml-auto" onClick={reset}> - Reset Filter - </button> - )} - - {!disableFilter.includes('orderBy') && ( - <div> - <label>Urutkan</label> - <div className="flex gap-2 mt-2 overflow-x-auto w-full"> - {sortOptions.map((sortOption, index) => ( - <button - key={index} - type="button" - className={"p-2 rounded border border-gray_r-6 flex-shrink-0" + (orderBy == sortOption.value ? ' border-yellow_r-10 bg-yellow_r-3 text-yellow_r-11' : '')} - onClick={() => changeOrderBy(sortOption.value)} - > - {sortOption.name} - </button> - ))} - </div> - </div> - )} - - {!disableFilter.includes('category') && ( - <div> - <label>Kategori</label> - <select className="form-input mt-2" value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)}> - <option value="">Pilih kategori...</option> - {categories?.map((category, index) => ( - <option key={index} value={category}>{category}</option> - ))} - </select> - </div> - )} - - {!disableFilter.includes('brand') && ( - <div> - <label>Brand</label> - <select className="form-input mt-2" value={selectedBrand} onChange={(e) => setSelectedBrand(e.target.value)}> - <option value="">Pilih brand...</option> - {brands?.map((brand, index) => ( - <option key={index} value={brand}>{brand}</option> - ))} - </select> - </div> - )} - - {!disableFilter.includes('price') && ( - <div> - <label>Harga</label> - <div className="flex gap-x-4 mt-2 items-center"> - <input className="form-input" type="number" placeholder="Dari" value={priceFrom} onChange={(e) => setPriceFrom(e.target.value)}/> - <span>—</span> - <input className="form-input" type="number" placeholder="Sampai" value={priceTo} onChange={(e) => setPriceTo(e.target.value)}/> - </div> - </div> - )} - <button type="submit" className="btn-yellow font-semibold mt-2 w-full"> - Terapkan Filter - </button> - </form> - </BottomPopup> - </> - ) -}; - -export default Filter;
\ No newline at end of file diff --git a/src/components/elements/Image.js b/src/components/elements/Image.js deleted file mode 100644 index 60e249b9..00000000 --- a/src/components/elements/Image.js +++ /dev/null @@ -1,17 +0,0 @@ -import { LazyLoadImage } from "react-lazy-load-image-component" -import "react-lazy-load-image-component/src/effects/opacity.css" - -const Image = ({ ...props }) => { - return ( - <LazyLoadImage - {...props} - effect="opacity" - src={props.src || '/images/noimage.jpeg'} - alt={props.src ? props.alt : 'Image Not Found - Indoteknik'} - /> - ) -} - -Image.defaultProps = LazyLoadImage.defaultProps - -export default Image
\ No newline at end of file diff --git a/src/components/elements/LineDivider.js b/src/components/elements/LineDivider.js deleted file mode 100644 index 4e8c7b52..00000000 --- a/src/components/elements/LineDivider.js +++ /dev/null @@ -1,7 +0,0 @@ -const LineDivider = () => { - return ( - <hr className="h-1 bg-gray_r-4 border-none"/> - ); -}; - -export default LineDivider;
\ No newline at end of file diff --git a/src/components/elements/Link.js b/src/components/elements/Link.js deleted file mode 100644 index 065b5c9e..00000000 --- a/src/components/elements/Link.js +++ /dev/null @@ -1,13 +0,0 @@ -import NextLink from "next/link"; - -const Link = ({ children, ...props }) => { - return ( - <NextLink {...props} scroll={false}> - {children} - </NextLink> - ) -} - -Link.defaultProps = NextLink.defaultProps - -export default Link
\ No newline at end of file diff --git a/src/components/elements/Pagination.js b/src/components/elements/Pagination.js deleted file mode 100644 index ff2a8462..00000000 --- a/src/components/elements/Pagination.js +++ /dev/null @@ -1,58 +0,0 @@ -import Link from "./Link"; - -export default function Pagination({ pageCount, currentPage, url }) { - let firstPage = false; - let lastPage = false; - let dotsPrevPage = false; - let dotsNextPage = false; - let urlParameterPrefix = url.includes('?') ? '&' : '?'; - - return pageCount > 1 && ( - <div className="pagination"> - {Array.from(Array(pageCount)).map((v, i) => { - let page = i + 1; - let rangePrevPage = currentPage - 2; - let rangeNextPage = currentPage + 2; - let PageComponent = <Link key={i} href={`${url + urlParameterPrefix}page=${page}`} className={"pagination-item" + (page == currentPage ? " pagination-item--active " : "")}>{page}</Link>; - let DotsComponent = <div key={i} className="pagination-dots">...</div>; - - if (pageCount == 7) { - return PageComponent; - } - - if (currentPage == 1) rangeNextPage += 3; - if (currentPage == 2) rangeNextPage += 2; - if (currentPage == 3) rangeNextPage += 1; - if (currentPage == 4) rangePrevPage -= 1; - if (currentPage == pageCount) rangePrevPage -= 3; - if (currentPage == pageCount - 1) rangePrevPage -= 2; - if (currentPage == pageCount - 2) rangePrevPage -= 1; - if (currentPage == pageCount - 3) rangeNextPage += 1; - - if (page > rangePrevPage && page < rangeNextPage) { - return PageComponent; - } - - if (page == 1 && rangePrevPage >= 1 && !firstPage) { - firstPage = true; - return PageComponent; - } - - if (page == pageCount && rangeNextPage <= pageCount && !lastPage) { - lastPage = true; - return PageComponent; - } - - if (page > currentPage && (pageCount - currentPage) > 1 && !dotsNextPage) { - dotsNextPage = true; - return DotsComponent; - } - - if (page < currentPage && (currentPage - 1) > 1 && !dotsPrevPage) { - dotsPrevPage = true; - return DotsComponent; - } - })} - </div> - ) -}
\ No newline at end of file diff --git a/src/components/elements/ProgressBar.js b/src/components/elements/ProgressBar.js deleted file mode 100644 index 0adedcdf..00000000 --- a/src/components/elements/ProgressBar.js +++ /dev/null @@ -1,25 +0,0 @@ -import { Fragment } from "react"; - -const ProgressBar = ({ current, labels }) => { - return ( - <div className="bg-gray_r-1 flex gap-x-2 p-4 rounded-md"> - {labels.map((label, index) => ( - <Fragment key={index}> - <div className={"flex gap-x-2 items-center " + (index < current ? 'text-gray_r-12' : 'text-gray_r-11')}> - <div className={"leading-none p-2 rounded-full w-7 text-center text-caption-2 " + (index < current ? 'bg-yellow_r-9' : 'bg-gray_r-5')}> - { index + 1 } - </div> - <p className="font-medium text-caption-2">{ label }</p> - </div> - { index < (labels.length - 1) && ( - <div className="flex-1 flex items-center"> - <div className="h-0.5 w-full bg-gray_r-7"></div> - </div> - ) } - </Fragment> - ))} - </div> - ) -} - -export default ProgressBar;
\ No newline at end of file diff --git a/src/components/elements/Skeleton.js b/src/components/elements/Skeleton.js deleted file mode 100644 index fbdbc245..00000000 --- a/src/components/elements/Skeleton.js +++ /dev/null @@ -1,48 +0,0 @@ -import ImagePlaceholderIcon from "../../icons/image-placeholder.svg"; - -const SkeletonList = ({ number }) => ( - <div role="status" className="space-y-6 animate-pulse"> - { Array.from(Array(number), (e, i) => ( - <div className="flex items-center justify-between" key={i}> - <div> - <div className="h-2.5 bg-gray-300 rounded-full w-24 mb-2.5"></div> - <div className="w-32 h-2 bg-gray-200 rounded-full"></div> - </div> - <div className="h-2.5 bg-gray-300 rounded-full w-12"></div> - </div> - )) } - <span className="sr-only">Loading...</span> - </div> -); - -const SkeletonProduct = () => ( - <div className="grid grid-cols-2 gap-x-4"> - <div role="status" className="p-4 max-w-sm rounded border border-gray-300 shadow animate-pulse md:p-6"> - <div className="flex justify-center items-center mb-4 h-48 bg-gray-300 rounded"> - <ImagePlaceholderIcon className="w-12 h-12 text-gray-200" /> - </div> - <div className="h-2 bg-gray-200 rounded-full w-10 mb-1"></div> - <div className="h-2.5 bg-gray-200 rounded-full w-full mb-4"></div> - <div className="h-2 bg-gray-200 rounded-full mb-2.5"></div> - <div className="h-2 bg-gray-200 rounded-full mb-2.5"></div> - <div className="h-2 bg-gray-200 rounded-full"></div> - <span className="sr-only">Loading...</span> - </div> - <div role="status" className="p-4 max-w-sm rounded border border-gray-300 shadow animate-pulse md:p-6"> - <div className="flex justify-center items-center mb-4 h-48 bg-gray-300 rounded"> - <ImagePlaceholderIcon className="w-12 h-12 text-gray-200" /> - </div> - <div className="h-2 bg-gray-200 rounded-full w-10 mb-1"></div> - <div className="h-2.5 bg-gray-200 rounded-full w-full mb-4"></div> - <div className="h-2 bg-gray-200 rounded-full mb-2.5"></div> - <div className="h-2 bg-gray-200 rounded-full mb-2.5"></div> - <div className="h-2 bg-gray-200 rounded-full"></div> - <span className="sr-only">Loading...</span> - </div> - </div> -); - -export { - SkeletonList, - SkeletonProduct -};
\ No newline at end of file diff --git a/src/components/elements/Spinner.js b/src/components/elements/Spinner.js deleted file mode 100644 index 21006ecd..00000000 --- a/src/components/elements/Spinner.js +++ /dev/null @@ -1,13 +0,0 @@ -const Spinner = ({ className }) => { - return ( - <div role="status"> - <svg aria-hidden="true" className={"animate-spin " + className} viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/> - <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/> - </svg> - <span className="sr-only">Loading...</span> - </div> - ) -} - -export default Spinner;
\ No newline at end of file diff --git a/src/components/layouts/AppBar.js b/src/components/layouts/AppBar.js deleted file mode 100644 index fe74c940..00000000 --- a/src/components/layouts/AppBar.js +++ /dev/null @@ -1,47 +0,0 @@ -import { Bars3Icon, ChevronLeftIcon, HomeIcon, ShoppingCartIcon } from "@heroicons/react/24/outline"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import Link from "../elements/Link"; - -const AppBar = ({ title }) => { - const router = useRouter(); - - const handleBackButtonClick = (event) => { - event.currentTarget.disabled = true; - router.back(); - } - - return ( - <> - <Head> - <title>{ title } - Indoteknik</title> - </Head> - <div className="sticky-header flex justify-between !p-0 !pr-4 idt-transition"> - {/* --- Start Title */} - <div className="flex items-center"> - <button type="button" onClick={handleBackButtonClick} className="text-gray_r-12 px-4 py-5"> - <ChevronLeftIcon className="w-6 stroke-2"/> - </button> - <h1 className="text-h-md">{ title }</h1> - </div> - {/* --- End Title */} - - {/* --- Start Icons */} - <div className="flex gap-x-4 items-center"> - <Link href="/shop/cart"> - <ShoppingCartIcon className="w-6 stroke-2 text-gray_r-12"/> - </Link> - <Link href="/"> - <HomeIcon className="w-6 stroke-2 text-gray_r-12"/> - </Link> - <Link href="/my/menu"> - <Bars3Icon className="w-6 stroke-2 text-gray_r-12"/> - </Link> - </div> - {/* --- End Icons */} - </div> - </> - ); -}; - -export default AppBar;
\ No newline at end of file diff --git a/src/components/layouts/Footer.js b/src/components/layouts/Footer.js deleted file mode 100644 index d173a525..00000000 --- a/src/components/layouts/Footer.js +++ /dev/null @@ -1,91 +0,0 @@ -import { - PhoneIcon, - DevicePhoneMobileIcon, - EnvelopeIcon -} from "@heroicons/react/24/outline"; -import Image from "next/image"; -import InstagramIcon from "@/icons/instagram.svg"; -import LinkedinIcon from "@/icons/linkedin.svg"; -import Link from "../elements/Link"; - -export default function Footer() { - return ( - <div className="p-4 bg-gray_r-3"> - <div className="grid grid-cols-2 gap-x-2 mb-4"> - <div> - <p className="font-medium mb-2">Kantor Pusat</p> - <p className="text-gray_r-11 leading-6 text-caption-2"> - Jl. Bandengan Utara 85A No. 8-9 RT.3/RW.16, Penjaringan, Kec. Penjaringan - </p> - - <p className="font-medium mb-2 mt-6">Layanan Informasi</p> - <div className="flex items-center gap-x-2 text-gray_r-11 text-caption-2 mb-2"> - <PhoneIcon className="w-5 h-5 stroke-2"/> - <a className="text-gray_r-11 font-normal" href="tel:02129338828"> - (021) 2933-8828 - </a> - {'/'} - <a className="text-gray_r-11 font-normal" href="tel:02129338829"> - 29 - </a> - </div> - <div className="flex items-center gap-x-2 text-gray_r-11 text-caption-2 mb-2"> - <DevicePhoneMobileIcon className="w-5 h-5 stroke-2"/> - <a className="text-gray_r-11 font-normal" href="https://wa.me/628128080622"> - 0812-8080-622 - </a> - </div> - <div className="flex items-center gap-x-2 text-gray_r-11 text-caption-2"> - <EnvelopeIcon className="w-5 h-5 stroke-2"/> - <a className="text-gray_r-11 font-normal" href="mailto:sales@indoteknik.com"> - sales@indoteknik.com - </a> - </div> - - <p className="font-medium mb-2 mt-6">Panduan Pelanggan</p> - <div className="text-caption-2 flex flex-col gap-y-2"> - <Link className="text-gray_r-11 font-normal" href="/faqs">FAQ</Link> - <Link className="text-gray_r-11 font-normal" href="/">Kebijakan Privasi</Link> - <Link className="text-gray_r-11 font-normal" href="/">Pengajuan Tempo</Link> - <Link className="text-gray_r-11 font-normal" href="/">Garansi Produk</Link> - <Link className="text-gray_r-11 font-normal" href="/">Online Quotation</Link> - <Link className="text-gray_r-11 font-normal" href="/">Pengiriman</Link> - <Link className="text-gray_r-11 font-normal" href="/">Pembayaran</Link> - <Link className="text-gray_r-11 font-normal" href="/">Syarat & Ketentuan</Link> - - </div> - </div> - <div> - <p className="font-medium mb-2">Jam Operasional</p> - <p className="text-gray_r-11 leading-6 text-caption-2"> - <span className="font-medium">Senin - Jumat:</span> 08:30 - 17:00 - </p> - <p className="text-gray_r-11 leading-6 text-caption-2"> - <span className="font-medium">Sabtu:</span> 08:30 - 14:00 - </p> - - <p className="font-medium mb-2 mt-6">Temukan Kami</p> - <div className="flex gap-x-2"> - <InstagramIcon className="w-5 h-5 stroke-gray_r-11" /> - <LinkedinIcon className="w-5 h-5 stroke-gray_r-11" /> - </div> - - <p className="font-medium mb-2 mt-6">Pembayaran</p> - <div className="grid grid-cols-4 gap-2"> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - <Image src="/images/payments/bca.webp" alt="BCA Payment" width={48} height={48} className="w-full" /> - </div> - - {/* <p className="font-medium mb-2 mt-6">Pengiriman</p> */} - </div> - </div> - <h6 className="h2">PT. Indoteknik Dotcom Gemilang</h6> - </div> - ); -}
\ No newline at end of file diff --git a/src/components/layouts/Header.js b/src/components/layouts/Header.js deleted file mode 100644 index 23fda642..00000000 --- a/src/components/layouts/Header.js +++ /dev/null @@ -1,253 +0,0 @@ -import Image from "next/image"; -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import axios from "axios"; -import { - MagnifyingGlassIcon, - Bars3Icon, - ShoppingCartIcon, - ChevronRightIcon, - Cog6ToothIcon, - HeartIcon, - ChevronDownIcon, - ChevronUpIcon -} from "@heroicons/react/24/outline"; - -// Helpers -import { useAuth } from "@/core/utils/auth"; -// Components -import Link from "../elements/Link"; -// Images -import Logo from "@/images/logo.png"; -import greeting from "@/core/utils/greeting"; -import apiOdoo from "@/core/utils/apiOdoo"; - -const menus = [ - { name: 'Semua Brand', href: '/shop/brands' }, - { name: 'Blog Indoteknik', href: '/' }, - { name: 'Tentang Indoteknik', href: '/' }, - { name: 'Pusat Bantuan', href: '/' }, -]; - -export default function Header({ title }) { - const router = useRouter(); - const { q = '' } = router.query; - const [searchQuery, setSearchQuery] = useState(q != '*' ? q : ''); - const [suggestions, setSuggestions] = useState([]); - const searchQueryRef = useRef(); - const [isMenuActive, setIsMenuActive] = useState(false); - const [auth] = useAuth(); - - useEffect(() => { - if (q) { - searchQueryRef.current.blur(); - setSuggestions([]); - }; - }, [q]); - - const clickSuggestion = (value) => { - router.push(`/shop/search?q=${value}`, undefined, { scroll: false }); - }; - - const getSuggestion = useCallback(async () => { - if (searchQuery.trim().length > 0) { - let result = await axios(`${process.env.SELF_HOST}/api/shop/suggest?q=${searchQuery.trim()}`); - setSuggestions(result.data.suggest.mySuggester[searchQuery.trim()].suggestions); - } else { - setSuggestions([]); - } - }, [searchQuery]); - - useEffect(() => { - if (document.activeElement == searchQueryRef.current) getSuggestion(); - }, [getSuggestion]); - - const openMenu = () => setIsMenuActive(true); - const closeMenu = () => setIsMenuActive(false); - - const searchSubmit = (e) => { - e.preventDefault(); - if (searchQuery.length > 0) { - router.push(`/shop/search?q=${searchQuery}`, undefined, { scroll: false }); - } else { - searchQueryRef.current.focus(); - } - } - - const [ isOpenCategory, setOpenCategory ] = useState(false); - const [ categories, setCategories ] = useState([]); - - useEffect(() => { - const loadCategories = async () => { - if (isOpenCategory && categories.length == 0) { - let dataCategories = await apiOdoo('GET', '/api/v1/category/tree'); - dataCategories = dataCategories.map((category) => { - category.childs = category.childs.map((child1Category) => { - return { - ...child1Category, - isOpen: false - } - }) - return { - ...category, - isOpen: false - } - }); - setCategories(dataCategories); - } - } - loadCategories(); - }, [ isOpenCategory, categories ]); - - const toggleCategories = (id = 0) => { - let newCategories = categories.map((category) => { - category.childs = category.childs.map((child1Category) => { - return { - ...child1Category, - isOpen: id == child1Category.id ? !child1Category.isOpen : child1Category.isOpen - } - }) - return { - ...category, - isOpen: id == category.id ? !category.isOpen : category.isOpen - } - }); - setCategories(newCategories); - } - - return ( - <> - <Head> - <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> - <title>{title}</title> - </Head> - - <div className={'menu-wrapper' + (isMenuActive ? ' active ' : '')}> - <div className="flex gap-x-2 items-center border-b border-gray_r-6 p-4"> - { auth && ( - <Link href="/my/menu" className="w-full flex items-center text-gray_r-12" onClick={closeMenu}> - <div> - <p className="text-gray_r-11 text-caption-2">{ greeting() },</p> - <h1>{auth.name}</h1> - </div> - <div className="ml-auto"> - <Cog6ToothIcon className="w-5" /> - </div> - </Link> - ) } - - { !auth && ( - <> - <Link href="/login" onClick={closeMenu} className="w-full py-2 btn-light text-gray_r-12">Masuk</Link> - <Link href="/register" onClick={closeMenu} className="w-full py-2 btn-yellow text-gray_r-12">Daftar</Link> - </> - ) } - </div> - <div className="flex flex-col"> - { menus.map((menu, index) => ( - <Link className="flex w-full font-normal text-gray_r-11 border-b border-gray_r-6 p-4" href={menu.href} key={index} onClick={closeMenu}> - <span>{ menu.name }</span> - <div className="ml-auto"> - <ChevronRightIcon className="text-gray_r-12 w-5" /> - </div> - </Link> - )) } - <div className="flex w-full font-normal text-gray_r-11 border-b border-gray_r-6 p-4" onClick={() => setOpenCategory(!isOpenCategory)}> - <span>Kategori</span> - <div className="ml-auto"> - { !isOpenCategory && <ChevronDownIcon className="text-gray_r-12 w-5" /> } - { isOpenCategory && <ChevronUpIcon className="text-gray_r-12 w-5" /> } - </div> - </div> - { isOpenCategory && categories.map((category) => ( - <Fragment key={category.id}> - <div className="flex w-full text-gray_r-11 border-b border-gray_r-6 px-4 pl-8"> - <Link href={`/shop/search?category=${category.name}`} className="flex-1 font-normal text-gray_r-11 py-4"> - { category.name } - </Link> - <div className="ml-4 h-full py-4" onClick={() => toggleCategories(category.id)}> - { !category.isOpen && <ChevronDownIcon className="text-gray_r-12 w-5" /> } - { category.isOpen && <ChevronUpIcon className="text-gray_r-12 w-5" /> } - </div> - </div> - { category.isOpen && category.childs.map((child1Category) => ( - <Fragment key={child1Category.id}> - <div className={`flex w-full text-gray_r-11 border-b border-gray_r-6 p-4 pl-12 ${category.isOpen ? 'bg-gray_r-2' : ''}`}> - <Link href={`/shop/search?category=${child1Category.name}`} className="flex-1 font-normal text-gray_r-11"> - { child1Category.name } - </Link> - { child1Category.childs.length > 0 && ( - <div className="ml-4 h-full" onClick={() => toggleCategories(child1Category.id)}> - { !child1Category.isOpen && <ChevronDownIcon className="text-gray_r-12 w-5" /> } - { child1Category.isOpen && <ChevronUpIcon className="text-gray_r-12 w-5" /> } - </div> - ) } - </div> - { child1Category.isOpen && child1Category.childs.map((child2Category) => ( - <Link key={child2Category.id} href={`/shop/search?category=${child2Category.name}`} className="flex w-full font-normal text-gray_r-11 border-b border-gray_r-6 p-4 pl-16"> - { child2Category.name } - </Link> - )) } - </Fragment> - )) } - </Fragment> - )) } - </div> - </div> - <div className={isMenuActive ? 'menu-overlay block opacity-100' : 'menu-overlay hidden opacity-0'} onClick={closeMenu}></div> - - <div className="sticky-header"> - <div className="flex justify-between items-center"> - <Link href="/" scroll={false}> - <Image src={Logo} alt="Logo Indoteknik" width={120} height={40} /> - </Link> - <div className="flex gap-x-4"> - <Link href="/my/wishlist"> - <HeartIcon className="w-6 text-gray_r-12" /> - </Link> - <Link href="/shop/cart"> - <ShoppingCartIcon className="w-6 text-gray_r-12" /> - </Link> - <button onClick={openMenu}> - <Bars3Icon className="w-6 text-gray_r-12" /> - </button> - </div> - </div> - <form onSubmit={searchSubmit} className="relative flex mt-2"> - <input - ref={searchQueryRef} - type="text" - name="q" - onChange={(e) => setSearchQuery(e.target.value)} - onFocus={getSuggestion} - value={searchQuery} - className="form-input rounded-r-none border-r-0 focus:border-gray_r-7" - placeholder="Ketikan nama, merek, part number" - autoComplete="off" - /> - - <button - type="submit" - aria-label="search" - className="btn-light bg-transparent px-2 py-1 rounded-l-none border-l-0" - > - <MagnifyingGlassIcon className="w-6" /> - </button> - - {suggestions.length > 1 && ( - <div className="absolute w-full top-[50px] rounded-b bg-gray_r-2 border border-gray_r-6"> - {suggestions.map((suggestion, index) => ( - <p onClick={() => clickSuggestion(suggestion.term)} className="w-full p-2" key={index}>{suggestion.term}</p> - ))} - </div> - )} - </form> - </div> - - {suggestions.length > 1 && ( - <div className="menu-overlay !z-40" onClick={() => setSuggestions([])}></div> - )} - </> - ) -}
\ No newline at end of file diff --git a/src/components/layouts/Layout.js b/src/components/layouts/Layout.js deleted file mode 100644 index fd507963..00000000 --- a/src/components/layouts/Layout.js +++ /dev/null @@ -1,20 +0,0 @@ -import { motion } from 'framer-motion'; - -export default function Layout({ children, ...pageProps }) { - const transition = { - ease: 'easeOut', - duration: 0.3 - }; - - return children && ( - <motion.main - initial={{ opacity: 0, x: 30, y: 0 }} - animate={{ opacity: 1, x: 0, y: 0 }} - exit={{ opacity: 0, x: 30, y: 0 }} - transition={transition} - {...pageProps} - > - {children} - </motion.main> - ); -}
\ No newline at end of file diff --git a/src/components/manufactures/ManufactureCard.js b/src/components/manufactures/ManufactureCard.js deleted file mode 100644 index 73a96902..00000000 --- a/src/components/manufactures/ManufactureCard.js +++ /dev/null @@ -1,18 +0,0 @@ -import { createSlug } from "@/core/utils/slug"; -import Image from "../elements/Image"; -import Link from "../elements/Link"; - -export default function ManufactureCard({ data }) { - const manufacture = data; - return ( - <Link href={`/shop/brands/${createSlug(manufacture.name, manufacture.id)}`} className="flex justify-center items-center border border-gray-300 p-1 rounded h-16 text-gray-800 text-sm text-center bg-white"> - {manufacture.logo ? ( - <Image - src={manufacture.logo} - alt={manufacture.name} - className="w-full max-h-full object-contain object-center" - /> - ) : manufacture.name} - </Link> - ); -}
\ No newline at end of file diff --git a/src/components/products/ProductCard.js b/src/components/products/ProductCard.js deleted file mode 100644 index c79a4900..00000000 --- a/src/components/products/ProductCard.js +++ /dev/null @@ -1,69 +0,0 @@ -import Link from "../elements/Link"; -import currencyFormat from "@/core/utils/currencyFormat"; -import { createSlug } from "@/core/utils/slug"; -import { ChevronRightIcon } from "@heroicons/react/20/solid"; -import Image from "../elements/Image"; - - -export default function ProductCard({ - data, - simpleProductTitleLine = false -}) { - let product = data; - return ( - <div className="product-card"> - <Link href={'/shop/product/' + createSlug(product.name, product.id)} className="block relative bg-white"> - <Image - src={product.image} - alt={product.name} - className="product-card__image" - /> - {product.variant_total > 1 ? ( - <div className="absolute bottom-2 left-2 badge-gray">{product.variant_total} Varian</div> - ) : ''} - </Link> - <div className="product-card__content"> - <div> - {typeof product.manufacture.name !== "undefined" ? ( - <Link href={'/shop/brands/' + createSlug(product.manufacture.name, product.manufacture.id)} className="product-card__brand">{product.manufacture.name}</Link> - ) : ( - <span className="product-card__brand">-</span> - )} - <Link href={'/shop/product/' + createSlug(product.name, product.id)} className={`product-card__title ${simpleProductTitleLine ? 'wrap-line-ellipsis-2' : 'wrap-line-ellipsis-3'}`}> - {product.name} - </Link> - </div> - <div className="mt-2"> - {product.lowest_price.discount_percentage > 0 ? ( - <div className="flex gap-x-1 items-center mb-1"> - <p className="text-caption-2 text-gray_r-11 line-through">{currencyFormat(product.lowest_price.price)}</p> - <span className="badge-solid-red">{product.lowest_price.discount_percentage}%</span> - </div> - ) : ''} - - {product.lowest_price.price_discount > 0 ? ( - <p className="text-caption-1 text-gray_r-12 font-bold"> - {currencyFormat(product.lowest_price.price_discount)} - </p> - ) : ( - <a - href="https://wa.me" - target="_blank" - rel="noreferrer" - className="flex items-center gap-x-1 text-caption-1" - > - Tanya Harga <ChevronRightIcon className="text-yellow_r-11 w-5 h-5" /> - </a> - )} - - {product.stock_total > 0 ? ( - <div className="flex gap-x-1 mt-2"> - <div className="badge-solid-red">Ready Stock</div> - <div className="badge-gray">{product.stock_total > 5 ? '> 5' : '< 5'}</div> - </div> - ) : ''} - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/src/components/products/ProductCategories.js b/src/components/products/ProductCategories.js deleted file mode 100644 index 3b671f29..00000000 --- a/src/components/products/ProductCategories.js +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from "react"; -import ProductSlider from "./ProductSlider"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { LazyLoadComponent } from "react-lazy-load-image-component"; -import { SkeletonProduct } from "../elements/Skeleton"; - -const ProductCategory = ({ id }) => { - const [ content, setContent ] = useState(null); - - useEffect(() => { - const loadContent = async () => { - if (!content) { - const dataContent = await apiOdoo('GET', `/api/v1/categories_homepage?id=${id}`); - setContent(dataContent[0]); - } - } - loadContent(); - }, [id, content]); - - return ( - <div className="p-4 relative bg-yellow_r-2"> - { content ? ( - <ProductSlider - products={{ - products: content.products, - banner: { - image: content.image, - name: content.name, - url: `/shop/search?category=${content.name}` - } - }} - simpleProductTitleLine - bannerMode - /> - ) : <SkeletonProduct /> } - </div> - ); -} - -export default function ProductCategories() { - const [ contentIds, setContentIds ] = useState([]); - - useEffect(() => { - const getContentIds = async () => { - if (contentIds.length == 0) { - const dataContentIds = await apiOdoo('GET', '/api/v1/categories_homepage/ids'); - setContentIds(dataContentIds); - } - } - getContentIds(); - }, [ contentIds ]); - - return ( - <div className="flex flex-col gap-y-6"> - { contentIds.map((contentId) => ( - <LazyLoadComponent placeholder={<SkeletonProduct/>} key={contentId}> - <ProductCategory id={contentId} /> - </LazyLoadComponent> - )) } - </div> - ) -}
\ No newline at end of file diff --git a/src/components/products/ProductSimilar.js b/src/components/products/ProductSimilar.js deleted file mode 100644 index 9e2292cb..00000000 --- a/src/components/products/ProductSimilar.js +++ /dev/null @@ -1,25 +0,0 @@ -import apiOdoo from '@/core/utils/apiOdoo'; -import { useEffect, useState } from 'react'; -import ProductSlider from './ProductSlider'; - -export default function ProductSimilar({ productId }) { - const [similarProducts, setSimilarProducts] = useState(null); - - useEffect(() => { - const getSimilarProducts = async () => { - if (productId && !similarProducts) { - const dataSimilarProducts = await apiOdoo('GET', `/api/v1/product/${productId}/similar?limit=20`); - setSimilarProducts(dataSimilarProducts); - } - } - getSimilarProducts(); - }, [productId, similarProducts]); - - - return ( - <div className="p-4"> - <h2 className="font-bold mb-4">Kamu Mungkin Juga Suka</h2> - <ProductSlider products={similarProducts}/> - </div> - ) -}
\ No newline at end of file diff --git a/src/components/products/ProductSlider.js b/src/components/products/ProductSlider.js deleted file mode 100644 index 662a6511..00000000 --- a/src/components/products/ProductSlider.js +++ /dev/null @@ -1,39 +0,0 @@ -import { Swiper, SwiperSlide } from "swiper/react"; -import ProductCard from "./ProductCard"; -import "swiper/css"; -import Image from "../elements/Image"; -import Link from "../elements/Link"; -import { SkeletonProduct } from "../elements/Skeleton"; -import { useState } from "react"; - -export default function ProductSlider({ - products, - simpleProductTitleLine = false, - bannerMode = false -}) { - const [ activeIndex, setActiveIndex ] = useState(0); - const swiperSliderFirstMove = (swiper) => { - setActiveIndex(swiper.activeIndex); - }; - - return ( - <> - { bannerMode && ( - <Image src={products.banner.image} alt={products.banner.name} className={`absolute rounded-r top-0 left-0 h-full max-w-[52%] idt-transition border border-gray_r-6 ` + (activeIndex > 0 ? 'opacity-0' : 'opacity-100')} /> - ) } - <Swiper freeMode={true} slidesPerView={2.2} spaceBetween={8} onSlideChange={swiperSliderFirstMove} prefix="product"> - { bannerMode && ( - <SwiperSlide> - <Link href={products.banner.url} className="w-full h-full block"></Link> - </SwiperSlide> - ) } - {products?.products?.map((product, index) => ( - <SwiperSlide key={index}> - <ProductCard data={product} simpleProductTitleLine={simpleProductTitleLine} /> - </SwiperSlide> - ))} - </Swiper> - { !products ? <SkeletonProduct /> : ''} - </> - ) -}
\ No newline at end of file diff --git a/src/components/transactions/TransactionDetail.js b/src/components/transactions/TransactionDetail.js deleted file mode 100644 index 295a4f9f..00000000 --- a/src/components/transactions/TransactionDetail.js +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from "react"; -import DescriptionRow from "../elements/DescriptionRow"; -import Disclosure from "../elements/Disclosure"; - -const DetailAddress = ({ address }) => { - const fullAddress = []; - if (address?.street) fullAddress.push(address.street); - if (address?.sub_district?.name) fullAddress.push(address.sub_district.name); - if (address?.district?.name) fullAddress.push(address.district.name); - if (address?.city?.name) fullAddress.push(address.city.name); - return ( - <div className="p-4 flex flex-col gap-y-4"> - <DescriptionRow label="Nama">{ address?.name }</DescriptionRow> - <DescriptionRow label="Email">{ address?.email || '-' }</DescriptionRow> - <DescriptionRow label="No Telepon">{ address?.mobile || '-' }</DescriptionRow> - <DescriptionRow label="Alamat">{ fullAddress.join(', ') }</DescriptionRow> - </div> - ); -}; - -const TransactionDetailAddress = ({ transaction }) => { - const [ activeSection, setActiveSection ] = useState({ - purchase: false, - shipping: false, - invoice: false, - }); - - const toggleSection = ( name ) => { - setActiveSection({ - ...activeSection, - [name]: !activeSection[name] - }); - }; - - return ( - <div className="m-4 rounded-md border border-gray_r-6 divide-y divide-gray_r-6"> - <Disclosure - label="Detail Pelanggan" - active={activeSection.purchase} - onClick={() => toggleSection('purchase')} - /> - { activeSection.purchase && ( - <DetailAddress address={transaction?.address?.customer} /> - ) } - - <Disclosure - label="Detail Pengiriman" - active={activeSection.shipping} - onClick={() => toggleSection('shipping')} - /> - { activeSection.shipping && ( - <DetailAddress address={transaction?.address?.shipping} /> - ) } - - <Disclosure - label="Detail Penagihan" - active={activeSection.invoice} - onClick={() => toggleSection('invoice')} - /> - { activeSection.invoice && ( - <DetailAddress address={transaction?.address?.invoice} /> - ) } - </div> - ); -}; - -export { TransactionDetailAddress };
\ No newline at end of file diff --git a/src/components/transactions/TransactionStatusBadge.js b/src/components/transactions/TransactionStatusBadge.js deleted file mode 100644 index f94fd3fd..00000000 --- a/src/components/transactions/TransactionStatusBadge.js +++ /dev/null @@ -1,45 +0,0 @@ -const TransactionStatusBadge = ({ status }) => { - let badgeProps = { - className: ['h-fit'], - text: '' - }; - switch (status) { - case 'cancel': - badgeProps.className.push('badge-solid-red'); - badgeProps.text = 'Pesanan Batal' - break; - case 'draft': - badgeProps.className.push('badge-red'); - badgeProps.text = 'Pending Quotation' - break; - case 'waiting': - badgeProps.className.push('badge-yellow'); - badgeProps.text = 'Pesanan diterima' - break; - case 'sale': - badgeProps.className.push('badge-yellow'); - badgeProps.text = 'Pesanan diproses' - break; - case 'shipping': - badgeProps.className.push('badge-green'); - badgeProps.text = 'Pesanan dikirim' - break; - case 'partial_shipping': - badgeProps.className.push('badge-green'); - badgeProps.text = 'Dikirim sebagian' - break; - case 'done': - badgeProps.className.push('badge-solid-green'); - badgeProps.text = 'Pesanan Selesai' - break; - } - badgeProps.className = badgeProps.className.join(' '); - - return ( - <div className={badgeProps.className}> - { badgeProps.text } - </div> - ) -}; - -export default TransactionStatusBadge;
\ No newline at end of file diff --git a/src/components/variants/VariantCard.js b/src/components/variants/VariantCard.js deleted file mode 100644 index a821480c..00000000 --- a/src/components/variants/VariantCard.js +++ /dev/null @@ -1,92 +0,0 @@ -import { createSlug } from "@/core/utils/slug"; -import Image from "../elements/Image"; -import Link from "../elements/Link"; -import currencyFormat from "@/core/utils/currencyFormat"; -import { useRouter } from "next/router"; -import { toast } from "react-hot-toast"; -import { createOrUpdateItemCart } from "@/core/utils/cart"; - -export default function VariantCard({ - data, - openOnClick = true, - buyMore = false -}) { - let product = data; - const router = useRouter(); - - const addItemToCart = () => { - toast.success('Berhasil menambahkan ke keranjang', { duration: 1500 }); - createOrUpdateItemCart(product.id, 1); - return; - }; - - const checkoutItem = () => { - router.push(`/shop/checkout?product_id=${product.id}&qty=${product.quantity}`); - } - - const Card = () => ( - <div className="flex gap-x-3"> - <div className="w-4/12 flex items-center gap-x-2"> - <Image - src={product.parent.image} - alt={product.parent.name} - className="object-contain object-center border border-gray_r-6 h-32 w-full rounded-md" - /> - </div> - <div className="w-8/12 flex flex-col"> - <p className="product-card__title wrap-line-ellipsis-2"> - {product.parent.name} - </p> - <p className="text-caption-2 text-gray_r-11 mt-1"> - {product.code || '-'} - {product.attributes.length > 0 ? ` ・ ${product.attributes.join(', ')}` : ''} - </p> - <div className="flex flex-wrap gap-x-1 items-center mt-auto"> - {product.price.discount_percentage > 0 && ( - <> - <p className="text-caption-2 text-gray_r-11 line-through">{currencyFormat(product.price.price)}</p> - <span className="badge-red">{product.price.discount_percentage}%</span> - </> - )} - <p className="text-caption-2 text-gray_r-12">{currencyFormat(product.price.price_discount)}</p> - </div> - <p className="text-caption-2 text-gray_r-11 mt-1"> - {currencyFormat(product.price.price_discount)} × {product.quantity} Barang - </p> - <p className="text-caption-2 text-gray_r-12 font-bold mt-2"> - {currencyFormat(product.quantity * product.price.price_discount)} - </p> - </div> - </div> - ); - - if (openOnClick) { - return ( - <> - <Link href={'/shop/product/' + createSlug(product.parent.name, product.parent.id)}> - <Card /> - </Link> - { buyMore && ( - <div className="flex justify-end gap-x-2 mb-2"> - <button - type="button" - onClick={addItemToCart} - className="btn-yellow text-gray_r-12 py-2 px-3" - > - Tambah Keranjang - </button> - <button - type="button" - onClick={checkoutItem} - className="btn-solid-red py-2 px-3" - > - Beli Lagi - </button> - </div> - ) } - </> - ); - } - - return <Card/>; -}
\ No newline at end of file diff --git a/src/components/variants/VariantGroupCard.js b/src/components/variants/VariantGroupCard.js deleted file mode 100644 index 462c63cf..00000000 --- a/src/components/variants/VariantGroupCard.js +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from "react" -import VariantCard from "./VariantCard" - -export default function VariantGroupCard({ - variants, - ...props -}) { - const [ showAll, setShowAll ] = useState(false) - const variantsToShow = showAll ? variants : variants.slice(0, 2) - - return ( - <> - { variantsToShow?.map((variant, index) => ( - <VariantCard - key={index} - data={variant} - {...props} - /> - )) } - { variants.length > 2 && ( - <button - type="button" - className="btn-light py-2 w-full" - onClick={() => setShowAll(!showAll)} - > - { !showAll ? `Lihat Semua +${variants.length - variantsToShow.length}` : 'Tutup' } - </button> - ) } - </> - ) -}
\ No newline at end of file diff --git a/src/core/api/odooApi.js b/src/core/api/odooApi.js new file mode 100644 index 00000000..202c355e --- /dev/null +++ b/src/core/api/odooApi.js @@ -0,0 +1,49 @@ +import axios from 'axios' +import camelcaseObjectDeep from 'camelcase-object-deep' +import { getCookie, setCookie } from 'cookies-next' +import { getAuth } from '../utils/auth' + +const renewToken = async () => { + let token = await axios.get(process.env.ODOO_HOST + '/api/token') + setCookie('token', token.data.result) + return token.data.result +} + +const getToken = async () => { + let token = getCookie('token') + if (token == undefined) token = await renewToken() + return token +} + +const maxConnectionAttempt = 15 +let connectionAttempt = 0 + +const odooApi = async (method, url, data = {}, headers = {}) => { + connectionAttempt++ + try { + let token = await getToken() + const auth = getAuth() + + let axiosParameter = { + method, + url: process.env.ODOO_HOST + url, + headers: { Authorization: token, ...headers } + } + if (auth) axiosParameter.headers['Token'] = auth.token + if (method.toUpperCase() == 'POST') + axiosParameter.headers['Content-Type'] = 'application/x-www-form-urlencoded' + if (Object.keys(data).length > 0) + axiosParameter.data = new URLSearchParams(Object.entries(data)).toString() + + let res = await axios(axiosParameter) + if (res.data.status.code == 401 && connectionAttempt < maxConnectionAttempt) { + await renewToken() + return odooApi(method, url, data, headers) + } + return camelcaseObjectDeep(res.data.result) || [] + } catch (error) { + console.log(error) + } +} + +export default odooApi diff --git a/src/core/api/searchSuggestApi.js b/src/core/api/searchSuggestApi.js new file mode 100644 index 00000000..e4445c9a --- /dev/null +++ b/src/core/api/searchSuggestApi.js @@ -0,0 +1,14 @@ +import axios from 'axios' + +const searchSuggestApi = async ({ query }) => { + const dataSearchSuggest = await axios( + `${process.env.SELF_HOST}/api/shop/suggest?q=${query.trim()}` + ) + return dataSearchSuggest +} + +searchSuggestApi.defaultProps = { + query: '' +} + +export default searchSuggestApi diff --git a/src/core/components/Seo.jsx b/src/core/components/Seo.jsx new file mode 100644 index 00000000..e688077e --- /dev/null +++ b/src/core/components/Seo.jsx @@ -0,0 +1,11 @@ +import Head from 'next/head' + +const Seo = ({ title }) => { + return ( + <Head> + <title>{title}</title> + </Head> + ) +} + +export default Seo diff --git a/src/core/components/elements/Alert/Alert.jsx b/src/core/components/elements/Alert/Alert.jsx new file mode 100644 index 00000000..695be8a3 --- /dev/null +++ b/src/core/components/elements/Alert/Alert.jsx @@ -0,0 +1,21 @@ +const Alert = ({ children, className, type }) => { + let typeClass = '' + switch (type) { + case 'info': + typeClass = 'bg-blue-100 text-blue-900 border-blue-400' + break + case 'success': + typeClass = 'bg-green-100 text-green-900 border-green-400' + break + case 'warning': + typeClass = 'bg-yellow-100 text-yellow-900 border-yellow-400' + break + } + return ( + <div className={`rounded-md w-full text-medium p-3 border ${typeClass} ${className}`}> + {children} + </div> + ) +} + +export default Alert diff --git a/src/core/components/elements/Appbar/Appbar.jsx b/src/core/components/elements/Appbar/Appbar.jsx new file mode 100644 index 00000000..098d0a33 --- /dev/null +++ b/src/core/components/elements/Appbar/Appbar.jsx @@ -0,0 +1,44 @@ +import { useRouter } from 'next/router' +import Link from '../Link/Link' +import { HomeIcon, Bars3Icon, ShoppingCartIcon, ChevronLeftIcon } from '@heroicons/react/24/outline' + +const AppBar = ({ title }) => { + const router = useRouter() + + return ( + <nav className='sticky top-0 z-50 bg-white border-b border-gray_r-6 flex justify-between'> + <div className='flex items-center'> + <button + type='button' + className='p-4' + onClick={() => router.back()} + > + <ChevronLeftIcon className='w-6 stroke-2' /> + </button> + <div className='font-medium text-h-sm line-clamp-1'>{title}</div> + </div> + <div className='flex items-center px-2'> + <Link + href='/shop/cart' + className='py-4 px-2' + > + <ShoppingCartIcon className='w-6 text-gray_r-12' /> + </Link> + <Link + href='/' + className='py-4 px-2' + > + <HomeIcon className='w-6 text-gray_r-12' /> + </Link> + <Link + href='/my/menu' + className='py-4 px-2' + > + <Bars3Icon className='w-6 text-gray_r-12' /> + </Link> + </div> + </nav> + ) +} + +export default AppBar diff --git a/src/core/components/elements/Badge/Badge.jsx b/src/core/components/elements/Badge/Badge.jsx new file mode 100644 index 00000000..e50cdc78 --- /dev/null +++ b/src/core/components/elements/Badge/Badge.jsx @@ -0,0 +1,29 @@ +const Badge = ({ children, type, ...props }) => { + return ( + <div + {...props} + className={`${badgeStyle(type)} ${props?.className}`} + > + {children} + </div> + ) +} + +Badge.defaultProps = { + className: '' +} + +const badgeStyle = (type) => { + let className = ['rounded px-1 text-[11px]'] + switch (type) { + case 'solid-red': + className.push('bg-red_r-11 text-white') + break + case 'light': + className.push('bg-gray_r-4 text-gray_r-11') + break + } + return className.join(' ') +} + +export default Badge diff --git a/src/core/components/elements/Divider/Divider.jsx b/src/core/components/elements/Divider/Divider.jsx new file mode 100644 index 00000000..ce54a2ea --- /dev/null +++ b/src/core/components/elements/Divider/Divider.jsx @@ -0,0 +1,7 @@ +const Divider = (props) => <div className={`h-1 bg-gray_r-4 ${props.className}`} /> + +Divider.defaultProps = { + className: '' +} + +export default Divider diff --git a/src/core/components/elements/Footer/BasicFooter.jsx b/src/core/components/elements/Footer/BasicFooter.jsx new file mode 100644 index 00000000..76a7cac2 --- /dev/null +++ b/src/core/components/elements/Footer/BasicFooter.jsx @@ -0,0 +1,268 @@ +import NextImage from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import { + DevicePhoneMobileIcon, + EnvelopeIcon, + PhoneArrowUpRightIcon +} from '@heroicons/react/24/outline' +import Link from '../Link/Link' + +const BasicFooter = () => { + return ( + <footer className='flex flex-wrap p-4 bg-gray_r-3 text-caption-1'> + <div className='w-1/2 flex flex-col gap-y-4 pr-1.5'> + <div> + <NextImage + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={90} + height={30} + /> + + <div className='font-semibold mt-2'>PT. Indoteknik Dotcom Gemilang</div> + </div> + + <OfficeLocation /> + <WarehouseLocation /> + <InformationCenter /> + <OpenHours /> + <SocialMedias /> + </div> + + <div className='w-1/2 flex flex-col gap-y-4 pl-1.5'> + <AboutUs /> + <CustomerGuide /> + <Payments /> + </div> + + <div className='w-full mt-4 leading-5 text-caption-2 text-gray_r-12/80'> + Copyright © 2007 - 2022, PT. Indoteknik Dotcom Gemilang + </div> + </footer> + ) +} + +const headerClassName = 'font-semibold mb-2' + +const OfficeLocation = () => ( + <div> + <div className={headerClassName}>Kantor Pusat</div> + <div className='leading-6 text-gray_r-12/80'> + Jl. Bandengan Utara 85A No. 8-9 RT.3/RW.16, Penjaringan, Kec. Penjaringan, Jakarta Utara + </div> + </div> +) + +const WarehouseLocation = () => ( + <div> + <div className={headerClassName}>Gudang Indoteknik</div> + <div className='leading-6 text-gray_r-12/80'> + Jl. Bandengan Utara Komp. 85 A dan B, Penjaringan, Kec. Penjaringan, Jakarta Utara + </div> + </div> +) + +const AboutUs = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Tentang Kami</div> + <ul className='flex flex-col gap-y-2'> + <li> + <InternalItemLink href='/'>Company Profile</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Karir</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pelanggan Kami</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Menjadi Supplier</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Garansi dan Pengembalian</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Metode Pembayaran</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Metode Pengiriman</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Testimonial</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Kebijakan Privacy</InternalItemLink> + </li> + </ul> + </div> +) + +const CustomerGuide = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Panduan Pelanggan</div> + <ul className='flex flex-col gap-y-2'> + <li> + <InternalItemLink href='/'>Panduan Belanja</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>F.A.Q</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Kebijakan Privasi</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pengajuan Tempo</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Garansi Produk</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Online Quotation</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pengiriman</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pembayaran</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Syarat & Ketentuan</InternalItemLink> + </li> + </ul> + </div> +) + +const InformationCenter = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Layanan Informasi</div> + <ul className='flex flex-col gap-y-2'> + <li className='text-gray_r-12/80 flex items-center'> + <PhoneArrowUpRightIcon className='w-[18px] mr-2' /> + <a href='tel:02129338828'>(021) 2933-8828 / 29</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <EnvelopeIcon className='w-[18px] mr-2' /> + <a href='mailto:sales@indoteknik.com'>sales@indoteknik.com</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <DevicePhoneMobileIcon className='w-[18px] mr-2' /> + <a href='https://wa.me/+628128080622'>0812-8080-622</a> + </li> + </ul> + </div> +) + +const OpenHours = () => ( + <div> + <div className={headerClassName}>Jam Operasional</div> + <ul className='flex flex-col gap-y-1'> + <li> + <div className='text-gray_r-12'>Senin - Jumat:</div> + <div className='text-gray_r-12/80'>08:30 - 17:00</div> + </li> + <li> + <div className='text-gray_r-12'>Sabtu:</div> + <div className='text-gray_r-12/80'>08:30 - 14:00</div> + </li> + </ul> + </div> +) + +const SocialMedias = () => ( + <div> + <div className={headerClassName}>Temukan Kami</div> + <div className='flex flex-wrap gap-2'> + <NextImage + src='/images/socials/Whatsapp.png' + alt='Whatsapp Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Facebook.png' + alt='Facebook Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Twitter.png' + alt='Twitter Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Linkedin.png' + alt='Linkedin Logo' + width={24} + height={24} + /> + </div> + </div> +) + +const Payments = () => ( + <div> + <div className={headerClassName}>Pembayaran</div> + <div className='flex flex-wrap gap-2'> + <NextImage + src='/images/payments/bca.png' + alt='Bank BCA Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/bni.png' + alt='Bank BNI Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/bri.png' + alt='Bank BRI Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/gopay.png' + alt='Gopay Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/mandiri.png' + alt='Bank Mandiri Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/mastercard.png' + alt='Mastercard Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/permata.png' + alt='Bank Permata Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/visa.png' + alt='Visa Logo' + width={48} + height={24} + /> + </div> + </div> +) + +const InternalItemLink = ({ href, children }) => ( + <Link + href={href} + className='!text-gray_r-12/80 font-normal line-clamp-1' + > + {children} + </Link> +) + +export default BasicFooter diff --git a/src/core/components/elements/Footer/SimpleFooter.jsx b/src/core/components/elements/Footer/SimpleFooter.jsx new file mode 100644 index 00000000..0fbc8f62 --- /dev/null +++ b/src/core/components/elements/Footer/SimpleFooter.jsx @@ -0,0 +1,43 @@ +import { + DevicePhoneMobileIcon, + EnvelopeIcon, + PhoneArrowUpRightIcon +} from '@heroicons/react/24/outline' + +const SimpleFooter = () => ( + <footer className='flex flex-wrap p-4 bg-gray_r-3 text-caption-1'> + <div className='w-full font-semibold mb-4 text-body-2'>Butuh bantuan? Hubungi Kami</div> + <div className='w-1/2 pr-2'> + <div className='font-semibold mb-3'>Hubungi Kami</div> + <ul className='flex flex-col gap-y-2'> + <li className='text-gray_r-12/80 flex items-center'> + <PhoneArrowUpRightIcon className='w-[18px] mr-2' /> + <a href='tel:02129338828'>(021) 2933-8828 / 29</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <EnvelopeIcon className='w-[18px] mr-2' /> + <a href='mailto:sales@indoteknik.com'>sales@indoteknik.com</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <DevicePhoneMobileIcon className='w-[18px] mr-2' /> + <a href='https://wa.me/+628128080622'>0812-8080-622</a> + </li> + </ul> + </div> + <div className='w-1/2 pl-2'> + <div className='font-semibold mb-3'>Jam Operasional</div> + <ul className='flex flex-col gap-y-1'> + <li> + <div className='text-gray_r-12 mb-0.5'>Senin - Jumat:</div> + <div className='text-gray_r-12/80'>08:30 - 17:00</div> + </li> + <li> + <div className='text-gray_r-12 mb-0.5'>Sabtu:</div> + <div className='text-gray_r-12/80'>08:30 - 14:00</div> + </li> + </ul> + </div> + </footer> +) + +export default SimpleFooter diff --git a/src/core/components/elements/Image/Image.jsx b/src/core/components/elements/Image/Image.jsx new file mode 100644 index 00000000..ac82aaaf --- /dev/null +++ b/src/core/components/elements/Image/Image.jsx @@ -0,0 +1,18 @@ +import { LazyLoadImage } from 'react-lazy-load-image-component' +import 'react-lazy-load-image-component/src/effects/opacity.css' + +const Image = ({ ...props }) => ( + <> + <LazyLoadImage + {...props} + src={props.src || '/images/noimage.jpeg'} + placeholderSrc='/images/indoteknik-placeholder.png' + alt={props.src ? props.alt : 'Image Not Found - Indoteknik'} + wrapperClassName='bg-white' + /> + </> +) + +Image.defaultProps = LazyLoadImage.defaultProps + +export default Image diff --git a/src/core/components/elements/Link/Link.jsx b/src/core/components/elements/Link/Link.jsx new file mode 100644 index 00000000..dbc65338 --- /dev/null +++ b/src/core/components/elements/Link/Link.jsx @@ -0,0 +1,17 @@ +import NextLink from 'next/link' + +const Link = ({ children, ...props }) => { + return ( + <NextLink + {...props} + scroll={false} + className={`block font-medium text-red_r-11 ${props?.className || ''}`} + > + {children} + </NextLink> + ) +} + +Link.defaultProps = NextLink.defaultProps + +export default Link diff --git a/src/core/components/elements/Navbar/Navbar.jsx b/src/core/components/elements/Navbar/Navbar.jsx new file mode 100644 index 00000000..8cecee5b --- /dev/null +++ b/src/core/components/elements/Navbar/Navbar.jsx @@ -0,0 +1,46 @@ +import dynamic from 'next/dynamic' +import Image from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import { Bars3Icon, HeartIcon, ShoppingCartIcon } from '@heroicons/react/24/outline' +import Link from '../Link/Link' +import useSidebar from '@/core/hooks/useSidebar' + +const Search = dynamic(() => import('./Search')) + +const Navbar = () => { + const { Sidebar, open } = useSidebar() + return ( + <> + <nav className='px-4 py-2 pb-3 sticky top-0 z-50 bg-white shadow'> + <div className='flex justify-between items-center mb-2'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Indoteknik Logo' + width={120} + height={40} + /> + </Link> + <div className='flex gap-x-3'> + <Link href='/my/wishlist'> + <HeartIcon className='w-6 text-gray_r-12' /> + </Link> + <Link href='/shop/cart'> + <ShoppingCartIcon className='w-6 text-gray_r-12' /> + </Link> + <button + type='button' + onClick={open} + > + <Bars3Icon className='w-6 text-gray_r-12' /> + </button> + </div> + </div> + <Search /> + </nav> + {Sidebar} + </> + ) +} + +export default Navbar diff --git a/src/core/components/elements/Navbar/Search.jsx b/src/core/components/elements/Navbar/Search.jsx new file mode 100644 index 00000000..ff2c7adb --- /dev/null +++ b/src/core/components/elements/Navbar/Search.jsx @@ -0,0 +1,93 @@ +import searchSuggestApi from '@/core/api/searchSuggestApi' +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' +import { useCallback, useEffect, useRef, useState } from 'react' +import Link from '../Link/Link' +import { useRouter } from 'next/router' + +const Search = () => { + const router = useRouter() + const queryRef = useRef() + const [query, setQuery] = useState('') + const [suggestions, setSuggestions] = useState([]) + + useEffect(() => { + setQuery(router.query.q) + }, [router.query]) + + const loadSuggestion = useCallback(() => { + if (query && document.activeElement == queryRef.current) { + ;(async () => { + const dataSuggestion = await searchSuggestApi({ query }) + setSuggestions(dataSuggestion.data.suggestions) + })() + return + } else { + setSuggestions([]) + } + }, [query]) + + useEffect(() => { + if (query && document.activeElement == queryRef.current) { + loadSuggestion() + } else { + setSuggestions([]) + } + }, [loadSuggestion, query]) + + const handleSubmit = (e) => { + e.preventDefault() + if (query) { + router.push(`/shop/search?q=${query}`) + } else { + queryRef.current.focus() + } + } + + const onInputBlur = () => { + setTimeout(() => { + setSuggestions([]) + }, 100) + } + + return ( + <form + onSubmit={handleSubmit} + className='flex relative' + > + <input + type='text' + ref={queryRef} + className='form-input p-3 rounded-r-none border-r-0 focus:border-gray_r-6' + placeholder='Ketik nama, part number, merk' + value={query} + onChange={(e) => setQuery(e.target.value)} + onBlur={onInputBlur} + onFocus={loadSuggestion} + /> + <button + type='submit' + className='rounded-r border border-l-0 border-gray_r-6 px-2' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + + {suggestions.length > 1 && ( + <> + <div className='absolute w-full top-[50px] rounded-b bg-gray_r-1 border border-gray_r-6 divide-y divide-gray_r-6'> + {suggestions.map((suggestion, index) => ( + <Link + href={`/shop/search?q=${suggestion.term}`} + key={index} + className='px-3 py-3 !text-gray_r-12 font-normal' + > + {suggestion.term} + </Link> + ))} + </div> + </> + )} + </form> + ) +} + +export default Search diff --git a/src/core/components/elements/Pagination/Pagination.js b/src/core/components/elements/Pagination/Pagination.js new file mode 100644 index 00000000..18964fc4 --- /dev/null +++ b/src/core/components/elements/Pagination/Pagination.js @@ -0,0 +1,83 @@ +import Link from '../Link/Link' + +const Pagination = ({ pageCount, currentPage, url, className }) => { + let firstPage = false + let lastPage = false + let dotsPrevPage = false + let dotsNextPage = false + let urlParameterPrefix = url.includes('?') ? '&' : '?' + + return ( + pageCount > 1 && ( + <div className={`pagination ${className}`}> + {Array.from(Array(pageCount)).map((v, i) => { + let page = i + 1 + let rangePrevPage = currentPage - 2 + let rangeNextPage = currentPage + 2 + let PageComponent = ( + <Link + key={i} + href={`${url + urlParameterPrefix}page=${page}`} + className={ + 'pagination-item' + (page == currentPage ? ' pagination-item--active ' : '') + } + > + {page} + </Link> + ) + let DotsComponent = ( + <div + key={i} + className='pagination-dots' + > + ... + </div> + ) + + if (pageCount == 7) { + return PageComponent + } + + if (currentPage == 1) rangeNextPage += 3 + if (currentPage == 2) rangeNextPage += 2 + if (currentPage == 3) rangeNextPage += 1 + if (currentPage == 4) rangePrevPage -= 1 + if (currentPage == pageCount) rangePrevPage -= 3 + if (currentPage == pageCount - 1) rangePrevPage -= 2 + if (currentPage == pageCount - 2) rangePrevPage -= 1 + if (currentPage == pageCount - 3) rangeNextPage += 1 + + if (page > rangePrevPage && page < rangeNextPage) { + return PageComponent + } + + if (page == 1 && rangePrevPage >= 1 && !firstPage) { + firstPage = true + return PageComponent + } + + if (page == pageCount && rangeNextPage <= pageCount && !lastPage) { + lastPage = true + return PageComponent + } + + if (page > currentPage && pageCount - currentPage > 1 && !dotsNextPage) { + dotsNextPage = true + return DotsComponent + } + + if (page < currentPage && currentPage - 1 > 1 && !dotsPrevPage) { + dotsPrevPage = true + return DotsComponent + } + })} + </div> + ) + ) +} + +Pagination.defaultProps = { + className: '' +} + +export default Pagination diff --git a/src/core/components/elements/Popup/BottomPopup.jsx b/src/core/components/elements/Popup/BottomPopup.jsx new file mode 100644 index 00000000..24366802 --- /dev/null +++ b/src/core/components/elements/Popup/BottomPopup.jsx @@ -0,0 +1,54 @@ +import { XMarkIcon } from '@heroicons/react/24/outline' +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect } from 'react' + +const transition = { ease: 'linear', duration: 0.2 } + +const BottomPopup = ({ children, active = false, title, close }) => { + useEffect(() => { + if (active) { + document.querySelector('html, body').classList.add('overflow-hidden') + } else { + document.querySelector('html, body').classList.remove('overflow-hidden') + } + }, [active]) + + return ( + <> + <AnimatePresence> + {active && ( + <> + <motion.div + className='overlay' + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={transition} + onClick={close} + /> + <motion.div + initial={{ bottom: '-100%' }} + animate={{ bottom: 0 }} + exit={{ bottom: '-100%' }} + transition={transition} + className='fixed left-0 w-full border-t border-gray_r-6 rounded-t-xl z-[60] p-4 pt-0 bg-white' + > + <div className='flex justify-between py-4'> + <div className='font-semibold text-h-sm'>{title}</div> + <button + type='button' + onClick={close} + > + <XMarkIcon className='w-5 stroke-2' /> + </button> + </div> + {children} + </motion.div> + </> + )} + </AnimatePresence> + </> + ) +} + +export default BottomPopup diff --git a/src/core/components/elements/Select/HookFormSelect.jsx b/src/core/components/elements/Select/HookFormSelect.jsx new file mode 100644 index 00000000..055a9c68 --- /dev/null +++ b/src/core/components/elements/Select/HookFormSelect.jsx @@ -0,0 +1,14 @@ +import ReactSelect from 'react-select' + +const HookFormSelect = ({ field, ...props }) => ( + <ReactSelect + classNamePrefix='form-select' + ref={field.ref} + onChange={(option) => field.onChange(option.value)} + value={field.value ? props.options.find((option) => option.value === field.value) : ''} + isDisabled={props.disabled} + {...props} + /> +) + +export default HookFormSelect diff --git a/src/core/components/elements/Sidebar/Sidebar.jsx b/src/core/components/elements/Sidebar/Sidebar.jsx new file mode 100644 index 00000000..c39b5e34 --- /dev/null +++ b/src/core/components/elements/Sidebar/Sidebar.jsx @@ -0,0 +1,220 @@ +import Link from '../Link/Link' +import greeting from '@/core/utils/greeting' +import useAuth from '@/core/hooks/useAuth' +import { AnimatePresence, motion } from 'framer-motion' +import { ChevronDownIcon, ChevronUpIcon, CogIcon } from '@heroicons/react/24/outline' +import { Fragment, useEffect, useState } from 'react' +import odooApi from '@/core/api/odooApi' + +const Sidebar = ({ active, close }) => { + const auth = useAuth() + + const SidebarLink = ({ children, ...props }) => ( + <Link + {...props} + onClick={close} + > + {children} + </Link> + ) + + const itemClassName = 'px-4 py-3 block !text-gray_r-12/80 font-normal' + const transition = { ease: 'linear', duration: 0.2 } + + const [isOpenCategory, setOpenCategory] = useState(false) + const [categories, setCategories] = useState([]) + + useEffect(() => { + const loadCategories = async () => { + if (isOpenCategory && categories.length == 0) { + let dataCategories = await odooApi('GET', '/api/v1/category/tree') + dataCategories = dataCategories.map((category) => { + category.childs = category.childs.map((child1Category) => { + return { + ...child1Category, + isOpen: false + } + }) + return { + ...category, + isOpen: false + } + }) + setCategories(dataCategories) + } + } + loadCategories() + }, [isOpenCategory, categories]) + + const toggleCategories = (id = 0) => { + let newCategories = categories.map((category) => { + category.childs = category.childs.map((child1Category) => { + return { + ...child1Category, + isOpen: id == child1Category.id ? !child1Category.isOpen : child1Category.isOpen + } + }) + return { + ...category, + isOpen: id == category.id ? !category.isOpen : category.isOpen + } + }) + setCategories(newCategories) + } + + return ( + <> + <AnimatePresence> + {active && ( + <> + <motion.div + className='overlay z-50' + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={transition} + onClick={close} + /> + <motion.div + className='fixed z-[55] top-0 h-full w-[80%] bg-white' + initial={{ left: '-80%' }} + animate={{ left: 0 }} + exit={{ left: '-80%' }} + transition={transition} + > + <div className='divide-y divide-gray_r-6'> + <div className='p-4 flex gap-x-3'> + {!auth && ( + <> + <Link + onClick={close} + href='/register' + className='btn-yellow !text-gray_r-12 py-2 flex-1' + > + Daftar + </Link> + <Link + onClick={close} + href='/login' + className='btn-solid-red !text-gray_r-1 py-2 flex-1' + > + Masuk + </Link> + </> + )} + {auth && ( + <> + <div className='text-caption-2 text-gray_r-11'> + {greeting()}, + <span className='text-body-2 text-gray_r-12 block mt-1 font-medium'> + {auth?.name} + </span> + </div> + <Link + onClick={close} + href='/my/menu' + className='!text-gray_r-11 ml-auto my-auto' + > + <CogIcon className='w-6' /> + </Link> + </> + )} + </div> + <SidebarLink + className={itemClassName} + href='/shop/brands' + > + Semua Brand + </SidebarLink> + <SidebarLink + className={itemClassName} + href='/' + > + Tentang Indoteknik + </SidebarLink> + <SidebarLink + className={itemClassName} + href='/' + > + Pusat Bantuan + </SidebarLink> + <button + className={`${itemClassName} w-full text-left flex`} + onClick={() => setOpenCategory(!isOpenCategory)} + > + Kategori + <div className='ml-auto'> + {!isOpenCategory && <ChevronDownIcon className='text-gray_r-12 w-5' />} + {isOpenCategory && <ChevronUpIcon className='text-gray_r-12 w-5' />} + </div> + </button> + {isOpenCategory && + categories.map((category) => ( + <Fragment key={category.id}> + <div className='flex w-full text-gray_r-11 border-b border-gray_r-6 px-4 pl-8 items-center'> + <Link + href={`/shop/search?category=${category.name}`} + className='flex-1 font-normal !text-gray_r-11 py-4' + > + {category.name} + </Link> + <div + className='ml-4 h-full py-4' + onClick={() => toggleCategories(category.id)} + > + {!category.isOpen && <ChevronDownIcon className='text-gray_r-11 w-5' />} + {category.isOpen && <ChevronUpIcon className='text-gray_r-11 w-5' />} + </div> + </div> + {category.isOpen && + category.childs.map((child1Category) => ( + <Fragment key={child1Category.id}> + <div + className={`flex w-full !text-gray_r-11 border-b border-gray_r-6 p-4 pl-12 ${ + category.isOpen ? 'bg-gray_r-2' : '' + }`} + > + <Link + href={`/shop/search?category=${child1Category.name}`} + className='flex-1 font-normal !text-gray_r-11' + > + {child1Category.name} + </Link> + {child1Category.childs.length > 0 && ( + <div + className='ml-4 h-full' + onClick={() => toggleCategories(child1Category.id)} + > + {!child1Category.isOpen && ( + <ChevronDownIcon className='text-gray_r-11 w-5' /> + )} + {child1Category.isOpen && ( + <ChevronUpIcon className='text-gray_r-11 w-5' /> + )} + </div> + )} + </div> + {child1Category.isOpen && + child1Category.childs.map((child2Category) => ( + <Link + key={child2Category.id} + href={`/shop/search?category=${child2Category.name}`} + className='flex w-full font-normal !text-gray_r-11 border-b border-gray_r-6 p-4 pl-16' + > + {child2Category.name} + </Link> + ))} + </Fragment> + ))} + </Fragment> + ))} + </div> + </motion.div> + </> + )} + </AnimatePresence> + </> + ) +} + +export default Sidebar diff --git a/src/core/components/elements/Skeleton/BrandSkeleton.jsx b/src/core/components/elements/Skeleton/BrandSkeleton.jsx new file mode 100644 index 00000000..9a34fb9b --- /dev/null +++ b/src/core/components/elements/Skeleton/BrandSkeleton.jsx @@ -0,0 +1,11 @@ +const BrandSkeleton = () => ( + <div + role='status' + className='animate-pulse' + > + <div className='h-12 bg-gray-200 rounded'></div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default BrandSkeleton diff --git a/src/core/components/elements/Skeleton/ImageSkeleton.jsx b/src/core/components/elements/Skeleton/ImageSkeleton.jsx new file mode 100644 index 00000000..39d06331 --- /dev/null +++ b/src/core/components/elements/Skeleton/ImageSkeleton.jsx @@ -0,0 +1,24 @@ +const ImageSkeleton = () => ( + <div + role='status' + className='animate-pulse' + > + <div + className='flex items-center justify-center h-56 mb-4 bg-gray-300 rounded' + aria-busy + > + <svg + className='w-12 h-12 text-gray-200' + xmlns='http://www.w3.org/2000/svg' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 640 512' + > + <path d='M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z' /> + </svg> + </div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default ImageSkeleton diff --git a/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx b/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx new file mode 100644 index 00000000..ddc0d3bc --- /dev/null +++ b/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx @@ -0,0 +1,29 @@ +const ProductCardSkeleton = () => ( + <div + role='status' + className='p-4 max-w-sm rounded border border-gray-300 shadow animate-pulse md:p-6' + > + <div + className='flex items-center justify-center h-36 mb-4 bg-gray-300 rounded' + aria-busy + > + <svg + className='w-12 h-12 text-gray-200' + xmlns='http://www.w3.org/2000/svg' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 640 512' + > + <path d='M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z' /> + </svg> + </div> + <div className='h-2 bg-gray-200 rounded-full w-10 mb-1'></div> + <div className='h-2.5 bg-gray-200 rounded-full w-full mb-4'></div> + <div className='h-2 bg-gray-200 rounded-full mb-2.5'></div> + <div className='h-2 bg-gray-200 rounded-full mb-2.5'></div> + <div className='h-2 bg-gray-200 rounded-full'></div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default ProductCardSkeleton diff --git a/src/core/components/elements/Spinner/Spinner.jsx b/src/core/components/elements/Spinner/Spinner.jsx new file mode 100644 index 00000000..4639db1d --- /dev/null +++ b/src/core/components/elements/Spinner/Spinner.jsx @@ -0,0 +1,25 @@ +const Spinner = ({ className }) => { + return ( + <div role='status'> + <svg + aria-hidden='true' + className={'animate-spin ' + className} + viewBox='0 0 100 101' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + <path + d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z' + fill='currentColor' + /> + <path + d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z' + fill='currentFill' + /> + </svg> + <span className='sr-only'>Loading...</span> + </div> + ) +} + +export default Spinner diff --git a/src/core/components/layouts/AnimationLayout.jsx b/src/core/components/layouts/AnimationLayout.jsx new file mode 100644 index 00000000..c4dee606 --- /dev/null +++ b/src/core/components/layouts/AnimationLayout.jsx @@ -0,0 +1,24 @@ +import { motion } from 'framer-motion' + +const AnimationLayout = ({ children, ...props }) => { + const transition = { + ease: 'easeIn', + duration: 0.2 + } + + return ( + children && ( + <motion.main + initial={{ opacity: 0, x: 0, y: 0 }} + animate={{ opacity: 1, x: 0, y: 0 }} + exit={{ opacity: 0, x: 30, y: 0 }} + transition={transition} + {...props} + > + {children} + </motion.main> + ) + ) +} + +export default AnimationLayout diff --git a/src/core/components/layouts/AppLayout.jsx b/src/core/components/layouts/AppLayout.jsx new file mode 100644 index 00000000..a325b1c1 --- /dev/null +++ b/src/core/components/layouts/AppLayout.jsx @@ -0,0 +1,17 @@ +import AppBar from '../elements/Appbar/Appbar' +import BasicFooter from '../elements/Footer/BasicFooter' +import AnimationLayout from './AnimationLayout' + +const AppLayout = ({ children, title }) => { + return ( + <> + <AnimationLayout> + <AppBar title={title} /> + {children} + </AnimationLayout> + <BasicFooter /> + </> + ) +} + +export default AppLayout diff --git a/src/core/components/layouts/BasicLayout.jsx b/src/core/components/layouts/BasicLayout.jsx new file mode 100644 index 00000000..1a7185cd --- /dev/null +++ b/src/core/components/layouts/BasicLayout.jsx @@ -0,0 +1,17 @@ +import dynamic from 'next/dynamic' +import BasicFooter from '../elements/Footer/BasicFooter' + +const Navbar = dynamic(() => import('../elements/Navbar/Navbar')) +const AnimationLayout = dynamic(() => import('./AnimationLayout')) + +const BasicLayout = ({ children }) => { + return ( + <> + <Navbar /> + <AnimationLayout>{children}</AnimationLayout> + <BasicFooter /> + </> + ) +} + +export default BasicLayout diff --git a/src/core/hooks/useActive.js b/src/core/hooks/useActive.js new file mode 100644 index 00000000..c39cbdca --- /dev/null +++ b/src/core/hooks/useActive.js @@ -0,0 +1,21 @@ +import { useState } from 'react' + +const useActive = () => { + const [active, setActive] = useState(false) + + const activate = () => { + setActive(true) + } + + const deactivate = () => { + setActive(false) + } + + return { + activate, + deactivate, + active + } +} + +export default useActive diff --git a/src/core/hooks/useAuth.js b/src/core/hooks/useAuth.js new file mode 100644 index 00000000..af62e45c --- /dev/null +++ b/src/core/hooks/useAuth.js @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' +import { getAuth } from '../utils/auth' + +const useAuth = () => { + const [auth, setAuth] = useState(null) + + useEffect(() => { + const handleIsAuthenticated = () => setAuth(getAuth()) + handleIsAuthenticated() + }, []) + + return auth +} + +export default useAuth diff --git a/src/core/hooks/useSidebar.js b/src/core/hooks/useSidebar.js new file mode 100644 index 00000000..4da61ac2 --- /dev/null +++ b/src/core/hooks/useSidebar.js @@ -0,0 +1,27 @@ +import useActive from './useActive' +import SidebarComponent from '../components/elements/Sidebar/Sidebar' +import { useEffect } from 'react' + +const useSidebar = () => { + const { active, activate, deactivate } = useActive() + + useEffect(() => { + if (active) { + document.querySelector('html, body').classList.add('overflow-hidden') + } else { + document.querySelector('html, body').classList.remove('overflow-hidden') + } + }, [active]) + + return { + open: activate, + Sidebar: ( + <SidebarComponent + active={active} + close={deactivate} + /> + ) + } +} + +export default useSidebar diff --git a/src/core/utils/address.js b/src/core/utils/address.js index c4a19af5..c545d34b 100644 --- a/src/core/utils/address.js +++ b/src/core/utils/address.js @@ -1,27 +1,28 @@ const getAddress = () => { - const address = localStorage.getItem('address'); - if (address) return JSON.parse(address); - return {}; + if (typeof window !== 'undefined') { + const address = localStorage.getItem('address') + if (address) return JSON.parse(address) + } + return {} } const setAddress = (address) => { - localStorage.setItem('address', JSON.stringify(address)); - return true; + if (typeof window !== 'undefined') { + localStorage.setItem('address', JSON.stringify(address)) + } + return } const getItemAddress = (key) => { - let address = getAddress(); - return address[key]; + let address = getAddress() + return address[key] } -const createOrUpdateItemAddress = (key, value) => { - let address = getAddress(); - address[key] = value; - setAddress(address); - return true; +const updateItemAddress = (key, value) => { + let address = getAddress() + address[key] = value + setAddress(address) + return } -export { - getItemAddress, - createOrUpdateItemAddress -};
\ No newline at end of file +export { getItemAddress, updateItemAddress } diff --git a/src/core/utils/apiOdoo.js b/src/core/utils/apiOdoo.js deleted file mode 100644 index 4d0adae3..00000000 --- a/src/core/utils/apiOdoo.js +++ /dev/null @@ -1,44 +0,0 @@ -import { getCookie, setCookie } from 'cookies-next'; -import axios from 'axios'; -import { getAuth } from './auth'; - -const renewToken = async () => { - let token = await axios.get(process.env.SELF_HOST + '/api/token'); - setCookie('token', token.data); - return token.data; -}; - -const getToken = async () => { - let token = getCookie('token'); - if (token == undefined) token = await renewToken(); - return token; -}; - -let connectionTry = 0; -const apiOdoo = async (method, url, data = {}, headers = {}) => { - try { - connectionTry++; - let token = await getToken(); - let axiosParameter = { - method, - url: process.env.ODOO_HOST + url, - headers: {'Authorization': token, ...headers} - } - const auth = getAuth(); - - if (auth) axiosParameter.headers['Token'] = auth.token; - if (method.toUpperCase() == 'POST') axiosParameter.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - if (Object.keys(data).length > 0) axiosParameter.data = new URLSearchParams(Object.entries(data)).toString(); - - let res = await axios(axiosParameter); - if (res.data.status.code == 401 && connectionTry < 15) { - await renewToken(); - return apiOdoo(method, url, data, headers); - } - return res.data.result || []; - } catch (error) { - console.log(error) - } -} - -export default apiOdoo;
\ No newline at end of file diff --git a/src/core/utils/auth.js b/src/core/utils/auth.js index 62eba2c0..13e0e79d 100644 --- a/src/core/utils/auth.js +++ b/src/core/utils/auth.js @@ -1,38 +1,21 @@ -import { deleteCookie, getCookie, setCookie } from 'cookies-next'; -import { useEffect, useState } from 'react'; +import { deleteCookie, getCookie, setCookie } from 'cookies-next' const getAuth = () => { - let auth = getCookie('auth'); + let auth = getCookie('auth') if (auth) { - return JSON.parse(auth); + return JSON.parse(auth) } - return false; + return false } const setAuth = (user) => { - setCookie('auth', JSON.stringify(user)); - return true; + setCookie('auth', JSON.stringify(user)) + return true } const deleteAuth = () => { - deleteCookie('auth'); - return true; + deleteCookie('auth') + return true } -const useAuth = () => { - const [auth, setAuth] = useState(null); - - useEffect(() => { - const handleIsAuthenticated = () => setAuth(getAuth()); - handleIsAuthenticated(); - }, []); - - return [auth, setAuth]; -} - -export { - getAuth, - setAuth, - deleteAuth, - useAuth -};
\ No newline at end of file +export { getAuth, setAuth, deleteAuth } diff --git a/src/core/utils/cart.js b/src/core/utils/cart.js index 66efcbf2..fd42ee4e 100644 --- a/src/core/utils/cart.js +++ b/src/core/utils/cart.js @@ -1,36 +1,36 @@ const getCart = () => { - const cart = localStorage.getItem('cart'); - if (cart) return JSON.parse(cart); - return {}; + if (typeof window !== 'undefined') { + const cart = localStorage.getItem('cart') + if (cart) return JSON.parse(cart) + } + return {} } const setCart = (cart) => { - localStorage.setItem('cart', JSON.stringify(cart)); - return true; + if (typeof window !== 'undefined') { + localStorage.setItem('cart', JSON.stringify(cart)) + } + return true } -const getItemCart = (product_id) => { - let cart = getCart(); - return cart[product_id]; +const getItemCart = ({ productId }) => { + let cart = getCart() + return cart[productId] } -const createOrUpdateItemCart = (product_id, quantity, selected = false) => { - let cart = getCart(); - cart[product_id] = { product_id, quantity, selected }; - setCart(cart); - return true; +const updateItemCart = ({ productId, quantity, selected = false }) => { + let cart = getCart() + quantity = parseInt(quantity) + cart[productId] = { productId, quantity, selected } + setCart(cart) + return true } -const deleteItemCart = (product_id) => { - let cart = getCart(); - delete cart[product_id]; - setCart(cart); - return true; +const deleteItemCart = ({ productId }) => { + let cart = getCart() + delete cart[productId] + setCart(cart) + return true } -export { - getCart, - getItemCart, - createOrUpdateItemCart, - deleteItemCart -}
\ No newline at end of file +export { getCart, getItemCart, updateItemCart, deleteItemCart } diff --git a/src/core/utils/convertToOption.js b/src/core/utils/convertToOption.js deleted file mode 100644 index 08fec08f..00000000 --- a/src/core/utils/convertToOption.js +++ /dev/null @@ -1,11 +0,0 @@ -const convertToOption = (data) => { - if (data) { - return { - value: data.id, - label: data.name, - } - } - return null; -}; - -export default convertToOption;
\ No newline at end of file diff --git a/src/core/utils/currencyFormat.js b/src/core/utils/currencyFormat.js index dadeaec6..12b68111 100644 --- a/src/core/utils/currencyFormat.js +++ b/src/core/utils/currencyFormat.js @@ -1,8 +1,10 @@ -export default function currencyFormat(value) { +const currencyFormat = (value) => { const currency = new Intl.NumberFormat('id-ID', { - style: 'currency', + style: 'currency', currency: 'IDR', maximumFractionDigits: 0 - }); - return currency.format(value); -}
\ No newline at end of file + }) + return currency.format(value) +} + +export default currencyFormat diff --git a/src/core/utils/formValidation.js b/src/core/utils/formValidation.js deleted file mode 100644 index 0e83f4cc..00000000 --- a/src/core/utils/formValidation.js +++ /dev/null @@ -1,107 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; - -const validateForm = (data, queries, hasChangedInputs = null) => { - let result = { valid: true, errors: {} }; - - for (const query in queries) { - if (!hasChangedInputs || (hasChangedInputs && hasChangedInputs[query])) { - const value = data[query]; - const rules = queries[query]; - let errors = []; - let label = null; - for (const rule of rules) { - let emailValidationRegex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - if (rule.startsWith('label:')) { - label = rule.replace('label:', ''); - } else if (rule === 'required' && !value) { - errors.push('tidak boleh kosong'); - } else if (rule === 'email' && !value.match(emailValidationRegex)) { - errors.push('harus format johndoe@example.com'); - } else if (rule.startsWith('maxLength:')) { - let maxLength = parseInt(rule.replace('maxLength:', '')); - if (value && value.length > maxLength) errors.push(`maksimal ${maxLength} karakter`); - } - } - if (errors.length > 0) { - result.errors[query] = (label || query) + ' ' + errors.join(', '); - } - } - } - - if (Object.keys(result.errors).length > 0) { - result.valid = false; - } - - return result; -} - -const useFormValidation = ({ initialFormValue = {}, validationScheme = {} }) => { - const [ formInputs, setFormInputs ] = useState(initialFormValue); - const [ formErrors, setFormErrors ] = useState({}); - const [ formValidation ] = useState(validationScheme); - const [ hasChangedInputs, setHasChangedInputs ] = useState({}); - - const handleFormSubmit = (event, func) => { - if (event) { - event.preventDefault(); - - // Make all input to be has changed mode to revalidate - const changedInputs = {}; - for (const key in formInputs) changedInputs[key] = true; - setHasChangedInputs(changedInputs); - - const { valid, errors } = validateForm(formInputs, formValidation, changedInputs); - setFormErrors(errors); - - if (valid) func(); - } - }; - - const setChangedInput = (name, value = true) => { - setHasChangedInputs((hasChangedInputs) => ({ - ...hasChangedInputs, - [name]: value - })); - }; - - const handleInputChange = (event) => { - setFormInputs((formInputs) => ({ - ...formInputs, - [event.target.name]: event.target.value - })); - setChangedInput(event.target.name); - }; - - const handleSelectChange = useCallback((name, value) => { - setFormInputs((formInputs) => ({ - ...formInputs, - [name]: value - })); - setChangedInput(name); - }, []); - - const handleFormReset = () => { - setFormInputs(initialFormValue); - setFormErrors({}); - setHasChangedInputs({}); - } - - useEffect(() => { - if (formInputs) { - const { errors } = validateForm(formInputs, formValidation, hasChangedInputs); - setFormErrors(errors); - } - }, [ formInputs, formValidation, hasChangedInputs ]) - - return { - handleFormReset, - handleFormSubmit, - handleInputChange, - handleSelectChange, - hasChangedInputs, - formInputs, - formErrors - }; - }; - -export default useFormValidation;
\ No newline at end of file diff --git a/src/core/utils/getFileBase64.js b/src/core/utils/getFileBase64.js index 78013e43..4fa7316b 100644 --- a/src/core/utils/getFileBase64.js +++ b/src/core/utils/getFileBase64.js @@ -1,11 +1,12 @@ -const getFileBase64 = file => new Promise((resolve, reject) => { - let reader = new FileReader(); - reader.readAsBinaryString(file); - reader.onload = () => { - let result = reader.result; - resolve(btoa(result)); - }; - reader.onerror = error => reject(error); -}); +const getFileBase64 = (file) => + new Promise((resolve, reject) => { + let reader = new FileReader() + reader.readAsBinaryString(file) + reader.onload = () => { + let result = reader.result + resolve(btoa(result)) + } + reader.onerror = (error) => reject(error) + }) -export default getFileBase64;
\ No newline at end of file +export default getFileBase64 diff --git a/src/core/utils/greeting.js b/src/core/utils/greeting.js index 7dc19f8f..aaaade7a 100644 --- a/src/core/utils/greeting.js +++ b/src/core/utils/greeting.js @@ -1,9 +1,9 @@ const greeting = () => { - let hours = new Date().getHours(); - if (hours < 11) return 'Selamat Pagi'; - if (hours < 15) return 'Selamat Siang'; - if (hours < 18) return 'Selamat Sore'; - return 'Selamat Malam'; + let hours = new Date().getHours() + if (hours < 11) return 'Selamat Pagi' + if (hours < 15) return 'Selamat Siang' + if (hours < 18) return 'Selamat Sore' + return 'Selamat Malam' } -export default greeting;
\ No newline at end of file +export default greeting diff --git a/src/core/utils/mailer.js b/src/core/utils/mailer.js index 4e7ff7cc..cab66bec 100644 --- a/src/core/utils/mailer.js +++ b/src/core/utils/mailer.js @@ -1,4 +1,4 @@ -const nodemailer = require('nodemailer'); +const nodemailer = require('nodemailer') const mailer = nodemailer.createTransport({ port: process.env.MAIL_PORT, host: process.env.MAIL_HOST, @@ -7,6 +7,6 @@ const mailer = nodemailer.createTransport({ pass: process.env.MAIL_PASS }, secure: true -}); +}) -export default mailer;
\ No newline at end of file +export default mailer diff --git a/src/core/utils/slug.js b/src/core/utils/slug.js index 0a7d30fc..7010008a 100644 --- a/src/core/utils/slug.js +++ b/src/core/utils/slug.js @@ -1,25 +1,27 @@ -import toTitleCase from './toTitleCase'; +import toTitleCase from './toTitleCase' -const createSlug = (name, id) => { - let slug = name?.trim().replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-').toLowerCase() + '-' + id; - let splitSlug = slug.split('-'); - let filterSlugFromEmptyChar = splitSlug.filter(x => x != ''); - return filterSlugFromEmptyChar.join('-'); +const createSlug = (prefix, name, id) => { + let slug = + name + ?.trim() + .replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-') + .toLowerCase() + + '-' + + id + let splitSlug = slug.split('-') + let filterSlugFromEmptyChar = splitSlug.filter((x) => x != '') + return prefix + filterSlugFromEmptyChar.join('-') } const getIdFromSlug = (slug) => { - let id = slug.split('-'); - return id[id.length-1]; + let id = slug.split('-') + return id[id.length - 1] } const getNameFromSlug = (slug) => { - let name = slug.split('-'); - name.pop(); - return toTitleCase(name.join(' ')); + let name = slug.split('-') + name.pop() + return toTitleCase(name.join(' ')) } -export { - createSlug, - getIdFromSlug, - getNameFromSlug -};
\ No newline at end of file +export { createSlug, getIdFromSlug, getNameFromSlug } diff --git a/src/core/utils/toTitleCase.js b/src/core/utils/toTitleCase.js index 5cfd70d0..4335824d 100644 --- a/src/core/utils/toTitleCase.js +++ b/src/core/utils/toTitleCase.js @@ -1,8 +1,7 @@ -export default function toTitleCase(str) { - return str.replace( - /\w\S*/g, - function(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - } - ); -}
\ No newline at end of file +const toTitleCase = (str) => { + return str.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + }) +} + +export default toTitleCase diff --git a/src/icons/chevron-left.svg b/src/icons/chevron-left.svg deleted file mode 100644 index a22ce386..00000000 --- a/src/icons/chevron-left.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"> - <polyline points="15 18 9 12 15 6"></polyline> -</svg>
\ No newline at end of file diff --git a/src/icons/chevron-right.svg b/src/icons/chevron-right.svg deleted file mode 100644 index eb58f2f2..00000000 --- a/src/icons/chevron-right.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="feather feather-chevron-right"> -<polyline points="9 18 15 12 9 6"></polyline> -</svg>
\ No newline at end of file diff --git a/src/icons/close.svg b/src/icons/close.svg deleted file mode 100644 index 50e0589d..00000000 --- a/src/icons/close.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
\ No newline at end of file diff --git a/src/icons/filter.svg b/src/icons/filter.svg deleted file mode 100644 index c15ce7b9..00000000 --- a/src/icons/filter.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-filter"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
\ No newline at end of file diff --git a/src/icons/image-placeholder.svg b/src/icons/image-placeholder.svg deleted file mode 100644 index 935e1097..00000000 --- a/src/icons/image-placeholder.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" fill="currentColor" viewBox="0 0 640 512"><path d="M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z"/></svg>
\ No newline at end of file diff --git a/src/icons/instagram.svg b/src/icons/instagram.svg deleted file mode 100644 index d90842c6..00000000 --- a/src/icons/instagram.svg +++ /dev/null @@ -1,5 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-instagram"> - <rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect> - <path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path> - <line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line> -</svg>
\ No newline at end of file diff --git a/src/icons/linkedin.svg b/src/icons/linkedin.svg deleted file mode 100644 index a68aec96..00000000 --- a/src/icons/linkedin.svg +++ /dev/null @@ -1,5 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-linkedin"> - <path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path> - <rect x="2" y="9" width="4" height="12"></rect> - <circle cx="4" cy="4" r="2"></circle> -</svg>
\ No newline at end of file diff --git a/src/icons/menu.svg b/src/icons/menu.svg deleted file mode 100644 index 5d067e8e..00000000 --- a/src/icons/menu.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-menu"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
\ No newline at end of file diff --git a/src/icons/minus.svg b/src/icons/minus.svg deleted file mode 100644 index 12a10199..00000000 --- a/src/icons/minus.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-minus"> - <line x1="5" y1="12" x2="19" y2="12"></line> -</svg>
\ No newline at end of file diff --git a/src/icons/plus.svg b/src/icons/plus.svg deleted file mode 100644 index 2923c684..00000000 --- a/src/icons/plus.svg +++ /dev/null @@ -1,4 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"> - <line x1="12" y1="5" x2="12" y2="19"></line> - <line x1="5" y1="12" x2="19" y2="12"></line> -</svg>
\ No newline at end of file diff --git a/src/icons/search.svg b/src/icons/search.svg deleted file mode 100644 index 6de1cdfa..00000000 --- a/src/icons/search.svg +++ /dev/null @@ -1,4 +0,0 @@ -<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M9.71061 15.8333C13.3925 15.8333 16.3773 12.8486 16.3773 9.16667C16.3773 5.48477 13.3925 2.5 9.71061 2.5C6.02871 2.5 3.04395 5.48477 3.04395 9.16667C3.04395 12.8486 6.02871 15.8333 9.71061 15.8333Z" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M18.0439 17.5L14.4189 13.875" stroke="#2B2B2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> -</svg> diff --git a/src/icons/shopping-cart.svg b/src/icons/shopping-cart.svg deleted file mode 100644 index 09f14ca6..00000000 --- a/src/icons/shopping-cart.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-shopping-cart"><circle cx="9" cy="21" r="1"></circle><circle cx="20" cy="21" r="1"></circle><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path></svg>
\ No newline at end of file diff --git a/src/icons/trash.svg b/src/icons/trash.svg deleted file mode 100644 index e23673ee..00000000 --- a/src/icons/trash.svg +++ /dev/null @@ -1,4 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash"> - <polyline points="3 6 5 6 21 6"></polyline> - <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> -</svg>
\ No newline at end of file diff --git a/src/images/page-not-found.png b/src/images/page-not-found.png Binary files differdeleted file mode 100644 index 296c0443..00000000 --- a/src/images/page-not-found.png +++ /dev/null diff --git a/src/lib/address/api/addressApi.js b/src/lib/address/api/addressApi.js new file mode 100644 index 00000000..f99d81c0 --- /dev/null +++ b/src/lib/address/api/addressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const addressApi = async ({ id }) => { + const dataAddress = await odooApi('GET', `/api/v1/partner/${id}/address`) + return dataAddress +} + +export default addressApi diff --git a/src/lib/address/api/addressesApi.js b/src/lib/address/api/addressesApi.js new file mode 100644 index 00000000..96f9e9d9 --- /dev/null +++ b/src/lib/address/api/addressesApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const addressesApi = async () => { + const auth = getAuth() + const dataAddresses = await odooApi('GET', `/api/v1/user/${auth.id}/address`) + return dataAddresses +} + +export default addressesApi diff --git a/src/lib/address/api/cityApi.js b/src/lib/address/api/cityApi.js new file mode 100644 index 00000000..7873435b --- /dev/null +++ b/src/lib/address/api/cityApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const cityApi = async () => { + const dataCities = await odooApi('GET', '/api/v1/city') + return dataCities +} + +export default cityApi diff --git a/src/lib/address/api/createAddressApi.js b/src/lib/address/api/createAddressApi.js new file mode 100644 index 00000000..b33b7ae1 --- /dev/null +++ b/src/lib/address/api/createAddressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const createAddressApi = async ({ data }) => { + const dataAddress = await odooApi('POST', '/api/v1/partner/address', data) + return dataAddress +} + +export default createAddressApi diff --git a/src/lib/address/api/districtApi.js b/src/lib/address/api/districtApi.js new file mode 100644 index 00000000..b0bcff16 --- /dev/null +++ b/src/lib/address/api/districtApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const districtApi = async ({ cityId }) => { + const dataDistricts = await odooApi('GET', `/api/v1/district?city_id=${cityId}`) + return dataDistricts +} + +export default districtApi diff --git a/src/lib/address/api/editAddressApi.js b/src/lib/address/api/editAddressApi.js new file mode 100644 index 00000000..ba383ef0 --- /dev/null +++ b/src/lib/address/api/editAddressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const editAddressApi = async ({ id, data }) => { + const dataAddress = await odooApi('PUT', `/api/v1/partner/${id}/address`, data) + return dataAddress +} + +export default editAddressApi diff --git a/src/lib/address/api/subDistrictApi.js b/src/lib/address/api/subDistrictApi.js new file mode 100644 index 00000000..3f834420 --- /dev/null +++ b/src/lib/address/api/subDistrictApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const subDistrictApi = async ({ districtId }) => { + const dataSubDistricts = await odooApi('GET', `/api/v1/sub_district?district_id=${districtId}`) + return dataSubDistricts +} + +export default subDistrictApi diff --git a/src/lib/address/components/Addresses.jsx b/src/lib/address/components/Addresses.jsx new file mode 100644 index 00000000..3ac06b6c --- /dev/null +++ b/src/lib/address/components/Addresses.jsx @@ -0,0 +1,70 @@ +import Link from '@/core/components/elements/Link/Link' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useAuth from '@/core/hooks/useAuth' +import { getItemAddress, updateItemAddress } from '@/core/utils/address' +import { useRouter } from 'next/router' +import useAddresses from '../hooks/useAddresses' + +const Addresses = () => { + const router = useRouter() + const { select = null } = router.query + const auth = useAuth() + const { addresses } = useAddresses() + const selectedAdress = getItemAddress(select || '') + const changeSelectedAddress = (id) => { + if (!select) return + updateItemAddress(select, id) + router.back() + } + + if (addresses.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='p-4'> + <div className='text-right'> + <Link href='/my/address/create'>Tambah Alamat</Link> + </div> + + <div className='grid gap-y-4 mt-4'> + {addresses.data?.map((address, index) => { + let type = address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address' + return ( + <div + key={index} + className={ + 'p-4 rounded-md border ' + + (selectedAdress && selectedAdress == address.id + ? 'border-gray_r-7 bg-gray_r-4' + : 'border-gray_r-7') + } + > + <div onClick={() => changeSelectedAddress(address.id)}> + <div className='flex gap-x-2'> + <div className='badge-red'>{type}</div> + {auth?.partnerId == address.id && <div className='badge-green'>Utama</div>} + </div> + <p className='font-medium mt-2'>{address.name}</p> + {address.mobile && <p className='mt-2 text-gray_r-11'>{address.mobile}</p>} + <p className='mt-1 leading-6 text-gray_r-11'>{address.street}</p> + </div> + <Link + href={`/my/address/${address.id}/edit`} + className='btn-light bg-white mt-3 w-full !text-gray_r-11' + > + Ubah Alamat + </Link> + </div> + ) + })} + </div> + </div> + ) +} + +export default Addresses diff --git a/src/lib/address/components/CreateAddress.jsx b/src/lib/address/components/CreateAddress.jsx new file mode 100644 index 00000000..849b4c01 --- /dev/null +++ b/src/lib/address/components/CreateAddress.jsx @@ -0,0 +1,250 @@ +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import useAuth from '@/core/hooks/useAuth' +import { useRouter } from 'next/router' +import { Controller, useForm } from 'react-hook-form' +import * as Yup from 'yup' +import cityApi from '../api/cityApi' +import districtApi from '../api/districtApi' +import subDistrictApi from '../api/subDistrictApi' +import { useEffect, useState } from 'react' +import createAddressApi from '../api/createAddressApi' +import { toast } from 'react-hot-toast' +import { yupResolver } from '@hookform/resolvers/yup' + +const CreateAddress = () => { + const auth = useAuth() + const router = useRouter() + const { + register, + formState: { errors }, + handleSubmit, + watch, + setValue, + control + } = useForm({ + resolver: yupResolver(validationSchema), + defaultValues + }) + + const [cities, setCities] = useState([]) + const [districts, setDistricts] = useState([]) + const [subDistricts, setSubDistricts] = useState([]) + + useEffect(() => { + const loadCities = async () => { + let dataCities = await cityApi() + dataCities = dataCities.map((city) => ({ value: city.id, label: city.name })) + setCities(dataCities) + } + loadCities() + }, []) + + const watchCity = watch('city') + useEffect(() => { + setValue('district', '') + if (watchCity) { + const loadDistricts = async () => { + let dataDistricts = await districtApi({ cityId: watchCity }) + dataDistricts = dataDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setDistricts(dataDistricts) + } + loadDistricts() + } + }, [watchCity, setValue]) + + const watchDistrict = watch('district') + useEffect(() => { + setValue('subDistrict', '') + if (watchDistrict) { + const loadSubDistricts = async () => { + let dataSubDistricts = await subDistrictApi({ districtId: watchDistrict }) + dataSubDistricts = dataSubDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setSubDistricts(dataSubDistricts) + } + loadSubDistricts() + } + }, [watchDistrict, setValue]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + city_id: values.city, + district_id: values.district, + sub_district_id: values.subDistrict, + parent_id: auth.partnerId + } + + const address = await createAddressApi({ data }) + if (address?.id) { + toast.success('Berhasil menambahkan alamat') + router.back() + } + } + + return ( + <form + className='p-4 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='form-label mb-2'>Label Alamat</label> + <Controller + name='type' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + isSearchable={false} + options={types} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.type?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Nama</label> + <input + {...register('name')} + placeholder='John Doe' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.name?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Email</label> + <input + {...register('email')} + placeholder='contoh@email.com' + type='email' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.email?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Mobile</label> + <input + {...register('mobile')} + placeholder='08xxxxxxxx' + type='tel' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.mobile?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Alamat</label> + <input + {...register('street')} + placeholder='Jl. Bandengan Utara 85A' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.street?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kode Pos</label> + <input + {...register('zip')} + placeholder='10100' + type='number' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.zip?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kota</label> + <Controller + name='city' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={cities} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.city?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kecamatan</label> + <Controller + name='district' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={districts} + disabled={!watchCity} + /> + )} + /> + </div> + + <div> + <label className='form-label mb-2'>Kelurahan</label> + <Controller + name='subDistrict' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={subDistricts} + disabled={!watchDistrict} + /> + )} + /> + </div> + + <button + type='submit' + className='btn-yellow mt-2 w-full' + > + Simpan + </button> + </form> + ) +} + +const validationSchema = Yup.object().shape({ + type: Yup.string().required('Harus di-pilih'), + name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), + email: Yup.string().email('Format harus seperti contoh@email.com').required('Harus di-isi'), + mobile: Yup.string().required('Harus di-isi'), + street: Yup.string().required('Harus di-isi'), + zip: Yup.string().required('Harus di-isi'), + city: Yup.string().required('Harus di-pilih') +}) + +const defaultValues = { + type: '', + name: '', + email: '', + mobile: '', + street: '', + city: '', + district: '', + subDistrict: '', + zip: '' +} + +const types = [ + { value: 'contact', label: 'Contact Address' }, + { value: 'invoice', label: 'Invoice Address' }, + { value: 'delivery', label: 'Delivery Address' }, + { value: 'other', label: 'Other Address' } +] + +export default CreateAddress diff --git a/src/lib/address/components/EditAddress.jsx b/src/lib/address/components/EditAddress.jsx new file mode 100644 index 00000000..a832edbc --- /dev/null +++ b/src/lib/address/components/EditAddress.jsx @@ -0,0 +1,252 @@ +import { yupResolver } from '@hookform/resolvers/yup' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import * as Yup from 'yup' +import cityApi from '../api/cityApi' +import { Controller, useForm } from 'react-hook-form' +import districtApi from '../api/districtApi' +import subDistrictApi from '../api/subDistrictApi' +import editAddressApi from '../api/editAddressApi' +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import { toast } from 'react-hot-toast' + +const EditAddress = ({ id, defaultValues }) => { + const router = useRouter() + const { + register, + formState: { errors }, + handleSubmit, + watch, + setValue, + getValues, + control + } = useForm({ + resolver: yupResolver(validationSchema), + defaultValues + }) + + const [cities, setCities] = useState([]) + const [districts, setDistricts] = useState([]) + const [subDistricts, setSubDistricts] = useState([]) + + useEffect(() => { + const loadCities = async () => { + let dataCities = await cityApi() + dataCities = dataCities.map((city) => ({ + value: city.id, + label: city.name + })) + setCities(dataCities) + } + loadCities() + }, []) + + const watchCity = watch('city') + useEffect(() => { + setValue('district', '') + 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', '') + } + } + loadDistricts() + } + }, [watchCity, setValue, getValues]) + + const watchDistrict = watch('district') + useEffect(() => { + setValue('subDistrict', '') + if (watchDistrict) { + const loadSubDistricts = async () => { + let dataSubDistricts = await subDistrictApi({ + districtId: watchDistrict + }) + dataSubDistricts = dataSubDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setSubDistricts(dataSubDistricts) + let oldSubDistrict = getValues('oldSubDistrict') + + if (oldSubDistrict) { + setValue('subDistrict', oldSubDistrict) + setValue('oldSubDistrict', '') + } + } + loadSubDistricts() + } + }, [watchDistrict, setValue, getValues]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + city_id: values.city, + district_id: values.district, + sub_district_id: values.subDistrict + } + + const address = await editAddressApi({ id, data }) + if (address?.id) { + toast.success('Berhasil mengubah alamat') + router.back() + } + } + + return ( + <form + className='p-4 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='form-label mb-2'>Label Alamat</label> + <Controller + name='type' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + isSearchable={false} + options={types} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.type?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Nama</label> + <input + {...register('name')} + placeholder='John Doe' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.name?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Email</label> + <input + {...register('email')} + placeholder='johndoe@example.com' + type='email' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.email?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Mobile</label> + <input + {...register('mobile')} + placeholder='08xxxxxxxx' + type='tel' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.mobile?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Alamat</label> + <input + {...register('street')} + placeholder='Jl. Bandengan Utara 85A' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.street?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kode Pos</label> + <input + {...register('zip')} + placeholder='10100' + type='number' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.zip?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kota</label> + <Controller + name='city' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={cities} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.city?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kecamatan</label> + <Controller + name='district' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={districts} + disabled={!watchCity} + /> + )} + /> + </div> + + <div> + <label className='form-label mb-2'>Kelurahan</label> + <Controller + name='subDistrict' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={subDistricts} + disabled={!watchDistrict} + /> + )} + /> + </div> + + <button + type='submit' + className='btn-yellow mt-2 w-full' + > + Simpan + </button> + </form> + ) +} + +const validationSchema = Yup.object().shape({ + type: Yup.string().required('Harus di-pilih'), + name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), + email: Yup.string().email('Format harus seperti johndoe@example.com').required('Harus di-isi'), + mobile: Yup.string().required('Harus di-isi'), + street: Yup.string().required('Harus di-isi'), + zip: Yup.string().required('Harus di-isi'), + city: Yup.string().required('Harus di-pilih') +}) + +const types = [ + { value: 'contact', label: 'Contact Address' }, + { value: 'invoice', label: 'Invoice Address' }, + { value: 'delivery', label: 'Delivery Address' }, + { value: 'other', label: 'Other Address' } +] + +export default EditAddress diff --git a/src/lib/address/hooks/useAddresses.js b/src/lib/address/hooks/useAddresses.js new file mode 100644 index 00000000..629e367c --- /dev/null +++ b/src/lib/address/hooks/useAddresses.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import addressesApi from '../api/addressesApi' + +const useAddresses = () => { + const fetchAddresses = async () => await addressesApi() + const { data, isLoading } = useQuery('addresses', fetchAddresses) + + return { + addresses: { data, isLoading } + } +} + +export default useAddresses diff --git a/src/lib/auth/api/editPersonalProfileApi.js b/src/lib/auth/api/editPersonalProfileApi.js new file mode 100644 index 00000000..39cd44c1 --- /dev/null +++ b/src/lib/auth/api/editPersonalProfileApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const editPersonalProfileApi = async ({ data }) => { + const auth = getAuth() + const dataProfile = await odooApi('PUT', `/api/v1/user/${auth.id}`, data) + return dataProfile +} + +export default editPersonalProfileApi diff --git a/src/lib/auth/api/loginApi.js b/src/lib/auth/api/loginApi.js new file mode 100644 index 00000000..e393309c --- /dev/null +++ b/src/lib/auth/api/loginApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const loginApi = async ({ email, password }) => { + let result = await odooApi('POST', '/api/v1/user/login', { email, password }) + return result +} + +export default loginApi diff --git a/src/lib/auth/api/registerApi.js b/src/lib/auth/api/registerApi.js new file mode 100644 index 00000000..f3d75ce8 --- /dev/null +++ b/src/lib/auth/api/registerApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const registerApi = async ({ data }) => { + const dataRegister = await odooApi('POST', '/api/v1/user/register', data) + return dataRegister +} + +export default registerApi diff --git a/src/lib/auth/components/Activate.jsx b/src/lib/auth/components/Activate.jsx new file mode 100644 index 00000000..7970524c --- /dev/null +++ b/src/lib/auth/components/Activate.jsx @@ -0,0 +1,166 @@ +import Image from 'next/image' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import IndoteknikLogo from '@/images/logo.png' +import axios from 'axios' +import { setAuth } from '@/core/utils/auth' +import Alert from '@/core/components/elements/Alert/Alert' +import odooApi from '@/core/api/odooApi' + +const Activate = () => { + const router = useRouter() + const { token } = router.query + + const [isLoading, setIsLoading] = useState(false) + const [alert, setAlert] = useState() + + const [email, setEmail] = useState(router.query?.email || '') + + useEffect(() => { + const activateIfTokenExist = async () => { + if (token) { + let isActivated = await odooApi('POST', '/api/v1/user/activation', { token }) + if (isActivated.activation) { + setAuth(isActivated.user) + setAlert({ + children: ( + <> + Selamat, akun anda berhasil diaktifkan,{' '} + <Link + className='text-gray_r-12' + href='/' + > + kembali ke beranda + </Link> + . + </> + ), + type: 'success' + }) + } else { + setAlert({ + children: ( + <> + Mohon maaf token sudah tidak aktif, lakukan permintaan aktivasi akun kembali atau{' '} + <Link + className='text-gray_r-12' + href='/login' + > + masuk + </Link>{' '} + jika sudah memiliki akun. + </> + ), + type: 'info' + }) + } + } + } + activateIfTokenExist() + }, [token]) + + useEffect(() => { + if (router.query.email) setEmail(router.query.email) + }, [router]) + + const activationRequest = async (e) => { + e.preventDefault() + setIsLoading(true) + let activationRequest = await axios.post(`${process.env.SELF_HOST}/api/activation-request`, { + email + }) + if (activationRequest.data.activationRequest) { + setAlert({ + children: <>Mohon cek email anda untuk aktivasi akun Indoteknik</>, + type: 'success' + }) + } else { + switch (activationRequest.data.reason) { + case 'NOT_FOUND': + setAlert({ + children: ( + <> + Email tersebut belum terdaftar,{' '} + <Link + className='text-gray_r-12' + href='/register' + > + daftar sekarang + </Link> + . + </> + ), + type: 'info' + }) + break + case 'ACTIVE': + setAlert({ + children: ( + <> + Email tersebut sudah terdaftar dan sudah aktif,{' '} + <Link + className='text-gray_r-12' + href='/login' + > + masuk sekarang + </Link> + . + </> + ), + type: 'info' + }) + break + } + } + setIsLoading(false) + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + + <h1 className='text-2xl mt-4 font-semibold'>Aktivasi Akun Indoteknik</h1> + + {alert && ( + <Alert + className='text-center mt-4' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + onSubmit={activationRequest} + className='mt-6 w-full' + > + <input + type='email' + id='email' + className='form-input w-full text-center' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='Masukan alamat email' + autoFocus + /> + <button + type='submit' + disabled={email != ''} + className='btn-yellow font-semibold mt-4 w-full' + > + {isLoading ? 'Loading...' : 'Aktivasi'} + </button> + </form> + </div> + ) +} + +export default Activate diff --git a/src/lib/auth/components/CompanyProfile.jsx b/src/lib/auth/components/CompanyProfile.jsx new file mode 100644 index 00000000..1b25551e --- /dev/null +++ b/src/lib/auth/components/CompanyProfile.jsx @@ -0,0 +1,158 @@ +import odooApi from '@/core/api/odooApi' +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import useAuth from '@/core/hooks/useAuth' +import addressApi from '@/lib/address/api/addressApi' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { toast } from 'react-hot-toast' + +const CompanyProfile = () => { + const auth = useAuth() + const [isOpen, setIsOpen] = useState(false) + const toggle = () => setIsOpen(!isOpen) + const { register, setValue, control, handleSubmit } = useForm({ + defaultValues: { + industry: '', + companyType: '', + name: '', + taxName: '', + npwp: '' + } + }) + + const [industries, setIndustries] = useState([]) + useEffect(() => { + const loadIndustries = async () => { + const dataIndustries = await odooApi('GET', '/api/v1/partner/industry') + setIndustries(dataIndustries?.map((o) => ({ value: o.id, label: o.name }))) + } + loadIndustries() + }, []) + + const [companyTypes, setCompanyTypes] = useState([]) + useEffect(() => { + const loadCompanyTypes = async () => { + const dataCompanyTypes = await odooApi('GET', '/api/v1/partner/company_type') + setCompanyTypes(dataCompanyTypes?.map((o) => ({ value: o.id, label: o.name }))) + } + loadCompanyTypes() + }, []) + + useEffect(() => { + const loadProfile = async () => { + const dataProfile = await addressApi({ id: auth.parentId }) + setValue('name', dataProfile.name) + setValue('industry', dataProfile.industryId) + setValue('companyType', dataProfile.companyTypeId) + setValue('taxName', dataProfile.taxName) + setValue('npwp', dataProfile.npwp) + } + if (auth) loadProfile() + }, [auth, setValue]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + company_type_id: values.companyType, + industry_id: values.industry, + tax_name: values.taxName + } + const isUpdated = await odooApi('PUT', `/api/v1/partner/${auth.parentId}`, data) + if (isUpdated?.id) { + setIsOpen(false) + toast.success('Berhasil mengubah profil', { duration: 1500 }) + return + } + toast.error('Terjadi kesalahan internal') + } + + return ( + <> + <button + type='button' + onClick={toggle} + className='p-4 flex items-center text-left' + > + <div> + <div className='font-semibold mb-2'>Informasi Usaha</div> + <div className='text-gray_r-11'> + Dibawah ini adalah data usaha yang anda masukkan, periksa kembali data usaha anda. + </div> + </div> + <div className='p-2 bg-gray_r-3 rounded'> + {!isOpen && <ChevronDownIcon className='w-6' />} + {isOpen && <ChevronUpIcon className='w-6' />} + </div> + </button> + + {isOpen && ( + <form + className='p-4 border-t border-gray_r-6 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='block mb-3'>Klasifikasi Jenis Usaha</label> + <Controller + name='industry' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={industries} + /> + )} + /> + </div> + <div className='flex flex-wrap'> + <div className='w-full mb-3'>Nama Usaha</div> + <div className='w-3/12 pr-1'> + <Controller + name='companyType' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={companyTypes} + /> + )} + /> + </div> + <div className='w-9/12 pl-1'> + <input + {...register('name')} + type='text' + className='form-input' + placeholder='Cth: Indoteknik Dotcom Gemilang' + /> + </div> + </div> + <div> + <label>Nama Wajib Pajak</label> + <input + {...register('taxName')} + type='text' + className='form-input mt-3' + /> + </div> + <div> + <label>Nomor NPWP</label> + <input + {...register('npwp')} + type='text' + className='form-input mt-3' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + > + Simpan + </button> + </form> + )} + </> + ) +} + +export default CompanyProfile diff --git a/src/lib/auth/components/IsAuth.jsx b/src/lib/auth/components/IsAuth.jsx new file mode 100644 index 00000000..1cfd3172 --- /dev/null +++ b/src/lib/auth/components/IsAuth.jsx @@ -0,0 +1,20 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { getAuth } from '@/core/utils/auth' + +const IsAuth = ({ children }) => { + const router = useRouter() + const [response, setResponse] = useState(<></>) + + useEffect(() => { + if (!getAuth()) { + router.replace('/login') + } else { + setResponse(children) + } + }, [children, router]) + + return response +} + +export default IsAuth diff --git a/src/lib/auth/components/Login.jsx b/src/lib/auth/components/Login.jsx new file mode 100644 index 00000000..b25cf4fe --- /dev/null +++ b/src/lib/auth/components/Login.jsx @@ -0,0 +1,125 @@ +import Image from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import Link from '@/core/components/elements/Link/Link' +import { useState } from 'react' +import loginApi from '../api/loginApi' +import { useRouter } from 'next/router' +import Alert from '@/core/components/elements/Alert/Alert' +import { setAuth } from '@/core/utils/auth' + +const Login = () => { + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [alert, setAlert] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + setAlert(null) + setIsLoading(true) + const login = await loginApi({ email, password }) + setIsLoading(false) + + if (login.isAuth) { + setAuth(login.user) + router.push('/') + return + } + switch (login.reason) { + case 'NOT_FOUND': + setAlert({ + children: 'Email atau password tidak cocok', + type: 'info' + }) + break + case 'NOT_ACTIVE': + setAlert({ + children: ( + <> + Email belum diaktivasi, + <Link + className='text-gray-900' + href={`/activate?email=${email}`} + > + aktivasi sekarang + </Link> + </> + ), + type: 'info' + }) + break + } + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + <h1 className='text-2xl mt-4 font-semibold'>Mulai Belanja Sekarang</h1> + <h2 className='text-gray_r-11 font-normal mt-1 mb-4'>Masuk ke akun kamu untuk belanja</h2> + + {alert && ( + <Alert + className='text-center' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + className='w-full mt-6 flex flex-col gap-y-4' + onSubmit={handleSubmit} + > + <div> + <label htmlFor='email'>Alamat Email</label> + <input + type='email' + id='email' + className='form-input w-full mt-3' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='contoh@email.com' + /> + </div> + <div> + <label htmlFor='password'>Kata Sandi</label> + <input + type='password' + id='password' + className='form-input w-full mt-3' + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder='••••••••••••' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + disabled={!email || !password || isLoading} + > + {!isLoading ? 'Masuk' : 'Loading...'} + </button> + </form> + + <div className='text-gray_r-11 mt-4'> + Belum punya akun Indoteknik?{' '} + <Link + href='/register' + className='inline' + > + Daftar + </Link> + </div> + </div> + ) +} + +export default Login diff --git a/src/lib/auth/components/PersonalProfile.jsx b/src/lib/auth/components/PersonalProfile.jsx new file mode 100644 index 00000000..0b387f2e --- /dev/null +++ b/src/lib/auth/components/PersonalProfile.jsx @@ -0,0 +1,118 @@ +import useAuth from '@/core/hooks/useAuth' +import { setAuth } from '@/core/utils/auth' +import addressApi from '@/lib/address/api/addressApi' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'react-hot-toast' +import editPersonalProfileApi from '../api/editPersonalProfileApi' + +const PersonalProfile = () => { + const auth = useAuth() + const [isOpen, setIsOpen] = useState(false) + const toggle = () => setIsOpen(!isOpen) + const { register, setValue, handleSubmit } = useForm({ + defaultValues: { + email: '', + name: '', + mobile: '', + password: '' + } + }) + + useEffect(() => { + const loadProfile = async () => { + const dataProfile = await addressApi({ id: auth.partnerId }) + setValue('email', dataProfile?.email) + setValue('name', dataProfile?.name) + setValue('mobile', dataProfile?.mobile) + } + if (auth) loadProfile() + }, [auth, setValue]) + + const onSubmitHandler = async (values) => { + let data = values + if (!values.password) delete data.password + const isUpdated = await editPersonalProfileApi({ data }) + console.log(isUpdated) + if (isUpdated?.user) { + setAuth(isUpdated.user) + setValue('password', '') + setIsOpen(false) + toast.success('Berhasil mengubah profil', { duration: 1500 }) + return + } + toast.error('Terjadi kesalahan internal') + } + + return ( + <> + <button + type='button' + onClick={toggle} + className='p-4 flex items-center text-left' + > + <div> + <div className='font-semibold mb-2'>Informasi Akun</div> + <div className='text-gray_r-11'> + Dibawah ini adalah data diri yang anda masukan, periksa kembali data diri anda + </div> + </div> + <div className='p-2 bg-gray_r-3 rounded'> + {!isOpen && <ChevronDownIcon className='w-6' />} + {isOpen && <ChevronUpIcon className='w-6' />} + </div> + </button> + + {isOpen && ( + <form + className='p-4 border-t border-gray_r-6 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label>Email</label> + <input + {...register('email')} + type='text' + disabled + className='form-input mt-3' + /> + </div> + <div> + <label>Nama Lengkap</label> + <input + {...register('name')} + type='text' + className='form-input mt-3' + /> + </div> + <div> + <label>No. Handphone</label> + <input + {...register('mobile')} + type='tel' + className='form-input mt-3' + /> + </div> + <div> + <label>Kata Sandi</label> + <input + {...register('password')} + type='password' + className='form-input mt-3' + placeholder='Isi jika ingin mengubah kata sandi' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + > + Simpan + </button> + </form> + )} + </> + ) +} + +export default PersonalProfile diff --git a/src/lib/auth/components/Register.jsx b/src/lib/auth/components/Register.jsx new file mode 100644 index 00000000..d02081ce --- /dev/null +++ b/src/lib/auth/components/Register.jsx @@ -0,0 +1,151 @@ +import Image from 'next/image' +import Link from '@/core/components/elements/Link/Link' +import IndoteknikLogo from '@/images/logo.png' +import { useState } from 'react' +import registerApi from '../api/registerApi' +import Alert from '@/core/components/elements/Alert/Alert' +import axios from 'axios' + +const Register = () => { + const [fullname, setFullname] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [companyName, setCompanyName] = useState('') + const [isLoading, setIsLoading] = useState('') + const [alert, setAlert] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + setAlert(null) + setIsLoading(true) + const data = { + name: fullname, + company: companyName, + email, + password + } + const isRegistered = await registerApi({ data }) + setIsLoading(false) + if (isRegistered.register) { + await axios.post(`${process.env.SELF_HOST}/api/activation-request`, { email }) + setAlert({ + children: 'Berhasil mendaftarkan akun anda, cek email untuk melakukan aktivasi akun', + type: 'success' + }) + setCompanyName('') + setFullname('') + setEmail('') + setPassword('') + } else { + switch (isRegistered.reason) { + case 'EMAIL_USED': + setAlert({ + children: 'Email telah digunakan', + type: 'info' + }) + break + } + } + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + + <h1 className='text-2xl mt-4 font-semibold'>Daftar Akun Indoteknik</h1> + <h2 className='text-gray_r-11 font-normal mt-1 mb-4 text-center'> + Buat akun sekarang lebih mudah dan terverifikasi + </h2> + + {alert && ( + <Alert + className='text-center' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + className='w-full mt-6 flex flex-col gap-y-4' + onSubmit={handleSubmit} + > + <div> + <label htmlFor='companyName'> + Nama Perusahaan <span className='text-gray_r-11'>(opsional)</span> + </label> + <input + type='text' + id='companyName' + className='form-input w-full mt-3' + value={companyName} + onChange={(e) => setCompanyName(e.target.value.toUpperCase())} + placeholder='cth: INDOTEKNIK DOTCOM GEMILANG' + autoCapitalize='true' + /> + </div> + + <div> + <label htmlFor='fullname'>Nama Lengkap</label> + <input + type='text' + id='fullname' + className='form-input w-full mt-3' + value={fullname} + onChange={(e) => setFullname(e.target.value)} + placeholder='John Doe' + /> + </div> + <div> + <label htmlFor='email'>Alamat Email</label> + <input + type='email' + id='email' + className='form-input w-full mt-3' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='contoh@email.com' + /> + </div> + <div> + <label htmlFor='password'>Kata Sandi</label> + <input + type='password' + id='password' + className='form-input w-full mt-3' + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder='••••••••••••' + /> + </div> + + <button + type='submit' + className='btn-yellow w-full mt-2' + disabled={!email || !password || !fullname || isLoading} + > + {!isLoading ? 'Daftar' : 'Loading...'} + </button> + </form> + + <div className='text-gray_r-11 mt-4'> + Sudah punya akun Indoteknik?{' '} + <Link + href='/login' + className='inline' + > + Masuk + </Link> + </div> + </div> + ) +} + +export default Register diff --git a/src/lib/brand/api/BrandApi.js b/src/lib/brand/api/BrandApi.js new file mode 100644 index 00000000..79801774 --- /dev/null +++ b/src/lib/brand/api/BrandApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const BrandApi = async ({ id }) => { + const dataBrand = await odooApi('GET', `/api/v1/manufacture/${id}`) + return dataBrand +} + +export default BrandApi diff --git a/src/lib/brand/components/Brand.jsx b/src/lib/brand/components/Brand.jsx new file mode 100644 index 00000000..c338c4c4 --- /dev/null +++ b/src/lib/brand/components/Brand.jsx @@ -0,0 +1,70 @@ +import useBrand from '../hooks/useBrand' +import Image from '@/core/components/elements/Image/Image' + +import { Swiper, SwiperSlide } from 'swiper/react' +import { Pagination, Autoplay } from 'swiper' +import 'swiper/css' +import 'swiper/css/pagination' +import 'swiper/css/autoplay' +import Divider from '@/core/components/elements/Divider/Divider' +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' + +const swiperBanner = { + pagination: { dynamicBullets: true }, + autoplay: { + delay: 6000, + disableOnInteraction: false + }, + modules: [Pagination, Autoplay] +} + +const Brand = ({ id }) => { + const { brand } = useBrand({ id }) + + return ( + <> + <div className='min-h-[150px]'> + {brand.isLoading && <ImageSkeleton />} + {brand.data && ( + <> + <Swiper + slidesPerView={1} + pagination={swiperBanner.pagination} + modules={swiperBanner.modules} + autoplay={swiperBanner.autoplay} + className='border-b border-gray_r-6' + > + {brand.data?.banners?.map((banner, index) => ( + <SwiperSlide key={index}> + <Image + src={banner} + alt={`Brand ${brand.data?.name} - Indoteknik`} + className='w-full h-auto' + /> + </SwiperSlide> + ))} + </Swiper> + <div className='p-4'> + <div className='text-caption-1 text-gray_r-11 mb-2'>Produk dari brand:</div> + {brand?.data?.logo && ( + <Image + src={brand?.data?.logo} + alt={brand?.data?.name} + className='w-32 p-2 border borde-gray_r-6 rounded' + /> + )} + {!brand?.data?.logo && ( + <div className='bg-red_r-10 text-white text-center text-caption-1 py-2 px-4 rounded w-fit'> + {brand?.data?.name} + </div> + )} + </div> + </> + )} + </div> + <Divider /> + </> + ) +} + +export default Brand diff --git a/src/lib/brand/components/BrandCard.jsx b/src/lib/brand/components/BrandCard.jsx new file mode 100644 index 00000000..1bcdb5ab --- /dev/null +++ b/src/lib/brand/components/BrandCard.jsx @@ -0,0 +1,30 @@ +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { createSlug } from '@/core/utils/slug' + +const BrandCard = ({ brand }) => { + return ( + <Link + href={createSlug('/shop/brands/', brand.name, brand.id)} + className='py-1 px-2 rounded border border-gray_r-6 h-12 flex justify-center items-center' + > + {brand.logo && ( + <Image + src={brand.logo} + alt={brand.name} + className='h-full object-contain object-center' + /> + )} + {!brand.logo && ( + <span + className='text-center' + style={{ fontSize: `${14 - brand.name.length * 0.5}px` }} + > + {brand.name} + </span> + )} + </Link> + ) +} + +export default BrandCard diff --git a/src/lib/brand/components/Brands.jsx b/src/lib/brand/components/Brands.jsx new file mode 100644 index 00000000..22f47975 --- /dev/null +++ b/src/lib/brand/components/Brands.jsx @@ -0,0 +1,80 @@ +import odooApi from '@/core/api/odooApi' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import { useCallback, useEffect, useState } from 'react' +import BrandCard from './BrandCard' + +const Brands = () => { + const alpha = Array.from(Array(26)).map((e, i) => i + 65) + const alphabets = alpha.map((x) => String.fromCharCode(x)) + + const [isLoading, setIsLoading] = useState(true) + const [startWith, setStartWith] = useState(null) + const [manufactures, setManufactures] = useState([]) + + const loadBrand = useCallback(async () => { + setIsLoading(true) + const name = startWith ? `${startWith}%` : '' + const result = await odooApi( + 'GET', + `/api/v1/manufacture?limit=0&offset=${manufactures.length}&name=${name}` + ) + setIsLoading(false) + setManufactures((manufactures) => [...manufactures, ...result.manufactures]) + }, [startWith]) + + const toggleStartWith = (alphabet) => { + setManufactures([]) + if (alphabet == startWith) { + setStartWith(null) + return + } + setStartWith(alphabet) + } + + useEffect(() => { + loadBrand() + }, [loadBrand]) + + if (isLoading) { + return ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='p-4'> + <h1 className='font-semibold'>Semua Brand di Indoteknik</h1> + <div className='flex overflow-x-auto gap-x-2 py-2'> + {alphabets.map((alphabet, index) => ( + <button + key={index} + className={ + 'p-2 py-1 border bg-white border-gray_r-6 rounded w-10 flex-shrink-0' + + (startWith == alphabet ? ' !bg-yellow_r-9 border-yellow_r-9 ' : '') + } + type='button' + onClick={() => toggleStartWith(alphabet)} + > + {alphabet} + </button> + ))} + </div> + + <div className='grid grid-cols-4 gap-4 mt-4 !overflow-x-hidden'> + {manufactures?.map( + (manufacture, index) => + manufacture.name && ( + <BrandCard + brand={manufacture} + key={index} + /> + ) + )} + </div> + </div> + ) +} + +export default Brands diff --git a/src/lib/brand/hooks/useBrand.js b/src/lib/brand/hooks/useBrand.js new file mode 100644 index 00000000..3ba65a97 --- /dev/null +++ b/src/lib/brand/hooks/useBrand.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import BrandApi from '../api/BrandApi' + +const useBrand = ({ id }) => { + const fetchBrand = async () => await BrandApi({ id }) + const { data, isLoading } = useQuery(`brand-${id}`, fetchBrand) + + return { + brand: { data, isLoading } + } +} + +export default useBrand diff --git a/src/lib/cart/api/CartApi.js b/src/lib/cart/api/CartApi.js new file mode 100644 index 00000000..33b61891 --- /dev/null +++ b/src/lib/cart/api/CartApi.js @@ -0,0 +1,11 @@ +import odooApi from '@/core/api/odooApi' + +const CartApi = async ({ variantIds }) => { + if (variantIds) { + const dataCart = await odooApi('GET', `/api/v1/product_variant/${variantIds}`) + return dataCart + } + return null +} + +export default CartApi diff --git a/src/lib/cart/components/Cart.jsx b/src/lib/cart/components/Cart.jsx new file mode 100644 index 00000000..6a503c0a --- /dev/null +++ b/src/lib/cart/components/Cart.jsx @@ -0,0 +1,289 @@ +import Link from '@/core/components/elements/Link/Link' +import useCart from '../hooks/useCart' +import Image from '@/core/components/elements/Image/Image' +import currencyFormat from '@/core/utils/currencyFormat' +import { useEffect, useState } from 'react' +import { deleteItemCart, getItemCart, updateItemCart } from '@/core/utils/cart' +import { CheckIcon, RectangleGroupIcon, TrashIcon } from '@heroicons/react/24/outline' +import { createSlug } from '@/core/utils/slug' +import { useRouter } from 'next/router' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { toast } from 'react-hot-toast' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Alert from '@/core/components/elements/Alert/Alert' + +const Cart = () => { + const router = useRouter() + const [products, setProducts] = useState(null) + const { cart } = useCart({ enabled: !products }) + + const [totalPriceBeforeTax, setTotalPriceBeforeTax] = useState(0) + const [totalTaxAmount, setTotalTaxAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + const [deleteConfirmation, setDeleteConfirmation] = useState(null) + + useEffect(() => { + if (cart.data && !products) { + const productsWithQuantity = cart.data.map((product) => { + const productInCart = getItemCart({ productId: product.id }) + if (!productInCart) return + return { + ...product, + quantity: productInCart.quantity, + selected: productInCart.selected + } + }) + setProducts(productsWithQuantity) + } + }, [cart, products]) + + useEffect(() => { + if (!products) return + + let calculateTotalPriceBeforeTax = 0 + let calculateTotalTaxAmount = 0 + let calculateTotalDiscountAmount = 0 + for (const product of products) { + if (product.quantity == '') continue + updateItemCart({ + productId: product.id, + quantity: product.quantity, + selected: product.selected + }) + + if (!product.selected) continue + let priceBeforeTax = product.price.price / 1.11 + calculateTotalPriceBeforeTax += priceBeforeTax * product.quantity + calculateTotalTaxAmount += (product.price.price - priceBeforeTax) * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + } + setTotalPriceBeforeTax(calculateTotalPriceBeforeTax) + setTotalTaxAmount(calculateTotalTaxAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + }, [products]) + + const updateQuantity = (value, productId, operation = '') => { + let productIndex = products.findIndex((product) => product.id == productId) + if (productIndex < 0) return + + let productsToUpdate = products + let quantity = productsToUpdate[productIndex].quantity + if (value != '' && isNaN(parseInt(value))) return + value = value != '' ? parseInt(value) : '' + switch (operation) { + case 'PLUS': + quantity += value + break + case 'MINUS': + if (quantity - value < 1) return + quantity -= value + break + case 'BLUR': + if (value != '') return + quantity = 1 + break + default: + quantity = value + break + } + productsToUpdate[productIndex].quantity = quantity + setProducts([...productsToUpdate]) + } + + const toggleSelected = (productId) => { + let productIndex = products.findIndex((product) => product.id == productId) + if (productIndex < 0) return + + let productsToUpdate = products + productsToUpdate[productIndex].selected = !productsToUpdate[productIndex].selected + setProducts([...productsToUpdate]) + } + + const selectedProduct = () => { + if (!products) return [] + return products?.filter((product) => product.selected == true) + } + + const deleteProduct = (productId) => { + const productsToUpdate = products.filter((product) => product.id != productId) + deleteItemCart({ productId }) + setDeleteConfirmation(null) + setProducts([...productsToUpdate]) + toast.success('Berhasil menghapus barang dari keranjang') + } + + return ( + <div className='pt-4'> + <div className='flex justify-between mb-4 px-4'> + <h1 className='font-semibold'>Daftar Produk Belanja</h1> + <Link href='/'>Cari Produk Lain</Link> + </div> + + <div className='flex flex-col gap-y-4 h-screen'> + {cart.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!cart.isLoading && (!products || products?.length == 0) && ( + <div className='px-4'> + <Alert + className='text-center my-2' + type='info' + > + Keranjang belanja anda masih kosong + </Alert> + </div> + )} + + {products?.map((product) => ( + <div + key={product?.id} + className='flex mx-4' + > + <button + type='button' + className='flex items-center mr-2' + onClick={() => toggleSelected(product.id)} + > + {!product?.selected && <div className='w-5 h-5 border border-gray_r-11 rounded' />} + {product?.selected && <CheckIcon className='border bg-red_r-10 w-5 text-white' />} + </button> + <Link + href={createSlug('/shop/product/', product?.parent.name, product?.parent.id)} + className='w-[30%] flex-shrink-0' + > + <Image + src={product?.parent?.image} + alt={product?.name} + className='object-contain object-center border border-gray_r-6 h-40 w-full rounded-md' + /> + </Link> + <div className='flex-1 px-2 text-caption-2'> + <Link + href={createSlug('/shop/product/', product?.parent.name, product?.parent.id)} + className='line-clamp-2 leading-6 !text-gray_r-12 font-normal' + > + {product?.parent?.name} + </Link> + <div className='text-gray_r-11 mt-1'> + {product?.code}{' '} + {product?.attributes.length > 0 ? `| ${product?.attributes.join(', ')}` : ''} + </div> + {product?.price?.discountPercentage > 0 && ( + <div className='flex gap-x-1 items-center mt-3'> + <div className='text-gray_r-11 line-through text-caption-2'> + {currencyFormat(product?.price?.price)} + </div> + <div className='badge-solid-red'>{product?.price?.discountPercentage}%</div> + </div> + )} + <div className='font-normal mt-1'> + {currencyFormat(product?.price?.priceDiscount)} + </div> + <div className='flex justify-between items-center mt-1'> + <div className='text-red_r-11 font-medium'> + {currencyFormat(product?.price?.priceDiscount * product?.quantity)} + </div> + <div className='flex gap-x-1'> + <button + type='button' + className='btn-light px-2 py-1' + onClick={() => updateQuantity(1, product?.id, 'MINUS')} + disabled={product?.quantity == 1} + > + - + </button> + <input + className='form-input w-6 border-0 border-b rounded-none py-1 px-0 text-center' + type='number' + value={product?.quantity} + onChange={(e) => updateQuantity(e.target.value, product?.id)} + onBlur={(e) => updateQuantity(e.target.value, product?.id, 'BLUR')} + /> + <button + type='button' + className='btn-light px-2 py-1' + onClick={() => updateQuantity(1, product?.id, 'PLUS')} + > + + + </button> + <button + className='btn-red p-1 ml-1' + onClick={() => setDeleteConfirmation(product)} + > + <TrashIcon className='w-4' /> + </button> + </div> + </div> + </div> + </div> + ))} + + <div className='sticky bottom-0 left-0 w-full p-4 mt-auto border-t border-gray_r-6 bg-white'> + <div className='flex justify-between mb-4'> + <div className='text-gray_r-11'> + Total: + <span className='text-red_r-11 font-semibold'> + + {selectedProduct().length > 0 + ? currencyFormat(totalPriceBeforeTax - totalDiscountAmount + totalTaxAmount) + : '-'} + </span> + </div> + </div> + <div className='flex gap-x-3'> + <button + type='button' + className='btn-yellow flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/quotation')} + > + Quotation + </button> + <button + type='button' + className='btn-solid-red flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/checkout')} + > + Checkout + </button> + </div> + </div> + </div> + + <BottomPopup + active={deleteConfirmation} + close={() => setDeleteConfirmation(null)} + title='Hapus dari Keranjang' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin menghapus barang{' '} + <span className='underline'>{deleteConfirmation?.name}</span> dari keranjang? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={() => deleteProduct(deleteConfirmation?.id)} + > + Ya, Hapus + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setDeleteConfirmation(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Cart diff --git a/src/lib/cart/hooks/useCart.js b/src/lib/cart/hooks/useCart.js new file mode 100644 index 00000000..bc1ea7ea --- /dev/null +++ b/src/lib/cart/hooks/useCart.js @@ -0,0 +1,17 @@ +import { getCart } from '@/core/utils/cart' +import { useQuery } from 'react-query' +import _ from 'lodash' +import CartApi from '../api/CartApi' + +const useCart = ({ enabled }) => { + const cart = getCart() + const variantIds = _.keys(cart).join(',') + const fetchCart = async () => CartApi({ variantIds }) + const { data, isLoading } = useQuery('cart', fetchCart, { enabled }) + + return { + cart: { data, isLoading } + } +} + +export default useCart diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js new file mode 100644 index 00000000..b76c9b7f --- /dev/null +++ b/src/lib/checkout/api/checkoutApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutApi = async ({ data }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/checkout`, + data + ) + return dataCheckout +} + +export default checkoutApi diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx new file mode 100644 index 00000000..f6170b13 --- /dev/null +++ b/src/lib/checkout/components/Checkout.jsx @@ -0,0 +1,323 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import { getItemAddress } from '@/core/utils/address' +import addressesApi from '@/lib/address/api/addressesApi' +import CartApi from '@/lib/cart/api/CartApi' +import VariantCard from '@/lib/variant/components/VariantCard' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useRef, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import checkoutApi from '../api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Checkout = () => { + const router = useRouter() + const auth = useAuth() + const [selectedAddress, setSelectedAddress] = useState({ + shipping: null, + invoicing: null + }) + const [addresses, setAddresses] = useState(null) + + useEffect(() => { + if (!auth) return + + const getAddresses = async () => { + const dataAddresses = await addressesApi() + setAddresses(dataAddresses) + } + + getAddresses() + }, [auth]) + + useEffect(() => { + if (!addresses) return + + const matchAddress = (key) => { + const addressToMatch = getItemAddress(key) + const foundAddress = addresses.filter((address) => address.id == addressToMatch) + if (foundAddress.length > 0) { + return foundAddress[0] + } + return addresses[0] + } + + setSelectedAddress({ + shipping: matchAddress('shipping'), + invoicing: matchAddress('invoicing') + }) + }, [addresses]) + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [selectedPayment, setSelectedPayment] = useState(null) + + const poNumber = useRef('') + const poFile = useRef('') + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!selectedPayment) { + toast.error('Pilih metode pembayaran', { position: 'bottom-center' }) + return + } + const file = poFile.current.files[0] + if (typeof file !== 'undefined' && file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: selectedAddress.shipping.id, + partner_invoice_id: selectedAddress.invoicing.id, + order_line: JSON.stringify(productOrder), + type: 'sale_order' + } + if (poNumber.current.value) data.po_number = poNumber.current.value + if (typeof file !== 'undefined') data.po_file = await getFileBase64(file) + + const isCheckouted = await checkoutApi({ data }) + setIsLoading(false) + if (isCheckouted?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/checkout/finish?id=${isCheckouted.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Pengiriman' + url='/my/address?select=shipping' + address={selectedAddress.shipping} + /> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Pesanan</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Penagihan' + url='/my/address?select=invoicing' + address={selectedAddress.invoicing} + /> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'> + Metode Pembayaran <span className='font-normal text-gray_r-11'>(Wajib dipilih)</span> + </div> + <div className='grid gap-y-3 mt-4'> + {payments.map((payment, index) => ( + <button + type='button' + className={ + 'text-left border border-gray_r-6 rounded-md p-3 ' + + (selectedPayment == payment.name && 'border-yellow_r-10 bg-yellow_r-3') + } + onClick={() => setSelectedPayment(payment.name)} + key={index} + > + <p> + {payment.name} - {payment.number} + </p> + <p className='mt-1 text-gray_r-11'>PT. Indoteknik Dotcom Gemilang</p> + </button> + ))} + </div> + </div> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'>Purchase Order</div> + + <div className='mt-4 flex gap-x-3'> + <div className='w-6/12'> + <label className='form-label font-normal'>Dokumen PO</label> + <input + type='file' + className='form-input mt-2 h-12' + accept='image/*,application/pdf' + ref={poFile} + /> + </div> + <div className='w-6/12'> + <label className='form-label font-normal'>Nomor PO</label> + <input + type='text' + className='form-input mt-2 h-12' + ref={poNumber} + /> + </div> + </div> + <p className='text-caption-2 text-gray_r-11 mt-2'>Ukuran dokumen PO Maksimal 5MB</p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Bayar'} + </button> + </div> + </> + ) +} + +const payments = [ + { name: 'BCA', number: '8870-4000-81' }, + { name: 'MANDIRI', number: '155-0067-6869-75' } +] + +const SectionAddress = ({ address, label, url }) => ( + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>{label}</div> + <Link + className='text-caption-1' + href={url} + > + Pilih Alamat Lain + </Link> + </div> + + {address && ( + <div className='mt-4 text-caption-1'> + <div className='badge-red mb-2'> + {address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address'} + </div> + <p className='font-medium'>{address.name}</p> + <p className='mt-2 text-gray_r-11'>{address.mobile}</p> + <p className='mt-1 text-gray_r-11'> + {address.street}, {address?.city?.name} + </p> + </div> + )} + </div> +) + +export default Checkout diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx new file mode 100644 index 00000000..a7d65dd0 --- /dev/null +++ b/src/lib/checkout/components/FinishCheckout.jsx @@ -0,0 +1,30 @@ +import Link from '@/core/components/elements/Link/Link' +import useTransaction from '@/lib/transaction/hooks/useTransaction' + +const FinishCheckout = ({ id }) => { + const { transaction } = useTransaction({ id }) + + return ( + <div className='p-4'> + <div className='rounded-xl bg-yellow_r-4 text-center border border-yellow_r-7'> + <div className='px-4 py-6 text-yellow_r-12'> + <p className='font-semibold mb-2'>Terima Kasih atas Pembelian Anda</p> + <p className='text-yellow_r-11 mb-4 leading-6'> + Rincian belanja sudah kami kirimkan ke email anda. Mohon dicek kembali. jika tidak + menerima email, anda dapat menghubungi kami disini. + </p> + <p className='mb-2 font-medium'>{transaction.data?.name}</p> + <p className='text-caption-2 text-yellow_r-11'>No. Transaksi</p> + </div> + <Link + href={transaction.data?.id ? `/my/transaction/${transaction.data.id}` : '/'} + className='bg-yellow_r-6 text-yellow_r-12 rounded-b-xl py-4 block' + > + Lihat detail pembelian Anda disini + </Link> + </div> + </div> + ) +} + +export default FinishCheckout diff --git a/src/lib/elements/hooks/useBottomPopup.js b/src/lib/elements/hooks/useBottomPopup.js deleted file mode 100644 index 88b72316..00000000 --- a/src/lib/elements/hooks/useBottomPopup.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useState } from "react"; -import dynamic from "next/dynamic"; - -const DynamicBottomPopup = dynamic(() => import('@/components/elements/BottomPopup')); - -const useBottomPopup = ({ - title, - children -}) => { - const [ isOpen, setIsOpen ] = useState(false); - const [ dataPopup, setDataPopup ] = useState(null); - - const closePopup = () => { - setIsOpen(false); - setDataPopup(null); - }; - const openPopup = ( data = null ) => { - setIsOpen(true); - setDataPopup(data); - }; - - const BottomPopup = ( - <DynamicBottomPopup - title={title} - active={isOpen} - closePopup={closePopup} - > - { children(dataPopup) } - </DynamicBottomPopup> - ); - - return { - dataPopup, - BottomPopup, - closePopup, - openPopup - } -} - -export default useBottomPopup;
\ No newline at end of file diff --git a/src/lib/elements/hooks/useConfirmAlert.js b/src/lib/elements/hooks/useConfirmAlert.js deleted file mode 100644 index 4975c57d..00000000 --- a/src/lib/elements/hooks/useConfirmAlert.js +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; -import dynamic from "next/dynamic"; - -const DynamicConfirmAlert = dynamic(() => import('@/components/elements/ConfirmAlert')); - -const useConfirmAlert = ({ - title, - caption, - closeText, - submitText, - onSubmit, -}) => { - const [ isOpen, setIsOpen ] = useState(false); - const [ data, setData ] = useState(null); - - const closeConfirmAlert = () => { - setIsOpen(false); - setData(null); - }; - const openConfirmAlert = ( data = null ) => { - setIsOpen(true); - setData(data); - }; - const handleSubmit = async () => { - await onSubmit(data); - closeConfirmAlert(); - }; - - const ConfirmAlert = ( - <DynamicConfirmAlert - title={title} - caption={caption} - closeText={closeText} - submitText={submitText} - onClose={closeConfirmAlert} - onSubmit={handleSubmit} - show={isOpen} - /> - ); - - return { - isOpen, - closeConfirmAlert, - openConfirmAlert, - ConfirmAlert - }; -} - -export default useConfirmAlert;
\ No newline at end of file diff --git a/src/lib/home/api/categoryHomeApi.js b/src/lib/home/api/categoryHomeApi.js new file mode 100644 index 00000000..81909d7b --- /dev/null +++ b/src/lib/home/api/categoryHomeApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async ({ id }) => { + const dataCategoryHomeId = await odooApi('GET', `/api/v1/categories_homepage?id=${id}`) + return dataCategoryHomeId +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/categoryHomeIdApi.js b/src/lib/home/api/categoryHomeIdApi.js new file mode 100644 index 00000000..6b820fd3 --- /dev/null +++ b/src/lib/home/api/categoryHomeIdApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async () => { + const dataCategoryHomeIds = await odooApi('GET', '/api/v1/categories_homepage/ids') + return dataCategoryHomeIds +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/heroBannerApi.js b/src/lib/home/api/heroBannerApi.js new file mode 100644 index 00000000..60a0702a --- /dev/null +++ b/src/lib/home/api/heroBannerApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const heroBannerApi = async () => { + const dataHeroBanners = await odooApi('GET', '/api/v1/banner?type=index-a-1') + return dataHeroBanners +} + +export default heroBannerApi diff --git a/src/lib/home/api/popularProductApi.js b/src/lib/home/api/popularProductApi.js new file mode 100644 index 00000000..5a6d3212 --- /dev/null +++ b/src/lib/home/api/popularProductApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const popularProductApi = async () => { + const dataPopularProducts = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=*&page=1&orderBy=popular` + ) + return dataPopularProducts.data.response +} + +export default popularProductApi diff --git a/src/lib/home/api/preferredBrandApi.js b/src/lib/home/api/preferredBrandApi.js new file mode 100644 index 00000000..0e3200e0 --- /dev/null +++ b/src/lib/home/api/preferredBrandApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const preferredBrandApi = async () => { + const dataPreferredBrands = await odooApi('GET', '/api/v1/manufacture?level=prioritas') + return dataPreferredBrands +} + +export default preferredBrandApi diff --git a/src/lib/home/components/CategoryHome.jsx b/src/lib/home/components/CategoryHome.jsx new file mode 100644 index 00000000..ac43e2bc --- /dev/null +++ b/src/lib/home/components/CategoryHome.jsx @@ -0,0 +1,30 @@ +import ProductSlider from '@/lib/product/components/ProductSlider' +import useCategoryHome from '../hooks/useCategoryHome' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' + +const CategoryHome = ({ id }) => { + const { categoryHome } = useCategoryHome({ id }) + + return ( + <div className='p-4 relative bg-yellow_r-2'> + {categoryHome.data ? ( + <ProductSlider + products={{ + products: categoryHome.data?.[0].products, + banner: { + image: categoryHome.data?.[0].image, + name: categoryHome.data?.[0].name, + url: `/shop/search?category=${categoryHome.data?.[0].name}` + } + }} + simpleTitle + bannerMode + /> + ) : ( + <PopularProductSkeleton /> + )} + </div> + ) +} + +export default CategoryHome diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx new file mode 100644 index 00000000..c37a6af7 --- /dev/null +++ b/src/lib/home/components/CategoryHomeId.jsx @@ -0,0 +1,19 @@ +import { LazyLoadComponent } from 'react-lazy-load-image-component' +import useCategoryHomeId from '../hooks/useCategoryHomeId' +import CategoryHome from './CategoryHome' + +const CategoryHomeId = () => { + const { categoryHomeIds } = useCategoryHomeId() + + return ( + <div className='flex flex-col gap-y-6'> + {categoryHomeIds.data?.map((id) => ( + <LazyLoadComponent key={id}> + <CategoryHome id={id} /> + </LazyLoadComponent> + ))} + </div> + ) +} + +export default CategoryHomeId diff --git a/src/lib/home/components/HeroBanner.jsx b/src/lib/home/components/HeroBanner.jsx new file mode 100644 index 00000000..0ac14bad --- /dev/null +++ b/src/lib/home/components/HeroBanner.jsx @@ -0,0 +1,50 @@ +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' +import useHeroBanner from '../hooks/useHeroBanner' +import Image from '@/core/components/elements/Image/Image' + +// Swiper +import { Swiper, SwiperSlide } from 'swiper/react' +import { Pagination, Autoplay } from 'swiper' +import 'swiper/css' +import 'swiper/css/pagination' +import 'swiper/css/autoplay' + +const swiperBanner = { + pagination: { dynamicBullets: true }, + autoplay: { + delay: 6000, + disableOnInteraction: false + }, + modules: [Pagination, Autoplay] +} + +const HeroBanner = () => { + const { heroBanners } = useHeroBanner() + + return ( + <div className='min-h-[200px]'> + {heroBanners.isLoading && <ImageSkeleton />} + {!heroBanners.isLoading && ( + <Swiper + slidesPerView={1} + pagination={swiperBanner.pagination} + modules={swiperBanner.modules} + autoplay={swiperBanner.autoplay} + className='border-b border-gray_r-6' + > + {heroBanners.data?.map((banner, index) => ( + <SwiperSlide key={index}> + <Image + src={banner.image} + alt={banner.name} + className='w-full h-auto' + /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default HeroBanner diff --git a/src/lib/home/components/PopularProduct.jsx b/src/lib/home/components/PopularProduct.jsx new file mode 100644 index 00000000..d23275f7 --- /dev/null +++ b/src/lib/home/components/PopularProduct.jsx @@ -0,0 +1,24 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePopularProduct from '../hooks/usePopularProduct' +import ProductCard from '@/lib/product/components/ProductCard' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' +import ProductSlider from '@/lib/product/components/ProductSlider' + +const PopularProduct = () => { + const { popularProducts } = usePopularProduct() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Produk Populer</div> + {popularProducts.isLoading && <PopularProductSkeleton />} + {!popularProducts.isLoading && ( + <ProductSlider + products={popularProducts.data} + simpleTitle + /> + )} + </div> + ) +} + +export default PopularProduct diff --git a/src/lib/home/components/PreferredBrand.jsx b/src/lib/home/components/PreferredBrand.jsx new file mode 100644 index 00000000..3df3cdb7 --- /dev/null +++ b/src/lib/home/components/PreferredBrand.jsx @@ -0,0 +1,30 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePreferredBrand from '../hooks/usePreferredBrand' +import PreferredBrandSkeleton from './Skeleton/PreferredBrandSkeleton' +import BrandCard from '@/lib/brand/components/BrandCard' + +const PreferredBrand = () => { + const { preferredBrands } = usePreferredBrand() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Brand Pilihan</div> + {preferredBrands.isLoading && <PreferredBrandSkeleton />} + {!preferredBrands.isLoading && ( + <Swiper + slidesPerView={3.5} + spaceBetween={12} + freeMode + > + {preferredBrands.data?.manufactures.map((brand) => ( + <SwiperSlide key={brand.id}> + <BrandCard brand={brand} /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default PreferredBrand diff --git a/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx new file mode 100644 index 00000000..18a1b3d3 --- /dev/null +++ b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx @@ -0,0 +1,10 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const PopularProductSkeleton = () => ( + <div className='grid grid-cols-2 gap-x-3'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default PopularProductSkeleton diff --git a/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx new file mode 100644 index 00000000..00589342 --- /dev/null +++ b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx @@ -0,0 +1,12 @@ +import BrandSkeleton from '@/core/components/elements/Skeleton/BrandSkeleton' + +const PreferredBrandSkeleton = () => ( + <div className='grid grid-cols-4 gap-x-3'> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + </div> +) + +export default PreferredBrandSkeleton diff --git a/src/lib/home/hooks/useCategoryHome.js b/src/lib/home/hooks/useCategoryHome.js new file mode 100644 index 00000000..cfaa3d9c --- /dev/null +++ b/src/lib/home/hooks/useCategoryHome.js @@ -0,0 +1,13 @@ +import categoryHomeApi from '../api/categoryHomeApi' +import { useQuery } from 'react-query' + +const useCategoryHome = ({ id }) => { + const fetchCategoryHome = async () => await categoryHomeApi({ id }) + const { isLoading, data } = useQuery(`categoryHome-${id}`, fetchCategoryHome) + + return { + categoryHome: { data, isLoading } + } +} + +export default useCategoryHome diff --git a/src/lib/home/hooks/useCategoryHomeId.js b/src/lib/home/hooks/useCategoryHomeId.js new file mode 100644 index 00000000..c6953db7 --- /dev/null +++ b/src/lib/home/hooks/useCategoryHomeId.js @@ -0,0 +1,13 @@ +import categoryHomeIdApi from '../api/categoryHomeIdApi' +import { useQuery } from 'react-query' + +const useCategoryHomeId = () => { + const fetchCategoryHomeId = async () => await categoryHomeIdApi() + const { isLoading, data } = useQuery('categoryHomeId', fetchCategoryHomeId) + + return { + categoryHomeIds: { data, isLoading } + } +} + +export default useCategoryHomeId diff --git a/src/lib/home/hooks/useHeroBanner.js b/src/lib/home/hooks/useHeroBanner.js new file mode 100644 index 00000000..5d2b0512 --- /dev/null +++ b/src/lib/home/hooks/useHeroBanner.js @@ -0,0 +1,13 @@ +import heroBannerApi from '../api/heroBannerApi' +import { useQuery } from 'react-query' + +const useHeroBanner = () => { + const fetchHeroBanner = async () => await heroBannerApi() + const { isLoading, data } = useQuery('heroBanner', fetchHeroBanner) + + return { + heroBanners: { data, isLoading } + } +} + +export default useHeroBanner diff --git a/src/lib/home/hooks/usePopularProduct.js b/src/lib/home/hooks/usePopularProduct.js new file mode 100644 index 00000000..d0c34bb0 --- /dev/null +++ b/src/lib/home/hooks/usePopularProduct.js @@ -0,0 +1,13 @@ +import popularProductApi from '../api/popularProductApi' +import { useQuery } from 'react-query' + +const usePopularProduct = () => { + const fetchPopularProduct = async () => await popularProductApi() + const { data, isLoading } = useQuery('popularProduct', fetchPopularProduct) + + return { + popularProducts: { data, isLoading } + } +} + +export default usePopularProduct diff --git a/src/lib/home/hooks/usePreferredBrand.js b/src/lib/home/hooks/usePreferredBrand.js new file mode 100644 index 00000000..e56d361f --- /dev/null +++ b/src/lib/home/hooks/usePreferredBrand.js @@ -0,0 +1,13 @@ +import preferredBrandApi from '../api/preferredBrandApi' +import { useQuery } from 'react-query' + +const usePreferredBrand = () => { + const fetchPreferredBrand = async () => await preferredBrandApi() + const { data, isLoading } = useQuery('preferredBrand', fetchPreferredBrand) + + return { + preferredBrands: { data, isLoading } + } +} + +export default usePreferredBrand diff --git a/src/lib/invoice/api/invoiceApi.js b/src/lib/invoice/api/invoiceApi.js new file mode 100644 index 00000000..056df6c6 --- /dev/null +++ b/src/lib/invoice/api/invoiceApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoiceApi = async ({ id }) => { + const auth = getAuth() + const dataInvoice = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice/${id}`) + return dataInvoice +} + +export default invoiceApi diff --git a/src/lib/invoice/api/invoicesApi.js b/src/lib/invoice/api/invoicesApi.js new file mode 100644 index 00000000..622fe6ee --- /dev/null +++ b/src/lib/invoice/api/invoicesApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoicesApi = async ({ query }) => { + const auth = getAuth() + const dataInvoices = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice?${query}`) + return dataInvoices +} + +export default invoicesApi diff --git a/src/lib/invoice/components/Invoice.jsx b/src/lib/invoice/components/Invoice.jsx new file mode 100644 index 00000000..e34ad8c2 --- /dev/null +++ b/src/lib/invoice/components/Invoice.jsx @@ -0,0 +1,110 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useInvoice from '../hooks/useInvoice' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' +import Divider from '@/core/components/elements/Divider/Divider' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import currencyFormat from '@/core/utils/currencyFormat' + +const Invoice = ({ id }) => { + const { invoice } = useInvoice({ id }) + + if (invoice.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + const address = invoice.data?.customer + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + invoice.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='No Invoice'>{invoice.data?.name}</DescriptionRow> + <DescriptionRow label='Status Transaksi'> + {invoice.data?.amountResidual > 0 ? ( + <span className='badge-solid-red'>Belum Lunas</span> + ) : ( + <span className='badge-solid-green'>Lunas</span> + )} + </DescriptionRow> + <DescriptionRow label='Purchase Order'> + {invoice.data?.purchaseOrderName || '-'} + </DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'>{invoice.data?.paymentTerm}</DescriptionRow> + {invoice.data?.amountResidual > 0 && invoice.invoiceDate != invoice.invoiceDateDue && ( + <DescriptionRow label='Tanggal Jatuh Tempo'> + {invoice.data?.invoiceDateDue} + </DescriptionRow> + )} + <DescriptionRow label='Nama Sales'>{invoice.data?.sales}</DescriptionRow> + <DescriptionRow label='Tanggal Invoice'>{invoice.data?.invoiceDate}</DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Invoice</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadInvoice(invoice.data)} + > + Download + </button> + </div> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Faktur Pajak</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadTaxInvoice(invoice.data)} + disabled={!invoice.data?.efaktur} + > + Download + </button> + </div> + </div> + + <Divider /> + + <div className='p-4 font-medium'>Detail Penagihan</div> + + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address?.name}</DescriptionRow> + <DescriptionRow label='Email'>{address?.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address?.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={invoice.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(invoice.data?.amountTotal)}</p> + </div> + </div> + </> + ) + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Invoice diff --git a/src/lib/invoice/components/Invoices.jsx b/src/lib/invoice/components/Invoices.jsx new file mode 100644 index 00000000..ab318a3c --- /dev/null +++ b/src/lib/invoice/components/Invoices.jsx @@ -0,0 +1,177 @@ +import { + CheckIcon, + ClockIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon +} from '@heroicons/react/24/outline' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import { useRouter } from 'next/router' +import { useState } from 'react' +import useInvoices from '../hooks/useInvoices' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' + +const Invoices = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { invoices } = useInvoices({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + + const pageCount = Math.ceil(invoices?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/invoices?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Invoice...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {invoices.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!invoices.isLoading && invoices.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data invoice + </Alert> + )} + + {invoices.data?.invoices?.map((invoice, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/invoice/${invoice.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Invoice</span> + <h2 className='text-red_r-11 mt-1'>{invoice.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + {invoice.amountResidual > 0 ? ( + <div className='badge-solid-red h-fit ml-auto'>Belum Lunas</div> + ) : ( + <div className='badge-solid-green h-fit ml-auto'>Lunas</div> + )} + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(invoice)} + /> + </div> + </div> + <Link href={`/my/invoice/${invoice.id}`}> + <div className='grid grid-cols-2 text-caption-2 text-gray_r-11 mt-2 font-normal'> + <p>{invoice.invoiceDate}</p> + <p className='text-right'>{invoice.paymentTerm}</p> + </div> + <hr className='my-3' /> + <div className='grid grid-cols-2'> + <div> + <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span> + <p className='mt-1 font-medium text-gray_r-12'> + {invoice.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'> + {currencyFormat(invoice.amountTotal)} + </p> + </div> + </div> + </Link> + {invoice.efaktur ? ( + <div className='badge-green h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <CheckIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + ) : ( + <div className='badge-red h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <ClockIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + )} + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/invoices${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + onClick={() => { + downloadInvoice(toOthers) + setToOthers(null) + }} + > + Download Invoice + </button> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.efaktur} + onClick={() => { + downloadTaxInvoice(toOthers) + setToOthers(null) + }} + > + Download Faktur Pajak + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Invoices diff --git a/src/lib/invoice/hooks/useInvoice.js b/src/lib/invoice/hooks/useInvoice.js new file mode 100644 index 00000000..2de5e91e --- /dev/null +++ b/src/lib/invoice/hooks/useInvoice.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import invoiceApi from '../api/invoiceApi' + +const useInvoice = ({ id }) => { + const fetchInvoice = async () => await invoiceApi({ id }) + const { data, isLoading, refetch } = useQuery(`invoice-${id}`, fetchInvoice) + + return { + invoice: { data, isLoading, refetch } + } +} + +export default useInvoice diff --git a/src/lib/invoice/hooks/useInvoices.js b/src/lib/invoice/hooks/useInvoices.js new file mode 100644 index 00000000..061626e4 --- /dev/null +++ b/src/lib/invoice/hooks/useInvoices.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import invoicesApi from '../api/invoicesApi' +import _ from 'lodash-contrib' + +const useInvoices = ({ query }) => { + const queryString = _.toQuery(query) + const fetchInvoices = async () => await invoicesApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`invoices-${queryString}`, fetchInvoices) + + return { + invoices: { data, isLoading, refetch } + } +} + +export default useInvoices diff --git a/src/lib/invoice/utils/invoices.js b/src/lib/invoice/utils/invoices.js new file mode 100644 index 00000000..221e53cf --- /dev/null +++ b/src/lib/invoice/utils/invoices.js @@ -0,0 +1,11 @@ +const downloadInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +const downloadTaxInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +export { downloadInvoice, downloadTaxInvoice } diff --git a/src/lib/product/api/productApi.js b/src/lib/product/api/productApi.js new file mode 100644 index 00000000..4fe4cd7d --- /dev/null +++ b/src/lib/product/api/productApi.js @@ -0,0 +1,9 @@ +import odooApi from '@/core/api/odooApi' + +const productApi = async ({ id }) => { + if (!id) return + const dataProduct = await odooApi('GET', `/api/v1/product/${id}`) + return dataProduct +} + +export default productApi diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js new file mode 100644 index 00000000..b9acd94b --- /dev/null +++ b/src/lib/product/api/productSearchApi.js @@ -0,0 +1,9 @@ +import _ from 'lodash-contrib' +import axios from 'axios' + +const productSearchApi = async ({ query }) => { + const dataProductSearch = await axios(`${process.env.SELF_HOST}/api/shop/search?${query}`) + return dataProductSearch.data +} + +export default productSearchApi diff --git a/src/lib/product/api/productSimilarApi.js b/src/lib/product/api/productSimilarApi.js new file mode 100644 index 00000000..7142fab4 --- /dev/null +++ b/src/lib/product/api/productSimilarApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const productSimilarApi = async ({ query }) => { + const dataProductSimilar = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=${query}&page=1&orderBy=popular` + ) + return dataProductSimilar.data.response +} + +export default productSimilarApi diff --git a/src/lib/product/components/Product.jsx b/src/lib/product/components/Product.jsx new file mode 100644 index 00000000..9e33316c --- /dev/null +++ b/src/lib/product/components/Product.jsx @@ -0,0 +1,315 @@ +import Badge from '@/core/components/elements/Badge/Badge' +import Divider from '@/core/components/elements/Divider/Divider' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { useEffect, useState } from 'react' +import Select from 'react-select' +import ProductSimilar from './ProductSimilar' +import LazyLoad from 'react-lazy-load' +import { toast } from 'react-hot-toast' +import { updateItemCart } from '@/core/utils/cart' +import useWishlist from '@/lib/wishlist/hooks/useWishlist' +import { HeartIcon } from '@heroicons/react/24/outline' +import useAuth from '@/core/hooks/useAuth' +import { useRouter } from 'next/router' +import createOrDeleteWishlistApi from '@/lib/wishlist/api/createOrDeleteWishlistApi' + +const informationTabOptions = [ + { value: 'specification', label: 'Spesifikasi' }, + { value: 'description', label: 'Deskripsi' }, + { value: 'important', label: 'Info Penting' } +] + +const Product = ({ product }) => { + const auth = useAuth() + const router = useRouter() + const { wishlist } = useWishlist({ productId: product?.id }) + const [quantity, setQuantity] = useState('1') + const [selectedVariant, setSelectedVariant] = useState(null) + const [informationTab, setInformationTab] = useState(null) + + const [activeVariant, setActiveVariant] = useState({ + id: product.id, + code: product.code, + name: product.name, + price: product.lowestPrice, + stock: product.stockTotal, + weight: product.weight + }) + + const variantOptions = product.variants?.map((variant) => ({ + value: variant.id, + label: + (variant.code ? `[${variant.code}] ` : '') + + (variant.attributes.length > 0 ? variant.attributes.join(', ') : product.name) + })) + + useEffect(() => { + if (!selectedVariant && variantOptions.length == 1) { + setSelectedVariant(variantOptions[0]) + } + }, [selectedVariant, variantOptions]) + + useEffect(() => { + if (selectedVariant) { + const variant = product.variants.find((variant) => variant.id == selectedVariant.value) + const variantAttributes = + variant.attributes.length > 0 ? ' - ' + variant.attributes.join(', ') : '' + + setActiveVariant({ + id: variant.id, + code: variant.code, + name: variant.parent.name + variantAttributes, + price: variant.price, + stock: variant.stock, + weight: variant.weight + }) + } + }, [selectedVariant, product]) + + useEffect(() => { + if (!informationTab) { + setInformationTab(informationTabOptions[0].value) + } + }, [informationTab]) + + const handleClickCart = () => { + if (!selectedVariant) { + toast.error('Pilih varian terlebih dahulu') + return + } + if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { + toast.error('Jumlah barang minimal 1') + return + } + updateItemCart({ + productId: activeVariant.id, + quantity + }) + toast.success('Berhasil menambahkan ke keranjang') + } + + const toggleWishlist = async () => { + if (!auth) { + router.push('/login') + return + } + const data = { product_id: product.id } + await createOrDeleteWishlistApi({ data }) + if (wishlist.data.productTotal > 0) { + toast.success('Berhasil menghapus dari wishlist') + } else { + toast.success('Berhasil menambahkan ke wishlist') + } + wishlist.refetch() + } + + return ( + <> + <Image + src={product.image} + alt={product.name} + className='h-72 object-contain object-center w-full border-b border-gray_r-4' + /> + + <div className='p-4'> + <div className='flex items-end mb-2'> + {product.manufacture?.name ? ( + <Link href='/'>{product.manufacture?.name}</Link> + ) : ( + <div>-</div> + )} + <button + type='button' + className='ml-auto' + onClick={toggleWishlist} + > + {wishlist.data?.productTotal > 0 ? ( + <HeartIcon className='w-6 fill-red_r-11 text-red_r-11' /> + ) : ( + <HeartIcon className='w-6' /> + )} + </button> + </div> + <h1 className='leading-6 font-medium'>{activeVariant?.name}</h1> + {activeVariant?.price?.discountPercentage > 0 && ( + <div className='flex gap-x-1 items-center mt-2'> + <div className='text-gray_r-11 line-through text-caption-1'> + {currencyFormat(activeVariant?.price?.price)} + </div> + <Badge type='solid-red'>{activeVariant?.price?.discountPercentage}%</Badge> + </div> + )} + <h3 className='text-red_r-11 font-semibold mt-1'> + {activeVariant?.price?.priceDiscount > 0 ? ( + currencyFormat(activeVariant?.price?.priceDiscount) + ) : ( + <span className='text-gray_r-11 leading-6 font-normal'> + Hubungi kami untuk dapatkan harga terbaik, + <a + href='https://wa.me/' + className='text-red_r-11 underline' + > + klik disini + </a> + </span> + )} + </h3> + </div> + + <Divider /> + + <div className='p-4'> + <div> + <label className='flex justify-between'> + Pilih Varian: + <span className='text-gray_r-11'>{product?.variantTotal} Varian</span> + </label> + <Select + name='variant' + classNamePrefix='form-select' + options={variantOptions} + className='mt-2' + value={selectedVariant} + onChange={(option) => setSelectedVariant(option)} + isSearchable={product.variantTotal > 10} + /> + </div> + <div className='mt-4 mb-2'>Jumlah</div> + <div className='flex gap-x-3'> + <div className='w-2/12'> + <input + name='quantity' + type='number' + className='form-input' + value={quantity} + onChange={(e) => setQuantity(e.target.value)} + /> + </div> + <button + type='button' + className='btn-yellow flex-1' + onClick={handleClickCart} + > + Keranjang + </button> + <button + type='button' + className='btn-solid-red flex-1' + > + Beli + </button> + </div> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold'>Informasi Produk</h2> + <div className='flex gap-x-4 mt-4 mb-3'> + {informationTabOptions.map((option) => ( + <TabButton + value={option.value} + key={option.value} + active={informationTab == option.value} + onClick={() => setInformationTab(option.value)} + > + {option.label} + </TabButton> + ))} + </div> + + <TabContent + active={informationTab == 'specification'} + className='rounded border border-gray_r-6 divide-y divide-gray_r-6' + > + <SpecificationContent label='Jumlah Varian'> + <span>{product?.variantTotal} Varian</span> + </SpecificationContent> + <SpecificationContent label='Nomor SKU'> + <span>SKU-{product?.id}</span> + </SpecificationContent> + <SpecificationContent label='Part Number'> + <span>{activeVariant?.code || '-'}</span> + </SpecificationContent> + <SpecificationContent label='Stok'> + {activeVariant?.stock > 0 && ( + <span className='flex gap-x-1.5'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{activeVariant?.stock > 5 ? '> 5' : '< 5'}</div> + </span> + )} + {activeVariant?.stock == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Stok + </a> + )} + </SpecificationContent> + <SpecificationContent label='Berat Barang'> + {activeVariant?.weight > 0 && <span>{activeVariant?.weight} KG</span>} + {activeVariant?.weight == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Berat + </a> + )} + </SpecificationContent> + </TabContent> + + <TabContent + active={informationTab == 'description'} + className='leading-6 text-gray_r-11' + dangerouslySetInnerHTML={{ + __html: product.description != '' ? product.description : 'Belum ada deskripsi produk.' + }} + /> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold mb-4'>Kamu Mungkin Juga Suka</h2> + <LazyLoad> + <ProductSimilar query={product?.name.split(' ').slice(1, 3).join(' ')} /> + </LazyLoad> + </div> + </> + ) +} + +const TabButton = ({ children, active, ...props }) => { + const activeClassName = active ? 'text-red_r-11 underline underline-offset-4' : 'text-gray_r-11' + return ( + <button + {...props} + type='button' + className={`font-medium pb-1 ${activeClassName}`} + > + {children} + </button> + ) +} + +const TabContent = ({ children, active, className, ...props }) => ( + <div + {...props} + className={`${active ? 'block' : 'hidden'} ${className}`} + > + {children} + </div> +) + +const SpecificationContent = ({ children, label }) => ( + <div className='flex justify-between p-3'> + <span className='text-gray_r-11'>{label}</span> + {children} + </div> +) + +export default Product diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx new file mode 100644 index 00000000..6b88a3bd --- /dev/null +++ b/src/lib/product/components/ProductCard.jsx @@ -0,0 +1,76 @@ +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { createSlug } from '@/core/utils/slug' + +const ProductCard = ({ product, simpleTitle }) => { + return ( + <> + <div className='rounded shadow-sm border border-gray_r-4 h-full bg-white'> + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className='border-b border-gray_r-4 relative' + > + <Image + src={product?.image} + alt={product?.name} + className='w-full object-contain object-center h-36' + /> + {product.variantTotal > 1 && ( + <div className='absolute badge-gray bottom-1.5 left-1.5'> + {product.variantTotal} Varian + </div> + )} + </Link> + <div className='p-2 pb-3 text-caption-2 leading-5'> + {product?.manufacture?.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product?.manufacture?.name, + product?.manufacture.id + )} + className='mb-1' + > + {product.manufacture.name} + </Link> + ) : ( + <div>-</div> + )} + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className={`font-medium mb-2 !text-gray_r-12 ${ + simpleTitle ? 'line-clamp-2' : 'line-clamp-3' + }`} + > + {product?.name} + </Link> + {product?.lowestPrice?.discountPercentage > 0 && ( + <div className='flex gap-x-1 mb-1 items-center'> + <div className='text-gray_r-11 line-through text-[11px]'> + {currencyFormat(product?.lowestPrice?.price)} + </div> + <div className='badge-solid-red'>{product?.lowestPrice?.discountPercentage}%</div> + </div> + )} + + <div className='text-red_r-11 font-semibold mb-2'> + {product?.lowestPrice?.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice?.priceDiscount) + ) : ( + <a href='https://wa.me/'>Call for price</a> + )} + </div> + {product?.stockTotal > 0 && ( + <div className='flex gap-x-1'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{product?.stockTotal > 5 ? '> 5' : '< 5'}</div> + </div> + )} + </div> + </div> + </> + ) +} + +export default ProductCard diff --git a/src/lib/product/components/ProductFilter.jsx b/src/lib/product/components/ProductFilter.jsx new file mode 100644 index 00000000..eca95f74 --- /dev/null +++ b/src/lib/product/components/ProductFilter.jsx @@ -0,0 +1,132 @@ +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { useRouter } from 'next/router' +import { useState } from 'react' +import _ from 'lodash' +import { toQuery } from 'lodash-contrib' + +const orderOptions = [ + { value: 'price-asc', label: 'Harga Terendah' }, + { value: 'price-desc', label: 'Harga Tertinggi' }, + { value: 'popular', label: 'Populer' }, + { value: 'stock', label: 'Ready Stock' } +] + +const ProductFilter = ({ active, close, brands, categories, prefixUrl, defaultBrand = null }) => { + const router = useRouter() + const { query } = router + const [order, setOrder] = useState(query?.orderBy) + const [brand, setBrand] = useState(query?.brand) + const [category, setCategory] = useState(query?.category) + const [priceFrom, setPriceFrom] = useState(query?.priceFrom) + const [priceTo, setPriceTo] = useState(query?.priceTo) + + const handleSubmit = () => { + let params = { + q: router.query.q, + orderBy: order, + brand, + category, + priceFrom, + priceTo + } + params = _.pickBy(params, _.identity) + params = toQuery(params) + router.push(`${prefixUrl}?${params}`) + } + + return ( + <BottomPopup + active={active} + close={close} + title='Filter Produk' + > + <div className='flex flex-col gap-y-4'> + {!defaultBrand && ( + <div> + <label>Brand</label> + <select + name='brand' + className='form-input mt-2' + value={brand} + onChange={(e) => setBrand(e.target.value)} + > + <option value=''>Pilih Brand...</option> + {brands.map((brand, index) => ( + <option + value={brand} + key={index} + > + {brand} + </option> + ))} + </select> + </div> + )} + <div> + <label>Kategori</label> + <select + name='category' + className='form-input mt-2' + value={category} + onChange={(e) => setCategory(e.target.value)} + > + <option value=''>Pilih Kategori...</option> + {categories.map((category, index) => ( + <option + value={category} + key={index} + > + {category} + </option> + ))} + </select> + </div> + <div> + <label>Urutkan</label> + <div className='flex mt-2 gap-x-2 overflow-x-auto'> + {orderOptions.map((orderOption) => ( + <button + key={orderOption.value} + className={`btn-light px-3 font-normal flex-shrink-0 ${ + order == orderOption.value ? 'bg-yellow_r-10' : 'bg-transparent' + }`} + onClick={() => setOrder(orderOption.value)} + > + {orderOption.label} + </button> + ))} + </div> + </div> + <div> + <label>Harga</label> + <div className='flex mt-2 gap-x-4 items-center'> + <input + type='number' + className='form-input' + placeholder='Dari' + value={priceFrom} + onChange={(e) => setPriceFrom(e.target.value)} + /> + <span>—</span> + <input + type='number' + className='form-input' + placeholder='Sampai' + value={priceTo} + onChange={(e) => setPriceTo(e.target.value)} + /> + </div> + </div> + <button + type='button' + className='btn-solid-red w-full mt-2' + onClick={handleSubmit} + > + Terapkan Filter + </button> + </div> + </BottomPopup> + ) +} + +export default ProductFilter diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx new file mode 100644 index 00000000..52bd5119 --- /dev/null +++ b/src/lib/product/components/ProductSearch.jsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import useProductSearch from '../hooks/useProductSearch' +import ProductCard from './ProductCard' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import ProductSearchSkeleton from './Skeleton/ProductSearchSkeleton' +import ProductFilter from './ProductFilter' +import useActive from '@/core/hooks/useActive' + +const ProductSearch = ({ query, prefixUrl, defaultBrand = null }) => { + const { page = 1 } = query + if (defaultBrand) query.brand = defaultBrand.toLowerCase() + const { productSearch } = useProductSearch({ query }) + const [products, setProducts] = useState(null) + const popup = useActive() + + const pageCount = Math.ceil( + productSearch.data?.response.numFound / productSearch.data?.responseHeader.params.rows + ) + const productStart = productSearch.data?.responseHeader.params.start + const productRows = productSearch.data?.responseHeader.params.rows + const productFound = productSearch.data?.response.numFound + + const brands = productSearch.data?.facetCounts?.facetFields?.brandStr?.filter((value, index) => { + if (index % 2 === 0) { + return true + } + }) + const categories = productSearch.data?.facetCounts?.facetFields?.categoryNameStr?.filter( + (value, index) => { + if (index % 2 === 0) { + return true + } + } + ) + + useEffect(() => { + if (!products) { + setProducts(productSearch.data?.response?.products) + } + }, [query, products, productSearch]) + + if (productSearch.isLoading) { + return <ProductSearchSkeleton /> + } + + return ( + <div className='p-4'> + <h1 className='mb-2 font-semibold text-h-sm'>Produk</h1> + + <div className='mb-2 leading-6 text-gray_r-11'> + {productFound > 0 ? ( + <> + Menampilkan + {pageCount > 1 ? ( + <> + {productStart + 1}- + {productStart + productRows > productFound + ? productFound + : productStart + productRows} + dari + </> + ) : ( + '' + )} + {productFound} + produk{' '} + {query.q && ( + <> + untuk pencarian <span className='font-semibold'>{query.q}</span> + </> + )} + </> + ) : ( + 'Mungkin yang anda cari' + )} + </div> + + <button + className='btn-light mb-6 py-2 px-5' + onClick={popup.activate} + > + Filter + </button> + + <div className='grid grid-cols-2 gap-3'> + {products && + products.map((product) => ( + <ProductCard + product={product} + key={product.id} + /> + ))} + </div> + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`${prefixUrl}?${toQuery(_.omit(query, ['page']))}`} + className='mt-6 mb-2' + /> + + <ProductFilter + active={popup.active} + close={popup.deactivate} + brands={brands || []} + categories={categories || []} + prefixUrl={prefixUrl} + defaultBrand={defaultBrand} + /> + </div> + ) +} + +export default ProductSearch diff --git a/src/lib/product/components/ProductSimilar.jsx b/src/lib/product/components/ProductSimilar.jsx new file mode 100644 index 00000000..63a33089 --- /dev/null +++ b/src/lib/product/components/ProductSimilar.jsx @@ -0,0 +1,15 @@ +import PopularProductSkeleton from '@/lib/home/components/Skeleton/PopularProductSkeleton' +import useProductSimilar from '../hooks/useProductSimilar' +import ProductSlider from './ProductSlider' + +const ProductSimilar = ({ query }) => { + const { productSimilar } = useProductSimilar({ query }) + + if (productSimilar.isLoading) { + return <PopularProductSkeleton /> + } + + return <ProductSlider products={productSimilar.data} /> +} + +export default ProductSimilar diff --git a/src/lib/product/components/ProductSlider.jsx b/src/lib/product/components/ProductSlider.jsx new file mode 100644 index 00000000..060d4638 --- /dev/null +++ b/src/lib/product/components/ProductSlider.jsx @@ -0,0 +1,64 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import { FreeMode } from 'swiper' +import ProductCard from './ProductCard' +import 'swiper/css' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { useRef } from 'react' + +const bannerClassName = + 'absolute rounded-r top-0 left-0 h-full max-w-[52%] idt-transition border border-gray_r-6' + +const ProductSlider = ({ products, simpleTitle = false, bannerMode = false }) => { + const bannerRef = useRef('') + + const changeBannerOpacity = (swiper) => { + if (!bannerMode) return + const calculateOpacity = (132 + swiper.translate) / 100 + bannerRef.current.style = `opacity: ${calculateOpacity > 0 ? calculateOpacity : 0}` + } + + return ( + <> + {bannerMode && ( + <div ref={bannerRef}> + <Image + src={products.banner.image} + alt={products.banner.name} + style={{ opacity: 1 }} + className={bannerClassName} + /> + </div> + )} + <Swiper + freeMode={{ enabled: true, sticky: false }} + slidesPerView={2.2} + spaceBetween={8} + onSliderMove={changeBannerOpacity} + onSlideChangeTransitionEnd={changeBannerOpacity} + onSlideChangeTransitionStart={changeBannerOpacity} + prefix='product' + modules={[FreeMode]} + > + {bannerMode && ( + <SwiperSlide> + <Link + href={products.banner.url} + className='w-full h-full block' + ></Link> + </SwiperSlide> + )} + {products?.products?.map((product, index) => ( + <SwiperSlide key={index}> + <ProductCard + product={product} + simpleTitle={simpleTitle} + /> + </SwiperSlide> + ))} + </Swiper> + </> + ) +} + +export default ProductSlider diff --git a/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx new file mode 100644 index 00000000..fa1e175d --- /dev/null +++ b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx @@ -0,0 +1,14 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const ProductSearchSkeleton = () => ( + <div className='p-4 grid grid-cols-2 gap-4'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default ProductSearchSkeleton diff --git a/src/lib/product/hooks/useProductSearch.js b/src/lib/product/hooks/useProductSearch.js new file mode 100644 index 00000000..0396caec --- /dev/null +++ b/src/lib/product/hooks/useProductSearch.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import productSearchApi from '../api/productSearchApi' +import _ from 'lodash-contrib' + +const useProductSearch = ({ query }) => { + const queryString = _.toQuery(query) + const fetchProductSearch = async () => await productSearchApi({ query: queryString }) + const { data, isLoading } = useQuery(`productSearch-${queryString}`, fetchProductSearch) + + return { + productSearch: { data, isLoading } + } +} + +export default useProductSearch diff --git a/src/lib/product/hooks/useProductSimilar.js b/src/lib/product/hooks/useProductSimilar.js new file mode 100644 index 00000000..d16e4c58 --- /dev/null +++ b/src/lib/product/hooks/useProductSimilar.js @@ -0,0 +1,13 @@ +import productSimilarApi from '../api/productSimilarApi' +import { useQuery } from 'react-query' + +const useProductSimilar = ({ query }) => { + const fetchProductSimilar = async () => await productSimilarApi({ query }) + const { data, isLoading } = useQuery(`productSimilar-${query}`, fetchProductSimilar) + + return { + productSimilar: { data, isLoading } + } +} + +export default useProductSimilar diff --git a/src/lib/quotation/components/Quotation.jsx b/src/lib/quotation/components/Quotation.jsx new file mode 100644 index 00000000..b6e276a3 --- /dev/null +++ b/src/lib/quotation/components/Quotation.jsx @@ -0,0 +1,167 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import CartApi from '@/lib/cart/api/CartApi' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import checkoutApi from '@/lib/checkout/api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Quotation = () => { + const router = useRouter() + const auth = useAuth() + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!products || products.length == 0) return + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: auth.partnerId, + partner_invoice_id: auth.partnerId, + order_line: JSON.stringify(productOrder) + } + const isSuccess = await checkoutApi({ data }) + setIsLoading(false) + if (isSuccess?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/quotation/finish?id=${isSuccess.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Penawaran</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Quotation'} + </button> + </div> + </> + ) +} + +export default Quotation diff --git a/src/lib/transaction/api/cancelTransactionApi.js b/src/lib/transaction/api/cancelTransactionApi.js new file mode 100644 index 00000000..1bba2bde --- /dev/null +++ b/src/lib/transaction/api/cancelTransactionApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const cancelTransactionApi = async ({ transaction }) => { + const auth = getAuth() + const dataCancelTransaction = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/cancel` + ) + return dataCancelTransaction +} + +export default cancelTransactionApi diff --git a/src/lib/transaction/api/checkoutPoApi.js b/src/lib/transaction/api/checkoutPoApi.js new file mode 100644 index 00000000..04421368 --- /dev/null +++ b/src/lib/transaction/api/checkoutPoApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutPoApi = async ({ id }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth?.partnerId}/sale_order/${id}/checkout` + ) + return dataCheckout +} + +export default checkoutPoApi diff --git a/src/lib/transaction/api/transactionApi.js b/src/lib/transaction/api/transactionApi.js new file mode 100644 index 00000000..e7c4c23f --- /dev/null +++ b/src/lib/transaction/api/transactionApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionApi = async ({ id }) => { + const auth = getAuth() + const dataTransaction = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/sale_order/${id}`) + return dataTransaction +} + +export default transactionApi diff --git a/src/lib/transaction/api/transactionsApi.js b/src/lib/transaction/api/transactionsApi.js new file mode 100644 index 00000000..f4e36e6f --- /dev/null +++ b/src/lib/transaction/api/transactionsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionsApi = async ({ query }) => { + const auth = getAuth() + const dataTransactions = await odooApi( + 'GET', + `/api/v1/partner/${auth.partnerId}/sale_order?${query}` + ) + return dataTransactions +} + +export default transactionsApi diff --git a/src/lib/transaction/api/uploadPoApi.js b/src/lib/transaction/api/uploadPoApi.js new file mode 100644 index 00000000..7feeff66 --- /dev/null +++ b/src/lib/transaction/api/uploadPoApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const uploadPoApi = async ({ id, data }) => { + const auth = getAuth() + const dataUploadPo = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${id}/upload_po`, + data + ) + return dataUploadPo +} + +export default uploadPoApi diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx new file mode 100644 index 00000000..7da33551 --- /dev/null +++ b/src/lib/transaction/components/Transaction.jsx @@ -0,0 +1,352 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useTransaction from '../hooks/useTransaction' +import TransactionStatusBadge from './TransactionStatusBadge' +import Divider from '@/core/components/elements/Divider/Divider' +import { useRef, useState } from 'react' +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import uploadPoApi from '../api/uploadPoApi' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import currencyFormat from '@/core/utils/currencyFormat' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import Link from '@/core/components/elements/Link/Link' +import Alert from '@/core/components/elements/Alert/Alert' +import checkoutPoApi from '../api/checkoutPoApi' +import cancelTransactionApi from '../api/cancelTransactionApi' + +const Transaction = ({ id }) => { + const { transaction } = useTransaction({ id }) + + const poNumber = useRef('') + const poFile = useRef('') + const [uploadPo, setUploadPo] = useState(false) + const openUploadPo = () => setUploadPo(true) + const closeUploadPo = () => setUploadPo(false) + const submitUploadPo = async () => { + const file = poFile.current.files[0] + const name = poNumber.current.value + if (typeof file === 'undefined' || !name) { + toast.error('Nomor dan Dokumen PO harus diisi', { position: 'bottom-center' }) + return + } + if (file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + const data = { name, file: await getFileBase64(file) } + const isUploaded = await uploadPoApi({ id, data }) + if (isUploaded) { + toast.success('Berhasil upload PO') + transaction.refetch() + closeUploadPo() + return + } + toast.error('Terjadi kesalahan internal, coba lagi nanti atau hubungi kami') + } + + const [cancelTransaction, setCancelTransaction] = useState(false) + const openCancelTransaction = () => setCancelTransaction(true) + const closeCancelTransaction = () => setCancelTransaction(false) + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ transaction: transaction.data }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transaction.refetch() + } + closeCancelTransaction() + } + + const checkout = async () => { + if (!transaction.data?.purchaseOrderFile) { + toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan') + return + } + await checkoutPoApi({ id }) + toast.success('Berhasil melanjutkan pesanan') + transaction.refetch() + } + + if (transaction.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + transaction.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='Status Transaksi'> + <div className='flex justify-end'> + <TransactionStatusBadge status={transaction.data?.status} /> + </div> + </DescriptionRow> + <DescriptionRow label='No Transaksi'>{transaction.data?.name}</DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'> + {transaction.data?.paymentTerm} + </DescriptionRow> + <DescriptionRow label='Nama Sales'>{transaction.data?.sales}</DescriptionRow> + <DescriptionRow label='Waktu Transaksi'>{transaction.data?.dateOrder}</DescriptionRow> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + <DescriptionRow label='Purchase Order'> + {transaction.data?.purchaseOrderName || '-'} + </DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Dokumen PO</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={ + transaction.data?.purchaseOrderFile + ? () => downloadPurchaseOrder(transaction.data) + : openUploadPo + } + > + {transaction.data?.purchaseOrderFile ? 'Download' : 'Upload'} + </button> + </div> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={transaction.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(transaction.data?.amountTotal)}</p> + </div> + </div> + + <Divider /> + + <SectionAddress address={transaction.data?.address} /> + + <Divider /> + + <div className='p-4'> + <p className='font-medium'>Invoice</p> + <div className='flex flex-col gap-y-3 mt-4'> + {transaction.data?.invoices?.map((invoice, index) => ( + <Link + href={`/my/invoice/${invoice.id}`} + key={index} + > + <div className='shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between'> + <div> + <p className='mb-2'>{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> + ))} + {transaction.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Belum ada Invoice + </Alert> + )} + </div> + </div> + + <Divider /> + + <div className='p-4 pt-0'> + {transaction.data?.status == 'draft' && ( + <button + className='btn-yellow w-full mt-4' + onClick={checkout} + > + Lanjutkan Transaksi + </button> + )} + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'draft'} + onClick={downloadQuotation} + > + Download Quotation + </button> + {transaction.data?.status != 'draft' && ( + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'waiting'} + onClick={openCancelTransaction} + > + Batalkan Transaksi + </button> + )} + </div> + + <BottomPopup + active={cancelTransaction} + close={closeCancelTransaction} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{transaction.data?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={closeCancelTransaction} + > + Batal + </button> + </div> + </BottomPopup> + + <BottomPopup + title='Upload PO' + close={closeUploadPo} + active={uploadPo} + > + <div> + <label>Nomor PO</label> + <input + type='text' + className='form-input mt-3' + ref={poNumber} + /> + </div> + <div className='mt-4'> + <label>Dokumen PO</label> + <input + type='file' + className='form-input mt-3 py-2' + ref={poFile} + /> + </div> + <div className='grid grid-cols-2 gap-x-3 mt-6'> + <button + type='button' + className='btn-light w-full' + onClick={closeUploadPo} + > + Batal + </button> + <button + type='button' + className='btn-solid-red w-full' + onClick={submitUploadPo} + > + Upload + </button> + </div> + </BottomPopup> + </> + ) + ) +} + +const SectionAddress = ({ address }) => { + const [section, setSection] = useState({ + customer: false, + invoice: false, + shipping: false + }) + const toggleSection = (name) => { + setSection({ ...section, [name]: !section[name] }) + } + + return ( + <> + <SectionButton + label='Detail Pelanggan' + active={section.customer} + toggle={() => toggleSection('customer')} + /> + + {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} />} + </> + ) +} + +const SectionButton = ({ label, active, toggle }) => ( + <button + className='p-4 font-medium flex justify-between w-full' + onClick={toggle} + > + <span>{label}</span> + {active ? <ChevronUpIcon className='w-5' /> : <ChevronDownIcon className='w-5' />} + </button> +) + +const SectionContent = ({ address }) => { + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address.name}</DescriptionRow> + <DescriptionRow label='Email'>{address.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Transaction diff --git a/src/lib/transaction/components/TransactionStatusBadge.jsx b/src/lib/transaction/components/TransactionStatusBadge.jsx new file mode 100644 index 00000000..7372e4da --- /dev/null +++ b/src/lib/transaction/components/TransactionStatusBadge.jsx @@ -0,0 +1,41 @@ +const TransactionStatusBadge = ({ status }) => { + let badgeProps = { + className: ['h-fit'], + text: '' + } + switch (status) { + case 'cancel': + badgeProps.className.push('badge-solid-red') + badgeProps.text = 'Pesanan batal' + break + case 'draft': + badgeProps.className.push('badge-red') + badgeProps.text = 'Pending quotation' + break + case 'waiting': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diterima' + break + case 'sale': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diproses' + break + case 'shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Pesanan dikirim' + break + case 'partial_shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Dikirim sebagian' + break + case 'done': + badgeProps.className.push('badge-solid-green') + badgeProps.text = 'Pesanan selesai' + break + } + badgeProps.className = badgeProps.className.join(' ') + + return <div className={badgeProps.className}>{badgeProps.text}</div> +} + +export default TransactionStatusBadge diff --git a/src/lib/transaction/components/Transactions.jsx b/src/lib/transaction/components/Transactions.jsx new file mode 100644 index 00000000..ccbdede2 --- /dev/null +++ b/src/lib/transaction/components/Transactions.jsx @@ -0,0 +1,216 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { EllipsisVerticalIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' + +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import useTransactions from '../hooks/useTransactions' +import currencyFormat from '@/core/utils/currencyFormat' +import cancelTransactionApi from '../api/cancelTransactionApi' +import TransactionStatusBadge from './TransactionStatusBadge' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Link from '@/core/components/elements/Link/Link' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import Alert from '@/core/components/elements/Alert/Alert' + +const Transactions = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { transactions } = useTransactions({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + const [toCancel, setToCancel] = useState(null) + + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ + transaction: toCancel + }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transactions.refetch() + } + setToCancel(null) + } + + const pageCount = Math.ceil(transactions?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/transactions?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Transaksi...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {transactions.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!transactions.isLoading && transactions.data?.saleOrders?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data transaksi + </Alert> + )} + + {transactions.data?.saleOrders?.map((saleOrder, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/transaction/${saleOrder.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Transaksi</span> + <h2 className='text-red_r-11 mt-1'>{saleOrder.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + <TransactionStatusBadge status={saleOrder.status} /> + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(saleOrder)} + /> + </div> + </div> + <Link href={`/my/transaction/${saleOrder.id}`}> + <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> + </div> + </Link> + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/transactions${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.purchaseOrderFile} + onClick={() => { + downloadPurchaseOrder(toOthers) + setToOthers(null) + }} + > + 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> + + <BottomPopup + active={toCancel} + close={() => setToCancel(null)} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{toCancel?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setToCancel(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Transactions diff --git a/src/lib/transaction/hooks/useTransaction.js b/src/lib/transaction/hooks/useTransaction.js new file mode 100644 index 00000000..6dda0573 --- /dev/null +++ b/src/lib/transaction/hooks/useTransaction.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import transactionApi from '../api/transactionApi' + +const useTransaction = ({ id }) => { + const fetchTransaction = async () => await transactionApi({ id }) + const { data, isLoading, refetch } = useQuery(`transaction-${id}`, fetchTransaction) + + return { + transaction: { data, isLoading, refetch } + } +} + +export default useTransaction diff --git a/src/lib/transaction/hooks/useTransactions.js b/src/lib/transaction/hooks/useTransactions.js new file mode 100644 index 00000000..5b40a05a --- /dev/null +++ b/src/lib/transaction/hooks/useTransactions.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import transactionsApi from '../api/transactionsApi' +import _ from 'lodash-contrib' + +const useTransactions = ({ query }) => { + const queryString = _.toQuery(query) + const fetchTransactions = async () => await transactionsApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`transactions-${queryString}`, fetchTransactions) + + return { + transactions: { data, isLoading, refetch } + } +} + +export default useTransactions diff --git a/src/lib/transaction/utils/transactions.js b/src/lib/transaction/utils/transactions.js new file mode 100644 index 00000000..4c7522be --- /dev/null +++ b/src/lib/transaction/utils/transactions.js @@ -0,0 +1,15 @@ +import { getAuth } from '@/core/utils/auth' + +const downloadPurchaseOrder = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download_po/${transaction.token}` + window.open(url, 'download') +} + +const downloadQuotation = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download/${transaction.token}` + window.open(url, 'download') +} + +export { downloadPurchaseOrder, downloadQuotation } diff --git a/src/lib/variant/components/VariantCard.jsx b/src/lib/variant/components/VariantCard.jsx new file mode 100644 index 00000000..6e7ea871 --- /dev/null +++ b/src/lib/variant/components/VariantCard.jsx @@ -0,0 +1,95 @@ +import { useRouter } from 'next/router' +import { toast } from 'react-hot-toast' + +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { createSlug } from '@/core/utils/slug' +import currencyFormat from '@/core/utils/currencyFormat' +import { updateItemCart } from '@/core/utils/cart' + +const VariantCard = ({ product, openOnClick = true, buyMore = false }) => { + const router = useRouter() + + const addItemToCart = () => { + toast.success('Berhasil menambahkan ke keranjang', { duration: 1500 }) + updateItemCart({ + productId: product.id, + quantity: 1 + }) + return + } + + const checkoutItem = () => { + router.push(`/shop/checkout?product_id=${product.id}&qty=${product.quantity}`) + } + + const Card = () => ( + <div className='flex gap-x-3'> + <div className='w-4/12 flex items-center gap-x-2'> + <Image + src={product.parent.image} + alt={product.parent.name} + className='object-contain object-center border border-gray_r-6 h-32 w-full rounded-md' + /> + </div> + <div className='w-8/12 flex flex-col'> + <p className='product-card__title wrap-line-ellipsis-2'>{product.parent.name}</p> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {product.code || '-'} + {product.attributes.length > 0 ? ` ・ ${product.attributes.join(', ')}` : ''} + </p> + <div className='flex flex-wrap gap-x-1 items-center mt-auto'> + {product.price.discountPercentage > 0 && ( + <> + <p className='text-caption-2 text-gray_r-11 line-through'> + {currencyFormat(product.price.price)} + </p> + <span className='badge-red'>{product.price.discountPercentage}%</span> + </> + )} + <p className='text-caption-2 text-gray_r-12'> + {currencyFormat(product.price.priceDiscount)} + </p> + </div> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {currencyFormat(product.price.priceDiscount)} × {product.quantity} Barang + </p> + <p className='text-caption-2 text-gray_r-12 font-bold mt-2'> + {currencyFormat(product.quantity * product.price.priceDiscount)} + </p> + </div> + </div> + ) + + if (openOnClick) { + return ( + <> + <Link href={createSlug('/shop/product/', product.parent.name, product.parent.id)}> + <Card /> + </Link> + {buyMore && ( + <div className='flex justify-end gap-x-2 mb-2'> + <button + type='button' + onClick={addItemToCart} + className='btn-yellow text-gray_r-12 py-2 px-3 text-caption-1' + > + Tambah Keranjang + </button> + <button + type='button' + onClick={checkoutItem} + className='btn-solid-red py-2 px-3 text-caption-1' + > + Beli Lagi + </button> + </div> + )} + </> + ) + } + + return <Card /> +} + +export default VariantCard diff --git a/src/lib/variant/components/VariantGroupCard.jsx b/src/lib/variant/components/VariantGroupCard.jsx new file mode 100644 index 00000000..e5f5c7fc --- /dev/null +++ b/src/lib/variant/components/VariantGroupCard.jsx @@ -0,0 +1,30 @@ +import { useState } from 'react' +import VariantCard from './VariantCard' + +const VariantGroupCard = ({ variants, ...props }) => { + const [showAll, setShowAll] = useState(false) + const variantsToShow = showAll ? variants : variants.slice(0, 2) + + return ( + <> + {variantsToShow?.map((variant, index) => ( + <VariantCard + key={index} + product={variant} + {...props} + /> + ))} + {variants.length > 2 && ( + <button + type='button' + className='btn-light py-2 w-full' + onClick={() => setShowAll(!showAll)} + > + {!showAll ? `Lihat Semua +${variants.length - variantsToShow.length}` : 'Tutup'} + </button> + )} + </> + ) +} + +export default VariantGroupCard diff --git a/src/lib/wishlist/api/createOrDeleteWishlistApi.js b/src/lib/wishlist/api/createOrDeleteWishlistApi.js new file mode 100644 index 00000000..617d139d --- /dev/null +++ b/src/lib/wishlist/api/createOrDeleteWishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const createOrDeleteWishlistApi = async ({ data }) => { + const auth = getAuth() + const dataWishlist = await odooApi( + 'POST', + `/api/v1/user/${auth.id}/wishlist/create-or-delete`, + data + ) + return dataWishlist +} + +export default createOrDeleteWishlistApi diff --git a/src/lib/wishlist/api/wishlistApi.js b/src/lib/wishlist/api/wishlistApi.js new file mode 100644 index 00000000..a8906dd4 --- /dev/null +++ b/src/lib/wishlist/api/wishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistApi = async ({ productId }) => { + const auth = getAuth() + if (!auth) return { productTotal: 0, products: [] } + const dataWishlist = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?product_id=${productId}` + ) + return dataWishlist +} + +export default wishlistApi diff --git a/src/lib/wishlist/api/wishlistsApi.js b/src/lib/wishlist/api/wishlistsApi.js new file mode 100644 index 00000000..dfcce028 --- /dev/null +++ b/src/lib/wishlist/api/wishlistsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistsApi = async ({ limit, offset }) => { + const auth = getAuth() + const dataWishlists = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?limit=${limit}&offset=${offset}` + ) + return dataWishlists +} + +export default wishlistsApi diff --git a/src/lib/wishlist/components/Wishlists.jsx b/src/lib/wishlist/components/Wishlists.jsx new file mode 100644 index 00000000..e61efcc3 --- /dev/null +++ b/src/lib/wishlist/components/Wishlists.jsx @@ -0,0 +1,55 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import ProductCard from '@/lib/product/components/ProductCard' +import { useRouter } from 'next/router' +import useWishlists from '../hooks/useWishlists' + +const Wishlists = () => { + const router = useRouter() + const { page = 1 } = router.query + const limit = 30 + const { wishlists } = useWishlists({ page, limit }) + + const pageCount = Math.ceil(wishlists.data?.productTotal / limit) + + if (wishlists.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='px-4 py-6'> + {wishlists.data?.products?.length == 0 && ( + <Alert + type='info' + className='text-center' + > + Wishlist anda masih kosong + </Alert> + )} + + <div className='grid grid-cols-2 gap-3'> + {wishlists.data?.products.map((product) => ( + <ProductCard + key={product.id} + product={product} + /> + ))} + </div> + + <div className='mt-6'> + <Pagination + currentPage={page} + pageCount={pageCount} + url={`/my/wishlist`} + /> + </div> + </div> + ) +} + +export default Wishlists diff --git a/src/lib/wishlist/hooks/useWishlist.js b/src/lib/wishlist/hooks/useWishlist.js new file mode 100644 index 00000000..8580a19d --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlist.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import wishlistApi from '../api/wishlistApi' + +const useWishlist = ({ productId }) => { + const fetchWishlist = async () => await wishlistApi({ productId }) + const { data, isLoading, refetch } = useQuery(`wishlist-${productId}`, fetchWishlist) + + return { + wishlist: { data, isLoading, refetch } + } +} + +export default useWishlist diff --git a/src/lib/wishlist/hooks/useWishlists.js b/src/lib/wishlist/hooks/useWishlists.js new file mode 100644 index 00000000..169fdf46 --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlists.js @@ -0,0 +1,14 @@ +import { useQuery } from 'react-query' +import wishlistsApi from '../api/wishlistsApi' + +const useWishlists = ({ page, limit }) => { + const offset = (page - 1) * limit + const fetchWishlists = async () => await wishlistsApi({ limit, offset }) + const { data, isLoading } = useQuery(`wishlists-${limit}-${offset}`, fetchWishlists) + + return { + wishlists: { data, isLoading } + } +} + +export default useWishlists diff --git a/src/pages/404.js b/src/pages/404.js deleted file mode 100644 index 1e1850f2..00000000 --- a/src/pages/404.js +++ /dev/null @@ -1,27 +0,0 @@ -import Image from "next/image"; -import Link from "@/components/elements/Link"; -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; -import PageNotFoundImage from "../images/page-not-found.png"; - -export default function PageNotFound() { - return ( - <> - <Header title="Halaman Tidak Ditemukan - Indoteknik" /> - <Layout> - <main className="pb-8"> - <Image src={PageNotFoundImage} alt="Halaman Tidak Ditemukan - Indoteknik" className="w-full" /> - <p className="mt-3 h1 text-center">Halaman tidak ditemukan</p> - <div className="mt-6 flex px-4 gap-x-3"> - <Link href="/" className="btn-light text-gray_r-12 flex-1"> - Kembali ke beranda - </Link> - <a href="https://send.whatsapp.com" className="btn-yellow text-gray_r-12 flex-1 h-fit"> - Tanya admin - </a> - </div> - </main> - </Layout> - </> - ); -}
\ No newline at end of file diff --git a/src/pages/_app.js b/src/pages/_app.js deleted file mode 100644 index 6a40f4e6..00000000 --- a/src/pages/_app.js +++ /dev/null @@ -1,31 +0,0 @@ -import '../styles/globals.css'; -import NextProgress from 'next-progress'; -import { useRouter } from 'next/router'; -import { AnimatePresence } from 'framer-motion'; -import { Toaster } from "react-hot-toast"; - -function MyApp({ Component, pageProps }) { - const router = useRouter(); - - return ( - <> - <Toaster - position="top-center" - toastOptions={{ - duration: 3000, - className: 'border border-gray_r-8' - }} - /> - <NextProgress color="#F01C21" options={{ showSpinner: false }} /> - <AnimatePresence - mode='wait' - initial={false} - onExitComplete={() => window.scrollTo(0, 0)} - > - <Component {...pageProps} key={router.asPath} /> - </AnimatePresence> - </> - ) -} - -export default MyApp diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx new file mode 100644 index 00000000..e32efc19 --- /dev/null +++ b/src/pages/_app.jsx @@ -0,0 +1,42 @@ +import '../styles/globals.css' +import NextProgress from 'next-progress' +import { useRouter } from 'next/router' +import { AnimatePresence } from 'framer-motion' +import { Toaster } from 'react-hot-toast' +import { QueryClient, QueryClientProvider } from 'react-query' + +const queryClient = new QueryClient() + +function MyApp({ Component, pageProps }) { + const router = useRouter() + + return ( + <> + <Toaster + position='top-center' + toastOptions={{ + duration: 3000, + className: 'border border-gray_r-8' + }} + /> + <NextProgress + color='#F01C21' + options={{ showSpinner: false }} + /> + <QueryClientProvider client={queryClient}> + <AnimatePresence + mode='wait' + initial={false} + onExitComplete={() => window.scrollTo(0, 0)} + > + <Component + {...pageProps} + key={router.asPath} + /> + </AnimatePresence> + </QueryClientProvider> + </> + ) +} + +export default MyApp diff --git a/src/pages/_error.js b/src/pages/_error.js deleted file mode 100644 index 107ddf46..00000000 --- a/src/pages/_error.js +++ /dev/null @@ -1,11 +0,0 @@ -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; - -export default function Error() { - return ( - <Layout> - <Header title="Kesalahan Internal"/> - - </Layout> - ); -}
\ No newline at end of file diff --git a/src/pages/activate.js b/src/pages/activate.js deleted file mode 100644 index d9b41bf4..00000000 --- a/src/pages/activate.js +++ /dev/null @@ -1,111 +0,0 @@ -import axios from "axios"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "@/components/elements/Link"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import Alert from "@/components/elements/Alert"; -import Layout from "@/components/layouts/Layout"; -import Spinner from "@/components/elements/Spinner"; -import { setAuth } from "@/core/utils/auth"; -import Logo from "@/images/logo.png"; - -export default function Activate() { - const [email, setEmail] = useState(''); - const [isInputFulfilled, setIsInputFulfilled] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [alert, setAlert] = useState(); - const router = useRouter(); - const { token } = router.query; - - useEffect(() => { - if (router.query.email) setEmail(router.query.email); - }, [router]) - - useEffect(() => { - const activateIfTokenExist = async () => { - if (token) { - let activation = await axios.post(`${process.env.SELF_HOST}/api/activation`, {token}); - if (activation.data.activation) { - setAuth(activation.data.user); - setAlert({ - component: <>Selamat, akun anda berhasil diaktifkan, <Link className="text-gray_r-12" href="/">kembali ke beranda</Link>.</>, - type: 'success' - }); - } else { - setAlert({ - component: <>Mohon maaf token sudah tidak aktif, lakukan permintaan aktivasi akun kembali atau <Link className="text-gray_r-12" href="/login">masuk</Link> jika sudah memiliki akun.</>, - type: 'info' - }); - } - } - } - activateIfTokenExist(); - }, [token]); - - useEffect(() => { - setIsInputFulfilled(email != ''); - }, [email]); - - const activationRequest = async (e) => { - e.preventDefault(); - setIsLoading(true); - let activationRequest = await axios.post(`${process.env.SELF_HOST}/api/activation-request`, {email}); - if (activationRequest.data.activation_request) { - setAlert({ - component: <>Mohon cek email anda untuk aktivasi akun Indoteknik</>, - type: 'success' - }); - } else { - switch (activationRequest.data.reason) { - case 'NOT_FOUND': - setAlert({ - component: <>Email tersebut belum terdaftar, <Link className="text-gray_r-12" href="/register">daftar sekarang</Link>.</>, - type: 'info' - }); - break; - case 'ACTIVE': - setAlert({ - component: <>Email tersebut sudah terdaftar dan sudah aktif, <Link className="text-gray_r-12" href="/login">masuk sekarang</Link>.</>, - type: 'info' - }); - break; - } - } - setIsLoading(false); - } - return ( - <> - <Head> - <title>Aktivasi Akun Indoteknik</title> - </Head> - <Layout className="max-w-lg mx-auto flex flex-col items-center px-4 pb-8"> - <Link href="/" className="mt-16"> - <Image src={Logo} alt="Logo Indoteknik" width={165} height={42} /> - </Link> - <h1 className="text-2xl text-gray_r-12 mt-4 text-center">Aktivasi Akun Indoteknik Anda</h1> - <h2 className="text-gray-800 mt-2 mb-4 text-center">Link aktivasi akan dikirimkan melalui email</h2> - {alert ? ( - <Alert className="text-center" type={alert.type}>{alert.component}</Alert> - ) : ''} - <form onSubmit={activationRequest} className="w-full"> - <input - type="text" - className="form-input bg-gray-100 mt-4 focus:ring-1 focus:ring-yellow-900" - placeholder="johndoe@gmail.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - autoFocus - /> - <button type="submit" disabled={!isInputFulfilled} className="btn-yellow font-semibold mt-4 w-full"> - {isLoading ? ( - <div className="flex justify-center items-center gap-x-2"> - <Spinner className="w-4 h-4 text-gray-600 fill-gray-900" /> <span>Loading...</span> - </div> - ) : 'Kirim Email'} - </button> - </form> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/activate.jsx b/src/pages/activate.jsx new file mode 100644 index 00000000..cbd10ac2 --- /dev/null +++ b/src/pages/activate.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import ActivateComponent from '@/lib/auth/components/Activate' + +export default function Activate() { + return ( + <> + <ActivateComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/api/activation-request.js b/src/pages/api/activation-request.js index 3f33875c..7fae2fd1 100644 --- a/src/pages/api/activation-request.js +++ b/src/pages/api/activation-request.js @@ -1,15 +1,11 @@ -import apiOdoo from "@/core/utils/apiOdoo"; -import mailer from "@/core/utils/mailer"; +import odooApi from '@/core/api/odooApi' +import mailer from '@/core/utils/mailer' export default async function handler(req, res) { try { - const { email } = req.body; - let result = await apiOdoo( - 'POST', - '/api/v1/user/activation-request', - {email} - ); - if (result.activation_request) { + const { email } = req.body + let result = await odooApi('POST', '/api/v1/user/activation-request', { email }) + if (result.activationRequest) { mailer.sendMail({ from: 'sales@indoteknik.com', to: result.user.email, @@ -19,13 +15,13 @@ export default async function handler(req, res) { <br> <p>Aktivasi akun anda melalui link berikut: <a href="${process.env.SELF_HOST}/activate?token=${result.token}">Aktivasi Akun</a></p> ` - }); + }) } - delete result.user; - delete result.token; - res.status(200).json(result); + delete result.user + delete result.token + res.status(200).json(result) } catch (error) { - console.log(error); - res.status(400).json({ error: error.message }); + console.log(error) + res.status(400).json({ error: error.message }) } -}
\ No newline at end of file +} diff --git a/src/pages/api/activation.js b/src/pages/api/activation.js deleted file mode 100644 index 8b22af8d..00000000 --- a/src/pages/api/activation.js +++ /dev/null @@ -1,16 +0,0 @@ -import apiOdoo from "@/core/utils/apiOdoo"; - -export default async function handler(req, res) { - try { - const { token } = req.body; - let result = await apiOdoo( - 'POST', - '/api/v1/user/activation', - {token} - ); - res.status(200).json(result); - } catch (error) { - console.log(error); - res.status(400).json({ error: error.message }); - } -}
\ No newline at end of file diff --git a/src/pages/api/login.js b/src/pages/api/login.js deleted file mode 100644 index e02a73cb..00000000 --- a/src/pages/api/login.js +++ /dev/null @@ -1,15 +0,0 @@ -import apiOdoo from "@/core/utils/apiOdoo"; - -export default async function handler(req, res) { - try { - const { email, password } = req.body; - let result = await apiOdoo( - 'POST', - '/api/v1/user/login', - {email, password} - ); - res.status(200).json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}
\ No newline at end of file diff --git a/src/pages/api/register.js b/src/pages/api/register.js deleted file mode 100644 index 7c8d8b39..00000000 --- a/src/pages/api/register.js +++ /dev/null @@ -1,15 +0,0 @@ -import apiOdoo from "@/core/utils/apiOdoo"; - -export default async function handler(req, res) { - try { - const { email, name, password } = req.body; - let result = await apiOdoo( - 'POST', - '/api/v1/user/register', - {email, name, password} - ); - res.status(200).json(result); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}
\ No newline at end of file diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index ad986c86..c1e00d16 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from 'axios' +import camelcaseObjectDeep from 'camelcase-object-deep' const productResponseMap = (products) => { return products.map((product) => { @@ -7,65 +8,65 @@ const productResponseMap = (products) => { image: product.image ? product.image[0] : '', code: product.default_code ? product.default_code[0] : '', name: product.product_name ? product.product_name[0] : '', - lowest_price: { + lowestPrice: { price: product.price ? product.price[0] : 0, - price_discount: product.price_discount ? product.price_discount[0] : 0, - discount_percentage: product.discount ? product.discount[0] : 0, + priceDiscount: product.price_discount ? product.price_discount[0] : 0, + discountPercentage: product.discount ? product.discount[0] : 0 }, - variant_total: product.variant_total ? product.variant_total[0] : 0, - stock_total: product.stock_total ? product.stock_total[0] : 0, + variantTotal: product.variant_total ? product.variant_total[0] : 0, + stockTotal: product.stock_total ? product.stock_total[0] : 0, weight: product.weight ? product.weight[0] : 0, manufacture: {}, - categories: [], - }; + categories: [] + } if (product.manufacture_id && product.brand) { productMapped.manufacture = { id: product.manufacture_id ? product.manufacture_id[0] : '', - name: product.brand ? product.brand[0] : '', - }; + name: product.brand ? product.brand[0] : '' + } } productMapped.categories = [ { id: product.category_id ? product.category_id[0] : '', - name: product.category_name ? product.category_name[0] : '', + name: product.category_name ? product.category_name[0] : '' } - ]; + ] - return productMapped; - }); + return productMapped + }) } export default async function handler(req, res) { - const { - q, + const { + q = '*', page = 1, brand = '', category = '', - price_from = 0, - price_to = 0, - order_by = '' - } = req.query; + priceFrom = 0, + priceTo = 0, + orderBy = '' + } = req.query - let paramOrderBy = ''; - switch (order_by) { + let paramOrderBy = '' + switch (orderBy) { case 'price-asc': - paramOrderBy = ', price_discount ASC'; - break; + paramOrderBy = ', price_discount ASC' + break case 'price-desc': - paramOrderBy = ', price_discount DESC'; - break; + paramOrderBy = ', price_discount DESC' + break case 'popular': - paramOrderBy = ', search_rank DESC'; - break; + paramOrderBy = ', search_rank DESC' + break case 'stock': - paramOrderBy = ', stock_total DESC'; - break; + paramOrderBy = ', stock_total DESC' + break } - let limit = 30; - let offset = (page - 1) * limit; + let limit = 30 + let offset = (page - 1) * limit let parameter = [ `facet.query=${q}`, 'facet=true', @@ -77,20 +78,21 @@ export default async function handler(req, res) { `start=${offset}`, `rows=${limit}`, `sort=product_rating DESC ${paramOrderBy}`, - `fq=price_discount:[${price_from == '' ? '*' : price_from} TO ${price_to == '' ? '*' : price_to}]` - ]; + `fq=price_discount:[${priceFrom == '' ? '*' : priceFrom} TO ${priceTo == '' ? '*' : priceTo}]` + ] - if (brand) parameter.push(`fq=brand:${brand}`); - if (category) parameter.push(`fq=category_name:${category}`); - - let result = await axios(process.env.SOLR_HOST + '/solr/products/select?' + parameter.join('&')); + if (brand) parameter.push(`fq=brand:${brand}`) + if (category) parameter.push(`fq=category_name:${category}`) + + let result = await axios(process.env.SOLR_HOST + '/solr/products/select?' + parameter.join('&')) try { - result.data.response.products = productResponseMap(result.data.response.docs); - result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start); - result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows); - delete result.data.response.docs; - res.status(200).json(result.data); + result.data.response.products = productResponseMap(result.data.response.docs) + result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start) + result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows) + delete result.data.response.docs + result.data = camelcaseObjectDeep(result.data) + res.status(200).json(result.data) } catch (error) { - res.status(400).json({ error: error.message }); + res.status(400).json({ error: error.message }) } -}
\ No newline at end of file +} diff --git a/src/pages/api/shop/suggest.js b/src/pages/api/shop/suggest.js index 6db1a851..cc0ff2b3 100644 --- a/src/pages/api/shop/suggest.js +++ b/src/pages/api/shop/suggest.js @@ -1,12 +1,18 @@ -import axios from "axios"; +import axios from 'axios' export default async function handler(req, res) { - const { q } = req.query; + const { q = '' } = req.query - let result = await axios(process.env.SOLR_HOST + `/solr/products/suggest?suggest=true&suggest.dictionary=mySuggester&suggest.q=${q}`); + let result = await axios( + process.env.SOLR_HOST + + `/solr/products/suggest?suggest=true&suggest.dictionary=mySuggester&suggest.q=${q}` + ) try { - res.status(200).json(result.data); + res.status(200).json(result.data.suggest.mySuggester[q]) } catch (error) { - res.status(400).json({ error: error.message }); + res.status(400).json({ + numFound: 0, + suggestions: [] + }) } -}
\ No newline at end of file +} diff --git a/src/pages/api/token.js b/src/pages/api/token.js deleted file mode 100644 index ec048158..00000000 --- a/src/pages/api/token.js +++ /dev/null @@ -1,10 +0,0 @@ -import axios from "axios"; - -export default async function handler(req, res) { - try { - let result = await axios.get(process.env.ODOO_HOST + '/api/token'); - res.status(200).json(result.data.result); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}
\ No newline at end of file diff --git a/src/pages/faqs.js b/src/pages/faqs.js deleted file mode 100644 index cdb8ef52..00000000 --- a/src/pages/faqs.js +++ /dev/null @@ -1,91 +0,0 @@ -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"; -import { useEffect, useState } from "react"; - -const dataFaqs = [ - { - id: 1, - name: 'Akun', - description: 'Bantuan tentang pengelolaan fitur dan akun' - }, - { - id: 2, - name: 'Pembelian', - description: 'Bantuan seputar status stock, layanan pengiriman & asuransi hingga seluruh indonesia' - }, - { - id: 3, - name: 'Metode Pembayaran', - description: 'Bantuan terkait layanan metode pembayaran' - }, - { - id: 4, - name: 'Quotation', - description: 'Bantuan fitur RFQ & quotation Express' - }, - { - id: 5, - name: 'Faktur Pajak & Invoice', - description: 'Bantuan seputar layanan terbit faktur pajak & invoice' - }, - { - id: 6, - name: 'Pengembalian & Garansi', - description: 'Bantuan cara pengembalian produk & garansi produk' - } -]; - -export default function Faqs() { - const [ faqs, setFaqs ] = useState([]); - - useEffect(() => { - if (faqs.length == 0) { - setFaqs(dataFaqs.map((dataFaq) => ({ - ...dataFaq, - isOpen: false - }))); - } - }, [ faqs ]); - - const toggleFaq = (id) => { - const faqsToUpdate = faqs.map(faq => { - if (faq.id == id) faq.isOpen = !faq.isOpen; - return faq; - }); - setFaqs(faqsToUpdate); - }; - - return ( - <Layout> - <AppBar title="FAQ's" /> - - <div className="divide-y divide-gray_r-6"> - { faqs.map((faq, index) => ( - <div className="p-4" key={index}> - <div className="flex gap-x-3 items-center"> - <div className="flex-1"> - <p className="font-medium mb-1">{ faq.name }</p> - <p className="text-caption-1 text-gray_r-11"> - { faq.description } - </p> - </div> - <button type="button" className="p-2 rounded bg-gray_r-4 h-fit" onClick={() => toggleFaq(faq.id)}> - { faq.isOpen ? ( - <ChevronUpIcon className="w-5"/> - ) : ( - <ChevronDownIcon className="w-5"/> - ) } - </button> - </div> - { faq.isOpen && ( - <p className="text-caption-1 text-gray_r-11 leading-7 mt-4"> - { faq?.content || 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.' } - </p> - ) } - </div> - )) } - </div> - </Layout> - ) -}
\ No newline at end of file diff --git a/src/pages/faqs.jsx b/src/pages/faqs.jsx new file mode 100644 index 00000000..ddbb4b0a --- /dev/null +++ b/src/pages/faqs.jsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import AppLayout from '@/core/components/layouts/AppLayout' + +const dataFaqs = [ + { + id: 1, + name: 'Akun', + description: 'Bantuan tentang pengelolaan fitur dan akun' + }, + { + id: 2, + name: 'Pembelian', + description: + 'Bantuan seputar status stock, layanan pengiriman & asuransi hingga seluruh indonesia' + }, + { + id: 3, + name: 'Metode Pembayaran', + description: 'Bantuan terkait layanan metode pembayaran' + }, + { + id: 4, + name: 'Quotation', + description: 'Bantuan fitur RFQ & quotation Express' + }, + { + id: 5, + name: 'Faktur Pajak & Invoice', + description: 'Bantuan seputar layanan terbit faktur pajak & invoice' + }, + { + id: 6, + name: 'Pengembalian & Garansi', + description: 'Bantuan cara pengembalian produk & garansi produk' + } +] + +export default function Faqs() { + const [faqs, setFaqs] = useState([]) + + useEffect(() => { + if (faqs.length == 0) { + setFaqs( + dataFaqs.map((dataFaq) => ({ + ...dataFaq, + isOpen: false + })) + ) + } + }, [faqs]) + + const toggleFaq = (id) => { + const faqsToUpdate = faqs.map((faq) => { + if (faq.id == id) faq.isOpen = !faq.isOpen + return faq + }) + setFaqs(faqsToUpdate) + } + + return ( + <AppLayout title='FAQs'> + <div className='divide-y divide-gray_r-6'> + {faqs.map((faq, index) => ( + <div + className='p-4' + key={index} + > + <div className='flex gap-x-3 items-center'> + <div className='flex-1'> + <p className='font-medium mb-1'>{faq.name}</p> + <p className='text-caption-1 text-gray_r-11'>{faq.description}</p> + </div> + <button + type='button' + className='p-2 rounded bg-gray_r-4 h-fit' + onClick={() => toggleFaq(faq.id)} + > + {faq.isOpen ? ( + <ChevronUpIcon className='w-5' /> + ) : ( + <ChevronDownIcon className='w-5' /> + )} + </button> + </div> + {faq.isOpen && ( + <p className='text-caption-1 text-gray_r-11 leading-7 mt-4'> + {faq?.content || + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.'} + </p> + )} + </div> + ))} + </div> + </AppLayout> + ) +} diff --git a/src/pages/index.js b/src/pages/index.js deleted file mode 100644 index 65999ff6..00000000 --- a/src/pages/index.js +++ /dev/null @@ -1,106 +0,0 @@ -import { useEffect, useState } from "react"; -import { Pagination, Autoplay } from "swiper"; -import axios from "axios"; -import { Swiper, SwiperSlide } from "swiper/react"; -import "swiper/css"; -import "swiper/css/pagination"; -import "swiper/css/autoplay"; - -// Helpers -import apiOdoo from "@/core/utils/apiOdoo"; - -// Components -import Header from "@/components/layouts/Header"; -import ProductSlider from "@/components/products/ProductSlider"; -import Layout from "@/components/layouts/Layout"; -import ManufactureCard from "@/components/manufactures/ManufactureCard"; -import Footer from "@/components/layouts/Footer"; -import Image from "@/components/elements/Image"; -import ProductCategories from "@/components/products/ProductCategories"; - -const swiperBanner = { - pagination: { dynamicBullets: true }, - autoplay: { - delay: 6000, - disableOnInteraction: false - }, - modules: [Pagination, Autoplay] -} - -export async function getServerSideProps() { - const heroBanners = await apiOdoo('GET', `/api/v1/banner?type=index-a-1`); - - return { props: { heroBanners } }; -} - -export default function Home({ heroBanners }) { - const [manufactures, setManufactures] = useState(null); - const [popularProducts, setPopularProducts] = useState(null); - - useEffect(() => { - const getManufactures = async () => { - const dataManufactures = await apiOdoo('GET', `/api/v1/manufacture?level=prioritas`); - setManufactures(dataManufactures); - } - getManufactures(); - - const getPopularProducts = async () => { - const dataPopularProducts = await axios(`${process.env.SELF_HOST}/api/shop/search?q=*&page=1&order_by=popular`); - setPopularProducts(dataPopularProducts.data.response); - } - getPopularProducts(); - }, []); - - return ( - <> - <Header title='Home - Indoteknik' /> - <Layout> - <Swiper - slidesPerView={1} - pagination={swiperBanner.pagination} - modules={swiperBanner.modules} - autoplay={swiperBanner.autoplay} - > - { - heroBanners?.map((banner, index) => ( - <SwiperSlide key={index}> - <Image - src={banner.image} - alt={banner.name} - className="w-full h-auto" - /> - </SwiperSlide> - )) - } - </Swiper> - <div className="mt-6 px-4"> - <h2 className="mb-3">Brand Pilihan</h2> - <Swiper slidesPerView={4} freeMode={true} spaceBetween={16}> - { - manufactures?.manufactures?.map((manufacture, index) => ( - <SwiperSlide key={index}> - <ManufactureCard data={manufacture} key={index} /> - </SwiperSlide> - )) - } - </Swiper> - </div> - <div className="my-6 p-4 py-0"> - <h2 className="mb-4">Produk Populer</h2> - <ProductSlider products={popularProducts} simpleProductTitleLine /> - </div> - - <ProductCategories /> - - <div className="px-4"> - <h5 className="h2 mb-2">Platform Belanja B2B Alat Teknik & Industri di Indonesia</h5> - <p className="text-gray_r-11 leading-6 text-caption-2 mb-4"> - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est - </p> - </div> - - <Footer /> - </Layout> - </> - ) -} diff --git a/src/pages/index.jsx b/src/pages/index.jsx new file mode 100644 index 00000000..19d3e59c --- /dev/null +++ b/src/pages/index.jsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic' +import Seo from '@/core/components/Seo' +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' +import PopularProductSkeleton from '@/lib/home/components/Skeleton/PopularProductSkeleton' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) + +const HeroBanner = dynamic(() => import('@/lib/home/components/HeroBanner'), { + loading: () => <ImageSkeleton /> +}) + +const PreferredBrand = dynamic(() => import('@/lib/home/components/PreferredBrand'), { + loading: () => <PopularProductSkeleton /> +}) + +const PopularProduct = dynamic(() => import('@/lib/home/components/PopularProduct'), { + loading: () => <PopularProductSkeleton /> +}) + +const CategoryHomeId = dynamic(() => import('@/lib/home/components/CategoryHomeId'), { + loading: () => <PopularProductSkeleton /> +}) + +export default function Home() { + return ( + <BasicLayout> + <Seo title='Beranda - Indoteknik' /> + <HeroBanner /> + <div className='flex flex-col gap-y-6 my-6'> + <PreferredBrand /> + <PopularProduct /> + <CategoryHomeId /> + </div> + </BasicLayout> + ) +} diff --git a/src/pages/login.js b/src/pages/login.js deleted file mode 100644 index e80de44e..00000000 --- a/src/pages/login.js +++ /dev/null @@ -1,97 +0,0 @@ -import axios from "axios"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "@/components/elements/Link"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import Alert from "@/components/elements/Alert"; -import Layout from "@/components/layouts/Layout"; -import Spinner from "@/components/elements/Spinner"; -import { setAuth } from "@/core/utils/auth"; -import Logo from "@/images/logo.png"; - -export default function Login() { - const router = useRouter(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [isInputFulfilled, setIsInputFulfilled] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [alert, setAlert] = useState(); - - useEffect(() => { - setIsInputFulfilled(email && password); - }, [email, password]); - - const login = async (e) => { - e.preventDefault(); - setIsLoading(true); - let login = await axios.post(`${process.env.SELF_HOST}/api/login`, {email, password}); - if (login.data.is_auth) { - setAuth(login.data.user); - router.push('/'); - } else { - switch (login.data.reason) { - case 'NOT_FOUND': - setAlert({ - component: <>Email atau password tidak cocok</>, - type: 'info' - }); - break; - case 'NOT_ACTIVE': - setAlert({ - component: <>Email belum diaktivasi, <Link className="text-gray-900" href={`/activate?email=${email}`}>aktivasi sekarang</Link></>, - type: 'info' - }); - break; - } - setIsLoading(false); - } - } - - return ( - <> - <Head> - <title>Masuk - Indoteknik</title> - </Head> - <Layout className="max-w-lg mx-auto flex flex-col items-center px-4 pb-8"> - <Link href="/" className="mt-16"> - <Image src={Logo} alt="Logo Indoteknik" width={165} height={42} /> - </Link> - <h1 className="text-2xl mt-4 text-center">Mulai Belanja Sekarang</h1> - <h2 className="text-gray_r-11 font-normal mt-2 mb-4">Masuk ke akun kamu untuk belanja</h2> - {alert ? ( - <Alert className="text-center" type={alert.type}>{alert.component}</Alert> - ) : ''} - <form onSubmit={login} className="w-full"> - <label className="form-label mt-4 mb-2">Alamat Email</label> - <input - type="email" - className="form-input bg-gray_r-2" - placeholder="johndoe@gmail.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - <label className="form-label mt-4 mb-2">Kata Sandi</label> - <input - type="password" - className="form-input bg-gray_r-2" - placeholder="••••••••" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <div className="flex justify-end mt-4 w-full"> - <Link href="/forgot-password">Lupa kata sandi</Link> - </div> - <button type="submit" disabled={!isInputFulfilled} className="btn-yellow font-semibold mt-4 w-full"> - {isLoading ? ( - <div className="flex justify-center items-center gap-x-2"> - <Spinner className="w-4 h-4 text-gray-600 fill-gray-900" /> <span>Loading...</span> - </div> - ) : 'Masuk'} - </button> - </form> - <p className="text-gray-700 mt-4">Belum punya akun Indoteknik? <Link href="/register">Daftar</Link></p> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/login.jsx b/src/pages/login.jsx new file mode 100644 index 00000000..03509e93 --- /dev/null +++ b/src/pages/login.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import LoginComponent from '@/lib/auth/components/Login' + +export default function Login() { + return ( + <> + <LoginComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/logout.js b/src/pages/logout.js deleted file mode 100644 index 8ea21fab..00000000 --- a/src/pages/logout.js +++ /dev/null @@ -1,14 +0,0 @@ -import { useRouter } from "next/router"; -import { useEffect } from "react"; -import { deleteAuth } from "@/core/utils/auth"; - -export default function Logout() { - const router = useRouter(); - - useEffect(() => { - deleteAuth(); - router.replace('/login'); - }, [router]); - - return null; -}
\ No newline at end of file diff --git a/src/pages/my/address/[id]/edit.js b/src/pages/my/address/[id]/edit.js deleted file mode 100644 index 838d39e7..00000000 --- a/src/pages/my/address/[id]/edit.js +++ /dev/null @@ -1,249 +0,0 @@ -import { Controller, useForm } from "react-hook-form" -import WithAuth from "@/components/auth/WithAuth"; -import Layout from "@/components/layouts/Layout"; -import AppBar from "@/components/layouts/AppBar"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as Yup from "yup"; -import { Select } from "@/components/elements/Fields"; -import { useEffect, useState } from "react"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { toast } from "react-hot-toast"; -import { useRouter } from "next/router"; - -const validationSchema = Yup.object().shape({ - type: Yup.string().required('Harus di-pilih'), - name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), - email: Yup.string().email('Format harus seperti johndoe@example.com').required('Harus di-isi'), - mobile: Yup.string().required('Harus di-isi'), - street: Yup.string().required('Harus di-isi'), - zip: Yup.string().required('Harus di-isi'), - city: Yup.string().required('Harus di-pilih'), -}); - -const types = [ - { value: 'contact', label: 'Contact Address' }, - { value: 'invoice', label: 'Invoice Address' }, - { value: 'delivery', label: 'Delivery Address' }, - { value: 'other', label: 'Other Address' }, -]; - -export async function getServerSideProps( context ) { - const { id } = context.query; - const address = await apiOdoo('GET', `/api/v1/partner/${id}/address`); - let defaultValues = { - type: address.type, - name: address.name, - email: address.email, - mobile: address.mobile, - street: address.street, - zip: address.zip, - city: address.city?.id || '', - oldDistrict: address.district?.id || '', - district: '', - oldSubDistrict: address.sub_district?.id || '', - subDistrict: '', - }; - return { props: { id, defaultValues } }; -} - -export default function EditAddress({ id, defaultValues }) { - const router = useRouter(); - const { - register, - formState: { errors }, - handleSubmit, - watch, - setValue, - getValues, - control, - } = useForm({ - resolver: yupResolver(validationSchema), - defaultValues - }); - - const [ cities, setCities ] = useState([]); - const [ districts, setDistricts ] = useState([]); - const [ subDistricts, setSubDistricts ] = useState([]); - - useEffect(() => { - const loadCities = async () => { - let dataCities = await apiOdoo('GET', '/api/v1/city'); - dataCities = dataCities.map((city) => ({ value: city.id, label: city.name })); - setCities(dataCities); - }; - loadCities(); - }, []); - - const watchCity = watch('city'); - useEffect(() => { - setValue('district', ''); - if (watchCity) { - const loadDistricts = async () => { - let dataDistricts = await apiOdoo('GET', `/api/v1/district?city_id=${watchCity}`); - dataDistricts = dataDistricts.map((district) => ({ value: district.id, label: district.name })); - setDistricts(dataDistricts); - let oldDistrict = getValues('oldDistrict'); - if (oldDistrict) { - setValue('district', oldDistrict); - setValue('oldDistrict', ''); - } - }; - loadDistricts(); - } - }, [ watchCity, setValue, getValues ]); - - const watchDistrict = watch('district'); - useEffect(() => { - setValue('subDistrict', ''); - if (watchDistrict) { - const loadSubDistricts = async () => { - let dataSubDistricts = await apiOdoo('GET', `/api/v1/sub_district?district_id=${watchDistrict}`); - dataSubDistricts = dataSubDistricts.map((district) => ({ value: district.id, label: district.name })); - setSubDistricts(dataSubDistricts); - let oldSubDistrict = getValues('oldSubDistrict'); - if (oldSubDistrict) { - setValue('subDistrict', oldSubDistrict); - setValue('oldSubDistrict', ''); - } - }; - loadSubDistricts(); - } - }, [ watchDistrict, setValue, getValues ]) - - const onSubmitHandler = async (values) => { - const parameters = { - ...values, - city_id: values.city, - district_id: values.district, - sub_district_id: values.subDistrict - } - - const address = await apiOdoo('PUT', `/api/v1/partner/${id}/address`, parameters); - if (address?.id) { - toast.success('Berhasil mengubah alamat'); - router.back(); - } - }; - - return ( - <WithAuth> - <Layout> - <AppBar title="Ubah Alamat" /> - - <form className="p-4 flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmitHandler)}> - <div> - <label className="form-label mb-2">Label Alamat</label> - <Controller - name="type" - control={control} - render={props => <Select {...props} isSearchable={false} options={types} />} - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.type?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Nama</label> - <input - {...register('name')} - placeholder="John Doe" - type="text" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.name?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Email</label> - <input - {...register('email')} - placeholder="johndoe@example.com" - type="email" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.email?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Mobile</label> - <input - {...register('mobile')} - placeholder="08xxxxxxxx" - type="tel" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.mobile?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Alamat</label> - <input - {...register('street')} - placeholder="Jl. Bandengan Utara 85A" - type="text" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.street?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kode Pos</label> - <input - {...register('zip')} - placeholder="10100" - type="number" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.zip?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kota</label> - <Controller - name="city" - control={control} - render={props => <Select {...props} options={cities} />} - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.city?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kecamatan</label> - <Controller - name="district" - control={control} - render={props => ( - <Select - {...props} - options={districts} - disabled={!watchCity} - /> - )} - /> - </div> - - <div> - <label className="form-label mb-2">Kelurahan</label> - <Controller - name="subDistrict" - control={control} - render={props => ( - <Select - {...props} - options={subDistricts} - disabled={!watchDistrict} - /> - )} - /> - </div> - - <button - type="submit" - className="btn-yellow mt-2 w-full" - > - Simpan - </button> - </form> - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/my/address/[id]/edit.jsx b/src/pages/my/address/[id]/edit.jsx new file mode 100644 index 00000000..bc5f3471 --- /dev/null +++ b/src/pages/my/address/[id]/edit.jsx @@ -0,0 +1,36 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import addressApi from '@/lib/address/api/addressApi' +import EditAddressComponent from '@/lib/address/components/EditAddress' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function EditAddress({ id, defaultValues }) { + return ( + <IsAuth> + <AppLayout title='Ubah Alamat'> + <EditAddressComponent + id={id} + defaultValues={defaultValues} + /> + </AppLayout> + </IsAuth> + ) +} + +export async function getServerSideProps(context) { + const { id } = context.query + const address = await addressApi({ id }) + const defaultValues = { + type: address.type, + name: address.name, + email: address.email, + mobile: address.mobile, + street: address.street, + zip: address.zip, + city: address.city?.id || '', + oldDistrict: address.district?.id || '', + district: '', + oldSubDistrict: address.subDistrict?.id || '', + subDistrict: '' + } + return { props: { id, defaultValues } } +} diff --git a/src/pages/my/address/create.js b/src/pages/my/address/create.js deleted file mode 100644 index 42cd117c..00000000 --- a/src/pages/my/address/create.js +++ /dev/null @@ -1,234 +0,0 @@ -import { Controller, useForm } from "react-hook-form" -import WithAuth from "@/components/auth/WithAuth"; -import Layout from "@/components/layouts/Layout"; -import AppBar from "@/components/layouts/AppBar"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as Yup from "yup"; -import { Select } from "@/components/elements/Fields"; -import { useEffect, useState } from "react"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useAuth } from "@/core/utils/auth"; -import { toast } from "react-hot-toast"; -import { useRouter } from "next/router"; - -const validationSchema = Yup.object().shape({ - type: Yup.string().required('Harus di-pilih'), - name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), - email: Yup.string().email('Format harus seperti johndoe@example.com').required('Harus di-isi'), - mobile: Yup.string().required('Harus di-isi'), - street: Yup.string().required('Harus di-isi'), - zip: Yup.string().required('Harus di-isi'), - city: Yup.string().required('Harus di-pilih'), -}); - -const defaultValues = { - type: '', - name: '', - email: '', - mobile: '', - street: '', - city: '', - district: '', - subDistrict: '', - zip: '', -}; - -const types = [ - { value: 'contact', label: 'Contact Address' }, - { value: 'invoice', label: 'Invoice Address' }, - { value: 'delivery', label: 'Delivery Address' }, - { value: 'other', label: 'Other Address' }, -]; - -export default function CreateAddress() { - const [ auth ] = useAuth(); - const router = useRouter(); - const { - register, - formState: { errors }, - handleSubmit, - watch, - setValue, - control, - } = useForm({ - resolver: yupResolver(validationSchema), - defaultValues - }); - - const [ cities, setCities ] = useState([]); - const [ districts, setDistricts ] = useState([]); - const [ subDistricts, setSubDistricts ] = useState([]); - - useEffect(() => { - const loadCities = async () => { - let dataCities = await apiOdoo('GET', '/api/v1/city'); - dataCities = dataCities.map((city) => ({ value: city.id, label: city.name })); - setCities(dataCities); - }; - loadCities(); - }, []); - - const watchCity = watch('city'); - useEffect(() => { - setValue('district', ''); - if (watchCity) { - const loadDistricts = async () => { - let dataDistricts = await apiOdoo('GET', `/api/v1/district?city_id=${watchCity}`); - dataDistricts = dataDistricts.map((district) => ({ value: district.id, label: district.name })); - setDistricts(dataDistricts); - }; - loadDistricts(); - } - }, [ watchCity, setValue ]); - - const watchDistrict = watch('district'); - useEffect(() => { - setValue('subDistrict', ''); - if (watchDistrict) { - const loadSubDistricts = async () => { - let dataSubDistricts = await apiOdoo('GET', `/api/v1/sub_district?district_id=${watchDistrict}`); - dataSubDistricts = dataSubDistricts.map((district) => ({ value: district.id, label: district.name })); - setSubDistricts(dataSubDistricts); - }; - loadSubDistricts(); - } - }, [ watchDistrict, setValue ]) - - const onSubmitHandler = async (values) => { - const parameters = { - ...values, - city_id: values.city, - district_id: values.district, - sub_district_id: values.subDistrict, - parent_id: auth.partner_id - }; - - const address = await apiOdoo('POST', '/api/v1/partner/address', parameters); - if (address?.id) { - toast.success('Berhasil menambahkan alamat'); - router.back(); - } - }; - - return ( - <WithAuth> - <Layout> - <AppBar title="Tambah Alamat" /> - - <form className="p-4 flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmitHandler)}> - <div> - <label className="form-label mb-2">Label Alamat</label> - <Controller - name="type" - control={control} - render={props => <Select {...props} isSearchable={false} options={types} />} - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.type?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Nama</label> - <input - {...register('name')} - placeholder="John Doe" - type="text" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.name?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Email</label> - <input - {...register('email')} - placeholder="johndoe@example.com" - type="email" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.email?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Mobile</label> - <input - {...register('mobile')} - placeholder="08xxxxxxxx" - type="tel" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.mobile?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Alamat</label> - <input - {...register('street')} - placeholder="Jl. Bandengan Utara 85A" - type="text" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.street?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kode Pos</label> - <input - {...register('zip')} - placeholder="10100" - type="number" - className="form-input" - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.zip?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kota</label> - <Controller - name="city" - control={control} - render={props => <Select {...props} options={cities} />} - /> - <div className="text-caption-2 text-red_r-11 mt-1">{ errors.city?.message }</div> - </div> - - <div> - <label className="form-label mb-2">Kecamatan</label> - <Controller - name="district" - control={control} - render={props => ( - <Select - {...props} - options={districts} - disabled={!watchCity} - /> - )} - /> - </div> - - <div> - <label className="form-label mb-2">Kelurahan</label> - <Controller - name="subDistrict" - control={control} - render={props => ( - <Select - {...props} - options={subDistricts} - disabled={!watchDistrict} - /> - )} - /> - </div> - - <button - type="submit" - className="btn-yellow mt-2 w-full" - > - Simpan - </button> - </form> - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/my/address/create.jsx b/src/pages/my/address/create.jsx new file mode 100644 index 00000000..ec17f987 --- /dev/null +++ b/src/pages/my/address/create.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import CreateAddressComponent from '@/lib/address/components/CreateAddress' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function CreateAddress() { + return ( + <IsAuth> + <AppLayout title='Tambah Alamat'> + <CreateAddressComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/address/index.js b/src/pages/my/address/index.js deleted file mode 100644 index 5cad4410..00000000 --- a/src/pages/my/address/index.js +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect, useState } from "react"; -import { useRouter } from "next/router"; - -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import Link from "@/components/elements/Link"; -import WithAuth from "@/components/auth/WithAuth"; - -import apiOdoo from "@/core/utils/apiOdoo"; -import { useAuth } from "@/core/utils/auth"; -import { createOrUpdateItemAddress, getItemAddress } from "@/core/utils/address"; -import { toast } from "react-hot-toast"; - -export default function Address() { - const router = useRouter(); - const { select } = router.query; - const [ auth ] = useAuth(); - const [ addresses, setAddresses ] = useState(null); - const [ selectedAdress, setSelectedAdress ] = useState(null); - - useEffect(() => { - const getAddress = async () => { - if (auth) { - const dataAddress = await apiOdoo('GET', `/api/v1/user/${auth.id}/address`); - setAddresses(dataAddress); - } - }; - getAddress(); - }, [auth]); - - useEffect(() => { - if (select) { - setSelectedAdress(getItemAddress(select)); - } - }, [select]); - - const changeSelectedAddress = (id) => { - if (select) { - createOrUpdateItemAddress(select, id); - router.back(); - } - }; - - return ( - <WithAuth> - <Layout> - <AppBar title="Daftar Alamat" /> - - <div className="text-right mt-4 px-4"> - <Link href="/my/address/create">Tambah Alamat</Link> - </div> - - <div className="grid gap-y-4 p-4"> - { auth && addresses && addresses.map((address, index) => { - let type = address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address'; - return ( - <div - key={index} - className={"p-4 rounded-md border " + (selectedAdress && selectedAdress == address.id ? "bg-gray_r-4" : "border-gray_r-7") } - > - <div onClick={() => changeSelectedAddress(address.id)}> - <div className="flex gap-x-2" > - <div className="badge-red">{ type }</div> - { auth?.partner_id == address.id && ( - <div className="badge-green">Utama</div> - ) } - </div> - <p className="font-medium mt-2">{ address.name }</p> - { address.mobile && ( - <p className="mt-2 text-gray_r-11">{ address.mobile }</p> - ) } - <p className={`mt-1 leading-6 ${selectedAdress && selectedAdress == address.id ? "text-gray_r-12" : "text-gray_r-11"}`}> - { address.street } - </p> - </div> - <Link href={`/my/address/${address.id}/edit`} className="btn-light bg-white mt-3 w-full text-gray_r-11">Ubah Alamat</Link> - </div> - ); - }) } - </div> - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/my/address/index.jsx b/src/pages/my/address/index.jsx new file mode 100644 index 00000000..93ed40b0 --- /dev/null +++ b/src/pages/my/address/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import AddressesComponent from '@/lib/address/components/Addresses' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function Addresses() { + return ( + <IsAuth> + <AppLayout title='Daftar Alamat'> + <AddressesComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/invoice/[id].js b/src/pages/my/invoice/[id].js deleted file mode 100644 index 820c9af8..00000000 --- a/src/pages/my/invoice/[id].js +++ /dev/null @@ -1,149 +0,0 @@ -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import LineDivider from "@/components/elements/LineDivider"; -import WithAuth from "@/components/auth/WithAuth"; -import { useEffect, useState } from "react"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useRouter } from "next/router"; -import { useAuth } from "@/core/utils/auth"; -import VariantCard from "@/components/variants/VariantCard"; -import currencyFormat from "@/core/utils/currencyFormat"; -import Disclosure from "@/components/elements/Disclosure"; -import DescriptionRow from "@/components/elements/DescriptionRow"; -import { SkeletonList } from "@/components/elements/Skeleton"; -import VariantGroupCard from "@/components/variants/VariantGroupCard"; - -export default function DetailInvoice() { - const router = useRouter(); - const { id } = router.query; - const [ auth ] = useAuth(); - const [ invoice, setInvoice ] = useState(null); - - useEffect(() => { - if (auth && id) { - const loadInvoice = async () => { - const dataInvoice = await apiOdoo('GET', `/api/v1/partner/${auth?.partner_id}/invoice/${id}`); - setInvoice(dataInvoice); - } - loadInvoice(); - } - }, [ auth, id ]); - - const Customer = () => { - const customer = invoice?.customer; - const fullAddress = []; - if (customer?.street) fullAddress.push(customer.street); - if (customer?.sub_district?.name) fullAddress.push(customer.sub_district.name); - if (customer?.district?.name) fullAddress.push(customer.district.name); - if (customer?.city?.name) fullAddress.push(customer.city.name); - - return ( - <div className="p-4 pt-0 flex flex-col gap-y-4"> - <DescriptionRow label="Nama">{ invoice?.customer?.name }</DescriptionRow> - <DescriptionRow label="Email">{ invoice?.customer?.email || '-' }</DescriptionRow> - <DescriptionRow label="No Telepon">{ invoice?.customer?.mobile || '-' }</DescriptionRow> - <DescriptionRow label="Alamat">{ fullAddress.join(', ') }</DescriptionRow> - </div> - ); - }; - - const downloadTaxInvoice = () => { - window.open(`${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${invoice.id}/${invoice.token}`, 'Download') - } - - const downloadInvoice = () => { - window.open(`${process.env.ODOO_HOST}/api/v1/download/invoice/${invoice.id}/${invoice.token}`, 'Download') - } - - return ( - <WithAuth> - <Layout className="pb-4"> - <AppBar title="Detail Invoice" /> - - { invoice ? ( - <> - <div className="p-4 flex flex-col gap-y-4"> - <DescriptionRow label="No Invoice"> - { invoice?.name } - </DescriptionRow> - <DescriptionRow label="Status Transaksi"> - { invoice?.amount_residual > 0 ? ( - <span className="badge-solid-red">Belum Lunas</span> - ) : ( - <span className="badge-solid-green">Lunas</span> - ) } - </DescriptionRow> - <DescriptionRow label="Purchase Order"> - { invoice?.purchase_order_name || '-' } - </DescriptionRow> - <DescriptionRow label="Ketentuan Pembayaran"> - { invoice?.payment_term } - </DescriptionRow> - { invoice?.amount_residual > 0 && invoice.invoice_date != invoice.invoice_date_due && ( - <DescriptionRow label="Tanggal Jatuh Tempo"> - { invoice?.invoice_date_due } - </DescriptionRow> - ) } - <DescriptionRow label="Nama Sales"> - { invoice?.sales } - </DescriptionRow> - <DescriptionRow label="Tanggal Invoice"> - { invoice?.invoice_date } - </DescriptionRow> - <div className="flex items-center"> - <p className="text-gray_r-11 leading-none">Faktur Pembelian</p> - <button - type="button" - className="btn-light py-1.5 px-3 ml-auto" - onClick={downloadInvoice} - > - Download - </button> - </div> - <div className="flex items-center"> - <p className="text-gray_r-11 leading-none">Faktur Pajak</p> - <button - type="button" - className="btn-light py-1.5 px-3 ml-auto" - onClick={downloadTaxInvoice} - disabled={!invoice.efaktur} - > - Download - </button> - </div> - </div> - - <LineDivider /> - - <Disclosure - label="Detail Penagihan" - /> - - <Customer /> - - <LineDivider /> - - <Disclosure - label="Detail Produk" - /> - - <div className="mt-2 p-4 pt-0 flex flex-col gap-y-3"> - <VariantGroupCard - variants={invoice?.products} - buyMore - /> - <div className="flex justify-between mt-3 font-medium"> - <p className="text-gray_r-11">Total Belanja</p> - <p>{ currencyFormat(invoice?.amount_total || 0) }</p> - </div> - </div> - </> - ) : ( - <div className="p-4 py-6"> - <SkeletonList number={12} /> - </div> - ) } - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/my/invoice/[id].jsx b/src/pages/my/invoice/[id].jsx new file mode 100644 index 00000000..4938d8f8 --- /dev/null +++ b/src/pages/my/invoice/[id].jsx @@ -0,0 +1,16 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import InvoiceComponent from '@/lib/invoice/components/Invoice' +import { useRouter } from 'next/router' + +export default function Invoice() { + const router = useRouter() + + return ( + <IsAuth> + <AppLayout title='Invoice & Faktur Pajak'> + <InvoiceComponent id={router.query.id} /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/invoices.js b/src/pages/my/invoices.js deleted file mode 100644 index 9b2e77dc..00000000 --- a/src/pages/my/invoices.js +++ /dev/null @@ -1,180 +0,0 @@ -import WithAuth from "@/components/auth/WithAuth" -import Alert from "@/components/elements/Alert" -import Link from "@/components/elements/Link" -import Pagination from "@/components/elements/Pagination" -import AppBar from "@/components/layouts/AppBar" -import Layout from "@/components/layouts/Layout" -import apiOdoo from "@/core/utils/apiOdoo" -import { useAuth } from "@/core/utils/auth" -import currencyFormat from "@/core/utils/currencyFormat" -import useBottomPopup from "@/lib/elements/hooks/useBottomPopup" -import { CheckIcon, ClockIcon, EllipsisVerticalIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline" -import { useRouter } from "next/router" -import { useEffect, useRef, useState } from "react" - -export default function Invoices() { - const [ auth ] = useAuth() - const router = useRouter() - const { - q, - page = 1 - } = router.query - - const [ invoices, setInvoices ] = useState([]) - - const [ pageCount, setPageCount ] = useState(0) - const [ isLoading, setIsLoading ] = useState(true) - - const searchQueryRef = useRef() - - useEffect(() => { - const loadInvoices = async () => { - if (auth) { - const limit = 10 - let offset = (page - 1) * 10 - let queryParams = [`limit=${limit}`, `offset=${offset}`] - if (q) queryParams.push(`name=${q}`) - queryParams = queryParams.join('&') - queryParams = queryParams ? '?' + queryParams : '' - - const dataInvoices = await apiOdoo('GET', `/api/v1/partner/${auth.partner_id}/invoice${queryParams}`) - setInvoices(dataInvoices) - setPageCount(Math.ceil(dataInvoices.sale_order_total / limit)) - setIsLoading(false) - } - } - loadInvoices() - }, [ auth, q, page ]) - - const actionSearch = (e) => { - e.preventDefault() - let queryParams = [] - if (searchQueryRef.current.value) queryParams.push(`q=${searchQueryRef.current.value}`) - queryParams = queryParams.join('&') - queryParams = queryParams ? `?${queryParams}` : '' - router.push(`/my/invoices${queryParams}`) - } - - const downloadInvoice = (data) => { - const url = `${process.env.ODOO_HOST}/api/v1/download/invoice/${data.id}/${data.token}` - window.open(url, 'download') - closePopup() - } - - const downloadTaxInvoice = (data) => { - const url = `${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${data.id}/${data.token}` - window.open(url, 'download') - closePopup() - } - - const childrenPopup = (data) => ( - <div className="flex flex-col gap-y-6"> - <button - className="text-left disabled:opacity-60" - onClick={() => downloadInvoice(data)} - > - Download Faktur Pembelian - </button> - <button - className="text-left disabled:opacity-60" - disabled={!data?.efaktur} - onClick={() => downloadTaxInvoice(data)} - > - Download Faktur Pajak - </button> - </div> - ) - - const { - closePopup, - openPopup, - BottomPopup - } = useBottomPopup({ - title: 'Lainnya', - children: childrenPopup - }) - - return ( - <WithAuth> - <Layout> - <AppBar title="Invoice" /> - - <form onSubmit={actionSearch} className="p-4 pb-0 flex gap-x-4"> - <input - type="text" - className="form-input" - placeholder="Cari Transaksi" - ref={searchQueryRef} - defaultValue={q} - /> - <button type="submit" className="border border-gray_r-7 rounded px-3"> - <MagnifyingGlassIcon className="w-5"/> - </button> - </form> - - <div className="p-4 flex flex-col gap-y-5"> - { invoices?.invoice_total === 0 && !isLoading && ( - <Alert type="info" className="text-center"> - Invoice tidak ditemukan - </Alert> - ) } - { invoices?.invoices?.map((invoice, index) => ( - <div className="p-4 shadow border border-gray_r-3 rounded-md" key={index}> - <div className="grid grid-cols-2"> - <Link href={`/my/invoice/${invoice.id}`}> - <span className="text-caption-2 text-gray_r-11">No. Invoice</span> - <h2 className="text-red_r-11 mt-1">{ invoice.name }</h2> - </Link> - <div className="flex gap-x-1 justify-end"> - { invoice.amount_residual > 0 ? ( - <div className="badge-solid-red h-fit ml-auto">Belum Lunas</div> - ) : ( - <div className="badge-solid-green h-fit ml-auto">Lunas</div> - ) } - <EllipsisVerticalIcon className="w-5 h-5" onClick={() => openPopup(invoice)} /> - </div> - </div> - <Link href={`/my/invoice/${invoice.id}`}> - <div className="grid grid-cols-2 text-caption-2 text-gray_r-11 mt-2 font-normal"> - <p> - { invoice.invoice_date } - </p> - <p className="text-right"> - { invoice.payment_term } - </p> - </div> - <hr className="my-3"/> - <div className="grid grid-cols-2"> - <div> - <span className="text-caption-2 text-gray_r-11">No. Purchase Order</span> - <p className="mt-1 font-medium text-gray_r-12">{ invoice.purchase_order_name || '-' }</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">{ currencyFormat(invoice.amount_total) }</p> - </div> - </div> - </Link> - { invoice.efaktur ? ( - <div className="badge-green h-fit mt-3 ml-auto flex items-center gap-x-0.5"> - <CheckIcon className="w-4 stroke-2" /> - Faktur Pajak - </div> - ) : ( - <div className="badge-red h-fit mt-3 ml-auto flex items-center gap-x-0.5"> - <ClockIcon className="w-4 stroke-2" /> - Faktur Pajak - </div> - ) } - </div> - )) } - </div> - - <div className="pb-6 pt-2"> - <Pagination currentPage={page} pageCount={pageCount} url={`/my/invoices${q ? `?q=${q}` : ''}`} /> - </div> - { BottomPopup } - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/my/invoices.jsx b/src/pages/my/invoices.jsx new file mode 100644 index 00000000..12a5ff7e --- /dev/null +++ b/src/pages/my/invoices.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import InvoicesComponent from '@/lib/invoice/components/Invoices' + +export default function Invoices() { + return ( + <IsAuth> + <AppLayout title='Invoice & Faktur Pajak'> + <InvoicesComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/menu.js b/src/pages/my/menu.js deleted file mode 100644 index ae6c2af8..00000000 --- a/src/pages/my/menu.js +++ /dev/null @@ -1,82 +0,0 @@ - -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import Link from "@/components/elements/Link"; -import { useAuth } from "@/core/utils/auth"; -import { - ArrowRightOnRectangleIcon, - ChatBubbleLeftRightIcon, - ChevronRightIcon, - MapIcon, - PaperClipIcon, - PencilSquareIcon, - QuestionMarkCircleIcon, - ReceiptPercentIcon, - UserIcon, - HeartIcon -} from "@heroicons/react/24/outline"; -import WithAuth from "@/components/auth/WithAuth"; - -const Menu = ({ icon, name, url }) => { - return ( - <Link href={url} className="text-gray_r-11 font-normal flex gap-x-2 items-center py-4 border-b border-gray_r-6"> - <span className="flex gap-x-2"> - { icon } - { name } - </span> - <ChevronRightIcon className="w-5 ml-auto"/> - </Link> - ); -}; - -export default function MyMenu() { - const [auth] = useAuth(); - - return ( - <WithAuth> - <Layout> - <AppBar title="Menu Utama" /> - - <div className="p-4 flex gap-x-2 items-center"> - <div className="flex-1 flex gap-x-3 items-center"> - <div className="p-2 bg-gray_r-4 rounded-full h-fit"> - <UserIcon className="w-6" /> - </div> - <div> - <h2>{ auth?.name }</h2> - { auth?.company ? ( - <div className="badge-red font-normal text-xs">Akun Bisnis</div> - ) : ( - <div className="badge-gray font-normal text-xs">Akun Individu</div> - ) } - </div> - </div> - <Link href="/my/profile"> - <PencilSquareIcon className="w-6 text-yellow_r-12"/> - </Link> - </div> - - <div className="px-4 mt-4"> - <p className="font-medium mb-2">Aktivitas Pembelian</p> - <div className="flex flex-col mb-6"> - <Menu icon={<ReceiptPercentIcon className="w-5" />} name="Daftar Transaksi" url="/my/transactions" /> - <Menu icon={<PaperClipIcon className="w-5" />} name="Invoice & Faktur Pajak" url="/my/invoices" /> - <Menu icon={<HeartIcon className="w-5" />} name="Wishlist" url="/my/wishlist" /> - </div> - - <p className="font-medium mb-2">Pusat Bantuan</p> - <div className="flex flex-col mb-6"> - <Menu icon={<ChatBubbleLeftRightIcon className="w-5"/>} name="Layanan Pelanggan" url="/" /> - <Menu icon={<QuestionMarkCircleIcon className="w-5"/>} name="F.A.Q" url="/faqs" /> - </div> - - <p className="font-medium mb-2">Pengaturan Akun</p> - <div className="flex flex-col mb-6"> - <Menu icon={<MapIcon className="w-5" />} name="Daftar Alamat" url="/my/address" /> - <Menu icon={<ArrowRightOnRectangleIcon className="w-5" />} name="Keluar Akun" url="/logout" /> - </div> - </div> - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/my/menu.jsx b/src/pages/my/menu.jsx new file mode 100644 index 00000000..b9fd30ee --- /dev/null +++ b/src/pages/my/menu.jsx @@ -0,0 +1,100 @@ +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import AppLayout from '@/core/components/layouts/AppLayout' +import useAuth from '@/core/hooks/useAuth' +import { deleteAuth } from '@/core/utils/auth' +import IsAuth from '@/lib/auth/components/IsAuth' +import { ChevronRightIcon, UserIcon } from '@heroicons/react/24/solid' +import { useRouter } from 'next/router' + +export default function Menu() { + const auth = useAuth() + const router = useRouter() + + const logout = () => { + deleteAuth() + router.push('/login') + } + + return ( + <IsAuth> + <AppLayout title='Menu Utama'> + <Link + href='/my/profile' + className='p-4 flex items-center' + > + <div className='rounded-full p-3 bg-gray_r-6 text-gray_r-12/80'> + <UserIcon className='w-5' /> + </div> + <div className='ml-4'> + <div className='font-semibold text-gray_r-12'>{auth?.name}</div> + {auth?.company && <div className='badge-solid-red mt-1'>Akun Bisnis</div>} + {!auth?.company && <div className='badge-gray mt-1'>Akun Individu</div>} + </div> + <div className='ml-auto !text-gray_r-12'> + <ChevronRightIcon className='w-6' /> + </div> + </Link> + + <Divider /> + + <div className='flex flex-col gap-y-6 py-6'> + <div> + <MenuHeader>Aktivitas Pembelian</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/my/transactions'>Daftar Transaksi</LinkItem> + <LinkItem href='/my/invoices'>Invoice & Faktur Pajak</LinkItem> + <LinkItem href='/my/wishlist'>Wishlist</LinkItem> + </div> + </div> + + <div> + <MenuHeader>Pusat Bantuan</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/'>Customer Support</LinkItem> + <LinkItem href='/faqs'>F.A.Q</LinkItem> + </div> + </div> + + <div> + <MenuHeader>Pengaturan Akun</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/my/address'>Daftar Alamat</LinkItem> + </div> + + <div + onClick={logout} + className='p-4 mt-2' + > + <button className='w-full btn-red'>Keluar Akun</button> + </div> + </div> + </div> + </AppLayout> + </IsAuth> + ) +} + +const MenuHeader = ({ children, ...props }) => ( + <div + {...props} + className='font-medium px-4 flex' + > + {children} + </div> +) + +const LinkItem = ({ children, ...props }) => ( + <Link + {...props} + className='!text-gray_r-12/70 !font-normal p-4 flex items-center' + > + {children} + <div className='ml-auto !text-gray_r-11'> + <ChevronRightIcon className='w-5' /> + </div> + </Link> +) diff --git a/src/pages/my/profile.js b/src/pages/my/profile.js deleted file mode 100644 index 97891259..00000000 --- a/src/pages/my/profile.js +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { toast } from "react-hot-toast"; -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import WithAuth from "@/components/auth/WithAuth"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { - useAuth, - setAuth as setAuthCookie, - getAuth -} from "@/core/utils/auth"; - -export default function MyProfile() { - const [auth, setAuth] = useAuth(); - const [editMode, setEditMode] = useState(false); - const [password, setPassword] = useState(''); - - const update = async (e) => { - e.preventDefault(); - let dataToUpdate = { - name: auth.name, - phone: auth.phone, - mobile: auth.mobile - }; - if (password) dataToUpdate.password = password; - let update = await apiOdoo('PUT', `/api/v1/user/${auth.id}`, dataToUpdate); - setAuthCookie(update.user); - cancelEdit(); - toast.success('Berhasil mengubah profil', { duration: 1500 }); - }; - - const handleInput = (e) => { - let authToUpdate = auth; - authToUpdate[e.target.name] = e.target.value; - setAuth({ ...authToUpdate }); - }; - - const cancelEdit = () => { - setEditMode(false); - setAuth(getAuth()); - setPassword(''); - } - - return ( - <WithAuth> - <Layout> - <AppBar title="Akun Saya" /> - - <form onSubmit={update} className="w-full px-4"> - { auth && ( - <> - <label className="form-label mt-4 mb-2">Email</label> - <input - type="text" - className="form-input" - placeholder="johndoe@gmail.com" - name="email" - value={auth.email} - onChange={handleInput} - disabled={true} - /> - - <label className="form-label mt-4 mb-2">Nama Lengkap</label> - <input - type="text" - className="form-input" - placeholder="John Doe" - name="name" - value={auth.name} - onChange={handleInput} - disabled={!editMode} - /> - - <label className="form-label mt-4 mb-2">No Telepon</label> - <input - type="tel" - className="form-input" - placeholder="08xxxxxxxx" - name="phone" - value={auth.phone} - onChange={handleInput} - disabled={!editMode} - /> - - <label className="form-label mt-4 mb-2">No Handphone</label> - <input - type="tel" - className="form-input" - placeholder="08xxxxxxxx" - name="mobile" - value={auth.mobile} - onChange={handleInput} - disabled={!editMode} - /> - - <label className="form-label mt-4 mb-2">Kata Sandi</label> - <input - type="password" - className="form-input" - placeholder="••••••••" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={!editMode} - /> - </> - ) } - - { editMode && ( - <div className="flex gap-x-3 mt-6"> - <button - type="button" - className="btn-light flex-1 float-right" - onClick={cancelEdit} - > - Batal - </button> - <button type="submit" className="btn-yellow flex-1 float-right">Simpan</button> - </div> - ) } - - { !editMode && ( - <button - type="button" - className="btn-light float-right mt-6 w-full" - onClick={() => setEditMode(true)} - > - Ubah Profil - </button> - ) } - </form> - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/my/profile.jsx b/src/pages/my/profile.jsx new file mode 100644 index 00000000..72a1ee3c --- /dev/null +++ b/src/pages/my/profile.jsx @@ -0,0 +1,19 @@ +import Divider from '@/core/components/elements/Divider/Divider' +import AppLayout from '@/core/components/layouts/AppLayout' +import useAuth from '@/core/hooks/useAuth' +import CompanyProfile from '@/lib/auth/components/CompanyProfile' +import IsAuth from '@/lib/auth/components/IsAuth' +import PersonalProfile from '@/lib/auth/components/PersonalProfile' + +export default function Profile() { + const auth = useAuth() + return ( + <IsAuth> + <AppLayout title='Akun Saya'> + <PersonalProfile /> + <Divider /> + {auth?.parentId && <CompanyProfile />} + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/transaction/[id].js b/src/pages/my/transaction/[id].js deleted file mode 100644 index fb806aa4..00000000 --- a/src/pages/my/transaction/[id].js +++ /dev/null @@ -1,265 +0,0 @@ -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import LineDivider from "@/components/elements/LineDivider"; -import WithAuth from "@/components/auth/WithAuth"; -import { useCallback, useEffect, useRef, useState } from "react"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useRouter } from "next/router"; -import { useAuth } from "@/core/utils/auth"; -import currencyFormat from "@/core/utils/currencyFormat"; -import DescriptionRow from "@/components/elements/DescriptionRow"; -import { TransactionDetailAddress } from "@/components/transactions/TransactionDetail"; -import { SkeletonList } from "@/components/elements/Skeleton"; -import Link from "@/components/elements/Link"; -import { ChevronRightIcon } from "@heroicons/react/24/outline"; -import Alert from "@/components/elements/Alert"; -import TransactionStatusBadge from "@/components/transactions/TransactionStatusBadge"; -import useConfirmAlert from "@/lib/elements/hooks/useConfirmAlert"; -import { toast } from "react-hot-toast"; -import useBottomPopup from "@/lib/elements/hooks/useBottomPopup"; -import getFileBase64 from "@/core/utils/getFileBase64"; -import VariantGroupCard from "@/components/variants/VariantGroupCard"; - -export default function DetailTransaction() { - const router = useRouter(); - const { id } = router.query; - const [ auth ] = useAuth(); - const [ transaction, setTransaction ] = useState(null); - - const loadTransaction = useCallback(async () => { - if (auth && id) { - const dataTransaction = await apiOdoo('GET', `/api/v1/partner/${auth?.partner_id}/sale_order/${id}`); - setTransaction(dataTransaction); - } - }, [ auth, id ]); - - useEffect(() => { - loadTransaction(); - }, [ loadTransaction ]); - - const submitCancelTransaction = async (data) => { - const isCancelled = await apiOdoo('POST', `/api/v1/partner/${auth.partner_id}/sale_order/${data.id}/cancel`); - if (isCancelled) { - toast.success('Berhasil batalkan transaksi'); - loadTransaction(); - } - } - - const { - openConfirmAlert, - ConfirmAlert - } = useConfirmAlert({ - title: 'Batalkan Transaksi', - caption: 'Apakah anda yakin untuk membatalkan transaksi?', - closeText: 'Tidak', - submitText: 'Iya, batalkan', - onSubmit: submitCancelTransaction - }); - - const UploadPurchaseOrder = () => { - const nameRef = useRef(''); - const fileRef = useRef(''); - - const submitUploadPurchaseOrder = async (e) => { - e.preventDefault(); - const file = fileRef.current.files[0]; - const name = nameRef.current.value; - if (file.size > 5000000) { - toast.error('Maksimal ukuran file adalah 5MB', { - position: 'bottom-center' - }); - return; - } - const parameter = { - name, - file: await getFileBase64(file) - }; - const isUploaded = await apiOdoo('POST', `/api/v1/partner/${auth.partner_id}/sale_order/${transaction.id}/upload_po`, parameter); - if (isUploaded) { - toast.success('Berhasil upload PO'); - loadTransaction(); - closePopup(); - } - }; - - return ( - <form className="flex flex-col gap-y-4" onSubmit={submitUploadPurchaseOrder}> - <div> - <label className="form-label mb-2">Nama PO</label> - <input className="form-input" type="text" ref={nameRef} required /> - </div> - <div> - <label className="form-label mb-2">Dokumen PO</label> - <input className="form-input" type="file" ref={fileRef} required /> - </div> - <button type="submit" className="btn-yellow w-full mt-2">Upload</button> - </form> - ); - } - - const { - closePopup, - BottomPopup, - openPopup - } = useBottomPopup({ - title: 'Upload PO', - children: UploadPurchaseOrder - }); - - const downloadPurchaseOrder = () => { - const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partner_id}/sale_order/${transaction.id}/download_po/${transaction.token}`; - window.open(url, 'download') - }; - - const downloadQuotation = () => { - const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partner_id}/sale_order/${transaction.id}/download/${transaction.token}`; - window.open(url, 'download') - }; - - const checkout = async () => { - if (!transaction.purchase_order_file) { - toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan') - return - } - await apiOdoo('POST', `/api/v1/partner/${auth?.partner_id}/sale_order/${id}/checkout`) - toast.success('Berhasil melanjutkan pesanan') - loadTransaction() - } - - return ( - <WithAuth> - <Layout className="pb-4"> - <AppBar title="Detail Transaksi" /> - - { transaction ? ( - <> - <div className="p-4 flex flex-col gap-y-4"> - <DescriptionRow label="Status Transaksi"> - <div className="flex justify-end"> - <TransactionStatusBadge status={transaction?.status} /> - </div> - </DescriptionRow> - <DescriptionRow label="No Transaksi"> - { transaction?.name } - </DescriptionRow> - <DescriptionRow label="Ketentuan Pembayaran"> - { transaction?.payment_term } - </DescriptionRow> - <DescriptionRow label="Nama Sales"> - { transaction?.sales } - </DescriptionRow> - <DescriptionRow label="Waktu Transaksi"> - { transaction?.date_order } - </DescriptionRow> - </div> - - <LineDivider /> - - <div className="p-4 flex flex-col gap-y-4"> - <DescriptionRow label="Purchase Order"> - { transaction?.purchase_order_name || '-' } - </DescriptionRow> - <div className="flex items-center"> - <p className="text-gray_r-11 leading-none">Dokumen PO</p> - <button - type="button" - className="btn-light py-1.5 px-3 ml-auto" - onClick={transaction?.purchase_order_file ? downloadPurchaseOrder : openPopup} - > - { transaction?.purchase_order_file ? 'Download' : 'Upload' } - </button> - </div> - </div> - - <LineDivider /> - - <p className="h2 p-4">Detail Produk</p> - - <div className="mt-2 p-4 pt-0 flex flex-col gap-y-3"> - <VariantGroupCard - variants={transaction?.products} - buyMore - /> - <div className="flex justify-between mt-3 font-medium"> - <p>Total Belanja</p> - <p>{ currencyFormat(transaction?.amount_total || 0) }</p> - </div> - </div> - - <LineDivider /> - - <TransactionDetailAddress transaction={transaction} /> - - <LineDivider /> - - <div className="p-4"> - <p className="h2">Invoice</p> - <div className="flex flex-col gap-y-3 mt-4"> - { transaction?.invoices?.map((invoice, index) => ( - <Link href={`/my/invoice/${invoice.id}`} key={index}> - <div className="shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between"> - <div> - <p className="mb-2">{ invoice?.name }</p> - <div className="flex items-center gap-x-1"> - { invoice.amount_residual > 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.amount_total) } - </p> - </div> - </div> - <ChevronRightIcon className="w-5 stroke-2" /> - </div> - </Link> - )) } - { transaction?.invoices?.length === 0 && ( - <Alert type='info' className='text-center'> - Belum ada Invoice - </Alert> - ) } - </div> - </div> - - <LineDivider /> - - <div className="px-4"> - { transaction?.status == 'draft' && ( - <button - className="btn-yellow w-full mt-4" - onClick={checkout} - > - Lanjutkan Transaksi - </button> - ) } - <button - className="btn-light w-full mt-4" - disabled={transaction?.status != 'draft'} - onClick={downloadQuotation} - > - Download Quotation - </button> - { transaction?.status != 'draft' && ( - <button - className="btn-light w-full mt-4" - disabled={transaction?.status != 'waiting'} - onClick={() => openConfirmAlert(transaction)} - > - Batalkan Transaksi - </button> - ) } - </div> - </> - ) : ( - <div className="p-4 py-6"> - <SkeletonList number={12} /> - </div> - ) } - { ConfirmAlert } - { BottomPopup } - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/my/transaction/[id].jsx b/src/pages/my/transaction/[id].jsx new file mode 100644 index 00000000..5167748c --- /dev/null +++ b/src/pages/my/transaction/[id].jsx @@ -0,0 +1,16 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import TransactionComponent from '@/lib/transaction/components/Transaction' +import { useRouter } from 'next/router' + +export default function Transaction() { + const router = useRouter() + + return ( + <IsAuth> + <AppLayout title='Transaksi'> + <TransactionComponent id={router.query.id} /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/transactions.js b/src/pages/my/transactions.js deleted file mode 100644 index 8be43af7..00000000 --- a/src/pages/my/transactions.js +++ /dev/null @@ -1,198 +0,0 @@ -import { useRouter } from "next/router"; -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import WithAuth from "@/components/auth/WithAuth"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useAuth } from "@/core/utils/auth"; -import apiOdoo from "@/core/utils/apiOdoo"; -import currencyFormat from "@/core/utils/currencyFormat"; -import { EllipsisVerticalIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; -import Link from "@/components/elements/Link"; -import Pagination from "@/components/elements/Pagination"; -import Alert from "@/components/elements/Alert"; -import TransactionStatusBadge from "@/components/transactions/TransactionStatusBadge"; -import { toast } from "react-hot-toast"; -import useConfirmAlert from "@/lib/elements/hooks/useConfirmAlert"; -import useBottomPopup from "@/lib/elements/hooks/useBottomPopup"; - -export default function Transactions() { - const [ auth ] = useAuth(); - const router = useRouter(); - const { - q, - page = 1 - } = router.query; - - const [ transactions, setTransactions ] = useState([]); - - const [ pageCount, setPageCount ] = useState(0); - const [ isLoading, setIsLoading ] = useState(true); - - const searchQueryRef = useRef(); - const loadTransactions = useCallback(async () => { - if (auth) { - const limit = 10; - let offset = (page - 1) * 10; - let queryParams = [`limit=${limit}`, `offset=${offset}`]; - if (q) queryParams.push(`name=${q}`); - queryParams = queryParams.join('&'); - queryParams = queryParams ? '?' + queryParams : ''; - - const dataTransactions = await apiOdoo('GET', `/api/v1/partner/${auth.partner_id}/sale_order${queryParams}`); - setTransactions(dataTransactions); - setPageCount(Math.ceil(dataTransactions?.sale_order_total / limit)); - setIsLoading(false); - }; - }, [ auth, q, page ]); - - useEffect(() => { - loadTransactions(); - }, [ loadTransactions ]); - - const actionSearch = (e) => { - e.preventDefault(); - let queryParams = []; - if (searchQueryRef.current.value) queryParams.push(`q=${searchQueryRef.current.value}`); - queryParams = queryParams.join('&'); - queryParams = queryParams ? `?${queryParams}` : ''; - router.push(`/my/transactions${queryParams}`); - }; - - const downloadPurchaseOrder = (data) => { - const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partner_id}/sale_order/${data.id}/download_po/${data.token}`; - window.open(url, 'download'); - closePopup(); - }; - - const downloadQuotation = (data) => { - const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partner_id}/sale_order/${data.id}/download/${data.token}`; - window.open(url, 'download'); - closePopup(); - }; - - const childrenPopup = (data) => ( - <div className="flex flex-col gap-y-6"> - <button - className="text-left disabled:opacity-60" - disabled={!data?.purchase_order_file} - onClick={() => downloadPurchaseOrder(data)} - > - Download PO - </button> - <button - className="text-left disabled:opacity-60" - disabled={data?.status != 'draft'} - onClick={() => downloadQuotation(data)} - > - Download Quotation - </button> - <button - className="text-left disabled:opacity-60" - disabled={ data?.status != 'waiting' } - onClick={() => {openConfirmAlert(data); closePopup()}} - > - Batalkan Transaksi - </button> - </div> - ); - - const { - closePopup, - openPopup, - BottomPopup - } = useBottomPopup({ - title: 'Lainnya', - children: childrenPopup - }); - - const submitCancelTransaction = async (data) => { - const isCancelled = await apiOdoo('POST', `/api/v1/partner/${auth.partner_id}/sale_order/${data.id}/cancel`); - if (isCancelled) { - toast.success('Berhasil batalkan transaksi'); - loadTransactions(); - } - } - - const { - openConfirmAlert, - ConfirmAlert - } = useConfirmAlert({ - title: 'Batalkan Transaksi', - caption: 'Apakah anda yakin untuk membatalkan transaksi?', - closeText: 'Tidak', - submitText: 'Ya, Batalkan', - onSubmit: submitCancelTransaction - }); - - return ( - <WithAuth> - <Layout> - <AppBar title="Transaksi" /> - - <form onSubmit={actionSearch} className="p-4 pb-0 flex gap-x-4"> - <input - type="text" - className="form-input" - placeholder="Cari Transaksi" - ref={searchQueryRef} - defaultValue={q} - /> - <button type="submit" className="border border-gray_r-7 rounded px-3"> - <MagnifyingGlassIcon className="w-5"/> - </button> - </form> - - <div className="p-4 flex flex-col gap-y-5"> - { transactions?.sale_order_total === 0 && !isLoading && ( - <Alert type="info" className="text-center"> - Transaksi tidak ditemukan - </Alert> - ) } - { transactions?.sale_orders?.map((transaction, index) => ( - <div className="p-4 shadow border border-gray_r-3 rounded-md" key={index}> - <div className="grid grid-cols-2"> - <Link href={`/my/transaction/${transaction.id}`}> - <span className="text-caption-2 text-gray_r-11">No. Transaksi</span> - <h2 className="text-red_r-11 mt-1">{ transaction.name }</h2> - </Link> - <div className="flex gap-x-1 justify-end"> - <TransactionStatusBadge status={transaction.status} /> - <EllipsisVerticalIcon className="w-5 h-5" onClick={() => openPopup(transaction)} /> - </div> - </div> - <Link href={`/my/transaction/${transaction.id}`}> - <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">{ transaction.purchase_order_name || '-' }</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">{ transaction.invoice_count } 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">{ transaction.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(transaction.amount_total) }</p> - </div> - </div> - </Link> - </div> - )) } - </div> - - <div className="pb-6 pt-2"> - <Pagination currentPage={page} pageCount={pageCount} url={`/my/transactions${q ? `?q=${q}` : ''}`} /> - </div> - - { ConfirmAlert } - { BottomPopup } - </Layout> - </WithAuth> - ); -};
\ No newline at end of file diff --git a/src/pages/my/transactions.jsx b/src/pages/my/transactions.jsx new file mode 100644 index 00000000..30b9be07 --- /dev/null +++ b/src/pages/my/transactions.jsx @@ -0,0 +1,15 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import dynamic from 'next/dynamic' + +const TransactionsComponent = dynamic(() => import('@/lib/transaction/components/Transactions')) + +export default function Transactions() { + return ( + <IsAuth> + <AppLayout title='Transaksi'> + <TransactionsComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/wishlist.js b/src/pages/my/wishlist.js deleted file mode 100644 index 3d479802..00000000 --- a/src/pages/my/wishlist.js +++ /dev/null @@ -1,60 +0,0 @@ -import WithAuth from "@/components/auth/WithAuth"; -import Alert from "@/components/elements/Alert"; -import Pagination from "@/components/elements/Pagination"; -import Spinner from "@/components/elements/Spinner"; -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import ProductCard from "@/components/products/ProductCard"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useAuth } from "@/core/utils/auth"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; - -export default function Wishlist() { - const [ auth ] = useAuth(); - const router = useRouter(); - const { page = 1 } = router.query; - const [ wishlists, setWishlists ] = useState(null); - const [ pageCount, setPageCount ] = useState(0); - - useEffect(() => { - const loadWishlist = async () => { - const limit = 10; - const offset = (page - 1) * limit; - if (auth) { - const dataWishlist = await apiOdoo('GET', `/api/v1/user/${auth.id}/wishlist?limit=${limit}&offset=${offset}`); - setWishlists(dataWishlist); - setPageCount(Math.ceil(dataWishlist.product_total / limit)); - } - } - loadWishlist(); - }, [ auth, page ]); - - return ( - <WithAuth> - <Layout> - <AppBar title='Wishlist' /> - - <div className="px-4 py-6"> - { !wishlists && ( - <Spinner className="w-6 h-6 text-gray-600 fill-gray-900 mx-auto" /> - ) } - { wishlists?.products?.length == 0 && ( - <Alert type='info' className='text-center'> - Wishlist anda masih kosong - </Alert> - ) } - <div className="grid grid-cols-2 gap-3"> - {wishlists?.products.map((product) => ( - <ProductCard key={product.id} data={product} /> - ))} - </div> - - <div className="mt-6"> - <Pagination currentPage={page} pageCount={pageCount} url={`/my/wishlist`} /> - </div> - </div> - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/my/wishlist.jsx b/src/pages/my/wishlist.jsx new file mode 100644 index 00000000..196adf50 --- /dev/null +++ b/src/pages/my/wishlist.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import Wishlists from '@/lib/wishlist/components/Wishlists' + +export default function Wishlist() { + return ( + <IsAuth> + <AppLayout title='Wishlist'> + <Wishlists /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/register.js b/src/pages/register.js deleted file mode 100644 index 39bd137f..00000000 --- a/src/pages/register.js +++ /dev/null @@ -1,100 +0,0 @@ -import axios from "axios"; -import Head from "next/head"; -import Image from "next/image"; -import Link from "@/components/elements/Link"; -import { useEffect, useState } from "react"; -import Alert from "@/components/elements/Alert"; -import Layout from "@/components/layouts/Layout"; -import Spinner from "@/components/elements/Spinner"; -import Logo from "@/images/logo.png"; - -export default function Login() { - const [email, setEmail] = useState(''); - const [name, setName] = useState(''); - const [password, setPassword] = useState(''); - const [isInputFulfilled, setIsInputFulfilled] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [alert, setAlert] = useState(); - - useEffect(() => { - setIsInputFulfilled(email && name && password); - }, [email, name, password]); - - const register = async (e) => { - e.preventDefault(); - setIsLoading(true); - let register = await axios.post(`${process.env.SELF_HOST}/api/register`, {email, name, password}); - if (register.data.register) { - await axios.post(`${process.env.SELF_HOST}/api/activation-request`, {email}); - setAlert({ - component: <>Berhasil mendaftarkan akun anda, cek email untuk melakukan aktivasi akun</>, - type: 'success' - }); - setEmail(''); - setName(''); - setPassword(''); - } else { - switch (register.data.reason) { - case 'EMAIL_USED': - setAlert({ - component: <>Email telah digunakan</>, - type: 'info' - }); - break; - } - } - setIsLoading(false); - } - - return ( - <> - <Head> - <title>Daftar - Indoteknik</title> - </Head> - <Layout className="max-w-lg mx-auto flex flex-col items-center px-4 pb-8"> - <Link href="/" className="mt-16"> - <Image src={Logo} alt="Logo Indoteknik" width={165} height={42} /> - </Link> - <h1 className="text-2xl mt-4 text-center">Mudahkan Pembelian dengan Indoteknik</h1> - <h2 className="text-gray_r-11 font-normal mt-2 mb-4">Daftar untuk melanjutkan belanja</h2> - {alert ? ( - <Alert className="text-center" type={alert.type}>{alert.component}</Alert> - ) : ''} - <form onSubmit={register} className="w-full"> - <label className="form-label mt-4 mb-2">Alamat Email</label> - <input - type="email" - className="form-input bg-gray_r-2" - placeholder="johndoe@gmail.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - <label className="form-label mt-4 mb-2">Nama Lengkap</label> - <input - type="text" - className="form-input bg-gray_r-2" - placeholder="John Doe" - value={name} - onChange={(e) => setName(e.target.value)} - /> - <label className="form-label mt-4 mb-2">Kata Sandi</label> - <input - type="password" - className="form-input bg-gray_r-2" - placeholder="••••••••" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <button type="submit" disabled={!isInputFulfilled} className="btn-yellow font-semibold mt-4 w-full"> - {isLoading ? ( - <div className="flex justify-center items-center gap-x-2"> - <Spinner className="w-4 h-4 text-gray-600 fill-gray-900" /> <span>Loading...</span> - </div> - ) : 'Daftar'} - </button> - </form> - <p className="text-gray-700 mt-4">Sudah punya akun Indoteknik? <Link href="/login">Masuk</Link></p> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/register.jsx b/src/pages/register.jsx new file mode 100644 index 00000000..0ca1e81e --- /dev/null +++ b/src/pages/register.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import RegisterComponent from '@/lib/auth/components/Register' + +export default function Register() { + return ( + <> + <RegisterComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/shop/brands/[slug].js b/src/pages/shop/brands/[slug].js deleted file mode 100644 index a387e55d..00000000 --- a/src/pages/shop/brands/[slug].js +++ /dev/null @@ -1,178 +0,0 @@ -import axios from "axios"; -import { useEffect, useState } from "react"; -import Filter from "@/components/elements/Filter"; -import Footer from "@/components/layouts/Footer"; -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; -import Pagination from "@/components/elements/Pagination"; -import ProductCard from "@/components/products/ProductCard"; -import { getIdFromSlug, getNameFromSlug } from "@/core/utils/slug"; -import FilterIcon from "@/icons/filter.svg"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { Swiper, SwiperSlide } from "swiper/react"; -import "swiper/css"; -import "swiper/css/pagination"; -import "swiper/css/autoplay"; -import { Pagination as SwiperPagination } from "swiper"; -import Image from "@/components/elements/Image"; -import LineDivider from "@/components/elements/LineDivider"; - -export async function getServerSideProps(context) { - const { - slug, - page = 1, - category = '', - price_from = '', - price_to = '', - order_by = '', - } = context.query; - - let urlParameter = [ - 'q=*', - `page=${page}`, - `brand=${getNameFromSlug(slug)}`, - `category=${category}`, - `price_from=${price_from}`, - `price_to=${price_to}`, - `order_by=${order_by}` - ].join('&'); - let searchResults = await axios(`${process.env.SELF_HOST}/api/shop/search?${urlParameter}`); - searchResults = searchResults.data; - - const manufacture = await apiOdoo('GET', `/api/v1/manufacture/${getIdFromSlug(slug)}`); - - return { - props: { - searchResults, - page, - slug, - category, - price_from, - price_to, - order_by, - manufacture - } - }; -} - -export default function BrandDetail({ - searchResults, - page, - slug, - category, - price_from, - price_to, - order_by, - manufacture -}) { - const pageCount = Math.ceil(searchResults.response.numFound / searchResults.responseHeader.params.rows); - const productStart = searchResults.responseHeader.params.start; - const productRows = searchResults.responseHeader.params.rows; - const productFound = searchResults.response.numFound; - - const [activeFilter, setActiveFilter] = useState(false); - const [filterCount, setFilterCount] = useState(0); - - const route = () => { - let route = `/shop/brands/${slug}`; - if (category) route += `&category=${category}`; - if (price_from) route += `&price_from=${price_from}`; - if (price_to) route += `&price_to=${price_to}`; - if (order_by) route += `&order_by=${order_by}`; - return route; - } - - useEffect(() => { - let calculateFilterCount = 0; - if (category) calculateFilterCount++; - if (price_from || price_to) calculateFilterCount++; - if (order_by) calculateFilterCount++; - setFilterCount(calculateFilterCount); - }, [category, price_from, price_to, order_by]); - - return ( - <> - <Header title={`Distributor ${getNameFromSlug(slug)} Indonesia Harga Official - Indoteknik`} /> - <Filter - defaultRoute={`/shop/brands/${slug}`} - isActive={activeFilter} - closeFilter={() => setActiveFilter(false)} - defaultPriceFrom={price_from} - defaultPriceTo={price_to} - defaultBrand='' - defaultCategory={category} - defaultOrderBy={order_by} - searchResults={searchResults} - disableFilter={['brand']} - /> - <Layout> - <Swiper slidesPerView={1} pagination={{dynamicBullets: true}} modules={[SwiperPagination]}> - { - manufacture.banners?.map((banner, index) => ( - <SwiperSlide key={index}> - <Image - src={banner} - alt={`Banner ${manufacture.name}`} - className="w-full h-auto border-b border-gray_r-6" - /> - </SwiperSlide> - )) - } - </Swiper> - <div className="p-4 grid grid-cols-2"> - <div> - <p className="text-caption-2 text-gray_r-11 mb-2">Produk dari brand:</p> - { manufacture.logo ? ( - <div className="w-8/12"> - <Image src={manufacture?.logo} alt={manufacture.name} className="border border-gray_r-6 rounded p-3" /> - </div> - ) : ( - <p className="badge-solid-red text-caption-1">{ manufacture.name }</p> - ) } - </div> - <div className="text-right"> - <p className="text-caption-2 text-gray_r-11 mb-2">Jumlah Produk:</p> - <p>{ searchResults.response.numFound }</p> - </div> - </div> - - <LineDivider /> - - <div className="p-4"> - <h1 className="mb-2">Produk</h1> - <div className="text-caption-1 mb-4"> - {productFound > 0 ? ( - <> - Menampilkan - {pageCount > 1 ? ( - <> - {productStart + 1}-{ - (productStart + productRows) > productFound ? productFound : productStart + productRows - } - dari - </> - ) : ''} - {searchResults.response.numFound} - produk untuk brand <span className="font-semibold">{getNameFromSlug(slug)}</span> - </> - ) : 'Mungkin yang anda cari'} - </div> - <button className="btn-light py-2 flex items-center gap-x-2 mb-4" onClick={() => setActiveFilter(true)}> - <FilterIcon className="w-4 h-4" /> <span>Filter {filterCount > 0 ? `(${filterCount})` : ''}</span> - </button> - <div className="grid grid-cols-2 gap-3"> - {searchResults.response.products.map((product) => ( - <ProductCard key={product.id} data={product} /> - ))} - </div> - - <div className="mt-4"> - <Pagination pageCount={pageCount} currentPage={parseInt(page)} url={route()} /> - </div> - </div> - - <Footer /> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/brands/[slug].jsx b/src/pages/shop/brands/[slug].jsx new file mode 100644 index 00000000..33f81fa2 --- /dev/null +++ b/src/pages/shop/brands/[slug].jsx @@ -0,0 +1,25 @@ +import dynamic from 'next/dynamic' +import { getIdFromSlug, getNameFromSlug } from '@/core/utils/slug' +import { useRouter } from 'next/router' +import _ from 'lodash' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const ProductSearch = dynamic(() => import('@/lib/product/components/ProductSearch')) +const Brand = dynamic(() => import('@/lib/brand/components/Brand')) + +export default function BrandDetail() { + const router = useRouter() + const { slug = '' } = router.query + return ( + <BasicLayout> + <Brand id={getIdFromSlug(slug)} /> + {!_.isEmpty(router.query) && ( + <ProductSearch + query={_.omit(router.query, 'slug')} + prefixUrl={`/shop/brands/${slug}`} + defaultBrand={getNameFromSlug(slug)} + /> + )} + </BasicLayout> + ) +} diff --git a/src/pages/shop/brands/index.js b/src/pages/shop/brands/index.js deleted file mode 100644 index bfdcd403..00000000 --- a/src/pages/shop/brands/index.js +++ /dev/null @@ -1,79 +0,0 @@ -import Header from "@/components/layouts/Header"; -import apiOdoo from "@/core/utils/apiOdoo"; -import InfiniteScroll from "react-infinite-scroll-component"; -import { useCallback, useEffect, useState } from "react"; -import Spinner from "@/components/elements/Spinner"; -import Layout from "@/components/layouts/Layout"; -import ManufactureCard from "@/components/manufactures/ManufactureCard"; -import Footer from "@/components/layouts/Footer"; - -export async function getServerSideProps() { - let initialManufactures = await apiOdoo('GET', '/api/v1/manufacture?limit=31'); - return {props: {initialManufactures}}; -} - -export default function Brands({ initialManufactures }) { - const [manufactures, setManufactures] = useState(initialManufactures.manufactures); - const [hasMoreManufacture, setHasMoreManufacture] = useState(true); - const [manufactureStartwith, setManufactureStartWith] = useState(''); - - const alpha = Array.from(Array(26)).map((e, i) => i + 65); - const alphabets = alpha.map((x) => String.fromCharCode(x)); - - const getMoreManufactures = useCallback(async () => { - const name = manufactureStartwith != '' ? `${manufactureStartwith}%` : ''; - const result = await apiOdoo('GET', `/api/v1/manufacture?limit=30&offset=${manufactures.length}&name=${name}`); - setHasMoreManufacture(manufactures.length + 30 < result.manufacture_total) - setManufactures((manufactures) => [...manufactures, ...result.manufactures]); - }, [ manufactureStartwith ]); - - const filterManufactureStartWith = (character) => { - setManufactures([]); - if (manufactureStartwith == character) { - setManufactureStartWith(''); - } else { - setManufactureStartWith(character); - } - }; - - useEffect(() => { - getMoreManufactures(); - }, [ getMoreManufactures ]); - - return ( - <> - <Header title='Semua Brand di Indoteknik' /> - <Layout> - <div className="p-4"> - <h1>Semua Brand di Indoteknik</h1> - <div className="flex overflow-x-auto gap-x-2 py-2"> - {alphabets.map((alphabet, index) => ( - <button key={index} className={"p-2 py-1 border bg-white border-gray_r-6 rounded w-10 flex-shrink-0" + (manufactureStartwith == alphabet ? ' !bg-yellow_r-9 border-yellow_r-9 ' : '')} onClick={() => filterManufactureStartWith(alphabet)}> - {alphabet} - </button> - ))} - </div> - <InfiniteScroll - dataLength={manufactures.length} - next={getMoreManufactures} - hasMore={hasMoreManufacture} - className="grid grid-cols-4 gap-4 mt-6 !overflow-x-hidden" - loader={ - <div className="flex justify-center items-center border border-gray-300 p-2 rounded h-14"> - <Spinner className="w-6 h-6 text-gray-600 fill-gray-900"/> - </div> - } - > - {manufactures?.map((manufacture, index) => ( - manufacture.name ? ( - <ManufactureCard data={manufacture} key={index} /> - ) : '' - ))} - </InfiniteScroll> - </div> - - <Footer /> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/brands/index.jsx b/src/pages/shop/brands/index.jsx new file mode 100644 index 00000000..5c01fd19 --- /dev/null +++ b/src/pages/shop/brands/index.jsx @@ -0,0 +1,10 @@ +import BasicLayout from '@/core/components/layouts/BasicLayout' +import BrandsComponent from '@/lib/brand/components/Brands' + +export default function Brands() { + return ( + <BasicLayout> + <BrandsComponent /> + </BasicLayout> + ) +} diff --git a/src/pages/shop/cart.js b/src/pages/shop/cart.js deleted file mode 100644 index 1178781b..00000000 --- a/src/pages/shop/cart.js +++ /dev/null @@ -1,282 +0,0 @@ -import { useEffect, useState } from "react"; -import { toast } from "react-hot-toast"; -import { - TrashIcon, - PlusIcon, - MinusIcon, - ExclamationCircleIcon, -} from "@heroicons/react/24/solid"; -import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; -import { useRouter } from "next/router"; - -// Helpers -import { - createOrUpdateItemCart, - deleteItemCart, - getCart -} from "@/core/utils/cart"; -import { createSlug } from "@/core/utils/slug"; -import apiOdoo from "@/core/utils/apiOdoo"; -import currencyFormat from "@/core/utils/currencyFormat"; - -// Components -import Image from "@/components/elements/Image"; -import Layout from "@/components/layouts/Layout"; -import Link from "@/components/elements/Link"; -import Alert from "@/components/elements/Alert"; -import Spinner from "@/components/elements/Spinner"; -import AppBar from "@/components/layouts/AppBar"; -import ProgressBar from "@/components/elements/ProgressBar"; -import LineDivider from "@/components/elements/LineDivider"; -import useConfirmAlert from "@/lib/elements/hooks/useConfirmAlert"; - -export default function Cart() { - const router = useRouter(); - const [isLoadingProducts, setIsLoadingProducts] = useState(true); - const [products, setProducts] = useState([]); - const [totalPriceBeforeTax, setTotalPriceBeforeTax] = useState(0); - const [totalTaxAmount, setTotalTaxAmount] = useState(0); - const [totalDiscountAmount, setTotalDiscountAmount] = useState(0); - - useEffect(() => { - const getProducts = async () => { - let cart = getCart(); - let productIds = Object.keys(cart); - if (productIds.length > 0) { - productIds = productIds.join(','); - let dataProducts = await apiOdoo('GET', `/api/v1/product_variant/${productIds}`); - dataProducts = dataProducts.map((product) => ({ - ...product, - quantity: cart[product.id].quantity, - selected: cart[product.id].selected, - })); - setProducts(dataProducts); - } - setIsLoadingProducts(false); - } - getProducts(); - }, []); - - useEffect(() => { - for (const product of products) { - if (product.quantity != '') createOrUpdateItemCart(product.id, product.quantity, product.selected); - } - const productsSelected = products.filter((product) => product.selected == true); - let calculateTotalPriceBeforeTax = 0; - let calculateTotalTaxAmount = 0; - let calculateTotalDiscountAmount = 0; - productsSelected.forEach(product => { - let priceBeforeTax = product.price.price / 1.11; - calculateTotalPriceBeforeTax += priceBeforeTax * product.quantity; - calculateTotalTaxAmount += (product.price.price - priceBeforeTax) * product.quantity; - calculateTotalDiscountAmount += (product.price.price - product.price.price_discount) * product.quantity; - }); - setTotalPriceBeforeTax(calculateTotalPriceBeforeTax); - setTotalTaxAmount(calculateTotalTaxAmount); - setTotalDiscountAmount(calculateTotalDiscountAmount); - }, [products]); - - const getProductsSelected = () => { - return products.filter((product) => product.selected == true); - } - - const updateCart = (productId, quantity) => { - let productIndexToUpdate = products.findIndex((product) => product.id == productId); - let productsToUpdate = products; - productsToUpdate[productIndexToUpdate].quantity = quantity; - setProducts([...productsToUpdate]); - }; - - const blurQuantity = (productId, quantity) => { - quantity = quantity == ('' || 0) ? 1 : parseInt(quantity); - if (typeof quantity === 'number') { - quantity = parseInt(quantity); - quantity = Math.floor(quantity); - } - updateCart(productId, quantity); - }; - - const updateQuantity = (productId, quantity) => { - quantity = quantity == '' ? '' : parseInt(quantity); - updateCart(productId, quantity); - }; - - const plusQuantity = (productId) => { - let productIndexToUpdate = products.findIndex((product) => product.id == productId); - let quantity = products[productIndexToUpdate].quantity + 1; - updateCart(productId, quantity); - } - - const minusQuantity = (productId) => { - let productIndexToUpdate = products.findIndex((product) => product.id == productId); - let quantity = products[productIndexToUpdate].quantity - 1; - updateCart(productId, quantity); - } - - const toggleProductSelected = (productId) => { - let productIndexToUpdate = products.findIndex((product) => product.id == productId); - let productsToUpdate = products; - productsToUpdate[productIndexToUpdate].selected = !productsToUpdate[productIndexToUpdate].selected; - setProducts([...productsToUpdate]); - } - - const deleteItem = (productId) => { - let productIndexToUpdate = products.findIndex((product) => product.id == productId); - let productsToUpdate = products; - productsToUpdate.splice(productIndexToUpdate, 1); - setProducts([...productsToUpdate]); - deleteItemCart(productId); - toast.success('Berhasil menghapus 1 barang dari keranjang', { duration: 1500 }); - } - - const { - openConfirmAlert, - ConfirmAlert - } = useConfirmAlert({ - title: 'Hapus barang dari keranjang', - caption:'Apakah anda yakin menghapus barang dari keranjang?', - closeText: 'Batal', - submitText: 'Hapus', - onSubmit: deleteItem - }) - - return ( - <> - { ConfirmAlert } - - <Layout> - <AppBar title="Keranjang Saya" /> - - {isLoadingProducts && ( - <div className="flex justify-center items-center gap-x-3 mt-14"> - <Spinner className="w-10 text-gray_r-8 fill-gray_r-12" /> - </div> - ) } - - { !isLoadingProducts && products.length == 0 && ( - <div className="text-center mt-14"> - <ExclamationTriangleIcon className="w-12 mx-auto"/> - <p className="mt-2 h2">Keranjang belanja anda masih kosong.</p> - <Link href="/" className="btn-yellow text-gray_r-12 mx-auto mt-4">Mulai Belanja</Link> - </div> - ) } - - { !isLoadingProducts && products.length > 0 && ( - <> - <ProgressBar - current={1} - labels={['Keranjang', 'Pembayaran', 'Selesai']} - /> - - <LineDivider /> - - <div className="p-4"> - <Alert type="warning" className="text-caption-2 flex gap-x-3 items-center"> - <div> - <ExclamationCircleIcon className="w-8 text-yellow_r-11"/> - </div> - <span>Mohon dicek kembali & pastikan pesanan kamu sudah sesuai dengan yang kamu butuhkan. Atau bisa hubungi kami.</span> - </Alert> - </div> - - <LineDivider /> - - <div className="p-4 flex flex-col gap-y-6"> - <div className="flex justify-between items-center"> - <h2>Daftar Produk Belanja</h2> - <Link href="/" className="text-caption-1">Cari Produk Lain</Link> - </div> - {products.map((product, index) => ( - <div className="flex gap-x-3" key={index}> - <div className="w-4/12 flex items-center gap-x-2" onClick={() => toggleProductSelected(product.id)}> - <button - className={'p-2 rounded border-2 ' + (product.selected ? 'border-yellow_r-9 bg-yellow_r-9' : 'border-gray_r-12')} - ></button> - <Image - src={product.parent.image} - alt={product.parent.name} - className="object-contain object-center border border-gray_r-6 h-32 w-full rounded-md" - /> - </div> - <div className="w-8/12 flex flex-col"> - <Link href={'/shop/product/' + createSlug(product.parent.name, product.parent.id)} className="product-card__title wrap-line-ellipsis-2"> - {product.parent.name} - </Link> - <p className="text-caption-2 text-gray_r-11 mt-1"> - {product.code || '-'} - {product.attributes.length > 0 ? ` | ${product.attributes.join(', ')}` : ''} - </p> - <div className="flex flex-wrap gap-x-1 items-center mb-2 mt-auto"> - {product.price.discount_percentage > 0 && ( - <> - <p className="text-caption-2 text-gray_r-11 line-through">{currencyFormat(product.price.price)}</p> - <span className="badge-red">{product.price.discount_percentage}%</span> - </> - )} - <p className="text-caption-2 text-gray_r-12">{currencyFormat(product.price.price_discount)}</p> - </div> - <div className="flex items-center"> - <p className="mr-auto text-caption-2 text-gray_r-12 font-bold">{currencyFormat(product.quantity * product.price.price_discount)}</p> - <div className="flex gap-x-2 items-center"> - <button - className="btn-red p-2 rounded" - onClick={() => openConfirmAlert(product.id)} - > - <TrashIcon className="text-red_r-11 w-3"/> - </button> - <button - className="btn-light p-2 rounded" - disabled={product.quantity == 1} - onClick={() => minusQuantity(product.id)} - > - <MinusIcon className={'text-gray_r-12 w-3' + (product.quantity == 1 ? ' text-gray_r-11' : '')}/> - </button> - <input - type="number" - className="bg-transparent border-none w-6 text-center outline-none" - onBlur={(e) => blurQuantity(product.id, e.target.value)} - onChange={(e) => updateQuantity(product.id, e.target.value)} - value={product.quantity} - /> - <button className="btn-light p-2 rounded" onClick={() => plusQuantity(product.id)}> - <PlusIcon className="text-gray_r-12 w-3"/> - </button> - </div> - </div> - </div> - </div> - ))} - </div> - - <div className="p-4 bg-gray_r-1 sticky bottom-0 border-t-4 border-gray_r-4"> - <div className="flex"> - <p>Total</p> - <p className="text-gray_r-11 ml-1">{getProductsSelected().length > 0 && ( - <>({ getProductsSelected().length } Barang)</> - )}</p> - <p className="font-semibold text-red_r-11 ml-auto">{currencyFormat(totalPriceBeforeTax + totalTaxAmount - totalDiscountAmount)}</p> - </div> - - <div className="flex gap-x-3 mt-4"> - <button - className="flex-1 btn-light" - disabled={getProductsSelected().length == 0} - onClick={() => router.push('/shop/quotation')} - > - Quotation {getProductsSelected().length > 0 && `(${getProductsSelected().length})`} - </button> - <button - className="flex-1 btn-yellow" - disabled={getProductsSelected().length == 0} - onClick={() => router.push('/shop/checkout')} - > - Checkout {getProductsSelected().length > 0 && `(${getProductsSelected().length})`} - </button> - </div> - </div> - </> - ) } - </Layout> - </> - ); -}
\ No newline at end of file diff --git a/src/pages/shop/cart.jsx b/src/pages/shop/cart.jsx new file mode 100644 index 00000000..97f98843 --- /dev/null +++ b/src/pages/shop/cart.jsx @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic' + +const AppLayout = dynamic(() => import('@/core/components/layouts/AppLayout')) +const CartComponent = dynamic(() => import('@/lib/cart/components/Cart')) + +export default function Cart() { + return ( + <AppLayout title='Keranjang'> + <CartComponent /> + </AppLayout> + ) +} diff --git a/src/pages/shop/checkout/finish.js b/src/pages/shop/checkout/finish.js deleted file mode 100644 index df284f8a..00000000 --- a/src/pages/shop/checkout/finish.js +++ /dev/null @@ -1,47 +0,0 @@ -import WithAuth from "@/components/auth/WithAuth"; -import Link from "@/components/elements/Link"; -import AppBar from "@/components/layouts/AppBar"; -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useAuth } from "@/core/utils/auth"; -import { EnvelopeIcon } from "@heroicons/react/24/outline"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; - -export default function FinishCheckout() { - const router = useRouter(); - const { id } = router.query; - const [ auth ] = useAuth(); - const [ transaction, setTransactions ] = useState(null); - - useEffect(() => { - const loadTransaction = async () => { - if (auth && id) { - const dataTransaction = await apiOdoo('GET', `/api/v1/partner/${auth.partner_id}/sale_order/${id}`); - setTransactions(dataTransaction); - } - }; - loadTransaction(); - }); - - return ( - <WithAuth> - <Layout> - <AppBar title="Pembelian Berhasil" /> - - <div className="m-4 rounded-xl bg-yellow_r-4 text-center border border-yellow_r-7"> - <div className="px-4 py-6 text-yellow_r-12"> - <p className="h2 mb-2">Terima Kasih atas Pembelian Anda</p> - <p className="text-yellow_r-11 mb-4 leading-6">Rincian belanja sudah kami kirimkan ke email anda. Mohon dicek kembali. jika tidak menerima email, anda dapat menghubungi kami disini.</p> - <p className="mb-2 font-medium">{ transaction?.name }</p> - <p className="text-caption-2 text-yellow_r-11">No. Transaksi</p> - </div> - <Link href={transaction?.id ? `/my/transaction/${transaction.id}` : '/'} className="bg-yellow_r-6 text-yellow_r-12 rounded-b-xl py-4 block"> - Lihat detail pembelian Anda disini - </Link> - </div> - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/shop/checkout/finish.jsx b/src/pages/shop/checkout/finish.jsx new file mode 100644 index 00000000..cc64199f --- /dev/null +++ b/src/pages/shop/checkout/finish.jsx @@ -0,0 +1,16 @@ +import BasicLayout from '@/core/components/layouts/BasicLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import FinishCheckoutComponent from '@/lib/checkout/components/FinishCheckout' +import { useRouter } from 'next/router' + +export default function Finish() { + const router = useRouter() + + return ( + <IsAuth> + <BasicLayout> + <FinishCheckoutComponent id={router.query.id || 0} /> + </BasicLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/checkout/index.js b/src/pages/shop/checkout/index.js deleted file mode 100644 index 0a77ebed..00000000 --- a/src/pages/shop/checkout/index.js +++ /dev/null @@ -1,325 +0,0 @@ -import { ExclamationCircleIcon } from "@heroicons/react/24/solid" -import { useEffect, useState } from "react" -import Alert from "@/components/elements/Alert" -import AppBar from "@/components/layouts/AppBar" -import Layout from "@/components/layouts/Layout" -import LineDivider from "@/components/elements/LineDivider" -import Link from "@/components/elements/Link" -import ProgressBar from "@/components/elements/ProgressBar" -import Spinner from "@/components/elements/Spinner" -import apiOdoo from "@/core/utils/apiOdoo" -import { useAuth } from "@/core/utils/auth" -import { deleteItemCart, getCart } from "@/core/utils/cart" -import currencyFormat from "@/core/utils/currencyFormat" -import { getItemAddress } from "@/core/utils/address" -import { useRouter } from "next/router" -import WithAuth from "@/components/auth/WithAuth" -import { toast } from "react-hot-toast" -import getFileBase64 from "@/core/utils/getFileBase64" -import VariantCard from "@/components/variants/VariantCard" - -export default function Checkout() { - const router = useRouter() - const { product_id, qty } = router.query - const [ auth ] = useAuth() - const [ addresses, setAddresses ] = useState(null) - const [ poNumber, setPoNumber ] = useState('') - const [ poFile, setPoFile ] = useState('') - const [ selectedAddress, setSelectedAddress ] = useState({ - shipping: null, - invoicing: null - }) - const [ selectedPayment, setSelectedPayment ] = useState(null) - const [ products, setProducts ] = useState(null) - const [ totalAmount, setTotalAmount ] = useState(0) - const [ totalDiscountAmount, setTotalDiscountAmount ] = useState(0) - - const [ isLoading, setIsLoading ] = useState(false) - - const payments = [ - { name: 'BCA', number: '8870-4000-81' }, - { name: 'MANDIRI', number: '155-0067-6869-75' }, - ] - - useEffect(() => { - const getAddresses = async () => { - if (auth) { - const dataAddresses = await apiOdoo('GET', `/api/v1/user/${auth.id}/address`) - setAddresses(dataAddresses) - } - } - getAddresses() - }, [auth]) - - useEffect(() => { - const getProducts = async () => { - let cart = getCart() - let productIds = [] - if (product_id) { - productIds = [parseInt(product_id)] - } else { - productIds = Object - .values(cart) - .filter((itemCart) => itemCart.selected == true) - .map((itemCart) => itemCart.product_id) - } - if (productIds.length > 0) { - productIds = productIds.join(',') - let dataProducts = await apiOdoo('GET', `/api/v1/product_variant/${productIds}`) - dataProducts = dataProducts.map((product) => { - if (product_id) { - product.quantity = 1 - if (qty) product.quantity = parseInt(qty) - } else { - product.quantity = cart[product.id].quantity - } - return product - }) - setProducts(dataProducts) - } - } - getProducts() - }, [router, auth, product_id, qty]) - - useEffect(() => { - if (addresses) { - const matchAddress = (key) => { - const addressToMatch = getItemAddress(key) - let foundAddress = addresses.filter((address) => address.id == addressToMatch) - if (foundAddress.length > 0) { - return foundAddress[0] - } - return addresses[0] - } - setSelectedAddress({ - shipping: matchAddress('shipping'), - invoicing: matchAddress('invoicing'), - }) - } - }, [addresses]) - - useEffect(() => { - if (products) { - let calculateTotalAmount = 0 - let calculateTotalDiscountAmount = 0 - products.forEach(product => { - calculateTotalAmount += product.price.price * product.quantity - calculateTotalDiscountAmount += (product.price.price - product.price.price_discount) * product.quantity - }) - setTotalAmount(calculateTotalAmount) - setTotalDiscountAmount(calculateTotalDiscountAmount) - } - }, [products]) - - const checkout = async () => { - if (!selectedPayment) { - toast.error('Mohon pilih metode pembayaran', { - position: 'bottom-center' - }) - return - } - if (poFile && poFile.size > 5000000) { - toast.error('Maksimal ukuran file adalah 5MB', { - position: 'bottom-center' - }) - return - } - setIsLoading(true) - let productOrder = products.map((product) => ({ 'product_id': product.id, 'quantity': product.quantity })) - let data = { - 'partner_shipping_id': selectedAddress.shipping.id, - 'partner_invoice_id': selectedAddress.invoicing.id, - 'order_line': JSON.stringify(productOrder), - 'type': 'sale_order' - } - if (poNumber) data.po_number = poNumber - if (poFile) data.po_file = await getFileBase64(poFile) - - const checkoutToOdoo = await apiOdoo('POST', `/api/v1/partner/${auth.partner_id}/sale_order/checkout`, data) - for (const product of products) { - deleteItemCart(product.id) - } - router.push(`/shop/checkout/finish?id=${checkoutToOdoo.id}`) - setIsLoading(false) - } - - return ( - <WithAuth> - <Layout> - <AppBar title={"Checkout"} /> - { !products && !addresses && ( - <div className="flex justify-center items-center gap-x-3 mt-14"> - <Spinner className="w-10 text-gray_r-8 fill-gray_r-12" /> - </div> - ) } - - { products && addresses && ( - <> - <ProgressBar - current={2} - labels={['Keranjang', 'Pembayaran', 'Selesai']} - /> - - <LineDivider/> - - <div className="p-4"> - <Alert type="info" className="text-caption-2 flex gap-x-3 items-center"> - <div> - <ExclamationCircleIcon className="w-6 text-blue-700"/> - </div> - <span>Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami disini</span> - </Alert> - </div> - - <LineDivider/> - - <div className="p-4"> - <div className="flex justify-between items-center"> - <h2>Alamat Pengiriman</h2> - <Link className="text-caption-1" href="/my/address?select=shipping">Pilih Alamat Lain</Link> - </div> - - { selectedAddress.shipping && ( - <div className="mt-4 text-caption-1"> - <div className="badge-red mb-2">{ selectedAddress.shipping.type.charAt(0).toUpperCase() + selectedAddress.shipping.type.slice(1) + ' Address' }</div> - <p className="font-medium">{ selectedAddress.shipping.name }</p> - <p className="mt-2 text-gray_r-11">{ selectedAddress.shipping.mobile }</p> - <p className="mt-1 text-gray_r-11">{ selectedAddress.shipping.street }, { selectedAddress.shipping?.city?.name }</p> - </div> - ) } - </div> - - <LineDivider/> - - <div className="p-4 flex flex-col gap-y-4"> - {products.map((product, index) => ( - <VariantCard - data={product} - openOnClick={false} - key={index} - /> - ))} - </div> - - <LineDivider/> - - <div className="p-4"> - <div className="flex justify-between items-center"> - <h2>Ringkasan Pesanan</h2> - <p className="text-gray_r-11 text-caption-1">{products.length} Barang</p> - </div> - <hr className="my-4 border-gray_r-6"/> - <div className="flex flex-col gap-y-4"> - <div className="flex gap-x-2 justify-between"> - <p>Total Belanja</p> - <p className="font-medium">{currencyFormat(totalAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>Total Diskon</p> - <p className="font-medium text-red_r-11">- {currencyFormat(totalDiscountAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>Subtotal</p> - <p className="font-medium">{currencyFormat(totalAmount - totalDiscountAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>PPN 11% (Incl.)</p> - <p className="font-medium">{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</p> - </div> - </div> - <hr className="my-4 border-gray_r-6"/> - <div className="flex gap-x-2 justify-between mb-4"> - <p>Grand Total</p> - <p className="font-medium text-yellow_r-11">{currencyFormat(totalAmount - totalDiscountAmount)}</p> - </div> - <p className="text-caption-2 text-gray_r-10 mb-2">*) Belum termasuk biaya pengiriman</p> - <p className="text-caption-2 text-gray_r-10 leading-5"> - Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui <Link href="/">Syarat & Ketentuan</Link> yang berlaku - </p> - </div> - - <LineDivider/> - - <div className="p-4"> - <div className="flex justify-between items-center"> - <h2>Alamat Penagihan</h2> - <Link className="text-caption-1" href="/my/address?select=invoicing">Pilih Alamat Lain</Link> - </div> - - { selectedAddress.invoicing && ( - <div className="mt-4 text-caption-1"> - <div className="badge-red mb-2">{ selectedAddress.invoicing.type.charAt(0).toUpperCase() + selectedAddress.invoicing.type.slice(1) + ' Address' }</div> - <p className="font-medium">{ selectedAddress.invoicing.name }</p> - <p className="mt-2 text-gray_r-11">{ selectedAddress.invoicing.mobile }</p> - <p className="mt-1 text-gray_r-11">{ selectedAddress.invoicing.street } { selectedAddress.invoicing.street2 }</p> - </div> - ) } - </div> - - <LineDivider/> - - <div className="p-4"> - <h2>Metode Pembayaran <span className="font-normal text-gray_r-11">(Wajib dipilih)</span></h2> - <div className="grid gap-y-3 mt-4"> - { payments.map((payment, index) => ( - <button - type="button" - className={"text-left border border-gray_r-6 rounded-md p-3 " + (selectedPayment == payment.name && 'border-yellow_r-10 bg-yellow_r-3')} - onClick={() => setSelectedPayment(payment.name)} - key={index} - > - <p>{payment.name} - {payment.number}</p> - <p className="mt-1 text-gray_r-11">PT. Indoteknik Dotcom Gemilang</p> - </button> - )) } - </div> - </div> - - <LineDivider/> - - <div className="p-4"> - <h2>Purchase Order</h2> - - <div className="mt-4 flex gap-x-3"> - <div className="w-6/12"> - <label className="form-label font-normal"> - Dokumen PO - </label> - <input - type="file" - className="form-input mt-2 h-12" - accept="image/*,application/pdf" - onChange={(e) => setPoFile(e.target.files[0])} - /> - </div> - <div className="w-6/12"> - <label className="form-label font-normal">Nomor PO</label> - <input - type="text" - className="form-input mt-2 h-12" - value={poNumber} - onChange={(e) => setPoNumber(e.target.value)} - /> - </div> - </div> - <p className="text-caption-2 text-gray_r-11 mt-2">Ukuran dokumen PO Maksimal 5MB</p> - </div> - - <LineDivider/> - - <div className="flex gap-x-3 p-4"> - <button - className="flex-1 btn-yellow" - onClick={checkout} - disabled={isLoading} - > - { isLoading && 'Loading...' } - { !isLoading && 'Bayar' } - </button> - </div> - </> - ) } - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/checkout/index.jsx b/src/pages/shop/checkout/index.jsx new file mode 100644 index 00000000..015a11b3 --- /dev/null +++ b/src/pages/shop/checkout/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import CheckoutComponent from '@/lib/checkout/components/Checkout' + +export default function Checkout() { + return ( + <IsAuth> + <AppLayout title='Checkout'> + <CheckoutComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/product/[slug].js b/src/pages/shop/product/[slug].js deleted file mode 100644 index 61692c1c..00000000 --- a/src/pages/shop/product/[slug].js +++ /dev/null @@ -1,305 +0,0 @@ -import Link from "@/components/elements/Link" -import { useRouter } from "next/router" -import { useEffect, useState } from "react" -import Header from "@/components/layouts/Header" -import apiOdoo from "@/core/utils/apiOdoo" -import { createSlug, getIdFromSlug } from "@/core/utils/slug" -import currencyFormat from "@/core/utils/currencyFormat" -import Layout from "@/components/layouts/Layout" -import { createOrUpdateItemCart } from "@/core/utils/cart" -import toast from "react-hot-toast" -import Footer from "@/components/layouts/Footer" -import Image from "@/components/elements/Image" -import LineDivider from "@/components/elements/LineDivider" -import { HeartIcon as HeartIconSolid } from "@heroicons/react/24/solid" -import { useAuth } from "@/core/utils/auth" -import { HeartIcon } from "@heroicons/react/24/outline" -import LazyLoad from "react-lazy-load" -import ProductSimilar from "@/components/products/ProductSimilar" - -export async function getServerSideProps( context ) { - const { slug } = context.query - let product = await apiOdoo('GET', '/api/v1/product/' + getIdFromSlug(slug)) - if (product?.length == 1) { - product = product[0] - product.description = product.description.replaceAll('<p>', '||p||') - product.description = product.description.replaceAll('</p>', '||/p||') - product.description = product.description.replace(/(<([^>]+)>)/gi, ' ') - product.description = product.description.replaceAll('||p||', '<p>') - product.description = product.description.replaceAll('||/p||', '</p>') - product.description = product.description.trim() - } - return { props: { product } } -} - -export default function ProductDetail({ product }) { - const [ auth ] = useAuth() - const router = useRouter() - const { slug } = router.query - const [selectedVariant, setSelectedVariant] = useState("") - const [quantity, setQuantity] = useState("1") - const [activeVariant, setActiveVariant] = useState({ - id: product.id, - code: product.code, - price: product.lowest_price, - stock: product.stock_total, - weight: product.weight, - attributes: '', - }) - - const [ isAddedToWishlist, setAddedToWishlist ] = useState(false) - const [ activeTab, setActiveTab ] = useState('specification') - - const addOrDeleteWishlist = async () => { - if (auth) { - await apiOdoo('POST', `/api/v1/user/${auth.id}/wishlist/create-or-delete`, { - product_id: product.id - }) - if (isAddedToWishlist) { - toast.success('Berhasil menghapus dari wishlist') - } else { - toast.success('Berhasil menambahkan ke wishlist') - } - setAddedToWishlist(!isAddedToWishlist) - } else { - toast.error('Login terlebih dahulu untuk melanjutkan') - router.push('/login') - } - } - - useEffect(() => { - if (auth) { - const checkWishlist = async () => { - const wishlist = await apiOdoo('GET', `/api/v1/user/${auth.id}/wishlist?product_id=${product.id}`) - setAddedToWishlist(wishlist.product_total > 0 ? true : false) - } - checkWishlist() - } - }, [ auth, product ]) - - useEffect(() => { - if (product.variants.length == 1) { - setSelectedVariant(product.variants[0].id) - } - }, [ product ]) - - useEffect(() => { - if (selectedVariant != '') { - let newActiveVariant = product.variants.filter((variant) => { - return variant.id == selectedVariant - }) - - if (newActiveVariant.length == 1) { - newActiveVariant = newActiveVariant[0] - setActiveVariant({ - id: newActiveVariant.id, - code: newActiveVariant.code, - price: newActiveVariant.price, - stock: newActiveVariant.stock, - weight: newActiveVariant.weight, - attributes: newActiveVariant.attributes.join(', '), - }) - } - } - }, [selectedVariant, product]) - - const onchangeVariant = (e) => { - setSelectedVariant(e.target.value) - } - - const onChangeQuantity = (e) => { - let inputValue = e.target.value - inputValue = parseInt(inputValue) - inputValue = Math.floor(inputValue) - setQuantity(inputValue) - } - - const addItemToCart = () => { - if (product.variant_total > 1 && !selectedVariant) { - toast.error('Pilih varian terlebih dahulu untuk menambahkan ke keranjang', { duration: 2000 }) - return false - } - - if (quantity > 0) { - toast.success('Berhasil menambahkan ke keranjang', { duration: 1500 }) - createOrUpdateItemCart(activeVariant.id, parseInt(quantity)) - } else { - toast.error('Jumlah barang yang ditambahkan minimal 1 pcs', { duration: 2000 }) - } - - return true - } - - const checkoutProduct = () => { - if (!auth) { - toast.error('Login terlebih dahulu untuk melanjutkan', { duration: 2000 }) - router.push('/login') - return - } - if (product.variant_total > 1 && !selectedVariant) { - toast.error('Pilih varian terlebih dahulu untuk melanjutkan pembelian', { duration: 2000 }) - return - } - if (quantity < 0) { - toast.error('Jumlah barang yang ditambahkan minimal 1 pcs', { duration: 2000 }) - return - } - router.push(`/shop/checkout?product_id=${activeVariant.id}&qty=${quantity}`) - } - - const TabButton = ({ children, name }) => ( - <button - type="button" - className={`font-medium pb-1 ${activeTab == name ? 'text-red_r-11 border-b border-red_r-10' : 'text-gray_r-11'}`} - onClick={() => setActiveTab(name)} - > - { children } - </button> - ) - - return ( - <> - <Header title={`${product.name} - Indoteknik`}/> - <Layout> - <Image - src={product.image} - alt={product.name} - className="border-b border-gray_r-6 w-full h-[300px] object-contain object-center bg-white" - /> - - <div className="p-4"> - <div className="flex justify-between gap-x-3"> - <div> - <Link href={'/shop/brands/' + createSlug(product.manufacture.name, product.manufacture.id)}> - {product.manufacture.name ?? '-'} - </Link> - <h1 className="h2 mt-2 mb-3">{product.name}{activeVariant.attributes ? ' - ' + activeVariant.attributes : ''}</h1> - </div> - <button className="h-fit" onClick={addOrDeleteWishlist}> - { isAddedToWishlist && ( - <HeartIconSolid className="w-6 text-red_r-10" /> - ) } - { !isAddedToWishlist && ( - <HeartIcon className="w-6" /> - ) } - </button> - </div> - - {product.variant_total > 1 && !selectedVariant && product.lowest_price.price > 0 ? ( - <p className="text-caption-2 text-gray-800 mb-1">Harga mulai dari:</p> - ) : ''} - - {product.lowest_price.discount_percentage > 0 ? ( - <div className="flex gap-x-1 items-center mb-1"> - <p className="text-caption-2 text-gray_r-11 line-through">{currencyFormat(activeVariant.price.price)}</p> - <span className="badge-solid-red">{activeVariant.price.discount_percentage}%</span> - </div> - ) : ''} - - {product.lowest_price.price > 0 ? ( - <p className="text-body-lg font-semibold">{currencyFormat(activeVariant.price.price_discount)}</p> - ) : ( - <p className="text-gray_r-11">Dapatkan harga terbaik, <a href="">hubungi kami.</a></p> - )} - </div> - - <LineDivider /> - - <div className="p-4"> - <div className=""> - <label className="form-label mb-2">Pilih: <span className="text-gray_r-11 font-normal">{product.variant_total} Varian</span></label> - <select name="variant" className="form-input" value={selectedVariant} onChange={onchangeVariant} > - <option value="" disabled={selectedVariant != "" ? true : false}>Pilih Varian...</option> - {product.variants.length > 1 ? ( - product.variants.map((variant) => { - return ( - <option key={variant.id} value={variant.id}>{variant.attributes.join(', ')}</option> - ) - }) - ) : ( - <option key={product.variants[0].id} value={product.variants[0].id}>{product.variants[0].name}</option> - )} - </select> - </div> - - <label htmlFor="quantity" className="form-label mb-1 mt-3">Jumlah</label> - <div className="flex gap-x-2 mt-2"> - <input type="number" name="quantity" id="quantity" className="form-input h-full w-5/12 text-center" value={quantity} onChange={onChangeQuantity} /> - - <button - className="btn-yellow w-full" - onClick={addItemToCart} - disabled={(product.lowest_price.price == 0 ? true : false)} - > - Keranjang - </button> - <button - onClick={checkoutProduct} - className="btn-solid-red w-full" - > - Beli - </button> - </div> - </div> - - <LineDivider /> - - <div className="p-4"> - <h2 className="font-bold mb-4">Informasi Produk</h2> - <div className="flex gap-x-3 mb-4"> - <TabButton name="specification">Spesifikasi</TabButton> - <TabButton name="description">Deskripsi</TabButton> - <TabButton name="information">Info Penting</TabButton> - </div> - - <div className={`border border-gray_r-6 rounded divide-y ${activeTab == 'specification' ? 'block' : 'hidden'}`}> - <ProductSpecification label="Jumlah Varian"> - <p className="text-gray-800">{product.variant_total} Varian</p> - </ProductSpecification> - <ProductSpecification label="Nomor SKU"> - <p className="text-gray-800" id="sku_number">SKU-{activeVariant.id}</p> - </ProductSpecification> - <ProductSpecification label="Part Number"> - <p className="text-gray-800" id="part_number">{activeVariant.code}</p> - </ProductSpecification> - <ProductSpecification label="Stok"> - <div className="flex gap-x-2" id="stock"> - {activeVariant.stock > 0 ? (activeVariant.stock > 5 && ( - <> - <div className="badge-solid-red">Ready Stock</div> - <div className="badge-gray">{activeVariant.stock > 5 ? '> 5' : '< 5'}</div> - </> - )) : '0'} - </div> - </ProductSpecification> - <ProductSpecification label="Part Number"> - <p className="text-gray-800" id="weight">{activeVariant.weight > 0 ? activeVariant.weight : '1'} KG</p> - </ProductSpecification> - </div> - - <div - className={`text-gray-800 leading-7 ${activeTab == 'description' ? 'block' : 'hidden'}`} - dangerouslySetInnerHTML={{__html: (product.description != '' ? product.description : 'Belum ada deskripsi produk.')}} - ></div> - </div> - - <LineDivider /> - - <LazyLoad> - <ProductSimilar productId={getIdFromSlug(slug || '')} /> - </LazyLoad> - - <Footer /> - </Layout> - </> - ) -} - -const ProductSpecification = ({ children, ...props }) => { - return ( - <div className="flex p-3 justify-between items-center gap-x-1"> - <h3 className="text-gray-900">{ props.label }</h3> - { children } - </div> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/product/[slug].jsx b/src/pages/shop/product/[slug].jsx new file mode 100644 index 00000000..cc6924a3 --- /dev/null +++ b/src/pages/shop/product/[slug].jsx @@ -0,0 +1,31 @@ +import Seo from '@/core/components/Seo' +import { getIdFromSlug } from '@/core/utils/slug' +import productApi from '@/lib/product/api/productApi' +import dynamic from 'next/dynamic' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const Product = dynamic(() => import('@/lib/product/components/Product')) + +export async function getServerSideProps(context) { + const { slug } = context.query + let product = await productApi({ id: getIdFromSlug(slug) }) + if (product?.length == 1) { + product = product[0] + product.description = product.description.replaceAll('<p>', '||p||') + product.description = product.description.replaceAll('</p>', '||/p||') + product.description = product.description.replace(/(<([^>]+)>)/gi, ' ') + product.description = product.description.replaceAll('||p||', '<p>') + product.description = product.description.replaceAll('||/p||', '</p>') + product.description = product.description.trim() + } + return { props: { product } } +} + +export default function ProductDetail({ product }) { + return ( + <BasicLayout> + <Seo title={product?.name} /> + <Product product={product} /> + </BasicLayout> + ) +} diff --git a/src/pages/shop/quotation/finish.js b/src/pages/shop/quotation/finish.js deleted file mode 100644 index f7983fef..00000000 --- a/src/pages/shop/quotation/finish.js +++ /dev/null @@ -1,39 +0,0 @@ -import WithAuth from "@/components/auth/WithAuth"; -import Link from "@/components/elements/Link"; -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; -import { useAuth } from "@/core/utils/auth"; -import { EnvelopeIcon } from "@heroicons/react/24/outline"; -import { useRouter } from "next/router"; - -export default function FinishQuotation() { - const router = useRouter(); - const { id } = router.query; - const [ auth ] = useAuth(); - - return ( - <WithAuth> - <Layout> - <Header title="Penawaran Harga" /> - - <div className="m-4 px-4 py-6 shadow-md border border-gray_r-3"> - <div className="flex"> - <span className="p-3 mx-auto bg-yellow_r-3 border border-yellow_r-6 rounded"> - <EnvelopeIcon className="w-8 text-yellow_r-11" /> - </span> - </div> - <p className="h2 text-center mt-6"> - Terima Kasih { auth?.name } - </p> - <p className="text-center mt-3 leading-6 text-gray_r-11"> - Penawaran harga kamu di Indoteknik.com berhasil dikirimkan, tim kami akan segera menghubungi anda. - </p> - { id && ( - <Link href={`/my/transaction/${id}`} className="btn-yellow text-gray_r-12 mt-6 w-full">Lihat Penawaran</Link> - )} - <Link href="/" className="btn-light text-gray_r-12 mt-2 w-full">Ke Halaman Utama</Link> - </div> - </Layout> - </WithAuth> - ); -}
\ No newline at end of file diff --git a/src/pages/shop/quotation/finish.jsx b/src/pages/shop/quotation/finish.jsx new file mode 100644 index 00000000..15881ea0 --- /dev/null +++ b/src/pages/shop/quotation/finish.jsx @@ -0,0 +1,44 @@ +import Link from '@/core/components/elements/Link/Link' +import BasicLayout from '@/core/components/layouts/BasicLayout' +import useAuth from '@/core/hooks/useAuth' +import IsAuth from '@/lib/auth/components/IsAuth' +import { EnvelopeIcon } from '@heroicons/react/24/outline' +import { useRouter } from 'next/router' + +export default function FinishQuotation() { + const auth = useAuth() + const router = useRouter() + const { id } = router.query + return ( + <IsAuth> + <BasicLayout> + <div className='m-4 px-4 py-6 shadow-md border border-gray_r-3'> + <div className='flex'> + <span className='p-3 mx-auto bg-yellow_r-3 border border-yellow_r-6 rounded'> + <EnvelopeIcon className='w-8 text-yellow_r-11' /> + </span> + </div> + <p className='h2 text-center mt-6'>Terima Kasih {auth?.name}</p> + <p className='text-center mt-3 leading-6 text-gray_r-11'> + Penawaran harga kamu di Indoteknik.com berhasil dikirimkan, tim kami akan segera + menghubungi anda. + </p> + {id && ( + <Link + href={`/my/transaction/${id}`} + className='btn-yellow !text-gray_r-12 mt-6 w-full' + > + Lihat Penawaran + </Link> + )} + <Link + href='/' + className='btn-light !text-gray_r-12 mt-2 w-full' + > + Ke Halaman Utama + </Link> + </div> + </BasicLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/quotation/index.js b/src/pages/shop/quotation/index.js deleted file mode 100644 index e1c196db..00000000 --- a/src/pages/shop/quotation/index.js +++ /dev/null @@ -1,140 +0,0 @@ -import WithAuth from "@/components/auth/WithAuth"; -import LineDivider from "@/components/elements/LineDivider"; -import Link from "@/components/elements/Link"; -import AppBar from "@/components/layouts/AppBar"; -import Layout from "@/components/layouts/Layout"; -import VariantCard from "@/components/variants/VariantCard"; -import apiOdoo from "@/core/utils/apiOdoo"; -import { useAuth } from "@/core/utils/auth"; -import { deleteItemCart, getCart } from "@/core/utils/cart"; -import currencyFormat from "@/core/utils/currencyFormat"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; -import { toast } from "react-hot-toast"; - -export default function Quotation() { - const router = useRouter(); - const [ auth ] = useAuth(); - const [ products, setProducts ] = useState([]); - const [ totalAmount, setTotalAmount ] = useState(0); - const [ totalDiscountAmount, setTotalDiscountAmount ] = useState(0); - - useEffect(() => { - const getProducts = async () => { - let cart = getCart(); - let productIds = Object - .values(cart) - .filter((itemCart) => itemCart.selected == true) - .map((itemCart) => itemCart.product_id); - if (productIds.length > 0) { - productIds = productIds.join(','); - let dataProducts = await apiOdoo('GET', `/api/v1/product_variant/${productIds}`); - dataProducts = dataProducts.map((product) => ({ - ...product, - quantity: cart[product.id].quantity, - selected: cart[product.id].selected, - })); - setProducts(dataProducts); - } - }; - getProducts(); - }, [ router, auth ]); - - useEffect(() => { - if (products) { - let calculateTotalAmount = 0; - let calculateTotalDiscountAmount = 0; - products.forEach(product => { - calculateTotalAmount += product.price.price * product.quantity; - calculateTotalDiscountAmount += (product.price.price - product.price.price_discount) * product.quantity; - }); - setTotalAmount(calculateTotalAmount); - setTotalDiscountAmount(calculateTotalDiscountAmount); - } - }, [products]); - - const submitQuotation = async () => { - let productOrder = products.map((product) => ({ 'product_id': product.id, 'quantity': product.quantity })); - let data = { - 'partner_shipping_id': auth.partner_id, - 'partner_invoice_id': auth.partner_id, - 'order_line': JSON.stringify(productOrder) - }; - const quotation = await apiOdoo('POST', `/api/v1/partner/${auth.partner_id}/sale_order/checkout`, data); - for (const product of products) { - deleteItemCart(product.id); - } - if (quotation?.id) { - router.push(`/shop/quotation/finish?id=${quotation.id}`); - return; - }; - toast.error('Terdapat kesalahan internal, hubungi kami'); - } - return ( - <WithAuth> - <Layout> - <AppBar title="Penawaran Harga" /> - - <div className="p-4 flex flex-col gap-y-4"> - <p className="h2">Produk</p> - {products.map((product, index) => ( - <VariantCard - data={product} - openOnClick={false} - key={index} - /> - ))} - </div> - - <LineDivider /> - - <div className="p-4"> - <div className="flex justify-between items-center"> - <p className="h2">Ringkasan Penawaran</p> - <p className="text-gray_r-11 text-caption-1">{products.length} Barang</p> - </div> - <hr className="my-4 border-gray_r-6"/> - <div className="flex flex-col gap-y-4"> - <div className="flex gap-x-2 justify-between"> - <p>Total Belanja</p> - <p className="font-medium">{currencyFormat(totalAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>Total Diskon</p> - <p className="font-medium text-red_r-11">{'- ' + currencyFormat(totalDiscountAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>Subtotal</p> - <p className="font-medium">{currencyFormat(totalAmount - totalDiscountAmount)}</p> - </div> - <div className="flex gap-x-2 justify-between"> - <p>PPN 11% (Incl.)</p> - <p className="font-medium">{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</p> - </div> - </div> - <hr className="my-4 border-gray_r-6"/> - <div className="flex gap-x-2 justify-between mb-4"> - <p>Grand Total</p> - <p className="font-medium text-yellow_r-11">{currencyFormat(totalAmount - totalDiscountAmount)}</p> - </div> - <p className="text-caption-2 text-gray_r-10 mb-2">*) Belum termasuk biaya pengiriman</p> - <p className="text-caption-2 text-gray_r-10 leading-5"> - Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui <Link href="/">Syarat & Ketentuan</Link> yang berlaku - </p> - </div> - - <LineDivider /> - - <div className="p-4"> - <button - type="button" - className="btn-yellow w-full" - onClick={submitQuotation} - > - Kirim Penawaran - </button> - </div> - </Layout> - </WithAuth> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/quotation/index.jsx b/src/pages/shop/quotation/index.jsx new file mode 100644 index 00000000..ff8b8644 --- /dev/null +++ b/src/pages/shop/quotation/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import QuotationComponent from '@/lib/quotation/components/Quotation' + +export default function Quotation() { + return ( + <IsAuth> + <AppLayout title='Quotation'> + <QuotationComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/search.js b/src/pages/shop/search.js deleted file mode 100644 index 4152bd43..00000000 --- a/src/pages/shop/search.js +++ /dev/null @@ -1,125 +0,0 @@ -import axios from "axios"; -import Header from "@/components/layouts/Header"; -import Layout from "@/components/layouts/Layout"; -import Pagination from "@/components/elements/Pagination"; -import ProductCard from "@/components/products/ProductCard"; -import FilterIcon from "@/icons/filter.svg"; -import { useEffect, useState } from "react"; -import Filter from "@/components/elements/Filter"; -import Footer from "@/components/layouts/Footer"; - -export async function getServerSideProps(context) { - const { - q = '*', - page = 1, - brand = '', - category = '', - price_from = '', - price_to = '', - order_by = '', - } = context.query; - - let urlParameter = [ - `page=${page}`, - `brand=${brand}`, - `category=${category}`, - `price_from=${price_from}`, - `price_to=${price_to}`, - `order_by=${order_by}` - ].join('&'); - let searchResults = await axios(`${process.env.SELF_HOST}/api/shop/search?q=${q}&${urlParameter}`); - searchResults = searchResults.data; - return { props: { searchResults, q, page, brand, category, price_from, price_to, order_by } }; -} - -export default function ShopSearch({ - searchResults, - q, - page, - brand, - category, - price_from, - price_to, - order_by -}) { - const pageCount = Math.ceil(searchResults.response.numFound / searchResults.responseHeader.params.rows); - const productStart = searchResults.responseHeader.params.start; - const productRows = searchResults.responseHeader.params.rows; - const productFound = searchResults.response.numFound; - - // Variable for <Filter/> props state - const [activeFilter, setActiveFilter] = useState(false); - const [filterCount, setFilterCount] = useState(0); - - const route = () => { - let route = `/shop/search?q=${q}`; - if (brand) route += `&brand=${brand}`; - if (category) route += `&category=${category}`; - if (price_from) route += `&price_from=${price_from}`; - if (price_to) route += `&price_to=${price_to}`; - if (order_by) route += `&order_by=${order_by}`; - return route; - } - - useEffect(() => { - let calculateFilterCount = 0; - if (brand) calculateFilterCount++; - if (category) calculateFilterCount++; - if (price_from || price_to) calculateFilterCount++; - if (order_by) calculateFilterCount++; - setFilterCount(calculateFilterCount); - }, [brand, category, price_from, price_to, order_by]); - - return ( - <> - <Header title={`Jual ${q} - Indoteknik`} /> - <Filter - defaultRoute={`/shop/search?q=${q}`} - isActive={activeFilter} - closeFilter={() => setActiveFilter(false)} - defaultPriceFrom={price_from} - defaultPriceTo={price_to} - defaultBrand={brand} - defaultCategory={category} - defaultOrderBy={order_by} - searchResults={searchResults} - /> - <Layout> - <div className="p-4"> - <h1 className="mb-2">Produk</h1> - <div className="text-caption-1 mb-4"> - {productFound > 0 ? ( - <> - Menampilkan - {pageCount > 1 ? ( - <> - {productStart + 1}-{ - (productStart + productRows) > productFound ? productFound : productStart + productRows - } - dari - </> - ) : ''} - {searchResults.response.numFound} - produk { q != '*' && (<>untuk pencarian <span className="font-semibold">{q}</span></>) } - </> - ) : 'Mungkin yang anda cari'} - </div> - <button className="btn-light py-2 flex items-center gap-x-2 mb-4" onClick={() => setActiveFilter(true)}> - <FilterIcon className="w-4 h-4" /> <span>Filter {filterCount > 0 ? `(${filterCount})` : ''}</span> - </button> - <div className="grid grid-cols-2 gap-3"> - {searchResults.response.products.map((product) => ( - <ProductCard key={product.id} data={product} /> - ))} - </div> - - <div className="mt-4"> - <Pagination pageCount={pageCount} currentPage={parseInt(page)} url={route()} /> - </div> - </div> - - <Footer /> - </Layout> - </> - ) -}
\ No newline at end of file diff --git a/src/pages/shop/search.jsx b/src/pages/shop/search.jsx new file mode 100644 index 00000000..345b715a --- /dev/null +++ b/src/pages/shop/search.jsx @@ -0,0 +1,21 @@ +import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' +import _ from 'lodash-contrib' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const ProductSearch = dynamic(() => import('@/lib/product/components/ProductSearch')) + +export default function Search() { + const router = useRouter() + + return ( + <BasicLayout> + {!_.isEmpty(router.query) && ( + <ProductSearch + query={router.query} + prefixUrl='/shop/search' + /> + )} + </BasicLayout> + ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css index b871a325..0137351e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -8,47 +8,32 @@ -webkit-tap-highlight-color: transparent; } -html, body { - @apply - w-screen +html, +body { + @apply w-screen text-body-2 text-gray_r-12 bg-gray_r-1 - overflow-x-clip - ; + overflow-x-clip; +} + +#__next main { + @apply min-h-screen +} + +button { + @apply block; } @layer base { - input[type="number"]::-webkit-inner-spin-button, - input[type="number"]::-webkit-outer-spin-button { + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } - input[type=number] { - -moz-appearance:textfield; - } - - h1, .h1 { - @apply - text-h-md - font-semibold - ; - } - - h2, .h2 { - @apply - text-body-2 - font-semibold - leading-6 - ; - } - - a { - @apply - font-medium - text-red_r-11 - ; + input[type='number'] { + -moz-appearance: textfield; } } @@ -60,94 +45,73 @@ html, body { .badge-blue, .badge-green, .badge-solid-green { - @apply - text-caption-2 + @apply text-[11px] leading-none font-medium px-1 py-1 rounded - w-fit - ; + w-fit; } .badge-red { - @apply - bg-red_r-5 - text-red_r-10 - ; + @apply bg-red_r-5 + text-red_r-10; } .badge-solid-red { - @apply - bg-red_r-10 - text-white - ; + @apply bg-red_r-10 + text-white; } .badge-gray { - @apply - bg-gray_r-5 - text-gray_r-10 - ; + @apply bg-gray_r-5 + text-gray_r-10; } .badge-yellow { - @apply - bg-yellow_r-3 - text-yellow_r-11 - ; + @apply bg-yellow_r-3 + text-yellow_r-11; } .badge-blue { - @apply - bg-blue-200 - text-blue-600 - ; + @apply bg-blue-200 + text-blue-600; } .badge-green { - @apply - bg-green_r-5 - text-green_r-10 - ; + @apply bg-green_r-5 + text-green_r-10; } - + .badge-solid-green { - @apply - bg-green_r-10 - text-white - ; + @apply bg-green_r-10 + text-white; } .form-label { - @apply - font-medium - block - ; + @apply font-medium + block; } .form-input { - @apply - p-3 + @apply p-3 rounded border text-gray_r-12 border-gray_r-7 + bg-white bg-transparent w-full leading-none focus:outline-none focus:border-yellow_r-9 - disabled:bg-gray_r-5 - ; + disabled:bg-gray_r-5; } .form-input[aria-invalid] { - @apply - border-red_r-10 - focus:border-red_r-10 - ; + @apply border-red_r-10 + focus:border-red_r-10; } .btn-yellow, @@ -155,8 +119,7 @@ html, body { .btn-red, .btn-solid-red, .btn-green { - @apply - block + @apply block w-fit py-3 px-6 @@ -165,63 +128,51 @@ html, body { text-center font-medium ease-linear - duration-150 - ; + duration-150; } .btn-yellow { - @apply - bg-yellow_r-9 + @apply bg-yellow_r-9 border-yellow_r-9 disabled:text-gray_r-10 disabled:bg-yellow_r-7 - disabled:border-yellow_r-7 - ; + disabled:border-yellow_r-7; } .btn-red { - @apply - bg-red_r-3 + @apply bg-red_r-3 border-red_r-6 text-red_r-11 disabled:text-red_r-10 - disabled:bg-red_r-6 - ; + disabled:bg-red_r-6; } .btn-solid-red { - @apply - bg-red_r-11 + @apply bg-red_r-11 border-red_r-11 text-gray_r-1 disabled:text-gray_r-1 disabled:bg-red_r-8 - disabled:border-red_r-8 - ; + disabled:border-red_r-8; } .btn-green { - @apply - bg-green_r-3 + @apply bg-green_r-3 border-green_r-6 text-green_r-11 disabled:text-green_r-10 - disabled:bg-green_r-6 - ; + disabled:bg-green_r-6; } .btn-light { - @apply - bg-gray_r-3 + @apply bg-gray_r-3 border-gray_r-6 disabled:text-gray_r-10 - disabled:bg-gray_r-6 - ; + disabled:bg-gray_r-6; } .product-card { - @apply - w-full + @apply w-full h-full border border-gray_r-3 @@ -230,43 +181,34 @@ html, body { rounded relative flex - flex-col - ; + flex-col; } .product-card__image { - @apply - w-full + @apply w-full h-[160px] object-contain object-center border-b - border-gray_r-6 - ; + border-gray_r-6; } .product-card__content { - @apply - p-2 + @apply p-2 pb-3 - flex-1 - ; + flex-1; } .product-card__title { - @apply - text-caption-1 + @apply text-caption-1 text-gray_r-12 - leading-5 - ; + leading-5; } .product-card__brand { - @apply - text-caption-1 + @apply text-caption-1 mb-1 - block - ; + block; } } @@ -287,15 +229,14 @@ html, body { .wrap-line-ellipsis-2 { -webkit-line-clamp: 2; } - + .wrap-line-ellipsis-3 { -webkit-line-clamp: 3; } } .menu-wrapper { - @apply - fixed + @apply fixed top-0 left-0 bg-white @@ -305,29 +246,25 @@ html, body { overflow-y-auto translate-x-[-100%] ease-linear - duration-150 - ; + duration-150; } -.menu-wrapper.active{ +.menu-wrapper.active { @apply translate-x-0; } -.menu-overlay { - @apply - fixed +.overlay { + @apply fixed top-0 left-0 w-full h-full z-[55] - bg-gray_r-12/40 - ; + bg-gray_r-12/40; } .sticky-header { - @apply - px-4 + @apply px-4 py-3 bg-gray_r-1/90 backdrop-blur-lg @@ -335,20 +272,16 @@ html, body { top-0 border-b border-gray_r-7 - z-50 - ; + z-50; } .content-container { - @apply - max-w-full - overflow-x-hidden - ; + @apply max-w-full + overflow-x-hidden; } #indoteknik_toast { - @apply - fixed + @apply fixed bottom-4 translate-y-[200%] left-[50%] @@ -366,8 +299,7 @@ html, body { rounded-lg shadow ease-linear - duration-300 - ; + duration-300; } #indoteknik_toast.active { @@ -383,10 +315,8 @@ html, body { } .lazy-load-image-background { - @apply - !block - w-full - ; + @apply !block + w-full; } .swiper-pagination-bullet-active { @@ -394,16 +324,13 @@ html, body { } .pagination { - @apply - flex + @apply flex justify-center - gap-x-1 - ; + gap-x-1; } .pagination-item { - @apply - p-1 + @apply p-1 flex justify-center items-center @@ -415,20 +342,16 @@ html, body { border-gray_r-6 bg-gray_r-3 hover:bg-gray_r-5 - text-gray_r-12 - ; + text-gray_r-12; } .pagination-item--active { - @apply - border-yellow_r-9 - bg-yellow_r-9 - ; + @apply border-yellow_r-9 + bg-yellow_r-9; } .pagination-dots { - @apply - p-1 + @apply p-1 flex justify-center items-end @@ -436,33 +359,24 @@ html, body { rounded ease-linear bg-gray_r-3 - text-caption-2 - ; + text-caption-2; } .idt-transition { - @apply - transition-all - ease-linear - duration-300 - ; + @apply transition-all + ease-out + duration-200; } .form-select__placeholder { - @apply - !text-gray_r-9 - ; + @apply !text-gray_r-9; } .form-select__control { - @apply - !shadow-none - !border-gray_r-7 - ; + @apply !shadow-none + !border-gray_r-7; } .form-select__control--menu-is-open { - @apply - !border-yellow_r-9 - ; -}
\ No newline at end of file + @apply !border-yellow_r-9; +} |
