diff options
| author | Rafi Zadanly <zadanlyr@gmail.com> | 2023-02-17 17:07:50 +0700 |
|---|---|---|
| committer | Rafi Zadanly <zadanlyr@gmail.com> | 2023-02-17 17:07:50 +0700 |
| commit | f99e0aba70efad0deb907d8e27f09fc9f527c8a4 (patch) | |
| tree | f0ac96e4e736a1d385e32553f0e641ee27e11fd3 /src/lib | |
| parent | 90e1edab9b6a8ccc09a49fed3addbec2cbc4e4c3 (diff) | |
Refactor
Diffstat (limited to 'src/lib')
38 files changed, 1150 insertions, 89 deletions
diff --git a/src/lib/brand/api/BrandApi.js b/src/lib/brand/api/BrandApi.js new file mode 100644 index 00000000..15634cc4 --- /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
\ No newline at end of file diff --git a/src/lib/brand/components/Brand.jsx b/src/lib/brand/components/Brand.jsx new file mode 100644 index 00000000..a42f4c81 --- /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-[200px]"> + { 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 pt-2"> + <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
\ No newline at end of file diff --git a/src/lib/brand/components/BrandCard.jsx b/src/lib/brand/components/BrandCard.jsx new file mode 100644 index 00000000..8783010e --- /dev/null +++ b/src/lib/brand/components/BrandCard.jsx @@ -0,0 +1,20 @@ +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" + > + <Image + src={brand.logo} + alt={brand.name} + className="h-10 object-contain object-center" + /> + </Link> + ) +} + +export default BrandCard
\ No newline at end of file diff --git a/src/lib/brand/hooks/useBrand.js b/src/lib/brand/hooks/useBrand.js new file mode 100644 index 00000000..be42a44c --- /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
\ No newline at end of file diff --git a/src/lib/cart/api/CartApi.js b/src/lib/cart/api/CartApi.js new file mode 100644 index 00000000..9a5b5053 --- /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
\ No newline at end of file diff --git a/src/lib/cart/components/Cart.jsx b/src/lib/cart/components/Cart.jsx new file mode 100644 index 00000000..5f9ae1c0 --- /dev/null +++ b/src/lib/cart/components/Cart.jsx @@ -0,0 +1,30 @@ +import Link from "@/core/components/elements/Link/Link" +import useCart from "../hooks/useCart" +import Image from "@/core/components/elements/Image/Image" + +const Cart = () => { + const { cart } = useCart() + + return ( + <div className="p-4"> + <div className="flex justify-between mb-4"> + <h1 className="font-semibold">Daftar Produk Belanja</h1> + <Link href="/">Cari Produk Lain</Link> + </div> + <div className="flex flex-col gap-y-4"> + { cart.data?.map((product) => ( + <div key={product.id} className="flex"> + <div className="w-4/12"> + <Image src={product?.parent?.image} alt={product?.name} className="object-contain object-center border border-gray_r-6 h-32 w-full rounded-md" /> + </div> + <div className="flex-1 px-2"> + <div>{ product?.parent?.name }</div> + </div> + </div> + )) } + </div> + </div> + ) +} + +export default Cart
\ No newline at end of file diff --git a/src/lib/cart/hooks/useCart.js b/src/lib/cart/hooks/useCart.js new file mode 100644 index 00000000..44931b8a --- /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 = () => { + const cart = getCart() + const variantIds = _.keys(cart).join(',') + const fetchCart = async () => CartApi({ variantIds }) + const { data, isLoading } = useQuery('cart', fetchCart) + + return { + cart: { data, isLoading } + } +} + +export default useCart
\ No newline at end of file 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..efb31240 --- /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
\ No newline at end of file diff --git a/src/lib/home/api/categoryHomeIdApi.js b/src/lib/home/api/categoryHomeIdApi.js new file mode 100644 index 00000000..d5612195 --- /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
\ No newline at end of file diff --git a/src/lib/home/api/heroBannerApi.js b/src/lib/home/api/heroBannerApi.js new file mode 100644 index 00000000..7ba84bc6 --- /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
\ No newline at end of file diff --git a/src/lib/home/api/popularProductApi.js b/src/lib/home/api/popularProductApi.js new file mode 100644 index 00000000..d7adca83 --- /dev/null +++ b/src/lib/home/api/popularProductApi.js @@ -0,0 +1,8 @@ +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
\ No newline at end of file diff --git a/src/lib/home/api/preferredBrandApi.js b/src/lib/home/api/preferredBrandApi.js new file mode 100644 index 00000000..f289f387 --- /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
\ No newline at end of file diff --git a/src/lib/home/components/CategoryHome.jsx b/src/lib/home/components/CategoryHome.jsx new file mode 100644 index 00000000..0bca9846 --- /dev/null +++ b/src/lib/home/components/CategoryHome.jsx @@ -0,0 +1,28 @@ +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
\ No newline at end of file diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx new file mode 100644 index 00000000..4cbbd1fc --- /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
\ No newline at end of file diff --git a/src/lib/home/components/HeroBanner.jsx b/src/lib/home/components/HeroBanner.jsx new file mode 100644 index 00000000..604ca8ac --- /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
\ No newline at end of file diff --git a/src/lib/home/components/PopularProduct.jsx b/src/lib/home/components/PopularProduct.jsx new file mode 100644 index 00000000..87e47218 --- /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
\ No newline at end of file diff --git a/src/lib/home/components/PreferredBrand.jsx b/src/lib/home/components/PreferredBrand.jsx new file mode 100644 index 00000000..3d3b1b69 --- /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={8} + freeMode + > + { preferredBrands.data?.manufactures.map((brand) => ( + <SwiperSlide key={brand.id}> + <BrandCard brand={brand} /> + </SwiperSlide> + )) } + </Swiper> + ) } + </div> + ) +} + +export default PreferredBrand
\ No newline at end of file diff --git a/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx new file mode 100644 index 00000000..c5b0fcaa --- /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
\ No newline at end of file diff --git a/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx new file mode 100644 index 00000000..6bdd3c82 --- /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
\ No newline at end of file diff --git a/src/lib/home/hooks/useCategoryHome.js b/src/lib/home/hooks/useCategoryHome.js new file mode 100644 index 00000000..14ef2a0f --- /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
\ No newline at end of file diff --git a/src/lib/home/hooks/useCategoryHomeId.js b/src/lib/home/hooks/useCategoryHomeId.js new file mode 100644 index 00000000..bb61b655 --- /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
\ No newline at end of file diff --git a/src/lib/home/hooks/useHeroBanner.js b/src/lib/home/hooks/useHeroBanner.js new file mode 100644 index 00000000..a15dda60 --- /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
\ No newline at end of file diff --git a/src/lib/home/hooks/usePopularProduct.js b/src/lib/home/hooks/usePopularProduct.js new file mode 100644 index 00000000..f69c2f71 --- /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
\ No newline at end of file diff --git a/src/lib/home/hooks/usePreferredBrand.js b/src/lib/home/hooks/usePreferredBrand.js new file mode 100644 index 00000000..4be9793e --- /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
\ No newline at end of file diff --git a/src/lib/product/api/productApi.js b/src/lib/product/api/productApi.js new file mode 100644 index 00000000..a543f086 --- /dev/null +++ b/src/lib/product/api/productApi.js @@ -0,0 +1,8 @@ +import odooApi from "@/core/api/odooApi" + +const productApi = async ({ id }) => { + const dataProduct = await odooApi('GET', `/api/v1/product/${id}`) + return dataProduct +} + +export default productApi
\ No newline at end of file diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js new file mode 100644 index 00000000..86b2914f --- /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
\ No newline at end of file diff --git a/src/lib/product/api/productSimilarApi.js b/src/lib/product/api/productSimilarApi.js new file mode 100644 index 00000000..1449d9ca --- /dev/null +++ b/src/lib/product/api/productSimilarApi.js @@ -0,0 +1,8 @@ +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
\ No newline at end of file diff --git a/src/lib/product/components/Product.jsx b/src/lib/product/components/Product.jsx new file mode 100644 index 00000000..2a3624e7 --- /dev/null +++ b/src/lib/product/components/Product.jsx @@ -0,0 +1,276 @@ +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 { addItemCart } from "@/core/utils/cart" + +const informationTabOptions = [ + { value: 'specification', label: 'Spesifikasi' }, + { value: 'description', label: 'Deskripsi' }, + { value: 'important', label: 'Info Penting' }, +] + +const Product = ({ product }) => { + 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(', ') : '' + console.log(variant); + 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 + } + addItemCart({ + productId: activeVariant.id, + quantity + }) + toast.success('Berhasil menambahkan ke keranjang') + } + + return ( + <> + <Image + src={product.image} + alt={product.name} + className="h-72 object-contain object-center w-full border-b border-gray_r-4 bg-white" + /> + + <div className="p-4"> + <Link href="/" className="mb-2">{ product.manufacture?.name }</Link> + <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?.priceDiscount)} + </div> + <Badge type="solid-red"> + {activeVariant?.price?.discountPercentage}% + </Badge> + </div> + ) } + <h3 className="text-red_r-11 font-semibold mt-1"> + { activeVariant?.price?.price > 0 ? currencyFormat(activeVariant?.price?.price) : ( + <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 border-b border-red_r-11' : '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
\ No newline at end of file diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx new file mode 100644 index 00000000..86ac3a64 --- /dev/null +++ b/src/lib/product/components/ProductCard.jsx @@ -0,0 +1,68 @@ +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"> + <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 bg-white" + /> + { 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 bg-white"> + <Link + href={createSlug('/shop/brands/', product?.manufacture?.name, product?.manufacture.id)} + className="mb-1" + > + {product?.manufacture?.name} + </Link> + <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
\ No newline at end of file diff --git a/src/lib/product/components/ProductFilter.jsx b/src/lib/product/components/ProductFilter.jsx new file mode 100644 index 00000000..023b6a8b --- /dev/null +++ b/src/lib/product/components/ProductFilter.jsx @@ -0,0 +1,131 @@ +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
\ No newline at end of file diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx new file mode 100644 index 00000000..14df9864 --- /dev/null +++ b/src/lib/product/components/ProductSearch.jsx @@ -0,0 +1,95 @@ +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
\ No newline at end of file diff --git a/src/lib/product/components/ProductSimilar.jsx b/src/lib/product/components/ProductSimilar.jsx new file mode 100644 index 00000000..89cab536 --- /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
\ No newline at end of file diff --git a/src/lib/product/components/ProductSlider.jsx b/src/lib/product/components/ProductSlider.jsx new file mode 100644 index 00000000..8d677547 --- /dev/null +++ b/src/lib/product/components/ProductSlider.jsx @@ -0,0 +1,51 @@ +import { Swiper, SwiperSlide } from "swiper/react" +import ProductCard from "./ProductCard" +import "swiper/css" +import Image from "@/core/components/elements/Image/Image" +import Link from "@/core/components/elements/Link/Link" +import { useState } 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 [ activeIndex, setActiveIndex ] = useState(0) + const swiperSliderFirstMove = (swiper) => { + setActiveIndex(swiper.activeIndex) + } + + return ( + <> + { bannerMode && ( + <Image + src={products.banner.image} + alt={products.banner.name} + className={`${bannerClassName} ${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 product={product} simpleTitle={simpleTitle} /> + </SwiperSlide> + )) } + </Swiper> + </> + ) +} + +export default ProductSlider
\ No newline at end of file diff --git a/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx new file mode 100644 index 00000000..e51a565c --- /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
\ No newline at end of file diff --git a/src/lib/product/hooks/useProductSearch.js b/src/lib/product/hooks/useProductSearch.js new file mode 100644 index 00000000..d23a8098 --- /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
\ No newline at end of file diff --git a/src/lib/product/hooks/useProductSimilar.js b/src/lib/product/hooks/useProductSimilar.js new file mode 100644 index 00000000..444fec0b --- /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
\ No newline at end of file |
