From f99e0aba70efad0deb907d8e27f09fc9f527c8a4 Mon Sep 17 00:00:00 2001 From: Rafi Zadanly Date: Fri, 17 Feb 2023 17:07:50 +0700 Subject: Refactor --- src2/components/auth/WithAuth.js | 20 + src2/components/elements/Alert.js | 19 + src2/components/elements/BottomPopup.js | 25 ++ src2/components/elements/ConfirmAlert.js | 27 ++ src2/components/elements/DescriptionRow.js | 10 + src2/components/elements/Disclosure.js | 14 + src2/components/elements/Fields.js | 21 + src2/components/elements/Filter.js | 176 ++++++++ src2/components/elements/Image.js | 17 + src2/components/elements/LineDivider.js | 7 + src2/components/elements/Link.js | 13 + src2/components/elements/Pagination.js | 58 +++ src2/components/elements/ProgressBar.js | 25 ++ src2/components/elements/Skeleton.js | 48 +++ src2/components/elements/Spinner.js | 13 + src2/components/layouts/AppBar.js | 47 +++ src2/components/layouts/Footer.js | 91 ++++ src2/components/layouts/Header.js | 253 +++++++++++ src2/components/layouts/Layout.js | 20 + src2/components/manufactures/ManufactureCard.js | 18 + src2/components/products/ProductCard.js | 69 +++ src2/components/products/ProductCategories.js | 62 +++ src2/components/products/ProductSimilar.js | 25 ++ src2/components/products/ProductSlider.js | 39 ++ src2/components/transactions/TransactionDetail.js | 67 +++ .../transactions/TransactionStatusBadge.js | 45 ++ src2/components/variants/VariantCard.js | 92 ++++ src2/components/variants/VariantGroupCard.js | 31 ++ src2/core/utils/address.js | 27 ++ src2/core/utils/apiOdoo.js | 44 ++ src2/core/utils/auth.js | 38 ++ src2/core/utils/cart.js | 36 ++ src2/core/utils/convertToOption.js | 11 + src2/core/utils/currencyFormat.js | 8 + src2/core/utils/formValidation.js | 107 +++++ src2/core/utils/getFileBase64.js | 11 + src2/core/utils/greeting.js | 9 + src2/core/utils/mailer.js | 12 + src2/core/utils/slug.js | 25 ++ src2/core/utils/toTitleCase.js | 8 + src2/icons/chevron-left.svg | 3 + src2/icons/chevron-right.svg | 3 + src2/icons/close.svg | 1 + src2/icons/filter.svg | 1 + src2/icons/image-placeholder.svg | 1 + src2/icons/instagram.svg | 5 + src2/icons/linkedin.svg | 5 + src2/icons/menu.svg | 1 + src2/icons/minus.svg | 3 + src2/icons/plus.svg | 4 + src2/icons/search.svg | 4 + src2/icons/shopping-cart.svg | 1 + src2/icons/trash.svg | 4 + src2/images/logo.png | Bin 0 -> 49879 bytes src2/images/page-not-found.png | Bin 0 -> 42280 bytes src2/lib/elements/hooks/useBottomPopup.js | 40 ++ src2/lib/elements/hooks/useConfirmAlert.js | 49 +++ src2/pages/404.js | 27 ++ src2/pages/_app.js | 31 ++ src2/pages/_error.js | 11 + src2/pages/activate.js | 111 +++++ src2/pages/api/activation-request.js | 31 ++ src2/pages/api/activation.js | 16 + src2/pages/api/login.js | 15 + src2/pages/api/register.js | 15 + src2/pages/api/shop/search.js | 96 +++++ src2/pages/api/shop/suggest.js | 12 + src2/pages/api/token.js | 10 + src2/pages/faqs.js | 91 ++++ src2/pages/index.js | 106 +++++ src2/pages/login.js | 97 +++++ src2/pages/logout.js | 14 + src2/pages/my/address/[id]/edit.js | 249 +++++++++++ src2/pages/my/address/create.js | 234 +++++++++++ src2/pages/my/address/index.js | 84 ++++ src2/pages/my/invoice/[id].js | 149 +++++++ src2/pages/my/invoices.js | 180 ++++++++ src2/pages/my/menu.js | 82 ++++ src2/pages/my/profile.js | 134 ++++++ src2/pages/my/transaction/[id].js | 265 ++++++++++++ src2/pages/my/transactions.js | 198 +++++++++ src2/pages/my/wishlist.js | 60 +++ src2/pages/register.js | 100 +++++ src2/pages/shop/brands/[slug].js | 178 ++++++++ src2/pages/shop/brands/index.js | 79 ++++ src2/pages/shop/cart.js | 282 +++++++++++++ src2/pages/shop/checkout/finish.js | 47 +++ src2/pages/shop/checkout/index.js | 325 ++++++++++++++ src2/pages/shop/product/[slug].js | 305 ++++++++++++++ src2/pages/shop/quotation/finish.js | 39 ++ src2/pages/shop/quotation/index.js | 140 ++++++ src2/pages/shop/search.js | 125 ++++++ src2/styles/globals.css | 468 +++++++++++++++++++++ 93 files changed, 6219 insertions(+) create mode 100644 src2/components/auth/WithAuth.js create mode 100644 src2/components/elements/Alert.js create mode 100644 src2/components/elements/BottomPopup.js create mode 100644 src2/components/elements/ConfirmAlert.js create mode 100644 src2/components/elements/DescriptionRow.js create mode 100644 src2/components/elements/Disclosure.js create mode 100644 src2/components/elements/Fields.js create mode 100644 src2/components/elements/Filter.js create mode 100644 src2/components/elements/Image.js create mode 100644 src2/components/elements/LineDivider.js create mode 100644 src2/components/elements/Link.js create mode 100644 src2/components/elements/Pagination.js create mode 100644 src2/components/elements/ProgressBar.js create mode 100644 src2/components/elements/Skeleton.js create mode 100644 src2/components/elements/Spinner.js create mode 100644 src2/components/layouts/AppBar.js create mode 100644 src2/components/layouts/Footer.js create mode 100644 src2/components/layouts/Header.js create mode 100644 src2/components/layouts/Layout.js create mode 100644 src2/components/manufactures/ManufactureCard.js create mode 100644 src2/components/products/ProductCard.js create mode 100644 src2/components/products/ProductCategories.js create mode 100644 src2/components/products/ProductSimilar.js create mode 100644 src2/components/products/ProductSlider.js create mode 100644 src2/components/transactions/TransactionDetail.js create mode 100644 src2/components/transactions/TransactionStatusBadge.js create mode 100644 src2/components/variants/VariantCard.js create mode 100644 src2/components/variants/VariantGroupCard.js create mode 100644 src2/core/utils/address.js create mode 100644 src2/core/utils/apiOdoo.js create mode 100644 src2/core/utils/auth.js create mode 100644 src2/core/utils/cart.js create mode 100644 src2/core/utils/convertToOption.js create mode 100644 src2/core/utils/currencyFormat.js create mode 100644 src2/core/utils/formValidation.js create mode 100644 src2/core/utils/getFileBase64.js create mode 100644 src2/core/utils/greeting.js create mode 100644 src2/core/utils/mailer.js create mode 100644 src2/core/utils/slug.js create mode 100644 src2/core/utils/toTitleCase.js create mode 100644 src2/icons/chevron-left.svg create mode 100644 src2/icons/chevron-right.svg create mode 100644 src2/icons/close.svg create mode 100644 src2/icons/filter.svg create mode 100644 src2/icons/image-placeholder.svg create mode 100644 src2/icons/instagram.svg create mode 100644 src2/icons/linkedin.svg create mode 100644 src2/icons/menu.svg create mode 100644 src2/icons/minus.svg create mode 100644 src2/icons/plus.svg create mode 100644 src2/icons/search.svg create mode 100644 src2/icons/shopping-cart.svg create mode 100644 src2/icons/trash.svg create mode 100644 src2/images/logo.png create mode 100644 src2/images/page-not-found.png create mode 100644 src2/lib/elements/hooks/useBottomPopup.js create mode 100644 src2/lib/elements/hooks/useConfirmAlert.js create mode 100644 src2/pages/404.js create mode 100644 src2/pages/_app.js create mode 100644 src2/pages/_error.js create mode 100644 src2/pages/activate.js create mode 100644 src2/pages/api/activation-request.js create mode 100644 src2/pages/api/activation.js create mode 100644 src2/pages/api/login.js create mode 100644 src2/pages/api/register.js create mode 100644 src2/pages/api/shop/search.js create mode 100644 src2/pages/api/shop/suggest.js create mode 100644 src2/pages/api/token.js create mode 100644 src2/pages/faqs.js create mode 100644 src2/pages/index.js create mode 100644 src2/pages/login.js create mode 100644 src2/pages/logout.js create mode 100644 src2/pages/my/address/[id]/edit.js create mode 100644 src2/pages/my/address/create.js create mode 100644 src2/pages/my/address/index.js create mode 100644 src2/pages/my/invoice/[id].js create mode 100644 src2/pages/my/invoices.js create mode 100644 src2/pages/my/menu.js create mode 100644 src2/pages/my/profile.js create mode 100644 src2/pages/my/transaction/[id].js create mode 100644 src2/pages/my/transactions.js create mode 100644 src2/pages/my/wishlist.js create mode 100644 src2/pages/register.js create mode 100644 src2/pages/shop/brands/[slug].js create mode 100644 src2/pages/shop/brands/index.js create mode 100644 src2/pages/shop/cart.js create mode 100644 src2/pages/shop/checkout/finish.js create mode 100644 src2/pages/shop/checkout/index.js create mode 100644 src2/pages/shop/product/[slug].js create mode 100644 src2/pages/shop/quotation/finish.js create mode 100644 src2/pages/shop/quotation/index.js create mode 100644 src2/pages/shop/search.js create mode 100644 src2/styles/globals.css (limited to 'src2') diff --git a/src2/components/auth/WithAuth.js b/src2/components/auth/WithAuth.js new file mode 100644 index 00000000..ef975873 --- /dev/null +++ b/src2/components/auth/WithAuth.js @@ -0,0 +1,20 @@ +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/src2/components/elements/Alert.js b/src2/components/elements/Alert.js new file mode 100644 index 00000000..914d1590 --- /dev/null +++ b/src2/components/elements/Alert.js @@ -0,0 +1,19 @@ +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 ( +
{children}
+ ); +} + +export default Alert; \ No newline at end of file diff --git a/src2/components/elements/BottomPopup.js b/src2/components/elements/BottomPopup.js new file mode 100644 index 00000000..c1a56e10 --- /dev/null +++ b/src2/components/elements/BottomPopup.js @@ -0,0 +1,25 @@ +import CloseIcon from "@/icons/close.svg"; + +const BottomPopup = ({ + active = false, + title, + children, + closePopup = () => {} +}) => { + return ( + <> +
+
+
+

{ title }

+ +
+ { children } +
+ + ); +}; + +export default BottomPopup; \ No newline at end of file diff --git a/src2/components/elements/ConfirmAlert.js b/src2/components/elements/ConfirmAlert.js new file mode 100644 index 00000000..d33abb89 --- /dev/null +++ b/src2/components/elements/ConfirmAlert.js @@ -0,0 +1,27 @@ +const ConfirmAlert = ({ + title, + caption, + show, + onClose, + onSubmit, + closeText, + submitText +}) => { + return ( + <> + {show && ( +
+ )} +
+

{title}

+

{caption}

+
+ + +
+
+ + ); +}; + +export default ConfirmAlert; \ No newline at end of file diff --git a/src2/components/elements/DescriptionRow.js b/src2/components/elements/DescriptionRow.js new file mode 100644 index 00000000..7fe9e3a1 --- /dev/null +++ b/src2/components/elements/DescriptionRow.js @@ -0,0 +1,10 @@ +const DescriptionRow = ({ label, children }) => ( +
+

{ label }

+
+ { children } +
+
+); + +export default DescriptionRow; \ No newline at end of file diff --git a/src2/components/elements/Disclosure.js b/src2/components/elements/Disclosure.js new file mode 100644 index 00000000..1f334be3 --- /dev/null +++ b/src2/components/elements/Disclosure.js @@ -0,0 +1,14 @@ +const { ChevronUpIcon, ChevronDownIcon } = require("@heroicons/react/24/outline"); + +const Disclosure = ({ label, active, onClick }) => ( +
+

{ label }

+ { onClick && ( active ? ( + + ) : ( + + ) ) } +
+); + +export default Disclosure; \ No newline at end of file diff --git a/src2/components/elements/Fields.js b/src2/components/elements/Fields.js new file mode 100644 index 00000000..586a6a22 --- /dev/null +++ b/src2/components/elements/Fields.js @@ -0,0 +1,21 @@ +import ReactSelect from "react-select"; + +const Select = ({ + field, + ...props +}) => ( + <> + 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/src2/components/elements/Filter.js b/src2/components/elements/Filter.js new file mode 100644 index 00000000..f2051ba8 --- /dev/null +++ b/src2/components/elements/Filter.js @@ -0,0 +1,176 @@ +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 ( + <> + +
+ {(selectedBrand || selectedCategory || priceFrom || priceTo || orderBy) && ( + + )} + + {!disableFilter.includes('orderBy') && ( +
+ +
+ {sortOptions.map((sortOption, index) => ( + + ))} +
+
+ )} + + {!disableFilter.includes('category') && ( +
+ + +
+ )} + + {!disableFilter.includes('brand') && ( +
+ + +
+ )} + + {!disableFilter.includes('price') && ( +
+ +
+ setPriceFrom(e.target.value)}/> + + setPriceTo(e.target.value)}/> +
+
+ )} + +
+
+ + ) +}; + +export default Filter; \ No newline at end of file diff --git a/src2/components/elements/Image.js b/src2/components/elements/Image.js new file mode 100644 index 00000000..60e249b9 --- /dev/null +++ b/src2/components/elements/Image.js @@ -0,0 +1,17 @@ +import { LazyLoadImage } from "react-lazy-load-image-component" +import "react-lazy-load-image-component/src/effects/opacity.css" + +const Image = ({ ...props }) => { + return ( + + ) +} + +Image.defaultProps = LazyLoadImage.defaultProps + +export default Image \ No newline at end of file diff --git a/src2/components/elements/LineDivider.js b/src2/components/elements/LineDivider.js new file mode 100644 index 00000000..4e8c7b52 --- /dev/null +++ b/src2/components/elements/LineDivider.js @@ -0,0 +1,7 @@ +const LineDivider = () => { + return ( +
+ ); +}; + +export default LineDivider; \ No newline at end of file diff --git a/src2/components/elements/Link.js b/src2/components/elements/Link.js new file mode 100644 index 00000000..065b5c9e --- /dev/null +++ b/src2/components/elements/Link.js @@ -0,0 +1,13 @@ +import NextLink from "next/link"; + +const Link = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +Link.defaultProps = NextLink.defaultProps + +export default Link \ No newline at end of file diff --git a/src2/components/elements/Pagination.js b/src2/components/elements/Pagination.js new file mode 100644 index 00000000..ff2a8462 --- /dev/null +++ b/src2/components/elements/Pagination.js @@ -0,0 +1,58 @@ +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 && ( +
+ {Array.from(Array(pageCount)).map((v, i) => { + let page = i + 1; + let rangePrevPage = currentPage - 2; + let rangeNextPage = currentPage + 2; + let PageComponent = {page}; + let DotsComponent =
...
; + + 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; + } + })} +
+ ) +} \ No newline at end of file diff --git a/src2/components/elements/ProgressBar.js b/src2/components/elements/ProgressBar.js new file mode 100644 index 00000000..0adedcdf --- /dev/null +++ b/src2/components/elements/ProgressBar.js @@ -0,0 +1,25 @@ +import { Fragment } from "react"; + +const ProgressBar = ({ current, labels }) => { + return ( +
+ {labels.map((label, index) => ( + +
+
+ { index + 1 } +
+

{ label }

+
+ { index < (labels.length - 1) && ( +
+
+
+ ) } +
+ ))} +
+ ) +} + +export default ProgressBar; \ No newline at end of file diff --git a/src2/components/elements/Skeleton.js b/src2/components/elements/Skeleton.js new file mode 100644 index 00000000..fbdbc245 --- /dev/null +++ b/src2/components/elements/Skeleton.js @@ -0,0 +1,48 @@ +import ImagePlaceholderIcon from "../../icons/image-placeholder.svg"; + +const SkeletonList = ({ number }) => ( +
+ { Array.from(Array(number), (e, i) => ( +
+
+
+
+
+
+
+ )) } + Loading... +
+); + +const SkeletonProduct = () => ( +
+
+
+ +
+
+
+
+
+
+ Loading... +
+
+
+ +
+
+
+
+
+
+ Loading... +
+
+); + +export { + SkeletonList, + SkeletonProduct +}; \ No newline at end of file diff --git a/src2/components/elements/Spinner.js b/src2/components/elements/Spinner.js new file mode 100644 index 00000000..21006ecd --- /dev/null +++ b/src2/components/elements/Spinner.js @@ -0,0 +1,13 @@ +const Spinner = ({ className }) => { + return ( +
+ + Loading... +
+ ) +} + +export default Spinner; \ No newline at end of file diff --git a/src2/components/layouts/AppBar.js b/src2/components/layouts/AppBar.js new file mode 100644 index 00000000..fe74c940 --- /dev/null +++ b/src2/components/layouts/AppBar.js @@ -0,0 +1,47 @@ +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 ( + <> + + { title } - Indoteknik + +
+ {/* --- Start Title */} +
+ +

{ title }

+
+ {/* --- End Title */} + + {/* --- Start Icons */} +
+ + + + + + + + + +
+ {/* --- End Icons */} +
+ + ); +}; + +export default AppBar; \ No newline at end of file diff --git a/src2/components/layouts/Footer.js b/src2/components/layouts/Footer.js new file mode 100644 index 00000000..d173a525 --- /dev/null +++ b/src2/components/layouts/Footer.js @@ -0,0 +1,91 @@ +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 ( +
+
+
+

Kantor Pusat

+

+ Jl. Bandengan Utara 85A No. 8-9 RT.3/RW.16, Penjaringan, Kec. Penjaringan +

+ +

Layanan Informasi

+ + + + +

Panduan Pelanggan

+
+ FAQ + Kebijakan Privasi + Pengajuan Tempo + Garansi Produk + Online Quotation + Pengiriman + Pembayaran + Syarat & Ketentuan + +
+
+
+

Jam Operasional

+

+ Senin - Jumat: 08:30 - 17:00 +

+

+ Sabtu: 08:30 - 14:00 +

+ +

Temukan Kami

+
+ + +
+ +

Pembayaran

+
+ BCA Payment + BCA Payment + BCA Payment + BCA Payment + BCA Payment + BCA Payment + BCA Payment + BCA Payment +
+ + {/*

Pengiriman

*/} +
+
+
PT. Indoteknik Dotcom Gemilang
+
+ ); +} \ No newline at end of file diff --git a/src2/components/layouts/Header.js b/src2/components/layouts/Header.js new file mode 100644 index 00000000..23fda642 --- /dev/null +++ b/src2/components/layouts/Header.js @@ -0,0 +1,253 @@ +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 ( + <> + + + {title} + + +
+
+ { auth && ( + +
+

{ greeting() },

+

{auth.name}

+
+
+ +
+ + ) } + + { !auth && ( + <> + Masuk + Daftar + + ) } +
+
+ { menus.map((menu, index) => ( + + { menu.name } +
+ +
+ + )) } +
setOpenCategory(!isOpenCategory)}> + Kategori +
+ { !isOpenCategory && } + { isOpenCategory && } +
+
+ { isOpenCategory && categories.map((category) => ( + +
+ + { category.name } + +
toggleCategories(category.id)}> + { !category.isOpen && } + { category.isOpen && } +
+
+ { category.isOpen && category.childs.map((child1Category) => ( + +
+ + { child1Category.name } + + { child1Category.childs.length > 0 && ( +
toggleCategories(child1Category.id)}> + { !child1Category.isOpen && } + { child1Category.isOpen && } +
+ ) } +
+ { child1Category.isOpen && child1Category.childs.map((child2Category) => ( + + { child2Category.name } + + )) } +
+ )) } +
+ )) } +
+
+ + +
+
+ + Logo Indoteknik + +
+ + + + + + + +
+
+
+ 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" + /> + + + + {suggestions.length > 1 && ( +
+ {suggestions.map((suggestion, index) => ( +

clickSuggestion(suggestion.term)} className="w-full p-2" key={index}>{suggestion.term}

+ ))} +
+ )} +
+
+ + {suggestions.length > 1 && ( +
setSuggestions([])}>
+ )} + + ) +} \ No newline at end of file diff --git a/src2/components/layouts/Layout.js b/src2/components/layouts/Layout.js new file mode 100644 index 00000000..fd507963 --- /dev/null +++ b/src2/components/layouts/Layout.js @@ -0,0 +1,20 @@ +import { motion } from 'framer-motion'; + +export default function Layout({ children, ...pageProps }) { + const transition = { + ease: 'easeOut', + duration: 0.3 + }; + + return children && ( + + {children} + + ); +} \ No newline at end of file diff --git a/src2/components/manufactures/ManufactureCard.js b/src2/components/manufactures/ManufactureCard.js new file mode 100644 index 00000000..73a96902 --- /dev/null +++ b/src2/components/manufactures/ManufactureCard.js @@ -0,0 +1,18 @@ +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 ( + + {manufacture.logo ? ( + {manufacture.name} + ) : manufacture.name} + + ); +} \ No newline at end of file diff --git a/src2/components/products/ProductCard.js b/src2/components/products/ProductCard.js new file mode 100644 index 00000000..c79a4900 --- /dev/null +++ b/src2/components/products/ProductCard.js @@ -0,0 +1,69 @@ +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 ( +
+ + {product.name} + {product.variant_total > 1 ? ( +
{product.variant_total} Varian
+ ) : ''} + +
+
+ {typeof product.manufacture.name !== "undefined" ? ( + {product.manufacture.name} + ) : ( + - + )} + + {product.name} + +
+
+ {product.lowest_price.discount_percentage > 0 ? ( +
+

{currencyFormat(product.lowest_price.price)}

+ {product.lowest_price.discount_percentage}% +
+ ) : ''} + + {product.lowest_price.price_discount > 0 ? ( +

+ {currencyFormat(product.lowest_price.price_discount)} +

+ ) : ( + + Tanya Harga + + )} + + {product.stock_total > 0 ? ( +
+
Ready Stock
+
{product.stock_total > 5 ? '> 5' : '< 5'}
+
+ ) : ''} +
+
+
+ ) +} \ No newline at end of file diff --git a/src2/components/products/ProductCategories.js b/src2/components/products/ProductCategories.js new file mode 100644 index 00000000..3b671f29 --- /dev/null +++ b/src2/components/products/ProductCategories.js @@ -0,0 +1,62 @@ +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 ( +
+ { content ? ( + + ) : } +
+ ); +} + +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 ( +
+ { contentIds.map((contentId) => ( + } key={contentId}> + + + )) } +
+ ) +} \ No newline at end of file diff --git a/src2/components/products/ProductSimilar.js b/src2/components/products/ProductSimilar.js new file mode 100644 index 00000000..9e2292cb --- /dev/null +++ b/src2/components/products/ProductSimilar.js @@ -0,0 +1,25 @@ +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 ( +
+

Kamu Mungkin Juga Suka

+ +
+ ) +} \ No newline at end of file diff --git a/src2/components/products/ProductSlider.js b/src2/components/products/ProductSlider.js new file mode 100644 index 00000000..662a6511 --- /dev/null +++ b/src2/components/products/ProductSlider.js @@ -0,0 +1,39 @@ +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 && ( + {products.banner.name} 0 ? 'opacity-0' : 'opacity-100')} /> + ) } + + { bannerMode && ( + + + + ) } + {products?.products?.map((product, index) => ( + + + + ))} + + { !products ? : ''} + + ) +} \ No newline at end of file diff --git a/src2/components/transactions/TransactionDetail.js b/src2/components/transactions/TransactionDetail.js new file mode 100644 index 00000000..295a4f9f --- /dev/null +++ b/src2/components/transactions/TransactionDetail.js @@ -0,0 +1,67 @@ +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 ( +
+ { address?.name } + { address?.email || '-' } + { address?.mobile || '-' } + { fullAddress.join(', ') } +
+ ); +}; + +const TransactionDetailAddress = ({ transaction }) => { + const [ activeSection, setActiveSection ] = useState({ + purchase: false, + shipping: false, + invoice: false, + }); + + const toggleSection = ( name ) => { + setActiveSection({ + ...activeSection, + [name]: !activeSection[name] + }); + }; + + return ( +
+ toggleSection('purchase')} + /> + { activeSection.purchase && ( + + ) } + + toggleSection('shipping')} + /> + { activeSection.shipping && ( + + ) } + + toggleSection('invoice')} + /> + { activeSection.invoice && ( + + ) } +
+ ); +}; + +export { TransactionDetailAddress }; \ No newline at end of file diff --git a/src2/components/transactions/TransactionStatusBadge.js b/src2/components/transactions/TransactionStatusBadge.js new file mode 100644 index 00000000..f94fd3fd --- /dev/null +++ b/src2/components/transactions/TransactionStatusBadge.js @@ -0,0 +1,45 @@ +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 ( +
+ { badgeProps.text } +
+ ) +}; + +export default TransactionStatusBadge; \ No newline at end of file diff --git a/src2/components/variants/VariantCard.js b/src2/components/variants/VariantCard.js new file mode 100644 index 00000000..a821480c --- /dev/null +++ b/src2/components/variants/VariantCard.js @@ -0,0 +1,92 @@ +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 = () => ( +
+
+ {product.parent.name} +
+
+

+ {product.parent.name} +

+

+ {product.code || '-'} + {product.attributes.length > 0 ? ` ・ ${product.attributes.join(', ')}` : ''} +

+
+ {product.price.discount_percentage > 0 && ( + <> +

{currencyFormat(product.price.price)}

+ {product.price.discount_percentage}% + + )} +

{currencyFormat(product.price.price_discount)}

+
+

+ {currencyFormat(product.price.price_discount)} × {product.quantity} Barang +

+

+ {currencyFormat(product.quantity * product.price.price_discount)} +

+
+
+ ); + + if (openOnClick) { + return ( + <> + + + + { buyMore && ( +
+ + +
+ ) } + + ); + } + + return ; +} \ No newline at end of file diff --git a/src2/components/variants/VariantGroupCard.js b/src2/components/variants/VariantGroupCard.js new file mode 100644 index 00000000..462c63cf --- /dev/null +++ b/src2/components/variants/VariantGroupCard.js @@ -0,0 +1,31 @@ +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) => ( + + )) } + { variants.length > 2 && ( + + ) } + + ) +} \ No newline at end of file diff --git a/src2/core/utils/address.js b/src2/core/utils/address.js new file mode 100644 index 00000000..c4a19af5 --- /dev/null +++ b/src2/core/utils/address.js @@ -0,0 +1,27 @@ +const getAddress = () => { + const address = localStorage.getItem('address'); + if (address) return JSON.parse(address); + return {}; +} + +const setAddress = (address) => { + localStorage.setItem('address', JSON.stringify(address)); + return true; +} + +const getItemAddress = (key) => { + let address = getAddress(); + return address[key]; +} + +const createOrUpdateItemAddress = (key, value) => { + let address = getAddress(); + address[key] = value; + setAddress(address); + return true; +} + +export { + getItemAddress, + createOrUpdateItemAddress +}; \ No newline at end of file diff --git a/src2/core/utils/apiOdoo.js b/src2/core/utils/apiOdoo.js new file mode 100644 index 00000000..4d0adae3 --- /dev/null +++ b/src2/core/utils/apiOdoo.js @@ -0,0 +1,44 @@ +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/src2/core/utils/auth.js b/src2/core/utils/auth.js new file mode 100644 index 00000000..62eba2c0 --- /dev/null +++ b/src2/core/utils/auth.js @@ -0,0 +1,38 @@ +import { deleteCookie, getCookie, setCookie } from 'cookies-next'; +import { useEffect, useState } from 'react'; + +const getAuth = () => { + let auth = getCookie('auth'); + if (auth) { + return JSON.parse(auth); + } + return false; +} + +const setAuth = (user) => { + setCookie('auth', JSON.stringify(user)); + return true; +} + +const deleteAuth = () => { + 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 diff --git a/src2/core/utils/cart.js b/src2/core/utils/cart.js new file mode 100644 index 00000000..66efcbf2 --- /dev/null +++ b/src2/core/utils/cart.js @@ -0,0 +1,36 @@ +const getCart = () => { + const cart = localStorage.getItem('cart'); + if (cart) return JSON.parse(cart); + return {}; +} + +const setCart = (cart) => { + localStorage.setItem('cart', JSON.stringify(cart)); + return true; +} + +const getItemCart = (product_id) => { + let cart = getCart(); + return cart[product_id]; +} + +const createOrUpdateItemCart = (product_id, quantity, selected = false) => { + let cart = getCart(); + cart[product_id] = { product_id, quantity, selected }; + setCart(cart); + return true; +} + +const deleteItemCart = (product_id) => { + let cart = getCart(); + delete cart[product_id]; + setCart(cart); + return true; +} + +export { + getCart, + getItemCart, + createOrUpdateItemCart, + deleteItemCart +} \ No newline at end of file diff --git a/src2/core/utils/convertToOption.js b/src2/core/utils/convertToOption.js new file mode 100644 index 00000000..08fec08f --- /dev/null +++ b/src2/core/utils/convertToOption.js @@ -0,0 +1,11 @@ +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/src2/core/utils/currencyFormat.js b/src2/core/utils/currencyFormat.js new file mode 100644 index 00000000..dadeaec6 --- /dev/null +++ b/src2/core/utils/currencyFormat.js @@ -0,0 +1,8 @@ +export default function currencyFormat(value) { + const currency = new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0 + }); + return currency.format(value); +} \ No newline at end of file diff --git a/src2/core/utils/formValidation.js b/src2/core/utils/formValidation.js new file mode 100644 index 00000000..0e83f4cc --- /dev/null +++ b/src2/core/utils/formValidation.js @@ -0,0 +1,107 @@ +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/src2/core/utils/getFileBase64.js b/src2/core/utils/getFileBase64.js new file mode 100644 index 00000000..78013e43 --- /dev/null +++ b/src2/core/utils/getFileBase64.js @@ -0,0 +1,11 @@ +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 diff --git a/src2/core/utils/greeting.js b/src2/core/utils/greeting.js new file mode 100644 index 00000000..7dc19f8f --- /dev/null +++ b/src2/core/utils/greeting.js @@ -0,0 +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'; +} + +export default greeting; \ No newline at end of file diff --git a/src2/core/utils/mailer.js b/src2/core/utils/mailer.js new file mode 100644 index 00000000..4e7ff7cc --- /dev/null +++ b/src2/core/utils/mailer.js @@ -0,0 +1,12 @@ +const nodemailer = require('nodemailer'); +const mailer = nodemailer.createTransport({ + port: process.env.MAIL_PORT, + host: process.env.MAIL_HOST, + auth: { + user: process.env.MAIL_USER, + pass: process.env.MAIL_PASS + }, + secure: true +}); + +export default mailer; \ No newline at end of file diff --git a/src2/core/utils/slug.js b/src2/core/utils/slug.js new file mode 100644 index 00000000..0a7d30fc --- /dev/null +++ b/src2/core/utils/slug.js @@ -0,0 +1,25 @@ +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 getIdFromSlug = (slug) => { + let id = slug.split('-'); + return id[id.length-1]; +} + +const getNameFromSlug = (slug) => { + let name = slug.split('-'); + name.pop(); + return toTitleCase(name.join(' ')); +} + +export { + createSlug, + getIdFromSlug, + getNameFromSlug +}; \ No newline at end of file diff --git a/src2/core/utils/toTitleCase.js b/src2/core/utils/toTitleCase.js new file mode 100644 index 00000000..5cfd70d0 --- /dev/null +++ b/src2/core/utils/toTitleCase.js @@ -0,0 +1,8 @@ +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 diff --git a/src2/icons/chevron-left.svg b/src2/icons/chevron-left.svg new file mode 100644 index 00000000..a22ce386 --- /dev/null +++ b/src2/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src2/icons/chevron-right.svg b/src2/icons/chevron-right.svg new file mode 100644 index 00000000..eb58f2f2 --- /dev/null +++ b/src2/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src2/icons/close.svg b/src2/icons/close.svg new file mode 100644 index 00000000..50e0589d --- /dev/null +++ b/src2/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src2/icons/filter.svg b/src2/icons/filter.svg new file mode 100644 index 00000000..c15ce7b9 --- /dev/null +++ b/src2/icons/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src2/icons/image-placeholder.svg b/src2/icons/image-placeholder.svg new file mode 100644 index 00000000..935e1097 --- /dev/null +++ b/src2/icons/image-placeholder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src2/icons/instagram.svg b/src2/icons/instagram.svg new file mode 100644 index 00000000..d90842c6 --- /dev/null +++ b/src2/icons/instagram.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src2/icons/linkedin.svg b/src2/icons/linkedin.svg new file mode 100644 index 00000000..a68aec96 --- /dev/null +++ b/src2/icons/linkedin.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src2/icons/menu.svg b/src2/icons/menu.svg new file mode 100644 index 00000000..5d067e8e --- /dev/null +++ b/src2/icons/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src2/icons/minus.svg b/src2/icons/minus.svg new file mode 100644 index 00000000..12a10199 --- /dev/null +++ b/src2/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src2/icons/plus.svg b/src2/icons/plus.svg new file mode 100644 index 00000000..2923c684 --- /dev/null +++ b/src2/icons/plus.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src2/icons/search.svg b/src2/icons/search.svg new file mode 100644 index 00000000..6de1cdfa --- /dev/null +++ b/src2/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src2/icons/shopping-cart.svg b/src2/icons/shopping-cart.svg new file mode 100644 index 00000000..09f14ca6 --- /dev/null +++ b/src2/icons/shopping-cart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src2/icons/trash.svg b/src2/icons/trash.svg new file mode 100644 index 00000000..e23673ee --- /dev/null +++ b/src2/icons/trash.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src2/images/logo.png b/src2/images/logo.png new file mode 100644 index 00000000..87c696aa Binary files /dev/null and b/src2/images/logo.png differ diff --git a/src2/images/page-not-found.png b/src2/images/page-not-found.png new file mode 100644 index 00000000..296c0443 Binary files /dev/null and b/src2/images/page-not-found.png differ diff --git a/src2/lib/elements/hooks/useBottomPopup.js b/src2/lib/elements/hooks/useBottomPopup.js new file mode 100644 index 00000000..88b72316 --- /dev/null +++ b/src2/lib/elements/hooks/useBottomPopup.js @@ -0,0 +1,40 @@ +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 = ( + + { children(dataPopup) } + + ); + + return { + dataPopup, + BottomPopup, + closePopup, + openPopup + } +} + +export default useBottomPopup; \ No newline at end of file diff --git a/src2/lib/elements/hooks/useConfirmAlert.js b/src2/lib/elements/hooks/useConfirmAlert.js new file mode 100644 index 00000000..4975c57d --- /dev/null +++ b/src2/lib/elements/hooks/useConfirmAlert.js @@ -0,0 +1,49 @@ +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 = ( + + ); + + return { + isOpen, + closeConfirmAlert, + openConfirmAlert, + ConfirmAlert + }; +} + +export default useConfirmAlert; \ No newline at end of file diff --git a/src2/pages/404.js b/src2/pages/404.js new file mode 100644 index 00000000..1e1850f2 --- /dev/null +++ b/src2/pages/404.js @@ -0,0 +1,27 @@ +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 ( + <> +
+ +
+ Halaman Tidak Ditemukan - Indoteknik +

Halaman tidak ditemukan

+
+ + Kembali ke beranda + + + Tanya admin + +
+
+
+ + ); +} \ No newline at end of file diff --git a/src2/pages/_app.js b/src2/pages/_app.js new file mode 100644 index 00000000..6a40f4e6 --- /dev/null +++ b/src2/pages/_app.js @@ -0,0 +1,31 @@ +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 ( + <> + + + window.scrollTo(0, 0)} + > + + + + ) +} + +export default MyApp diff --git a/src2/pages/_error.js b/src2/pages/_error.js new file mode 100644 index 00000000..107ddf46 --- /dev/null +++ b/src2/pages/_error.js @@ -0,0 +1,11 @@ +import Header from "@/components/layouts/Header"; +import Layout from "@/components/layouts/Layout"; + +export default function Error() { + return ( + +
+ + + ); +} \ No newline at end of file diff --git a/src2/pages/activate.js b/src2/pages/activate.js new file mode 100644 index 00000000..d9b41bf4 --- /dev/null +++ b/src2/pages/activate.js @@ -0,0 +1,111 @@ +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, kembali ke beranda., + type: 'success' + }); + } else { + setAlert({ + component: <>Mohon maaf token sudah tidak aktif, lakukan permintaan aktivasi akun kembali atau masuk 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, daftar sekarang., + type: 'info' + }); + break; + case 'ACTIVE': + setAlert({ + component: <>Email tersebut sudah terdaftar dan sudah aktif, masuk sekarang., + type: 'info' + }); + break; + } + } + setIsLoading(false); + } + return ( + <> + + Aktivasi Akun Indoteknik + + + + Logo Indoteknik + +

Aktivasi Akun Indoteknik Anda

+

Link aktivasi akan dikirimkan melalui email

+ {alert ? ( + {alert.component} + ) : ''} +
+ setEmail(e.target.value)} + autoFocus + /> + +
+
+ + ) +} \ No newline at end of file diff --git a/src2/pages/api/activation-request.js b/src2/pages/api/activation-request.js new file mode 100644 index 00000000..3f33875c --- /dev/null +++ b/src2/pages/api/activation-request.js @@ -0,0 +1,31 @@ +import apiOdoo from "@/core/utils/apiOdoo"; +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) { + mailer.sendMail({ + from: 'sales@indoteknik.com', + to: result.user.email, + subject: 'Permintaan Aktivasi Akun Indoteknik', + html: ` +

Permintaan Aktivasi Akun Indoteknik

+
+

Aktivasi akun anda melalui link berikut: Aktivasi Akun

+ ` + }); + } + delete result.user; + delete result.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/src2/pages/api/activation.js b/src2/pages/api/activation.js new file mode 100644 index 00000000..8b22af8d --- /dev/null +++ b/src2/pages/api/activation.js @@ -0,0 +1,16 @@ +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/src2/pages/api/login.js b/src2/pages/api/login.js new file mode 100644 index 00000000..e02a73cb --- /dev/null +++ b/src2/pages/api/login.js @@ -0,0 +1,15 @@ +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/src2/pages/api/register.js b/src2/pages/api/register.js new file mode 100644 index 00000000..7c8d8b39 --- /dev/null +++ b/src2/pages/api/register.js @@ -0,0 +1,15 @@ +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/src2/pages/api/shop/search.js b/src2/pages/api/shop/search.js new file mode 100644 index 00000000..ad986c86 --- /dev/null +++ b/src2/pages/api/shop/search.js @@ -0,0 +1,96 @@ +import axios from "axios"; + +const productResponseMap = (products) => { + return products.map((product) => { + let productMapped = { + id: product.product_id ? product.product_id[0] : '', + image: product.image ? product.image[0] : '', + code: product.default_code ? product.default_code[0] : '', + name: product.product_name ? product.product_name[0] : '', + lowest_price: { + 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, + }, + variant_total: product.variant_total ? product.variant_total[0] : 0, + stock_total: product.stock_total ? product.stock_total[0] : 0, + weight: product.weight ? product.weight[0] : 0, + manufacture: {}, + categories: [], + }; + + if (product.manufacture_id && product.brand) { + productMapped.manufacture = { + id: product.manufacture_id ? product.manufacture_id[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] : '', + } + ]; + + return productMapped; + }); +} + +export default async function handler(req, res) { + const { + q, + page = 1, + brand = '', + category = '', + price_from = 0, + price_to = 0, + order_by = '' + } = req.query; + + let paramOrderBy = ''; + switch (order_by) { + case 'price-asc': + paramOrderBy = ', price_discount ASC'; + break; + case 'price-desc': + paramOrderBy = ', price_discount DESC'; + break; + case 'popular': + paramOrderBy = ', search_rank DESC'; + break; + case 'stock': + paramOrderBy = ', stock_total DESC'; + break; + } + + let limit = 30; + let offset = (page - 1) * limit; + let parameter = [ + `facet.query=${q}`, + 'facet=true', + 'indent=true', + 'q.op=AND', + `q=${q}`, + 'facet.field=brand_str', + 'facet.field=category_name_str', + `start=${offset}`, + `rows=${limit}`, + `sort=product_rating DESC ${paramOrderBy}`, + `fq=price_discount:[${price_from == '' ? '*' : price_from} TO ${price_to == '' ? '*' : price_to}]` + ]; + + 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); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} \ No newline at end of file diff --git a/src2/pages/api/shop/suggest.js b/src2/pages/api/shop/suggest.js new file mode 100644 index 00000000..6db1a851 --- /dev/null +++ b/src2/pages/api/shop/suggest.js @@ -0,0 +1,12 @@ +import axios from "axios"; + +export default async function handler(req, res) { + const { q } = req.query; + + 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); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} \ No newline at end of file diff --git a/src2/pages/api/token.js b/src2/pages/api/token.js new file mode 100644 index 00000000..ec048158 --- /dev/null +++ b/src2/pages/api/token.js @@ -0,0 +1,10 @@ +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/src2/pages/faqs.js b/src2/pages/faqs.js new file mode 100644 index 00000000..cdb8ef52 --- /dev/null +++ b/src2/pages/faqs.js @@ -0,0 +1,91 @@ +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 ( + + + +
+ { faqs.map((faq, index) => ( +
+
+
+

{ faq.name }

+

+ { faq.description } +

+
+ +
+ { faq.isOpen && ( +

+ { 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.' } +

+ ) } +
+ )) } +
+
+ ) +} \ No newline at end of file diff --git a/src2/pages/index.js b/src2/pages/index.js new file mode 100644 index 00000000..65999ff6 --- /dev/null +++ b/src2/pages/index.js @@ -0,0 +1,106 @@ +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 ( + <> +
+ + + { + heroBanners?.map((banner, index) => ( + + {banner.name} + + )) + } + +
+

Brand Pilihan

+ + { + manufactures?.manufactures?.map((manufacture, index) => ( + + + + )) + } + +
+
+

Produk Populer

+ +
+ + + +
+
Platform Belanja B2B Alat Teknik & Industri di Indonesia
+

+ 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 +

+
+ +