diff options
264 files changed, 7814 insertions, 383 deletions
diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..b377f1b3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": false, + "jsxSingleQuote": true, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "none", + "overrides": [ + { + "files": "*.css", + "options": { + "tabWidth": 4 + } + } + ], + "singleAttributePerLine": true +} diff --git a/package.json b/package.json index da31e920..20398943 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,18 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "format": "prettier --write \"./src/**/*.{js,jsx,ts,tsx}\" --config ./.prettierrc" }, "dependencies": { "@heroicons/react": "^2.0.13", "@hookform/resolvers": "^2.9.10", + "@tailwindcss/line-clamp": "^0.4.2", "axios": "^1.1.3", + "camelcase-object-deep": "^1.1.7", "cookies-next": "^2.1.1", "framer-motion": "^7.6.7", - "lodash": "^4.17.21", + "lodash-contrib": "^4.1200.1", "next": "13.0.0", "next-progress": "^2.2.0", "nodemailer": "^6.8.0", @@ -25,6 +28,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-lazy-load": "^4.0.1", "react-lazy-load-image-component": "^1.5.5", + "react-query": "^3.39.3", "react-select": "^5.7.0", "swiper": "^8.4.4", "yup": "^0.32.11" diff --git a/postcss.config.js b/postcss.config.js index 33ad091d..85f717cc 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { tailwindcss: {}, - autoprefixer: {}, - }, + autoprefixer: {} + } } diff --git a/public/images/indoteknik-logo.png b/public/images/indoteknik-logo.png Binary files differnew file mode 100644 index 00000000..87c696aa --- /dev/null +++ b/public/images/indoteknik-logo.png diff --git a/public/images/indoteknik-placeholder.png b/public/images/indoteknik-placeholder.png Binary files differnew file mode 100644 index 00000000..a83b012d --- /dev/null +++ b/public/images/indoteknik-placeholder.png diff --git a/public/images/payments/bca.png b/public/images/payments/bca.png Binary files differnew file mode 100644 index 00000000..ba28b17e --- /dev/null +++ b/public/images/payments/bca.png diff --git a/public/images/payments/bca.webp b/public/images/payments/bca.webp Binary files differdeleted file mode 100644 index e216fb1d..00000000 --- a/public/images/payments/bca.webp +++ /dev/null diff --git a/public/images/payments/bni.png b/public/images/payments/bni.png Binary files differnew file mode 100644 index 00000000..f7c2d4fa --- /dev/null +++ b/public/images/payments/bni.png diff --git a/public/images/payments/bri.png b/public/images/payments/bri.png Binary files differnew file mode 100644 index 00000000..7895dacb --- /dev/null +++ b/public/images/payments/bri.png diff --git a/public/images/payments/gopay.png b/public/images/payments/gopay.png Binary files differnew file mode 100644 index 00000000..809af949 --- /dev/null +++ b/public/images/payments/gopay.png diff --git a/public/images/payments/mandiri.png b/public/images/payments/mandiri.png Binary files differindex ceb3e396..54dbb5d9 100644 --- a/public/images/payments/mandiri.png +++ b/public/images/payments/mandiri.png diff --git a/public/images/payments/mastercard.png b/public/images/payments/mastercard.png Binary files differnew file mode 100644 index 00000000..3b5cd41d --- /dev/null +++ b/public/images/payments/mastercard.png diff --git a/public/images/payments/permata.png b/public/images/payments/permata.png Binary files differnew file mode 100644 index 00000000..c62bae89 --- /dev/null +++ b/public/images/payments/permata.png diff --git a/public/images/payments/visa.png b/public/images/payments/visa.png Binary files differnew file mode 100644 index 00000000..5c30dc20 --- /dev/null +++ b/public/images/payments/visa.png diff --git a/public/images/socials/Facebook.png b/public/images/socials/Facebook.png Binary files differnew file mode 100644 index 00000000..4dc1c6fb --- /dev/null +++ b/public/images/socials/Facebook.png diff --git a/public/images/socials/Linkedin.png b/public/images/socials/Linkedin.png Binary files differnew file mode 100644 index 00000000..2c07e153 --- /dev/null +++ b/public/images/socials/Linkedin.png diff --git a/public/images/socials/Twitter.png b/public/images/socials/Twitter.png Binary files differnew file mode 100644 index 00000000..9ac88be7 --- /dev/null +++ b/public/images/socials/Twitter.png diff --git a/public/images/socials/Whatsapp.png b/public/images/socials/Whatsapp.png Binary files differnew file mode 100644 index 00000000..059ee982 --- /dev/null +++ b/public/images/socials/Whatsapp.png diff --git a/src/core/api/odooApi.js b/src/core/api/odooApi.js new file mode 100644 index 00000000..202c355e --- /dev/null +++ b/src/core/api/odooApi.js @@ -0,0 +1,49 @@ +import axios from 'axios' +import camelcaseObjectDeep from 'camelcase-object-deep' +import { getCookie, setCookie } from 'cookies-next' +import { getAuth } from '../utils/auth' + +const renewToken = async () => { + let token = await axios.get(process.env.ODOO_HOST + '/api/token') + setCookie('token', token.data.result) + return token.data.result +} + +const getToken = async () => { + let token = getCookie('token') + if (token == undefined) token = await renewToken() + return token +} + +const maxConnectionAttempt = 15 +let connectionAttempt = 0 + +const odooApi = async (method, url, data = {}, headers = {}) => { + connectionAttempt++ + try { + let token = await getToken() + const auth = getAuth() + + let axiosParameter = { + method, + url: process.env.ODOO_HOST + url, + headers: { Authorization: token, ...headers } + } + if (auth) axiosParameter.headers['Token'] = auth.token + if (method.toUpperCase() == 'POST') + axiosParameter.headers['Content-Type'] = 'application/x-www-form-urlencoded' + if (Object.keys(data).length > 0) + axiosParameter.data = new URLSearchParams(Object.entries(data)).toString() + + let res = await axios(axiosParameter) + if (res.data.status.code == 401 && connectionAttempt < maxConnectionAttempt) { + await renewToken() + return odooApi(method, url, data, headers) + } + return camelcaseObjectDeep(res.data.result) || [] + } catch (error) { + console.log(error) + } +} + +export default odooApi diff --git a/src/core/api/searchSuggestApi.js b/src/core/api/searchSuggestApi.js new file mode 100644 index 00000000..e4445c9a --- /dev/null +++ b/src/core/api/searchSuggestApi.js @@ -0,0 +1,14 @@ +import axios from 'axios' + +const searchSuggestApi = async ({ query }) => { + const dataSearchSuggest = await axios( + `${process.env.SELF_HOST}/api/shop/suggest?q=${query.trim()}` + ) + return dataSearchSuggest +} + +searchSuggestApi.defaultProps = { + query: '' +} + +export default searchSuggestApi diff --git a/src/core/components/Seo.jsx b/src/core/components/Seo.jsx new file mode 100644 index 00000000..e688077e --- /dev/null +++ b/src/core/components/Seo.jsx @@ -0,0 +1,11 @@ +import Head from 'next/head' + +const Seo = ({ title }) => { + return ( + <Head> + <title>{title}</title> + </Head> + ) +} + +export default Seo diff --git a/src/core/components/elements/Alert/Alert.jsx b/src/core/components/elements/Alert/Alert.jsx new file mode 100644 index 00000000..695be8a3 --- /dev/null +++ b/src/core/components/elements/Alert/Alert.jsx @@ -0,0 +1,21 @@ +const Alert = ({ children, className, type }) => { + let typeClass = '' + switch (type) { + case 'info': + typeClass = 'bg-blue-100 text-blue-900 border-blue-400' + break + case 'success': + typeClass = 'bg-green-100 text-green-900 border-green-400' + break + case 'warning': + typeClass = 'bg-yellow-100 text-yellow-900 border-yellow-400' + break + } + return ( + <div className={`rounded-md w-full text-medium p-3 border ${typeClass} ${className}`}> + {children} + </div> + ) +} + +export default Alert diff --git a/src/core/components/elements/Appbar/Appbar.jsx b/src/core/components/elements/Appbar/Appbar.jsx new file mode 100644 index 00000000..098d0a33 --- /dev/null +++ b/src/core/components/elements/Appbar/Appbar.jsx @@ -0,0 +1,44 @@ +import { useRouter } from 'next/router' +import Link from '../Link/Link' +import { HomeIcon, Bars3Icon, ShoppingCartIcon, ChevronLeftIcon } from '@heroicons/react/24/outline' + +const AppBar = ({ title }) => { + const router = useRouter() + + return ( + <nav className='sticky top-0 z-50 bg-white border-b border-gray_r-6 flex justify-between'> + <div className='flex items-center'> + <button + type='button' + className='p-4' + onClick={() => router.back()} + > + <ChevronLeftIcon className='w-6 stroke-2' /> + </button> + <div className='font-medium text-h-sm line-clamp-1'>{title}</div> + </div> + <div className='flex items-center px-2'> + <Link + href='/shop/cart' + className='py-4 px-2' + > + <ShoppingCartIcon className='w-6 text-gray_r-12' /> + </Link> + <Link + href='/' + className='py-4 px-2' + > + <HomeIcon className='w-6 text-gray_r-12' /> + </Link> + <Link + href='/my/menu' + className='py-4 px-2' + > + <Bars3Icon className='w-6 text-gray_r-12' /> + </Link> + </div> + </nav> + ) +} + +export default AppBar diff --git a/src/core/components/elements/Badge/Badge.jsx b/src/core/components/elements/Badge/Badge.jsx new file mode 100644 index 00000000..e50cdc78 --- /dev/null +++ b/src/core/components/elements/Badge/Badge.jsx @@ -0,0 +1,29 @@ +const Badge = ({ children, type, ...props }) => { + return ( + <div + {...props} + className={`${badgeStyle(type)} ${props?.className}`} + > + {children} + </div> + ) +} + +Badge.defaultProps = { + className: '' +} + +const badgeStyle = (type) => { + let className = ['rounded px-1 text-[11px]'] + switch (type) { + case 'solid-red': + className.push('bg-red_r-11 text-white') + break + case 'light': + className.push('bg-gray_r-4 text-gray_r-11') + break + } + return className.join(' ') +} + +export default Badge diff --git a/src/core/components/elements/Divider/Divider.jsx b/src/core/components/elements/Divider/Divider.jsx new file mode 100644 index 00000000..ce54a2ea --- /dev/null +++ b/src/core/components/elements/Divider/Divider.jsx @@ -0,0 +1,7 @@ +const Divider = (props) => <div className={`h-1 bg-gray_r-4 ${props.className}`} /> + +Divider.defaultProps = { + className: '' +} + +export default Divider diff --git a/src/core/components/elements/Footer/BasicFooter.jsx b/src/core/components/elements/Footer/BasicFooter.jsx new file mode 100644 index 00000000..76a7cac2 --- /dev/null +++ b/src/core/components/elements/Footer/BasicFooter.jsx @@ -0,0 +1,268 @@ +import NextImage from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import { + DevicePhoneMobileIcon, + EnvelopeIcon, + PhoneArrowUpRightIcon +} from '@heroicons/react/24/outline' +import Link from '../Link/Link' + +const BasicFooter = () => { + return ( + <footer className='flex flex-wrap p-4 bg-gray_r-3 text-caption-1'> + <div className='w-1/2 flex flex-col gap-y-4 pr-1.5'> + <div> + <NextImage + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={90} + height={30} + /> + + <div className='font-semibold mt-2'>PT. Indoteknik Dotcom Gemilang</div> + </div> + + <OfficeLocation /> + <WarehouseLocation /> + <InformationCenter /> + <OpenHours /> + <SocialMedias /> + </div> + + <div className='w-1/2 flex flex-col gap-y-4 pl-1.5'> + <AboutUs /> + <CustomerGuide /> + <Payments /> + </div> + + <div className='w-full mt-4 leading-5 text-caption-2 text-gray_r-12/80'> + Copyright © 2007 - 2022, PT. Indoteknik Dotcom Gemilang + </div> + </footer> + ) +} + +const headerClassName = 'font-semibold mb-2' + +const OfficeLocation = () => ( + <div> + <div className={headerClassName}>Kantor Pusat</div> + <div className='leading-6 text-gray_r-12/80'> + Jl. Bandengan Utara 85A No. 8-9 RT.3/RW.16, Penjaringan, Kec. Penjaringan, Jakarta Utara + </div> + </div> +) + +const WarehouseLocation = () => ( + <div> + <div className={headerClassName}>Gudang Indoteknik</div> + <div className='leading-6 text-gray_r-12/80'> + Jl. Bandengan Utara Komp. 85 A dan B, Penjaringan, Kec. Penjaringan, Jakarta Utara + </div> + </div> +) + +const AboutUs = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Tentang Kami</div> + <ul className='flex flex-col gap-y-2'> + <li> + <InternalItemLink href='/'>Company Profile</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Karir</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pelanggan Kami</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Menjadi Supplier</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Garansi dan Pengembalian</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Metode Pembayaran</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Metode Pengiriman</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Testimonial</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Kebijakan Privacy</InternalItemLink> + </li> + </ul> + </div> +) + +const CustomerGuide = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Panduan Pelanggan</div> + <ul className='flex flex-col gap-y-2'> + <li> + <InternalItemLink href='/'>Panduan Belanja</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>F.A.Q</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Kebijakan Privasi</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pengajuan Tempo</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Garansi Produk</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Online Quotation</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pengiriman</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Pembayaran</InternalItemLink> + </li> + <li> + <InternalItemLink href='/'>Syarat & Ketentuan</InternalItemLink> + </li> + </ul> + </div> +) + +const InformationCenter = () => ( + <div> + <div className={`${headerClassName} mb-3`}>Layanan Informasi</div> + <ul className='flex flex-col gap-y-2'> + <li className='text-gray_r-12/80 flex items-center'> + <PhoneArrowUpRightIcon className='w-[18px] mr-2' /> + <a href='tel:02129338828'>(021) 2933-8828 / 29</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <EnvelopeIcon className='w-[18px] mr-2' /> + <a href='mailto:sales@indoteknik.com'>sales@indoteknik.com</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <DevicePhoneMobileIcon className='w-[18px] mr-2' /> + <a href='https://wa.me/+628128080622'>0812-8080-622</a> + </li> + </ul> + </div> +) + +const OpenHours = () => ( + <div> + <div className={headerClassName}>Jam Operasional</div> + <ul className='flex flex-col gap-y-1'> + <li> + <div className='text-gray_r-12'>Senin - Jumat:</div> + <div className='text-gray_r-12/80'>08:30 - 17:00</div> + </li> + <li> + <div className='text-gray_r-12'>Sabtu:</div> + <div className='text-gray_r-12/80'>08:30 - 14:00</div> + </li> + </ul> + </div> +) + +const SocialMedias = () => ( + <div> + <div className={headerClassName}>Temukan Kami</div> + <div className='flex flex-wrap gap-2'> + <NextImage + src='/images/socials/Whatsapp.png' + alt='Whatsapp Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Facebook.png' + alt='Facebook Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Twitter.png' + alt='Twitter Logo' + width={24} + height={24} + /> + <NextImage + src='/images/socials/Linkedin.png' + alt='Linkedin Logo' + width={24} + height={24} + /> + </div> + </div> +) + +const Payments = () => ( + <div> + <div className={headerClassName}>Pembayaran</div> + <div className='flex flex-wrap gap-2'> + <NextImage + src='/images/payments/bca.png' + alt='Bank BCA Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/bni.png' + alt='Bank BNI Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/bri.png' + alt='Bank BRI Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/gopay.png' + alt='Gopay Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/mandiri.png' + alt='Bank Mandiri Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/mastercard.png' + alt='Mastercard Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/permata.png' + alt='Bank Permata Logo' + width={48} + height={24} + /> + <NextImage + src='/images/payments/visa.png' + alt='Visa Logo' + width={48} + height={24} + /> + </div> + </div> +) + +const InternalItemLink = ({ href, children }) => ( + <Link + href={href} + className='!text-gray_r-12/80 font-normal line-clamp-1' + > + {children} + </Link> +) + +export default BasicFooter diff --git a/src/core/components/elements/Footer/SimpleFooter.jsx b/src/core/components/elements/Footer/SimpleFooter.jsx new file mode 100644 index 00000000..0fbc8f62 --- /dev/null +++ b/src/core/components/elements/Footer/SimpleFooter.jsx @@ -0,0 +1,43 @@ +import { + DevicePhoneMobileIcon, + EnvelopeIcon, + PhoneArrowUpRightIcon +} from '@heroicons/react/24/outline' + +const SimpleFooter = () => ( + <footer className='flex flex-wrap p-4 bg-gray_r-3 text-caption-1'> + <div className='w-full font-semibold mb-4 text-body-2'>Butuh bantuan? Hubungi Kami</div> + <div className='w-1/2 pr-2'> + <div className='font-semibold mb-3'>Hubungi Kami</div> + <ul className='flex flex-col gap-y-2'> + <li className='text-gray_r-12/80 flex items-center'> + <PhoneArrowUpRightIcon className='w-[18px] mr-2' /> + <a href='tel:02129338828'>(021) 2933-8828 / 29</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <EnvelopeIcon className='w-[18px] mr-2' /> + <a href='mailto:sales@indoteknik.com'>sales@indoteknik.com</a> + </li> + <li className='text-gray_r-12/80 flex items-center'> + <DevicePhoneMobileIcon className='w-[18px] mr-2' /> + <a href='https://wa.me/+628128080622'>0812-8080-622</a> + </li> + </ul> + </div> + <div className='w-1/2 pl-2'> + <div className='font-semibold mb-3'>Jam Operasional</div> + <ul className='flex flex-col gap-y-1'> + <li> + <div className='text-gray_r-12 mb-0.5'>Senin - Jumat:</div> + <div className='text-gray_r-12/80'>08:30 - 17:00</div> + </li> + <li> + <div className='text-gray_r-12 mb-0.5'>Sabtu:</div> + <div className='text-gray_r-12/80'>08:30 - 14:00</div> + </li> + </ul> + </div> + </footer> +) + +export default SimpleFooter diff --git a/src/core/components/elements/Image/Image.jsx b/src/core/components/elements/Image/Image.jsx new file mode 100644 index 00000000..ac82aaaf --- /dev/null +++ b/src/core/components/elements/Image/Image.jsx @@ -0,0 +1,18 @@ +import { LazyLoadImage } from 'react-lazy-load-image-component' +import 'react-lazy-load-image-component/src/effects/opacity.css' + +const Image = ({ ...props }) => ( + <> + <LazyLoadImage + {...props} + src={props.src || '/images/noimage.jpeg'} + placeholderSrc='/images/indoteknik-placeholder.png' + alt={props.src ? props.alt : 'Image Not Found - Indoteknik'} + wrapperClassName='bg-white' + /> + </> +) + +Image.defaultProps = LazyLoadImage.defaultProps + +export default Image diff --git a/src/core/components/elements/Link/Link.jsx b/src/core/components/elements/Link/Link.jsx new file mode 100644 index 00000000..dbc65338 --- /dev/null +++ b/src/core/components/elements/Link/Link.jsx @@ -0,0 +1,17 @@ +import NextLink from 'next/link' + +const Link = ({ children, ...props }) => { + return ( + <NextLink + {...props} + scroll={false} + className={`block font-medium text-red_r-11 ${props?.className || ''}`} + > + {children} + </NextLink> + ) +} + +Link.defaultProps = NextLink.defaultProps + +export default Link diff --git a/src/core/components/elements/Navbar/Navbar.jsx b/src/core/components/elements/Navbar/Navbar.jsx new file mode 100644 index 00000000..8cecee5b --- /dev/null +++ b/src/core/components/elements/Navbar/Navbar.jsx @@ -0,0 +1,46 @@ +import dynamic from 'next/dynamic' +import Image from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import { Bars3Icon, HeartIcon, ShoppingCartIcon } from '@heroicons/react/24/outline' +import Link from '../Link/Link' +import useSidebar from '@/core/hooks/useSidebar' + +const Search = dynamic(() => import('./Search')) + +const Navbar = () => { + const { Sidebar, open } = useSidebar() + return ( + <> + <nav className='px-4 py-2 pb-3 sticky top-0 z-50 bg-white shadow'> + <div className='flex justify-between items-center mb-2'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Indoteknik Logo' + width={120} + height={40} + /> + </Link> + <div className='flex gap-x-3'> + <Link href='/my/wishlist'> + <HeartIcon className='w-6 text-gray_r-12' /> + </Link> + <Link href='/shop/cart'> + <ShoppingCartIcon className='w-6 text-gray_r-12' /> + </Link> + <button + type='button' + onClick={open} + > + <Bars3Icon className='w-6 text-gray_r-12' /> + </button> + </div> + </div> + <Search /> + </nav> + {Sidebar} + </> + ) +} + +export default Navbar diff --git a/src/core/components/elements/Navbar/Search.jsx b/src/core/components/elements/Navbar/Search.jsx new file mode 100644 index 00000000..ff2c7adb --- /dev/null +++ b/src/core/components/elements/Navbar/Search.jsx @@ -0,0 +1,93 @@ +import searchSuggestApi from '@/core/api/searchSuggestApi' +import { MagnifyingGlassIcon } from '@heroicons/react/24/outline' +import { useCallback, useEffect, useRef, useState } from 'react' +import Link from '../Link/Link' +import { useRouter } from 'next/router' + +const Search = () => { + const router = useRouter() + const queryRef = useRef() + const [query, setQuery] = useState('') + const [suggestions, setSuggestions] = useState([]) + + useEffect(() => { + setQuery(router.query.q) + }, [router.query]) + + const loadSuggestion = useCallback(() => { + if (query && document.activeElement == queryRef.current) { + ;(async () => { + const dataSuggestion = await searchSuggestApi({ query }) + setSuggestions(dataSuggestion.data.suggestions) + })() + return + } else { + setSuggestions([]) + } + }, [query]) + + useEffect(() => { + if (query && document.activeElement == queryRef.current) { + loadSuggestion() + } else { + setSuggestions([]) + } + }, [loadSuggestion, query]) + + const handleSubmit = (e) => { + e.preventDefault() + if (query) { + router.push(`/shop/search?q=${query}`) + } else { + queryRef.current.focus() + } + } + + const onInputBlur = () => { + setTimeout(() => { + setSuggestions([]) + }, 100) + } + + return ( + <form + onSubmit={handleSubmit} + className='flex relative' + > + <input + type='text' + ref={queryRef} + className='form-input p-3 rounded-r-none border-r-0 focus:border-gray_r-6' + placeholder='Ketik nama, part number, merk' + value={query} + onChange={(e) => setQuery(e.target.value)} + onBlur={onInputBlur} + onFocus={loadSuggestion} + /> + <button + type='submit' + className='rounded-r border border-l-0 border-gray_r-6 px-2' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + + {suggestions.length > 1 && ( + <> + <div className='absolute w-full top-[50px] rounded-b bg-gray_r-1 border border-gray_r-6 divide-y divide-gray_r-6'> + {suggestions.map((suggestion, index) => ( + <Link + href={`/shop/search?q=${suggestion.term}`} + key={index} + className='px-3 py-3 !text-gray_r-12 font-normal' + > + {suggestion.term} + </Link> + ))} + </div> + </> + )} + </form> + ) +} + +export default Search diff --git a/src/core/components/elements/Pagination/Pagination.js b/src/core/components/elements/Pagination/Pagination.js new file mode 100644 index 00000000..18964fc4 --- /dev/null +++ b/src/core/components/elements/Pagination/Pagination.js @@ -0,0 +1,83 @@ +import Link from '../Link/Link' + +const Pagination = ({ pageCount, currentPage, url, className }) => { + let firstPage = false + let lastPage = false + let dotsPrevPage = false + let dotsNextPage = false + let urlParameterPrefix = url.includes('?') ? '&' : '?' + + return ( + pageCount > 1 && ( + <div className={`pagination ${className}`}> + {Array.from(Array(pageCount)).map((v, i) => { + let page = i + 1 + let rangePrevPage = currentPage - 2 + let rangeNextPage = currentPage + 2 + let PageComponent = ( + <Link + key={i} + href={`${url + urlParameterPrefix}page=${page}`} + className={ + 'pagination-item' + (page == currentPage ? ' pagination-item--active ' : '') + } + > + {page} + </Link> + ) + let DotsComponent = ( + <div + key={i} + className='pagination-dots' + > + ... + </div> + ) + + if (pageCount == 7) { + return PageComponent + } + + if (currentPage == 1) rangeNextPage += 3 + if (currentPage == 2) rangeNextPage += 2 + if (currentPage == 3) rangeNextPage += 1 + if (currentPage == 4) rangePrevPage -= 1 + if (currentPage == pageCount) rangePrevPage -= 3 + if (currentPage == pageCount - 1) rangePrevPage -= 2 + if (currentPage == pageCount - 2) rangePrevPage -= 1 + if (currentPage == pageCount - 3) rangeNextPage += 1 + + if (page > rangePrevPage && page < rangeNextPage) { + return PageComponent + } + + if (page == 1 && rangePrevPage >= 1 && !firstPage) { + firstPage = true + return PageComponent + } + + if (page == pageCount && rangeNextPage <= pageCount && !lastPage) { + lastPage = true + return PageComponent + } + + if (page > currentPage && pageCount - currentPage > 1 && !dotsNextPage) { + dotsNextPage = true + return DotsComponent + } + + if (page < currentPage && currentPage - 1 > 1 && !dotsPrevPage) { + dotsPrevPage = true + return DotsComponent + } + })} + </div> + ) + ) +} + +Pagination.defaultProps = { + className: '' +} + +export default Pagination diff --git a/src/core/components/elements/Popup/BottomPopup.jsx b/src/core/components/elements/Popup/BottomPopup.jsx new file mode 100644 index 00000000..24366802 --- /dev/null +++ b/src/core/components/elements/Popup/BottomPopup.jsx @@ -0,0 +1,54 @@ +import { XMarkIcon } from '@heroicons/react/24/outline' +import { AnimatePresence, motion } from 'framer-motion' +import { useEffect } from 'react' + +const transition = { ease: 'linear', duration: 0.2 } + +const BottomPopup = ({ children, active = false, title, close }) => { + useEffect(() => { + if (active) { + document.querySelector('html, body').classList.add('overflow-hidden') + } else { + document.querySelector('html, body').classList.remove('overflow-hidden') + } + }, [active]) + + return ( + <> + <AnimatePresence> + {active && ( + <> + <motion.div + className='overlay' + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={transition} + onClick={close} + /> + <motion.div + initial={{ bottom: '-100%' }} + animate={{ bottom: 0 }} + exit={{ bottom: '-100%' }} + transition={transition} + className='fixed left-0 w-full border-t border-gray_r-6 rounded-t-xl z-[60] p-4 pt-0 bg-white' + > + <div className='flex justify-between py-4'> + <div className='font-semibold text-h-sm'>{title}</div> + <button + type='button' + onClick={close} + > + <XMarkIcon className='w-5 stroke-2' /> + </button> + </div> + {children} + </motion.div> + </> + )} + </AnimatePresence> + </> + ) +} + +export default BottomPopup diff --git a/src/core/components/elements/Select/HookFormSelect.jsx b/src/core/components/elements/Select/HookFormSelect.jsx new file mode 100644 index 00000000..055a9c68 --- /dev/null +++ b/src/core/components/elements/Select/HookFormSelect.jsx @@ -0,0 +1,14 @@ +import ReactSelect from 'react-select' + +const HookFormSelect = ({ field, ...props }) => ( + <ReactSelect + classNamePrefix='form-select' + ref={field.ref} + onChange={(option) => field.onChange(option.value)} + value={field.value ? props.options.find((option) => option.value === field.value) : ''} + isDisabled={props.disabled} + {...props} + /> +) + +export default HookFormSelect diff --git a/src/core/components/elements/Sidebar/Sidebar.jsx b/src/core/components/elements/Sidebar/Sidebar.jsx new file mode 100644 index 00000000..c39b5e34 --- /dev/null +++ b/src/core/components/elements/Sidebar/Sidebar.jsx @@ -0,0 +1,220 @@ +import Link from '../Link/Link' +import greeting from '@/core/utils/greeting' +import useAuth from '@/core/hooks/useAuth' +import { AnimatePresence, motion } from 'framer-motion' +import { ChevronDownIcon, ChevronUpIcon, CogIcon } from '@heroicons/react/24/outline' +import { Fragment, useEffect, useState } from 'react' +import odooApi from '@/core/api/odooApi' + +const Sidebar = ({ active, close }) => { + const auth = useAuth() + + const SidebarLink = ({ children, ...props }) => ( + <Link + {...props} + onClick={close} + > + {children} + </Link> + ) + + const itemClassName = 'px-4 py-3 block !text-gray_r-12/80 font-normal' + const transition = { ease: 'linear', duration: 0.2 } + + const [isOpenCategory, setOpenCategory] = useState(false) + const [categories, setCategories] = useState([]) + + useEffect(() => { + const loadCategories = async () => { + if (isOpenCategory && categories.length == 0) { + let dataCategories = await odooApi('GET', '/api/v1/category/tree') + dataCategories = dataCategories.map((category) => { + category.childs = category.childs.map((child1Category) => { + return { + ...child1Category, + isOpen: false + } + }) + return { + ...category, + isOpen: false + } + }) + setCategories(dataCategories) + } + } + loadCategories() + }, [isOpenCategory, categories]) + + const toggleCategories = (id = 0) => { + let newCategories = categories.map((category) => { + category.childs = category.childs.map((child1Category) => { + return { + ...child1Category, + isOpen: id == child1Category.id ? !child1Category.isOpen : child1Category.isOpen + } + }) + return { + ...category, + isOpen: id == category.id ? !category.isOpen : category.isOpen + } + }) + setCategories(newCategories) + } + + return ( + <> + <AnimatePresence> + {active && ( + <> + <motion.div + className='overlay z-50' + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={transition} + onClick={close} + /> + <motion.div + className='fixed z-[55] top-0 h-full w-[80%] bg-white' + initial={{ left: '-80%' }} + animate={{ left: 0 }} + exit={{ left: '-80%' }} + transition={transition} + > + <div className='divide-y divide-gray_r-6'> + <div className='p-4 flex gap-x-3'> + {!auth && ( + <> + <Link + onClick={close} + href='/register' + className='btn-yellow !text-gray_r-12 py-2 flex-1' + > + Daftar + </Link> + <Link + onClick={close} + href='/login' + className='btn-solid-red !text-gray_r-1 py-2 flex-1' + > + Masuk + </Link> + </> + )} + {auth && ( + <> + <div className='text-caption-2 text-gray_r-11'> + {greeting()}, + <span className='text-body-2 text-gray_r-12 block mt-1 font-medium'> + {auth?.name} + </span> + </div> + <Link + onClick={close} + href='/my/menu' + className='!text-gray_r-11 ml-auto my-auto' + > + <CogIcon className='w-6' /> + </Link> + </> + )} + </div> + <SidebarLink + className={itemClassName} + href='/shop/brands' + > + Semua Brand + </SidebarLink> + <SidebarLink + className={itemClassName} + href='/' + > + Tentang Indoteknik + </SidebarLink> + <SidebarLink + className={itemClassName} + href='/' + > + Pusat Bantuan + </SidebarLink> + <button + className={`${itemClassName} w-full text-left flex`} + onClick={() => setOpenCategory(!isOpenCategory)} + > + Kategori + <div className='ml-auto'> + {!isOpenCategory && <ChevronDownIcon className='text-gray_r-12 w-5' />} + {isOpenCategory && <ChevronUpIcon className='text-gray_r-12 w-5' />} + </div> + </button> + {isOpenCategory && + categories.map((category) => ( + <Fragment key={category.id}> + <div className='flex w-full text-gray_r-11 border-b border-gray_r-6 px-4 pl-8 items-center'> + <Link + href={`/shop/search?category=${category.name}`} + className='flex-1 font-normal !text-gray_r-11 py-4' + > + {category.name} + </Link> + <div + className='ml-4 h-full py-4' + onClick={() => toggleCategories(category.id)} + > + {!category.isOpen && <ChevronDownIcon className='text-gray_r-11 w-5' />} + {category.isOpen && <ChevronUpIcon className='text-gray_r-11 w-5' />} + </div> + </div> + {category.isOpen && + category.childs.map((child1Category) => ( + <Fragment key={child1Category.id}> + <div + className={`flex w-full !text-gray_r-11 border-b border-gray_r-6 p-4 pl-12 ${ + category.isOpen ? 'bg-gray_r-2' : '' + }`} + > + <Link + href={`/shop/search?category=${child1Category.name}`} + className='flex-1 font-normal !text-gray_r-11' + > + {child1Category.name} + </Link> + {child1Category.childs.length > 0 && ( + <div + className='ml-4 h-full' + onClick={() => toggleCategories(child1Category.id)} + > + {!child1Category.isOpen && ( + <ChevronDownIcon className='text-gray_r-11 w-5' /> + )} + {child1Category.isOpen && ( + <ChevronUpIcon className='text-gray_r-11 w-5' /> + )} + </div> + )} + </div> + {child1Category.isOpen && + child1Category.childs.map((child2Category) => ( + <Link + key={child2Category.id} + href={`/shop/search?category=${child2Category.name}`} + className='flex w-full font-normal !text-gray_r-11 border-b border-gray_r-6 p-4 pl-16' + > + {child2Category.name} + </Link> + ))} + </Fragment> + ))} + </Fragment> + ))} + </div> + </motion.div> + </> + )} + </AnimatePresence> + </> + ) +} + +export default Sidebar diff --git a/src/core/components/elements/Skeleton/BrandSkeleton.jsx b/src/core/components/elements/Skeleton/BrandSkeleton.jsx new file mode 100644 index 00000000..9a34fb9b --- /dev/null +++ b/src/core/components/elements/Skeleton/BrandSkeleton.jsx @@ -0,0 +1,11 @@ +const BrandSkeleton = () => ( + <div + role='status' + className='animate-pulse' + > + <div className='h-12 bg-gray-200 rounded'></div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default BrandSkeleton diff --git a/src/core/components/elements/Skeleton/ImageSkeleton.jsx b/src/core/components/elements/Skeleton/ImageSkeleton.jsx new file mode 100644 index 00000000..39d06331 --- /dev/null +++ b/src/core/components/elements/Skeleton/ImageSkeleton.jsx @@ -0,0 +1,24 @@ +const ImageSkeleton = () => ( + <div + role='status' + className='animate-pulse' + > + <div + className='flex items-center justify-center h-56 mb-4 bg-gray-300 rounded' + aria-busy + > + <svg + className='w-12 h-12 text-gray-200' + xmlns='http://www.w3.org/2000/svg' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 640 512' + > + <path d='M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z' /> + </svg> + </div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default ImageSkeleton diff --git a/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx b/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx new file mode 100644 index 00000000..ddc0d3bc --- /dev/null +++ b/src/core/components/elements/Skeleton/ProductCardSkeleton.jsx @@ -0,0 +1,29 @@ +const ProductCardSkeleton = () => ( + <div + role='status' + className='p-4 max-w-sm rounded border border-gray-300 shadow animate-pulse md:p-6' + > + <div + className='flex items-center justify-center h-36 mb-4 bg-gray-300 rounded' + aria-busy + > + <svg + className='w-12 h-12 text-gray-200' + xmlns='http://www.w3.org/2000/svg' + aria-hidden='true' + fill='currentColor' + viewBox='0 0 640 512' + > + <path d='M480 80C480 35.82 515.8 0 560 0C604.2 0 640 35.82 640 80C640 124.2 604.2 160 560 160C515.8 160 480 124.2 480 80zM0 456.1C0 445.6 2.964 435.3 8.551 426.4L225.3 81.01C231.9 70.42 243.5 64 256 64C268.5 64 280.1 70.42 286.8 81.01L412.7 281.7L460.9 202.7C464.1 196.1 472.2 192 480 192C487.8 192 495 196.1 499.1 202.7L631.1 419.1C636.9 428.6 640 439.7 640 450.9C640 484.6 612.6 512 578.9 512H55.91C25.03 512 .0006 486.1 .0006 456.1L0 456.1z' /> + </svg> + </div> + <div className='h-2 bg-gray-200 rounded-full w-10 mb-1'></div> + <div className='h-2.5 bg-gray-200 rounded-full w-full mb-4'></div> + <div className='h-2 bg-gray-200 rounded-full mb-2.5'></div> + <div className='h-2 bg-gray-200 rounded-full mb-2.5'></div> + <div className='h-2 bg-gray-200 rounded-full'></div> + <span className='sr-only'>Loading...</span> + </div> +) + +export default ProductCardSkeleton diff --git a/src/core/components/elements/Spinner/Spinner.jsx b/src/core/components/elements/Spinner/Spinner.jsx new file mode 100644 index 00000000..4639db1d --- /dev/null +++ b/src/core/components/elements/Spinner/Spinner.jsx @@ -0,0 +1,25 @@ +const Spinner = ({ className }) => { + return ( + <div role='status'> + <svg + aria-hidden='true' + className={'animate-spin ' + className} + viewBox='0 0 100 101' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + <path + d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z' + fill='currentColor' + /> + <path + d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z' + fill='currentFill' + /> + </svg> + <span className='sr-only'>Loading...</span> + </div> + ) +} + +export default Spinner diff --git a/src/core/components/layouts/AnimationLayout.jsx b/src/core/components/layouts/AnimationLayout.jsx new file mode 100644 index 00000000..c4dee606 --- /dev/null +++ b/src/core/components/layouts/AnimationLayout.jsx @@ -0,0 +1,24 @@ +import { motion } from 'framer-motion' + +const AnimationLayout = ({ children, ...props }) => { + const transition = { + ease: 'easeIn', + duration: 0.2 + } + + return ( + children && ( + <motion.main + initial={{ opacity: 0, x: 0, y: 0 }} + animate={{ opacity: 1, x: 0, y: 0 }} + exit={{ opacity: 0, x: 30, y: 0 }} + transition={transition} + {...props} + > + {children} + </motion.main> + ) + ) +} + +export default AnimationLayout diff --git a/src/core/components/layouts/AppLayout.jsx b/src/core/components/layouts/AppLayout.jsx new file mode 100644 index 00000000..a325b1c1 --- /dev/null +++ b/src/core/components/layouts/AppLayout.jsx @@ -0,0 +1,17 @@ +import AppBar from '../elements/Appbar/Appbar' +import BasicFooter from '../elements/Footer/BasicFooter' +import AnimationLayout from './AnimationLayout' + +const AppLayout = ({ children, title }) => { + return ( + <> + <AnimationLayout> + <AppBar title={title} /> + {children} + </AnimationLayout> + <BasicFooter /> + </> + ) +} + +export default AppLayout diff --git a/src/core/components/layouts/BasicLayout.jsx b/src/core/components/layouts/BasicLayout.jsx new file mode 100644 index 00000000..1a7185cd --- /dev/null +++ b/src/core/components/layouts/BasicLayout.jsx @@ -0,0 +1,17 @@ +import dynamic from 'next/dynamic' +import BasicFooter from '../elements/Footer/BasicFooter' + +const Navbar = dynamic(() => import('../elements/Navbar/Navbar')) +const AnimationLayout = dynamic(() => import('./AnimationLayout')) + +const BasicLayout = ({ children }) => { + return ( + <> + <Navbar /> + <AnimationLayout>{children}</AnimationLayout> + <BasicFooter /> + </> + ) +} + +export default BasicLayout diff --git a/src/core/hooks/useActive.js b/src/core/hooks/useActive.js new file mode 100644 index 00000000..c39cbdca --- /dev/null +++ b/src/core/hooks/useActive.js @@ -0,0 +1,21 @@ +import { useState } from 'react' + +const useActive = () => { + const [active, setActive] = useState(false) + + const activate = () => { + setActive(true) + } + + const deactivate = () => { + setActive(false) + } + + return { + activate, + deactivate, + active + } +} + +export default useActive diff --git a/src/core/hooks/useAuth.js b/src/core/hooks/useAuth.js new file mode 100644 index 00000000..af62e45c --- /dev/null +++ b/src/core/hooks/useAuth.js @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' +import { getAuth } from '../utils/auth' + +const useAuth = () => { + const [auth, setAuth] = useState(null) + + useEffect(() => { + const handleIsAuthenticated = () => setAuth(getAuth()) + handleIsAuthenticated() + }, []) + + return auth +} + +export default useAuth diff --git a/src/core/hooks/useSidebar.js b/src/core/hooks/useSidebar.js new file mode 100644 index 00000000..4da61ac2 --- /dev/null +++ b/src/core/hooks/useSidebar.js @@ -0,0 +1,27 @@ +import useActive from './useActive' +import SidebarComponent from '../components/elements/Sidebar/Sidebar' +import { useEffect } from 'react' + +const useSidebar = () => { + const { active, activate, deactivate } = useActive() + + useEffect(() => { + if (active) { + document.querySelector('html, body').classList.add('overflow-hidden') + } else { + document.querySelector('html, body').classList.remove('overflow-hidden') + } + }, [active]) + + return { + open: activate, + Sidebar: ( + <SidebarComponent + active={active} + close={deactivate} + /> + ) + } +} + +export default useSidebar diff --git a/src/core/utils/address.js b/src/core/utils/address.js index c4a19af5..c545d34b 100644 --- a/src/core/utils/address.js +++ b/src/core/utils/address.js @@ -1,27 +1,28 @@ const getAddress = () => { - const address = localStorage.getItem('address'); - if (address) return JSON.parse(address); - return {}; + if (typeof window !== 'undefined') { + const address = localStorage.getItem('address') + if (address) return JSON.parse(address) + } + return {} } const setAddress = (address) => { - localStorage.setItem('address', JSON.stringify(address)); - return true; + if (typeof window !== 'undefined') { + localStorage.setItem('address', JSON.stringify(address)) + } + return } const getItemAddress = (key) => { - let address = getAddress(); - return address[key]; + let address = getAddress() + return address[key] } -const createOrUpdateItemAddress = (key, value) => { - let address = getAddress(); - address[key] = value; - setAddress(address); - return true; +const updateItemAddress = (key, value) => { + let address = getAddress() + address[key] = value + setAddress(address) + return } -export { - getItemAddress, - createOrUpdateItemAddress -};
\ No newline at end of file +export { getItemAddress, updateItemAddress } diff --git a/src/core/utils/auth.js b/src/core/utils/auth.js index 62eba2c0..13e0e79d 100644 --- a/src/core/utils/auth.js +++ b/src/core/utils/auth.js @@ -1,38 +1,21 @@ -import { deleteCookie, getCookie, setCookie } from 'cookies-next'; -import { useEffect, useState } from 'react'; +import { deleteCookie, getCookie, setCookie } from 'cookies-next' const getAuth = () => { - let auth = getCookie('auth'); + let auth = getCookie('auth') if (auth) { - return JSON.parse(auth); + return JSON.parse(auth) } - return false; + return false } const setAuth = (user) => { - setCookie('auth', JSON.stringify(user)); - return true; + setCookie('auth', JSON.stringify(user)) + return true } const deleteAuth = () => { - deleteCookie('auth'); - return true; + deleteCookie('auth') + return true } -const useAuth = () => { - const [auth, setAuth] = useState(null); - - useEffect(() => { - const handleIsAuthenticated = () => setAuth(getAuth()); - handleIsAuthenticated(); - }, []); - - return [auth, setAuth]; -} - -export { - getAuth, - setAuth, - deleteAuth, - useAuth -};
\ No newline at end of file +export { getAuth, setAuth, deleteAuth } diff --git a/src/core/utils/cart.js b/src/core/utils/cart.js index 66efcbf2..fd42ee4e 100644 --- a/src/core/utils/cart.js +++ b/src/core/utils/cart.js @@ -1,36 +1,36 @@ const getCart = () => { - const cart = localStorage.getItem('cart'); - if (cart) return JSON.parse(cart); - return {}; + if (typeof window !== 'undefined') { + const cart = localStorage.getItem('cart') + if (cart) return JSON.parse(cart) + } + return {} } const setCart = (cart) => { - localStorage.setItem('cart', JSON.stringify(cart)); - return true; + if (typeof window !== 'undefined') { + localStorage.setItem('cart', JSON.stringify(cart)) + } + return true } -const getItemCart = (product_id) => { - let cart = getCart(); - return cart[product_id]; +const getItemCart = ({ productId }) => { + let cart = getCart() + return cart[productId] } -const createOrUpdateItemCart = (product_id, quantity, selected = false) => { - let cart = getCart(); - cart[product_id] = { product_id, quantity, selected }; - setCart(cart); - return true; +const updateItemCart = ({ productId, quantity, selected = false }) => { + let cart = getCart() + quantity = parseInt(quantity) + cart[productId] = { productId, quantity, selected } + setCart(cart) + return true } -const deleteItemCart = (product_id) => { - let cart = getCart(); - delete cart[product_id]; - setCart(cart); - return true; +const deleteItemCart = ({ productId }) => { + let cart = getCart() + delete cart[productId] + setCart(cart) + return true } -export { - getCart, - getItemCart, - createOrUpdateItemCart, - deleteItemCart -}
\ No newline at end of file +export { getCart, getItemCart, updateItemCart, deleteItemCart } diff --git a/src/core/utils/currencyFormat.js b/src/core/utils/currencyFormat.js index dadeaec6..12b68111 100644 --- a/src/core/utils/currencyFormat.js +++ b/src/core/utils/currencyFormat.js @@ -1,8 +1,10 @@ -export default function currencyFormat(value) { +const currencyFormat = (value) => { const currency = new Intl.NumberFormat('id-ID', { - style: 'currency', + style: 'currency', currency: 'IDR', maximumFractionDigits: 0 - }); - return currency.format(value); -}
\ No newline at end of file + }) + return currency.format(value) +} + +export default currencyFormat diff --git a/src/core/utils/getFileBase64.js b/src/core/utils/getFileBase64.js index 78013e43..4fa7316b 100644 --- a/src/core/utils/getFileBase64.js +++ b/src/core/utils/getFileBase64.js @@ -1,11 +1,12 @@ -const getFileBase64 = file => new Promise((resolve, reject) => { - let reader = new FileReader(); - reader.readAsBinaryString(file); - reader.onload = () => { - let result = reader.result; - resolve(btoa(result)); - }; - reader.onerror = error => reject(error); -}); +const getFileBase64 = (file) => + new Promise((resolve, reject) => { + let reader = new FileReader() + reader.readAsBinaryString(file) + reader.onload = () => { + let result = reader.result + resolve(btoa(result)) + } + reader.onerror = (error) => reject(error) + }) -export default getFileBase64;
\ No newline at end of file +export default getFileBase64 diff --git a/src/core/utils/greeting.js b/src/core/utils/greeting.js index 7dc19f8f..aaaade7a 100644 --- a/src/core/utils/greeting.js +++ b/src/core/utils/greeting.js @@ -1,9 +1,9 @@ const greeting = () => { - let hours = new Date().getHours(); - if (hours < 11) return 'Selamat Pagi'; - if (hours < 15) return 'Selamat Siang'; - if (hours < 18) return 'Selamat Sore'; - return 'Selamat Malam'; + let hours = new Date().getHours() + if (hours < 11) return 'Selamat Pagi' + if (hours < 15) return 'Selamat Siang' + if (hours < 18) return 'Selamat Sore' + return 'Selamat Malam' } -export default greeting;
\ No newline at end of file +export default greeting diff --git a/src/core/utils/mailer.js b/src/core/utils/mailer.js index 4e7ff7cc..cab66bec 100644 --- a/src/core/utils/mailer.js +++ b/src/core/utils/mailer.js @@ -1,4 +1,4 @@ -const nodemailer = require('nodemailer'); +const nodemailer = require('nodemailer') const mailer = nodemailer.createTransport({ port: process.env.MAIL_PORT, host: process.env.MAIL_HOST, @@ -7,6 +7,6 @@ const mailer = nodemailer.createTransport({ pass: process.env.MAIL_PASS }, secure: true -}); +}) -export default mailer;
\ No newline at end of file +export default mailer diff --git a/src/core/utils/slug.js b/src/core/utils/slug.js index 0a7d30fc..7010008a 100644 --- a/src/core/utils/slug.js +++ b/src/core/utils/slug.js @@ -1,25 +1,27 @@ -import toTitleCase from './toTitleCase'; +import toTitleCase from './toTitleCase' -const createSlug = (name, id) => { - let slug = name?.trim().replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-').toLowerCase() + '-' + id; - let splitSlug = slug.split('-'); - let filterSlugFromEmptyChar = splitSlug.filter(x => x != ''); - return filterSlugFromEmptyChar.join('-'); +const createSlug = (prefix, name, id) => { + let slug = + name + ?.trim() + .replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-') + .toLowerCase() + + '-' + + id + let splitSlug = slug.split('-') + let filterSlugFromEmptyChar = splitSlug.filter((x) => x != '') + return prefix + filterSlugFromEmptyChar.join('-') } const getIdFromSlug = (slug) => { - let id = slug.split('-'); - return id[id.length-1]; + let id = slug.split('-') + return id[id.length - 1] } const getNameFromSlug = (slug) => { - let name = slug.split('-'); - name.pop(); - return toTitleCase(name.join(' ')); + let name = slug.split('-') + name.pop() + return toTitleCase(name.join(' ')) } -export { - createSlug, - getIdFromSlug, - getNameFromSlug -};
\ No newline at end of file +export { createSlug, getIdFromSlug, getNameFromSlug } diff --git a/src/core/utils/toTitleCase.js b/src/core/utils/toTitleCase.js index 5cfd70d0..4335824d 100644 --- a/src/core/utils/toTitleCase.js +++ b/src/core/utils/toTitleCase.js @@ -1,8 +1,7 @@ -export default function toTitleCase(str) { - return str.replace( - /\w\S*/g, - function(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - } - ); -}
\ No newline at end of file +const toTitleCase = (str) => { + return str.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + }) +} + +export default toTitleCase diff --git a/src/lib/address/api/addressApi.js b/src/lib/address/api/addressApi.js new file mode 100644 index 00000000..f99d81c0 --- /dev/null +++ b/src/lib/address/api/addressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const addressApi = async ({ id }) => { + const dataAddress = await odooApi('GET', `/api/v1/partner/${id}/address`) + return dataAddress +} + +export default addressApi diff --git a/src/lib/address/api/addressesApi.js b/src/lib/address/api/addressesApi.js new file mode 100644 index 00000000..96f9e9d9 --- /dev/null +++ b/src/lib/address/api/addressesApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const addressesApi = async () => { + const auth = getAuth() + const dataAddresses = await odooApi('GET', `/api/v1/user/${auth.id}/address`) + return dataAddresses +} + +export default addressesApi diff --git a/src/lib/address/api/cityApi.js b/src/lib/address/api/cityApi.js new file mode 100644 index 00000000..7873435b --- /dev/null +++ b/src/lib/address/api/cityApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const cityApi = async () => { + const dataCities = await odooApi('GET', '/api/v1/city') + return dataCities +} + +export default cityApi diff --git a/src/lib/address/api/createAddressApi.js b/src/lib/address/api/createAddressApi.js new file mode 100644 index 00000000..b33b7ae1 --- /dev/null +++ b/src/lib/address/api/createAddressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const createAddressApi = async ({ data }) => { + const dataAddress = await odooApi('POST', '/api/v1/partner/address', data) + return dataAddress +} + +export default createAddressApi diff --git a/src/lib/address/api/districtApi.js b/src/lib/address/api/districtApi.js new file mode 100644 index 00000000..b0bcff16 --- /dev/null +++ b/src/lib/address/api/districtApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const districtApi = async ({ cityId }) => { + const dataDistricts = await odooApi('GET', `/api/v1/district?city_id=${cityId}`) + return dataDistricts +} + +export default districtApi diff --git a/src/lib/address/api/editAddressApi.js b/src/lib/address/api/editAddressApi.js new file mode 100644 index 00000000..ba383ef0 --- /dev/null +++ b/src/lib/address/api/editAddressApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const editAddressApi = async ({ id, data }) => { + const dataAddress = await odooApi('PUT', `/api/v1/partner/${id}/address`, data) + return dataAddress +} + +export default editAddressApi diff --git a/src/lib/address/api/subDistrictApi.js b/src/lib/address/api/subDistrictApi.js new file mode 100644 index 00000000..3f834420 --- /dev/null +++ b/src/lib/address/api/subDistrictApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const subDistrictApi = async ({ districtId }) => { + const dataSubDistricts = await odooApi('GET', `/api/v1/sub_district?district_id=${districtId}`) + return dataSubDistricts +} + +export default subDistrictApi diff --git a/src/lib/address/components/Addresses.jsx b/src/lib/address/components/Addresses.jsx new file mode 100644 index 00000000..3ac06b6c --- /dev/null +++ b/src/lib/address/components/Addresses.jsx @@ -0,0 +1,70 @@ +import Link from '@/core/components/elements/Link/Link' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useAuth from '@/core/hooks/useAuth' +import { getItemAddress, updateItemAddress } from '@/core/utils/address' +import { useRouter } from 'next/router' +import useAddresses from '../hooks/useAddresses' + +const Addresses = () => { + const router = useRouter() + const { select = null } = router.query + const auth = useAuth() + const { addresses } = useAddresses() + const selectedAdress = getItemAddress(select || '') + const changeSelectedAddress = (id) => { + if (!select) return + updateItemAddress(select, id) + router.back() + } + + if (addresses.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='p-4'> + <div className='text-right'> + <Link href='/my/address/create'>Tambah Alamat</Link> + </div> + + <div className='grid gap-y-4 mt-4'> + {addresses.data?.map((address, index) => { + let type = address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address' + return ( + <div + key={index} + className={ + 'p-4 rounded-md border ' + + (selectedAdress && selectedAdress == address.id + ? 'border-gray_r-7 bg-gray_r-4' + : 'border-gray_r-7') + } + > + <div onClick={() => changeSelectedAddress(address.id)}> + <div className='flex gap-x-2'> + <div className='badge-red'>{type}</div> + {auth?.partnerId == address.id && <div className='badge-green'>Utama</div>} + </div> + <p className='font-medium mt-2'>{address.name}</p> + {address.mobile && <p className='mt-2 text-gray_r-11'>{address.mobile}</p>} + <p className='mt-1 leading-6 text-gray_r-11'>{address.street}</p> + </div> + <Link + href={`/my/address/${address.id}/edit`} + className='btn-light bg-white mt-3 w-full !text-gray_r-11' + > + Ubah Alamat + </Link> + </div> + ) + })} + </div> + </div> + ) +} + +export default Addresses diff --git a/src/lib/address/components/CreateAddress.jsx b/src/lib/address/components/CreateAddress.jsx new file mode 100644 index 00000000..849b4c01 --- /dev/null +++ b/src/lib/address/components/CreateAddress.jsx @@ -0,0 +1,250 @@ +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import useAuth from '@/core/hooks/useAuth' +import { useRouter } from 'next/router' +import { Controller, useForm } from 'react-hook-form' +import * as Yup from 'yup' +import cityApi from '../api/cityApi' +import districtApi from '../api/districtApi' +import subDistrictApi from '../api/subDistrictApi' +import { useEffect, useState } from 'react' +import createAddressApi from '../api/createAddressApi' +import { toast } from 'react-hot-toast' +import { yupResolver } from '@hookform/resolvers/yup' + +const CreateAddress = () => { + const auth = useAuth() + const router = useRouter() + const { + register, + formState: { errors }, + handleSubmit, + watch, + setValue, + control + } = useForm({ + resolver: yupResolver(validationSchema), + defaultValues + }) + + const [cities, setCities] = useState([]) + const [districts, setDistricts] = useState([]) + const [subDistricts, setSubDistricts] = useState([]) + + useEffect(() => { + const loadCities = async () => { + let dataCities = await cityApi() + dataCities = dataCities.map((city) => ({ value: city.id, label: city.name })) + setCities(dataCities) + } + loadCities() + }, []) + + const watchCity = watch('city') + useEffect(() => { + setValue('district', '') + if (watchCity) { + const loadDistricts = async () => { + let dataDistricts = await districtApi({ cityId: watchCity }) + dataDistricts = dataDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setDistricts(dataDistricts) + } + loadDistricts() + } + }, [watchCity, setValue]) + + const watchDistrict = watch('district') + useEffect(() => { + setValue('subDistrict', '') + if (watchDistrict) { + const loadSubDistricts = async () => { + let dataSubDistricts = await subDistrictApi({ districtId: watchDistrict }) + dataSubDistricts = dataSubDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setSubDistricts(dataSubDistricts) + } + loadSubDistricts() + } + }, [watchDistrict, setValue]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + city_id: values.city, + district_id: values.district, + sub_district_id: values.subDistrict, + parent_id: auth.partnerId + } + + const address = await createAddressApi({ data }) + if (address?.id) { + toast.success('Berhasil menambahkan alamat') + router.back() + } + } + + return ( + <form + className='p-4 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='form-label mb-2'>Label Alamat</label> + <Controller + name='type' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + isSearchable={false} + options={types} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.type?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Nama</label> + <input + {...register('name')} + placeholder='John Doe' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.name?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Email</label> + <input + {...register('email')} + placeholder='contoh@email.com' + type='email' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.email?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Mobile</label> + <input + {...register('mobile')} + placeholder='08xxxxxxxx' + type='tel' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.mobile?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Alamat</label> + <input + {...register('street')} + placeholder='Jl. Bandengan Utara 85A' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.street?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kode Pos</label> + <input + {...register('zip')} + placeholder='10100' + type='number' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.zip?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kota</label> + <Controller + name='city' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={cities} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.city?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kecamatan</label> + <Controller + name='district' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={districts} + disabled={!watchCity} + /> + )} + /> + </div> + + <div> + <label className='form-label mb-2'>Kelurahan</label> + <Controller + name='subDistrict' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={subDistricts} + disabled={!watchDistrict} + /> + )} + /> + </div> + + <button + type='submit' + className='btn-yellow mt-2 w-full' + > + Simpan + </button> + </form> + ) +} + +const validationSchema = Yup.object().shape({ + type: Yup.string().required('Harus di-pilih'), + name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), + email: Yup.string().email('Format harus seperti contoh@email.com').required('Harus di-isi'), + mobile: Yup.string().required('Harus di-isi'), + street: Yup.string().required('Harus di-isi'), + zip: Yup.string().required('Harus di-isi'), + city: Yup.string().required('Harus di-pilih') +}) + +const defaultValues = { + type: '', + name: '', + email: '', + mobile: '', + street: '', + city: '', + district: '', + subDistrict: '', + zip: '' +} + +const types = [ + { value: 'contact', label: 'Contact Address' }, + { value: 'invoice', label: 'Invoice Address' }, + { value: 'delivery', label: 'Delivery Address' }, + { value: 'other', label: 'Other Address' } +] + +export default CreateAddress diff --git a/src/lib/address/components/EditAddress.jsx b/src/lib/address/components/EditAddress.jsx new file mode 100644 index 00000000..a832edbc --- /dev/null +++ b/src/lib/address/components/EditAddress.jsx @@ -0,0 +1,252 @@ +import { yupResolver } from '@hookform/resolvers/yup' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import * as Yup from 'yup' +import cityApi from '../api/cityApi' +import { Controller, useForm } from 'react-hook-form' +import districtApi from '../api/districtApi' +import subDistrictApi from '../api/subDistrictApi' +import editAddressApi from '../api/editAddressApi' +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import { toast } from 'react-hot-toast' + +const EditAddress = ({ id, defaultValues }) => { + const router = useRouter() + const { + register, + formState: { errors }, + handleSubmit, + watch, + setValue, + getValues, + control + } = useForm({ + resolver: yupResolver(validationSchema), + defaultValues + }) + + const [cities, setCities] = useState([]) + const [districts, setDistricts] = useState([]) + const [subDistricts, setSubDistricts] = useState([]) + + useEffect(() => { + const loadCities = async () => { + let dataCities = await cityApi() + dataCities = dataCities.map((city) => ({ + value: city.id, + label: city.name + })) + setCities(dataCities) + } + loadCities() + }, []) + + const watchCity = watch('city') + useEffect(() => { + setValue('district', '') + if (watchCity) { + const loadDistricts = async () => { + let dataDistricts = await districtApi({ cityId: watchCity }) + dataDistricts = dataDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setDistricts(dataDistricts) + let oldDistrict = getValues('oldDistrict') + if (oldDistrict) { + setValue('district', oldDistrict) + setValue('oldDistrict', '') + } + } + loadDistricts() + } + }, [watchCity, setValue, getValues]) + + const watchDistrict = watch('district') + useEffect(() => { + setValue('subDistrict', '') + if (watchDistrict) { + const loadSubDistricts = async () => { + let dataSubDistricts = await subDistrictApi({ + districtId: watchDistrict + }) + dataSubDistricts = dataSubDistricts.map((district) => ({ + value: district.id, + label: district.name + })) + setSubDistricts(dataSubDistricts) + let oldSubDistrict = getValues('oldSubDistrict') + + if (oldSubDistrict) { + setValue('subDistrict', oldSubDistrict) + setValue('oldSubDistrict', '') + } + } + loadSubDistricts() + } + }, [watchDistrict, setValue, getValues]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + city_id: values.city, + district_id: values.district, + sub_district_id: values.subDistrict + } + + const address = await editAddressApi({ id, data }) + if (address?.id) { + toast.success('Berhasil mengubah alamat') + router.back() + } + } + + return ( + <form + className='p-4 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='form-label mb-2'>Label Alamat</label> + <Controller + name='type' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + isSearchable={false} + options={types} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.type?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Nama</label> + <input + {...register('name')} + placeholder='John Doe' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.name?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Email</label> + <input + {...register('email')} + placeholder='johndoe@example.com' + type='email' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.email?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Mobile</label> + <input + {...register('mobile')} + placeholder='08xxxxxxxx' + type='tel' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.mobile?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Alamat</label> + <input + {...register('street')} + placeholder='Jl. Bandengan Utara 85A' + type='text' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.street?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kode Pos</label> + <input + {...register('zip')} + placeholder='10100' + type='number' + className='form-input' + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.zip?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kota</label> + <Controller + name='city' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={cities} + /> + )} + /> + <div className='text-caption-2 text-red_r-11 mt-1'>{errors.city?.message}</div> + </div> + + <div> + <label className='form-label mb-2'>Kecamatan</label> + <Controller + name='district' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={districts} + disabled={!watchCity} + /> + )} + /> + </div> + + <div> + <label className='form-label mb-2'>Kelurahan</label> + <Controller + name='subDistrict' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={subDistricts} + disabled={!watchDistrict} + /> + )} + /> + </div> + + <button + type='submit' + className='btn-yellow mt-2 w-full' + > + Simpan + </button> + </form> + ) +} + +const validationSchema = Yup.object().shape({ + type: Yup.string().required('Harus di-pilih'), + name: Yup.string().min(3, 'Minimal 3 karakter').required('Harus di-isi'), + email: Yup.string().email('Format harus seperti johndoe@example.com').required('Harus di-isi'), + mobile: Yup.string().required('Harus di-isi'), + street: Yup.string().required('Harus di-isi'), + zip: Yup.string().required('Harus di-isi'), + city: Yup.string().required('Harus di-pilih') +}) + +const types = [ + { value: 'contact', label: 'Contact Address' }, + { value: 'invoice', label: 'Invoice Address' }, + { value: 'delivery', label: 'Delivery Address' }, + { value: 'other', label: 'Other Address' } +] + +export default EditAddress diff --git a/src/lib/address/hooks/useAddresses.js b/src/lib/address/hooks/useAddresses.js new file mode 100644 index 00000000..629e367c --- /dev/null +++ b/src/lib/address/hooks/useAddresses.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import addressesApi from '../api/addressesApi' + +const useAddresses = () => { + const fetchAddresses = async () => await addressesApi() + const { data, isLoading } = useQuery('addresses', fetchAddresses) + + return { + addresses: { data, isLoading } + } +} + +export default useAddresses diff --git a/src/lib/auth/api/editPersonalProfileApi.js b/src/lib/auth/api/editPersonalProfileApi.js new file mode 100644 index 00000000..39cd44c1 --- /dev/null +++ b/src/lib/auth/api/editPersonalProfileApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const editPersonalProfileApi = async ({ data }) => { + const auth = getAuth() + const dataProfile = await odooApi('PUT', `/api/v1/user/${auth.id}`, data) + return dataProfile +} + +export default editPersonalProfileApi diff --git a/src/lib/auth/api/loginApi.js b/src/lib/auth/api/loginApi.js new file mode 100644 index 00000000..e393309c --- /dev/null +++ b/src/lib/auth/api/loginApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const loginApi = async ({ email, password }) => { + let result = await odooApi('POST', '/api/v1/user/login', { email, password }) + return result +} + +export default loginApi diff --git a/src/lib/auth/api/registerApi.js b/src/lib/auth/api/registerApi.js new file mode 100644 index 00000000..f3d75ce8 --- /dev/null +++ b/src/lib/auth/api/registerApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const registerApi = async ({ data }) => { + const dataRegister = await odooApi('POST', '/api/v1/user/register', data) + return dataRegister +} + +export default registerApi diff --git a/src/lib/auth/components/Activate.jsx b/src/lib/auth/components/Activate.jsx new file mode 100644 index 00000000..7970524c --- /dev/null +++ b/src/lib/auth/components/Activate.jsx @@ -0,0 +1,166 @@ +import Image from 'next/image' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import IndoteknikLogo from '@/images/logo.png' +import axios from 'axios' +import { setAuth } from '@/core/utils/auth' +import Alert from '@/core/components/elements/Alert/Alert' +import odooApi from '@/core/api/odooApi' + +const Activate = () => { + const router = useRouter() + const { token } = router.query + + const [isLoading, setIsLoading] = useState(false) + const [alert, setAlert] = useState() + + const [email, setEmail] = useState(router.query?.email || '') + + useEffect(() => { + const activateIfTokenExist = async () => { + if (token) { + let isActivated = await odooApi('POST', '/api/v1/user/activation', { token }) + if (isActivated.activation) { + setAuth(isActivated.user) + setAlert({ + children: ( + <> + Selamat, akun anda berhasil diaktifkan,{' '} + <Link + className='text-gray_r-12' + href='/' + > + kembali ke beranda + </Link> + . + </> + ), + type: 'success' + }) + } else { + setAlert({ + children: ( + <> + Mohon maaf token sudah tidak aktif, lakukan permintaan aktivasi akun kembali atau{' '} + <Link + className='text-gray_r-12' + href='/login' + > + masuk + </Link>{' '} + jika sudah memiliki akun. + </> + ), + type: 'info' + }) + } + } + } + activateIfTokenExist() + }, [token]) + + useEffect(() => { + if (router.query.email) setEmail(router.query.email) + }, [router]) + + const activationRequest = async (e) => { + e.preventDefault() + setIsLoading(true) + let activationRequest = await axios.post(`${process.env.SELF_HOST}/api/activation-request`, { + email + }) + if (activationRequest.data.activationRequest) { + setAlert({ + children: <>Mohon cek email anda untuk aktivasi akun Indoteknik</>, + type: 'success' + }) + } else { + switch (activationRequest.data.reason) { + case 'NOT_FOUND': + setAlert({ + children: ( + <> + Email tersebut belum terdaftar,{' '} + <Link + className='text-gray_r-12' + href='/register' + > + daftar sekarang + </Link> + . + </> + ), + type: 'info' + }) + break + case 'ACTIVE': + setAlert({ + children: ( + <> + Email tersebut sudah terdaftar dan sudah aktif,{' '} + <Link + className='text-gray_r-12' + href='/login' + > + masuk sekarang + </Link> + . + </> + ), + type: 'info' + }) + break + } + } + setIsLoading(false) + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + + <h1 className='text-2xl mt-4 font-semibold'>Aktivasi Akun Indoteknik</h1> + + {alert && ( + <Alert + className='text-center mt-4' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + onSubmit={activationRequest} + className='mt-6 w-full' + > + <input + type='email' + id='email' + className='form-input w-full text-center' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='Masukan alamat email' + autoFocus + /> + <button + type='submit' + disabled={email != ''} + className='btn-yellow font-semibold mt-4 w-full' + > + {isLoading ? 'Loading...' : 'Aktivasi'} + </button> + </form> + </div> + ) +} + +export default Activate diff --git a/src/lib/auth/components/CompanyProfile.jsx b/src/lib/auth/components/CompanyProfile.jsx new file mode 100644 index 00000000..1b25551e --- /dev/null +++ b/src/lib/auth/components/CompanyProfile.jsx @@ -0,0 +1,158 @@ +import odooApi from '@/core/api/odooApi' +import HookFormSelect from '@/core/components/elements/Select/HookFormSelect' +import useAuth from '@/core/hooks/useAuth' +import addressApi from '@/lib/address/api/addressApi' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { toast } from 'react-hot-toast' + +const CompanyProfile = () => { + const auth = useAuth() + const [isOpen, setIsOpen] = useState(false) + const toggle = () => setIsOpen(!isOpen) + const { register, setValue, control, handleSubmit } = useForm({ + defaultValues: { + industry: '', + companyType: '', + name: '', + taxName: '', + npwp: '' + } + }) + + const [industries, setIndustries] = useState([]) + useEffect(() => { + const loadIndustries = async () => { + const dataIndustries = await odooApi('GET', '/api/v1/partner/industry') + setIndustries(dataIndustries?.map((o) => ({ value: o.id, label: o.name }))) + } + loadIndustries() + }, []) + + const [companyTypes, setCompanyTypes] = useState([]) + useEffect(() => { + const loadCompanyTypes = async () => { + const dataCompanyTypes = await odooApi('GET', '/api/v1/partner/company_type') + setCompanyTypes(dataCompanyTypes?.map((o) => ({ value: o.id, label: o.name }))) + } + loadCompanyTypes() + }, []) + + useEffect(() => { + const loadProfile = async () => { + const dataProfile = await addressApi({ id: auth.parentId }) + setValue('name', dataProfile.name) + setValue('industry', dataProfile.industryId) + setValue('companyType', dataProfile.companyTypeId) + setValue('taxName', dataProfile.taxName) + setValue('npwp', dataProfile.npwp) + } + if (auth) loadProfile() + }, [auth, setValue]) + + const onSubmitHandler = async (values) => { + const data = { + ...values, + company_type_id: values.companyType, + industry_id: values.industry, + tax_name: values.taxName + } + const isUpdated = await odooApi('PUT', `/api/v1/partner/${auth.parentId}`, data) + if (isUpdated?.id) { + setIsOpen(false) + toast.success('Berhasil mengubah profil', { duration: 1500 }) + return + } + toast.error('Terjadi kesalahan internal') + } + + return ( + <> + <button + type='button' + onClick={toggle} + className='p-4 flex items-center text-left' + > + <div> + <div className='font-semibold mb-2'>Informasi Usaha</div> + <div className='text-gray_r-11'> + Dibawah ini adalah data usaha yang anda masukkan, periksa kembali data usaha anda. + </div> + </div> + <div className='p-2 bg-gray_r-3 rounded'> + {!isOpen && <ChevronDownIcon className='w-6' />} + {isOpen && <ChevronUpIcon className='w-6' />} + </div> + </button> + + {isOpen && ( + <form + className='p-4 border-t border-gray_r-6 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label className='block mb-3'>Klasifikasi Jenis Usaha</label> + <Controller + name='industry' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={industries} + /> + )} + /> + </div> + <div className='flex flex-wrap'> + <div className='w-full mb-3'>Nama Usaha</div> + <div className='w-3/12 pr-1'> + <Controller + name='companyType' + control={control} + render={(props) => ( + <HookFormSelect + {...props} + options={companyTypes} + /> + )} + /> + </div> + <div className='w-9/12 pl-1'> + <input + {...register('name')} + type='text' + className='form-input' + placeholder='Cth: Indoteknik Dotcom Gemilang' + /> + </div> + </div> + <div> + <label>Nama Wajib Pajak</label> + <input + {...register('taxName')} + type='text' + className='form-input mt-3' + /> + </div> + <div> + <label>Nomor NPWP</label> + <input + {...register('npwp')} + type='text' + className='form-input mt-3' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + > + Simpan + </button> + </form> + )} + </> + ) +} + +export default CompanyProfile diff --git a/src/lib/auth/components/IsAuth.jsx b/src/lib/auth/components/IsAuth.jsx new file mode 100644 index 00000000..1cfd3172 --- /dev/null +++ b/src/lib/auth/components/IsAuth.jsx @@ -0,0 +1,20 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { getAuth } from '@/core/utils/auth' + +const IsAuth = ({ children }) => { + const router = useRouter() + const [response, setResponse] = useState(<></>) + + useEffect(() => { + if (!getAuth()) { + router.replace('/login') + } else { + setResponse(children) + } + }, [children, router]) + + return response +} + +export default IsAuth diff --git a/src/lib/auth/components/Login.jsx b/src/lib/auth/components/Login.jsx new file mode 100644 index 00000000..b25cf4fe --- /dev/null +++ b/src/lib/auth/components/Login.jsx @@ -0,0 +1,125 @@ +import Image from 'next/image' +import IndoteknikLogo from '@/images/logo.png' +import Link from '@/core/components/elements/Link/Link' +import { useState } from 'react' +import loginApi from '../api/loginApi' +import { useRouter } from 'next/router' +import Alert from '@/core/components/elements/Alert/Alert' +import { setAuth } from '@/core/utils/auth' + +const Login = () => { + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [alert, setAlert] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + setAlert(null) + setIsLoading(true) + const login = await loginApi({ email, password }) + setIsLoading(false) + + if (login.isAuth) { + setAuth(login.user) + router.push('/') + return + } + switch (login.reason) { + case 'NOT_FOUND': + setAlert({ + children: 'Email atau password tidak cocok', + type: 'info' + }) + break + case 'NOT_ACTIVE': + setAlert({ + children: ( + <> + Email belum diaktivasi, + <Link + className='text-gray-900' + href={`/activate?email=${email}`} + > + aktivasi sekarang + </Link> + </> + ), + type: 'info' + }) + break + } + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + <h1 className='text-2xl mt-4 font-semibold'>Mulai Belanja Sekarang</h1> + <h2 className='text-gray_r-11 font-normal mt-1 mb-4'>Masuk ke akun kamu untuk belanja</h2> + + {alert && ( + <Alert + className='text-center' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + className='w-full mt-6 flex flex-col gap-y-4' + onSubmit={handleSubmit} + > + <div> + <label htmlFor='email'>Alamat Email</label> + <input + type='email' + id='email' + className='form-input w-full mt-3' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='contoh@email.com' + /> + </div> + <div> + <label htmlFor='password'>Kata Sandi</label> + <input + type='password' + id='password' + className='form-input w-full mt-3' + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder='••••••••••••' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + disabled={!email || !password || isLoading} + > + {!isLoading ? 'Masuk' : 'Loading...'} + </button> + </form> + + <div className='text-gray_r-11 mt-4'> + Belum punya akun Indoteknik?{' '} + <Link + href='/register' + className='inline' + > + Daftar + </Link> + </div> + </div> + ) +} + +export default Login diff --git a/src/lib/auth/components/PersonalProfile.jsx b/src/lib/auth/components/PersonalProfile.jsx new file mode 100644 index 00000000..0b387f2e --- /dev/null +++ b/src/lib/auth/components/PersonalProfile.jsx @@ -0,0 +1,118 @@ +import useAuth from '@/core/hooks/useAuth' +import { setAuth } from '@/core/utils/auth' +import addressApi from '@/lib/address/api/addressApi' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'react-hot-toast' +import editPersonalProfileApi from '../api/editPersonalProfileApi' + +const PersonalProfile = () => { + const auth = useAuth() + const [isOpen, setIsOpen] = useState(false) + const toggle = () => setIsOpen(!isOpen) + const { register, setValue, handleSubmit } = useForm({ + defaultValues: { + email: '', + name: '', + mobile: '', + password: '' + } + }) + + useEffect(() => { + const loadProfile = async () => { + const dataProfile = await addressApi({ id: auth.partnerId }) + setValue('email', dataProfile?.email) + setValue('name', dataProfile?.name) + setValue('mobile', dataProfile?.mobile) + } + if (auth) loadProfile() + }, [auth, setValue]) + + const onSubmitHandler = async (values) => { + let data = values + if (!values.password) delete data.password + const isUpdated = await editPersonalProfileApi({ data }) + console.log(isUpdated) + if (isUpdated?.user) { + setAuth(isUpdated.user) + setValue('password', '') + setIsOpen(false) + toast.success('Berhasil mengubah profil', { duration: 1500 }) + return + } + toast.error('Terjadi kesalahan internal') + } + + return ( + <> + <button + type='button' + onClick={toggle} + className='p-4 flex items-center text-left' + > + <div> + <div className='font-semibold mb-2'>Informasi Akun</div> + <div className='text-gray_r-11'> + Dibawah ini adalah data diri yang anda masukan, periksa kembali data diri anda + </div> + </div> + <div className='p-2 bg-gray_r-3 rounded'> + {!isOpen && <ChevronDownIcon className='w-6' />} + {isOpen && <ChevronUpIcon className='w-6' />} + </div> + </button> + + {isOpen && ( + <form + className='p-4 border-t border-gray_r-6 flex flex-col gap-y-4' + onSubmit={handleSubmit(onSubmitHandler)} + > + <div> + <label>Email</label> + <input + {...register('email')} + type='text' + disabled + className='form-input mt-3' + /> + </div> + <div> + <label>Nama Lengkap</label> + <input + {...register('name')} + type='text' + className='form-input mt-3' + /> + </div> + <div> + <label>No. Handphone</label> + <input + {...register('mobile')} + type='tel' + className='form-input mt-3' + /> + </div> + <div> + <label>Kata Sandi</label> + <input + {...register('password')} + type='password' + className='form-input mt-3' + placeholder='Isi jika ingin mengubah kata sandi' + /> + </div> + <button + type='submit' + className='btn-yellow w-full mt-2' + > + Simpan + </button> + </form> + )} + </> + ) +} + +export default PersonalProfile diff --git a/src/lib/auth/components/Register.jsx b/src/lib/auth/components/Register.jsx new file mode 100644 index 00000000..d02081ce --- /dev/null +++ b/src/lib/auth/components/Register.jsx @@ -0,0 +1,151 @@ +import Image from 'next/image' +import Link from '@/core/components/elements/Link/Link' +import IndoteknikLogo from '@/images/logo.png' +import { useState } from 'react' +import registerApi from '../api/registerApi' +import Alert from '@/core/components/elements/Alert/Alert' +import axios from 'axios' + +const Register = () => { + const [fullname, setFullname] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [companyName, setCompanyName] = useState('') + const [isLoading, setIsLoading] = useState('') + const [alert, setAlert] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + setAlert(null) + setIsLoading(true) + const data = { + name: fullname, + company: companyName, + email, + password + } + const isRegistered = await registerApi({ data }) + setIsLoading(false) + if (isRegistered.register) { + await axios.post(`${process.env.SELF_HOST}/api/activation-request`, { email }) + setAlert({ + children: 'Berhasil mendaftarkan akun anda, cek email untuk melakukan aktivasi akun', + type: 'success' + }) + setCompanyName('') + setFullname('') + setEmail('') + setPassword('') + } else { + switch (isRegistered.reason) { + case 'EMAIL_USED': + setAlert({ + children: 'Email telah digunakan', + type: 'info' + }) + break + } + } + } + + return ( + <div className='p-6 pt-10 flex flex-col items-center'> + <Link href='/'> + <Image + src={IndoteknikLogo} + alt='Logo Indoteknik' + width={150} + height={50} + /> + </Link> + + <h1 className='text-2xl mt-4 font-semibold'>Daftar Akun Indoteknik</h1> + <h2 className='text-gray_r-11 font-normal mt-1 mb-4 text-center'> + Buat akun sekarang lebih mudah dan terverifikasi + </h2> + + {alert && ( + <Alert + className='text-center' + type={alert.type} + > + {alert.children} + </Alert> + )} + + <form + className='w-full mt-6 flex flex-col gap-y-4' + onSubmit={handleSubmit} + > + <div> + <label htmlFor='companyName'> + Nama Perusahaan <span className='text-gray_r-11'>(opsional)</span> + </label> + <input + type='text' + id='companyName' + className='form-input w-full mt-3' + value={companyName} + onChange={(e) => setCompanyName(e.target.value.toUpperCase())} + placeholder='cth: INDOTEKNIK DOTCOM GEMILANG' + autoCapitalize='true' + /> + </div> + + <div> + <label htmlFor='fullname'>Nama Lengkap</label> + <input + type='text' + id='fullname' + className='form-input w-full mt-3' + value={fullname} + onChange={(e) => setFullname(e.target.value)} + placeholder='John Doe' + /> + </div> + <div> + <label htmlFor='email'>Alamat Email</label> + <input + type='email' + id='email' + className='form-input w-full mt-3' + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder='contoh@email.com' + /> + </div> + <div> + <label htmlFor='password'>Kata Sandi</label> + <input + type='password' + id='password' + className='form-input w-full mt-3' + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder='••••••••••••' + /> + </div> + + <button + type='submit' + className='btn-yellow w-full mt-2' + disabled={!email || !password || !fullname || isLoading} + > + {!isLoading ? 'Daftar' : 'Loading...'} + </button> + </form> + + <div className='text-gray_r-11 mt-4'> + Sudah punya akun Indoteknik?{' '} + <Link + href='/login' + className='inline' + > + Masuk + </Link> + </div> + </div> + ) +} + +export default Register diff --git a/src/lib/brand/api/BrandApi.js b/src/lib/brand/api/BrandApi.js new file mode 100644 index 00000000..79801774 --- /dev/null +++ b/src/lib/brand/api/BrandApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const BrandApi = async ({ id }) => { + const dataBrand = await odooApi('GET', `/api/v1/manufacture/${id}`) + return dataBrand +} + +export default BrandApi diff --git a/src/lib/brand/components/Brand.jsx b/src/lib/brand/components/Brand.jsx new file mode 100644 index 00000000..c338c4c4 --- /dev/null +++ b/src/lib/brand/components/Brand.jsx @@ -0,0 +1,70 @@ +import useBrand from '../hooks/useBrand' +import Image from '@/core/components/elements/Image/Image' + +import { Swiper, SwiperSlide } from 'swiper/react' +import { Pagination, Autoplay } from 'swiper' +import 'swiper/css' +import 'swiper/css/pagination' +import 'swiper/css/autoplay' +import Divider from '@/core/components/elements/Divider/Divider' +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' + +const swiperBanner = { + pagination: { dynamicBullets: true }, + autoplay: { + delay: 6000, + disableOnInteraction: false + }, + modules: [Pagination, Autoplay] +} + +const Brand = ({ id }) => { + const { brand } = useBrand({ id }) + + return ( + <> + <div className='min-h-[150px]'> + {brand.isLoading && <ImageSkeleton />} + {brand.data && ( + <> + <Swiper + slidesPerView={1} + pagination={swiperBanner.pagination} + modules={swiperBanner.modules} + autoplay={swiperBanner.autoplay} + className='border-b border-gray_r-6' + > + {brand.data?.banners?.map((banner, index) => ( + <SwiperSlide key={index}> + <Image + src={banner} + alt={`Brand ${brand.data?.name} - Indoteknik`} + className='w-full h-auto' + /> + </SwiperSlide> + ))} + </Swiper> + <div className='p-4'> + <div className='text-caption-1 text-gray_r-11 mb-2'>Produk dari brand:</div> + {brand?.data?.logo && ( + <Image + src={brand?.data?.logo} + alt={brand?.data?.name} + className='w-32 p-2 border borde-gray_r-6 rounded' + /> + )} + {!brand?.data?.logo && ( + <div className='bg-red_r-10 text-white text-center text-caption-1 py-2 px-4 rounded w-fit'> + {brand?.data?.name} + </div> + )} + </div> + </> + )} + </div> + <Divider /> + </> + ) +} + +export default Brand diff --git a/src/lib/brand/components/BrandCard.jsx b/src/lib/brand/components/BrandCard.jsx new file mode 100644 index 00000000..1bcdb5ab --- /dev/null +++ b/src/lib/brand/components/BrandCard.jsx @@ -0,0 +1,30 @@ +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { createSlug } from '@/core/utils/slug' + +const BrandCard = ({ brand }) => { + return ( + <Link + href={createSlug('/shop/brands/', brand.name, brand.id)} + className='py-1 px-2 rounded border border-gray_r-6 h-12 flex justify-center items-center' + > + {brand.logo && ( + <Image + src={brand.logo} + alt={brand.name} + className='h-full object-contain object-center' + /> + )} + {!brand.logo && ( + <span + className='text-center' + style={{ fontSize: `${14 - brand.name.length * 0.5}px` }} + > + {brand.name} + </span> + )} + </Link> + ) +} + +export default BrandCard diff --git a/src/lib/brand/components/Brands.jsx b/src/lib/brand/components/Brands.jsx new file mode 100644 index 00000000..22f47975 --- /dev/null +++ b/src/lib/brand/components/Brands.jsx @@ -0,0 +1,80 @@ +import odooApi from '@/core/api/odooApi' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import { useCallback, useEffect, useState } from 'react' +import BrandCard from './BrandCard' + +const Brands = () => { + const alpha = Array.from(Array(26)).map((e, i) => i + 65) + const alphabets = alpha.map((x) => String.fromCharCode(x)) + + const [isLoading, setIsLoading] = useState(true) + const [startWith, setStartWith] = useState(null) + const [manufactures, setManufactures] = useState([]) + + const loadBrand = useCallback(async () => { + setIsLoading(true) + const name = startWith ? `${startWith}%` : '' + const result = await odooApi( + 'GET', + `/api/v1/manufacture?limit=0&offset=${manufactures.length}&name=${name}` + ) + setIsLoading(false) + setManufactures((manufactures) => [...manufactures, ...result.manufactures]) + }, [startWith]) + + const toggleStartWith = (alphabet) => { + setManufactures([]) + if (alphabet == startWith) { + setStartWith(null) + return + } + setStartWith(alphabet) + } + + useEffect(() => { + loadBrand() + }, [loadBrand]) + + if (isLoading) { + return ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='p-4'> + <h1 className='font-semibold'>Semua Brand di Indoteknik</h1> + <div className='flex overflow-x-auto gap-x-2 py-2'> + {alphabets.map((alphabet, index) => ( + <button + key={index} + className={ + 'p-2 py-1 border bg-white border-gray_r-6 rounded w-10 flex-shrink-0' + + (startWith == alphabet ? ' !bg-yellow_r-9 border-yellow_r-9 ' : '') + } + type='button' + onClick={() => toggleStartWith(alphabet)} + > + {alphabet} + </button> + ))} + </div> + + <div className='grid grid-cols-4 gap-4 mt-4 !overflow-x-hidden'> + {manufactures?.map( + (manufacture, index) => + manufacture.name && ( + <BrandCard + brand={manufacture} + key={index} + /> + ) + )} + </div> + </div> + ) +} + +export default Brands diff --git a/src/lib/brand/hooks/useBrand.js b/src/lib/brand/hooks/useBrand.js new file mode 100644 index 00000000..3ba65a97 --- /dev/null +++ b/src/lib/brand/hooks/useBrand.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import BrandApi from '../api/BrandApi' + +const useBrand = ({ id }) => { + const fetchBrand = async () => await BrandApi({ id }) + const { data, isLoading } = useQuery(`brand-${id}`, fetchBrand) + + return { + brand: { data, isLoading } + } +} + +export default useBrand diff --git a/src/lib/cart/api/CartApi.js b/src/lib/cart/api/CartApi.js new file mode 100644 index 00000000..33b61891 --- /dev/null +++ b/src/lib/cart/api/CartApi.js @@ -0,0 +1,11 @@ +import odooApi from '@/core/api/odooApi' + +const CartApi = async ({ variantIds }) => { + if (variantIds) { + const dataCart = await odooApi('GET', `/api/v1/product_variant/${variantIds}`) + return dataCart + } + return null +} + +export default CartApi diff --git a/src/lib/cart/components/Cart.jsx b/src/lib/cart/components/Cart.jsx new file mode 100644 index 00000000..6a503c0a --- /dev/null +++ b/src/lib/cart/components/Cart.jsx @@ -0,0 +1,289 @@ +import Link from '@/core/components/elements/Link/Link' +import useCart from '../hooks/useCart' +import Image from '@/core/components/elements/Image/Image' +import currencyFormat from '@/core/utils/currencyFormat' +import { useEffect, useState } from 'react' +import { deleteItemCart, getItemCart, updateItemCart } from '@/core/utils/cart' +import { CheckIcon, RectangleGroupIcon, TrashIcon } from '@heroicons/react/24/outline' +import { createSlug } from '@/core/utils/slug' +import { useRouter } from 'next/router' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { toast } from 'react-hot-toast' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Alert from '@/core/components/elements/Alert/Alert' + +const Cart = () => { + const router = useRouter() + const [products, setProducts] = useState(null) + const { cart } = useCart({ enabled: !products }) + + const [totalPriceBeforeTax, setTotalPriceBeforeTax] = useState(0) + const [totalTaxAmount, setTotalTaxAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + const [deleteConfirmation, setDeleteConfirmation] = useState(null) + + useEffect(() => { + if (cart.data && !products) { + const productsWithQuantity = cart.data.map((product) => { + const productInCart = getItemCart({ productId: product.id }) + if (!productInCart) return + return { + ...product, + quantity: productInCart.quantity, + selected: productInCart.selected + } + }) + setProducts(productsWithQuantity) + } + }, [cart, products]) + + useEffect(() => { + if (!products) return + + let calculateTotalPriceBeforeTax = 0 + let calculateTotalTaxAmount = 0 + let calculateTotalDiscountAmount = 0 + for (const product of products) { + if (product.quantity == '') continue + updateItemCart({ + productId: product.id, + quantity: product.quantity, + selected: product.selected + }) + + if (!product.selected) continue + let priceBeforeTax = product.price.price / 1.11 + calculateTotalPriceBeforeTax += priceBeforeTax * product.quantity + calculateTotalTaxAmount += (product.price.price - priceBeforeTax) * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + } + setTotalPriceBeforeTax(calculateTotalPriceBeforeTax) + setTotalTaxAmount(calculateTotalTaxAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + }, [products]) + + const updateQuantity = (value, productId, operation = '') => { + let productIndex = products.findIndex((product) => product.id == productId) + if (productIndex < 0) return + + let productsToUpdate = products + let quantity = productsToUpdate[productIndex].quantity + if (value != '' && isNaN(parseInt(value))) return + value = value != '' ? parseInt(value) : '' + switch (operation) { + case 'PLUS': + quantity += value + break + case 'MINUS': + if (quantity - value < 1) return + quantity -= value + break + case 'BLUR': + if (value != '') return + quantity = 1 + break + default: + quantity = value + break + } + productsToUpdate[productIndex].quantity = quantity + setProducts([...productsToUpdate]) + } + + const toggleSelected = (productId) => { + let productIndex = products.findIndex((product) => product.id == productId) + if (productIndex < 0) return + + let productsToUpdate = products + productsToUpdate[productIndex].selected = !productsToUpdate[productIndex].selected + setProducts([...productsToUpdate]) + } + + const selectedProduct = () => { + if (!products) return [] + return products?.filter((product) => product.selected == true) + } + + const deleteProduct = (productId) => { + const productsToUpdate = products.filter((product) => product.id != productId) + deleteItemCart({ productId }) + setDeleteConfirmation(null) + setProducts([...productsToUpdate]) + toast.success('Berhasil menghapus barang dari keranjang') + } + + return ( + <div className='pt-4'> + <div className='flex justify-between mb-4 px-4'> + <h1 className='font-semibold'>Daftar Produk Belanja</h1> + <Link href='/'>Cari Produk Lain</Link> + </div> + + <div className='flex flex-col gap-y-4 h-screen'> + {cart.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!cart.isLoading && (!products || products?.length == 0) && ( + <div className='px-4'> + <Alert + className='text-center my-2' + type='info' + > + Keranjang belanja anda masih kosong + </Alert> + </div> + )} + + {products?.map((product) => ( + <div + key={product?.id} + className='flex mx-4' + > + <button + type='button' + className='flex items-center mr-2' + onClick={() => toggleSelected(product.id)} + > + {!product?.selected && <div className='w-5 h-5 border border-gray_r-11 rounded' />} + {product?.selected && <CheckIcon className='border bg-red_r-10 w-5 text-white' />} + </button> + <Link + href={createSlug('/shop/product/', product?.parent.name, product?.parent.id)} + className='w-[30%] flex-shrink-0' + > + <Image + src={product?.parent?.image} + alt={product?.name} + className='object-contain object-center border border-gray_r-6 h-40 w-full rounded-md' + /> + </Link> + <div className='flex-1 px-2 text-caption-2'> + <Link + href={createSlug('/shop/product/', product?.parent.name, product?.parent.id)} + className='line-clamp-2 leading-6 !text-gray_r-12 font-normal' + > + {product?.parent?.name} + </Link> + <div className='text-gray_r-11 mt-1'> + {product?.code}{' '} + {product?.attributes.length > 0 ? `| ${product?.attributes.join(', ')}` : ''} + </div> + {product?.price?.discountPercentage > 0 && ( + <div className='flex gap-x-1 items-center mt-3'> + <div className='text-gray_r-11 line-through text-caption-2'> + {currencyFormat(product?.price?.price)} + </div> + <div className='badge-solid-red'>{product?.price?.discountPercentage}%</div> + </div> + )} + <div className='font-normal mt-1'> + {currencyFormat(product?.price?.priceDiscount)} + </div> + <div className='flex justify-between items-center mt-1'> + <div className='text-red_r-11 font-medium'> + {currencyFormat(product?.price?.priceDiscount * product?.quantity)} + </div> + <div className='flex gap-x-1'> + <button + type='button' + className='btn-light px-2 py-1' + onClick={() => updateQuantity(1, product?.id, 'MINUS')} + disabled={product?.quantity == 1} + > + - + </button> + <input + className='form-input w-6 border-0 border-b rounded-none py-1 px-0 text-center' + type='number' + value={product?.quantity} + onChange={(e) => updateQuantity(e.target.value, product?.id)} + onBlur={(e) => updateQuantity(e.target.value, product?.id, 'BLUR')} + /> + <button + type='button' + className='btn-light px-2 py-1' + onClick={() => updateQuantity(1, product?.id, 'PLUS')} + > + + + </button> + <button + className='btn-red p-1 ml-1' + onClick={() => setDeleteConfirmation(product)} + > + <TrashIcon className='w-4' /> + </button> + </div> + </div> + </div> + </div> + ))} + + <div className='sticky bottom-0 left-0 w-full p-4 mt-auto border-t border-gray_r-6 bg-white'> + <div className='flex justify-between mb-4'> + <div className='text-gray_r-11'> + Total: + <span className='text-red_r-11 font-semibold'> + + {selectedProduct().length > 0 + ? currencyFormat(totalPriceBeforeTax - totalDiscountAmount + totalTaxAmount) + : '-'} + </span> + </div> + </div> + <div className='flex gap-x-3'> + <button + type='button' + className='btn-yellow flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/quotation')} + > + Quotation + </button> + <button + type='button' + className='btn-solid-red flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/checkout')} + > + Checkout + </button> + </div> + </div> + </div> + + <BottomPopup + active={deleteConfirmation} + close={() => setDeleteConfirmation(null)} + title='Hapus dari Keranjang' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin menghapus barang{' '} + <span className='underline'>{deleteConfirmation?.name}</span> dari keranjang? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={() => deleteProduct(deleteConfirmation?.id)} + > + Ya, Hapus + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setDeleteConfirmation(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Cart diff --git a/src/lib/cart/hooks/useCart.js b/src/lib/cart/hooks/useCart.js new file mode 100644 index 00000000..bc1ea7ea --- /dev/null +++ b/src/lib/cart/hooks/useCart.js @@ -0,0 +1,17 @@ +import { getCart } from '@/core/utils/cart' +import { useQuery } from 'react-query' +import _ from 'lodash' +import CartApi from '../api/CartApi' + +const useCart = ({ enabled }) => { + const cart = getCart() + const variantIds = _.keys(cart).join(',') + const fetchCart = async () => CartApi({ variantIds }) + const { data, isLoading } = useQuery('cart', fetchCart, { enabled }) + + return { + cart: { data, isLoading } + } +} + +export default useCart diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js new file mode 100644 index 00000000..b76c9b7f --- /dev/null +++ b/src/lib/checkout/api/checkoutApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutApi = async ({ data }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/checkout`, + data + ) + return dataCheckout +} + +export default checkoutApi diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx new file mode 100644 index 00000000..f6170b13 --- /dev/null +++ b/src/lib/checkout/components/Checkout.jsx @@ -0,0 +1,323 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import { getItemAddress } from '@/core/utils/address' +import addressesApi from '@/lib/address/api/addressesApi' +import CartApi from '@/lib/cart/api/CartApi' +import VariantCard from '@/lib/variant/components/VariantCard' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useRef, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import checkoutApi from '../api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Checkout = () => { + const router = useRouter() + const auth = useAuth() + const [selectedAddress, setSelectedAddress] = useState({ + shipping: null, + invoicing: null + }) + const [addresses, setAddresses] = useState(null) + + useEffect(() => { + if (!auth) return + + const getAddresses = async () => { + const dataAddresses = await addressesApi() + setAddresses(dataAddresses) + } + + getAddresses() + }, [auth]) + + useEffect(() => { + if (!addresses) return + + const matchAddress = (key) => { + const addressToMatch = getItemAddress(key) + const foundAddress = addresses.filter((address) => address.id == addressToMatch) + if (foundAddress.length > 0) { + return foundAddress[0] + } + return addresses[0] + } + + setSelectedAddress({ + shipping: matchAddress('shipping'), + invoicing: matchAddress('invoicing') + }) + }, [addresses]) + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [selectedPayment, setSelectedPayment] = useState(null) + + const poNumber = useRef('') + const poFile = useRef('') + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!selectedPayment) { + toast.error('Pilih metode pembayaran', { position: 'bottom-center' }) + return + } + const file = poFile.current.files[0] + if (typeof file !== 'undefined' && file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: selectedAddress.shipping.id, + partner_invoice_id: selectedAddress.invoicing.id, + order_line: JSON.stringify(productOrder), + type: 'sale_order' + } + if (poNumber.current.value) data.po_number = poNumber.current.value + if (typeof file !== 'undefined') data.po_file = await getFileBase64(file) + + const isCheckouted = await checkoutApi({ data }) + setIsLoading(false) + if (isCheckouted?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/checkout/finish?id=${isCheckouted.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Pengiriman' + url='/my/address?select=shipping' + address={selectedAddress.shipping} + /> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Pesanan</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Penagihan' + url='/my/address?select=invoicing' + address={selectedAddress.invoicing} + /> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'> + Metode Pembayaran <span className='font-normal text-gray_r-11'>(Wajib dipilih)</span> + </div> + <div className='grid gap-y-3 mt-4'> + {payments.map((payment, index) => ( + <button + type='button' + className={ + 'text-left border border-gray_r-6 rounded-md p-3 ' + + (selectedPayment == payment.name && 'border-yellow_r-10 bg-yellow_r-3') + } + onClick={() => setSelectedPayment(payment.name)} + key={index} + > + <p> + {payment.name} - {payment.number} + </p> + <p className='mt-1 text-gray_r-11'>PT. Indoteknik Dotcom Gemilang</p> + </button> + ))} + </div> + </div> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'>Purchase Order</div> + + <div className='mt-4 flex gap-x-3'> + <div className='w-6/12'> + <label className='form-label font-normal'>Dokumen PO</label> + <input + type='file' + className='form-input mt-2 h-12' + accept='image/*,application/pdf' + ref={poFile} + /> + </div> + <div className='w-6/12'> + <label className='form-label font-normal'>Nomor PO</label> + <input + type='text' + className='form-input mt-2 h-12' + ref={poNumber} + /> + </div> + </div> + <p className='text-caption-2 text-gray_r-11 mt-2'>Ukuran dokumen PO Maksimal 5MB</p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Bayar'} + </button> + </div> + </> + ) +} + +const payments = [ + { name: 'BCA', number: '8870-4000-81' }, + { name: 'MANDIRI', number: '155-0067-6869-75' } +] + +const SectionAddress = ({ address, label, url }) => ( + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>{label}</div> + <Link + className='text-caption-1' + href={url} + > + Pilih Alamat Lain + </Link> + </div> + + {address && ( + <div className='mt-4 text-caption-1'> + <div className='badge-red mb-2'> + {address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address'} + </div> + <p className='font-medium'>{address.name}</p> + <p className='mt-2 text-gray_r-11'>{address.mobile}</p> + <p className='mt-1 text-gray_r-11'> + {address.street}, {address?.city?.name} + </p> + </div> + )} + </div> +) + +export default Checkout diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx new file mode 100644 index 00000000..a7d65dd0 --- /dev/null +++ b/src/lib/checkout/components/FinishCheckout.jsx @@ -0,0 +1,30 @@ +import Link from '@/core/components/elements/Link/Link' +import useTransaction from '@/lib/transaction/hooks/useTransaction' + +const FinishCheckout = ({ id }) => { + const { transaction } = useTransaction({ id }) + + return ( + <div className='p-4'> + <div className='rounded-xl bg-yellow_r-4 text-center border border-yellow_r-7'> + <div className='px-4 py-6 text-yellow_r-12'> + <p className='font-semibold mb-2'>Terima Kasih atas Pembelian Anda</p> + <p className='text-yellow_r-11 mb-4 leading-6'> + Rincian belanja sudah kami kirimkan ke email anda. Mohon dicek kembali. jika tidak + menerima email, anda dapat menghubungi kami disini. + </p> + <p className='mb-2 font-medium'>{transaction.data?.name}</p> + <p className='text-caption-2 text-yellow_r-11'>No. Transaksi</p> + </div> + <Link + href={transaction.data?.id ? `/my/transaction/${transaction.data.id}` : '/'} + className='bg-yellow_r-6 text-yellow_r-12 rounded-b-xl py-4 block' + > + Lihat detail pembelian Anda disini + </Link> + </div> + </div> + ) +} + +export default FinishCheckout diff --git a/src/lib/home/api/categoryHomeApi.js b/src/lib/home/api/categoryHomeApi.js new file mode 100644 index 00000000..81909d7b --- /dev/null +++ b/src/lib/home/api/categoryHomeApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async ({ id }) => { + const dataCategoryHomeId = await odooApi('GET', `/api/v1/categories_homepage?id=${id}`) + return dataCategoryHomeId +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/categoryHomeIdApi.js b/src/lib/home/api/categoryHomeIdApi.js new file mode 100644 index 00000000..6b820fd3 --- /dev/null +++ b/src/lib/home/api/categoryHomeIdApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async () => { + const dataCategoryHomeIds = await odooApi('GET', '/api/v1/categories_homepage/ids') + return dataCategoryHomeIds +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/heroBannerApi.js b/src/lib/home/api/heroBannerApi.js new file mode 100644 index 00000000..60a0702a --- /dev/null +++ b/src/lib/home/api/heroBannerApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const heroBannerApi = async () => { + const dataHeroBanners = await odooApi('GET', '/api/v1/banner?type=index-a-1') + return dataHeroBanners +} + +export default heroBannerApi diff --git a/src/lib/home/api/popularProductApi.js b/src/lib/home/api/popularProductApi.js new file mode 100644 index 00000000..5a6d3212 --- /dev/null +++ b/src/lib/home/api/popularProductApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const popularProductApi = async () => { + const dataPopularProducts = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=*&page=1&orderBy=popular` + ) + return dataPopularProducts.data.response +} + +export default popularProductApi diff --git a/src/lib/home/api/preferredBrandApi.js b/src/lib/home/api/preferredBrandApi.js new file mode 100644 index 00000000..0e3200e0 --- /dev/null +++ b/src/lib/home/api/preferredBrandApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const preferredBrandApi = async () => { + const dataPreferredBrands = await odooApi('GET', '/api/v1/manufacture?level=prioritas') + return dataPreferredBrands +} + +export default preferredBrandApi diff --git a/src/lib/home/components/CategoryHome.jsx b/src/lib/home/components/CategoryHome.jsx new file mode 100644 index 00000000..ac43e2bc --- /dev/null +++ b/src/lib/home/components/CategoryHome.jsx @@ -0,0 +1,30 @@ +import ProductSlider from '@/lib/product/components/ProductSlider' +import useCategoryHome from '../hooks/useCategoryHome' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' + +const CategoryHome = ({ id }) => { + const { categoryHome } = useCategoryHome({ id }) + + return ( + <div className='p-4 relative bg-yellow_r-2'> + {categoryHome.data ? ( + <ProductSlider + products={{ + products: categoryHome.data?.[0].products, + banner: { + image: categoryHome.data?.[0].image, + name: categoryHome.data?.[0].name, + url: `/shop/search?category=${categoryHome.data?.[0].name}` + } + }} + simpleTitle + bannerMode + /> + ) : ( + <PopularProductSkeleton /> + )} + </div> + ) +} + +export default CategoryHome diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx new file mode 100644 index 00000000..c37a6af7 --- /dev/null +++ b/src/lib/home/components/CategoryHomeId.jsx @@ -0,0 +1,19 @@ +import { LazyLoadComponent } from 'react-lazy-load-image-component' +import useCategoryHomeId from '../hooks/useCategoryHomeId' +import CategoryHome from './CategoryHome' + +const CategoryHomeId = () => { + const { categoryHomeIds } = useCategoryHomeId() + + return ( + <div className='flex flex-col gap-y-6'> + {categoryHomeIds.data?.map((id) => ( + <LazyLoadComponent key={id}> + <CategoryHome id={id} /> + </LazyLoadComponent> + ))} + </div> + ) +} + +export default CategoryHomeId diff --git a/src/lib/home/components/HeroBanner.jsx b/src/lib/home/components/HeroBanner.jsx new file mode 100644 index 00000000..0ac14bad --- /dev/null +++ b/src/lib/home/components/HeroBanner.jsx @@ -0,0 +1,50 @@ +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' +import useHeroBanner from '../hooks/useHeroBanner' +import Image from '@/core/components/elements/Image/Image' + +// Swiper +import { Swiper, SwiperSlide } from 'swiper/react' +import { Pagination, Autoplay } from 'swiper' +import 'swiper/css' +import 'swiper/css/pagination' +import 'swiper/css/autoplay' + +const swiperBanner = { + pagination: { dynamicBullets: true }, + autoplay: { + delay: 6000, + disableOnInteraction: false + }, + modules: [Pagination, Autoplay] +} + +const HeroBanner = () => { + const { heroBanners } = useHeroBanner() + + return ( + <div className='min-h-[200px]'> + {heroBanners.isLoading && <ImageSkeleton />} + {!heroBanners.isLoading && ( + <Swiper + slidesPerView={1} + pagination={swiperBanner.pagination} + modules={swiperBanner.modules} + autoplay={swiperBanner.autoplay} + className='border-b border-gray_r-6' + > + {heroBanners.data?.map((banner, index) => ( + <SwiperSlide key={index}> + <Image + src={banner.image} + alt={banner.name} + className='w-full h-auto' + /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default HeroBanner diff --git a/src/lib/home/components/PopularProduct.jsx b/src/lib/home/components/PopularProduct.jsx new file mode 100644 index 00000000..d23275f7 --- /dev/null +++ b/src/lib/home/components/PopularProduct.jsx @@ -0,0 +1,24 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePopularProduct from '../hooks/usePopularProduct' +import ProductCard from '@/lib/product/components/ProductCard' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' +import ProductSlider from '@/lib/product/components/ProductSlider' + +const PopularProduct = () => { + const { popularProducts } = usePopularProduct() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Produk Populer</div> + {popularProducts.isLoading && <PopularProductSkeleton />} + {!popularProducts.isLoading && ( + <ProductSlider + products={popularProducts.data} + simpleTitle + /> + )} + </div> + ) +} + +export default PopularProduct diff --git a/src/lib/home/components/PreferredBrand.jsx b/src/lib/home/components/PreferredBrand.jsx new file mode 100644 index 00000000..3df3cdb7 --- /dev/null +++ b/src/lib/home/components/PreferredBrand.jsx @@ -0,0 +1,30 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePreferredBrand from '../hooks/usePreferredBrand' +import PreferredBrandSkeleton from './Skeleton/PreferredBrandSkeleton' +import BrandCard from '@/lib/brand/components/BrandCard' + +const PreferredBrand = () => { + const { preferredBrands } = usePreferredBrand() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Brand Pilihan</div> + {preferredBrands.isLoading && <PreferredBrandSkeleton />} + {!preferredBrands.isLoading && ( + <Swiper + slidesPerView={3.5} + spaceBetween={12} + freeMode + > + {preferredBrands.data?.manufactures.map((brand) => ( + <SwiperSlide key={brand.id}> + <BrandCard brand={brand} /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default PreferredBrand diff --git a/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx new file mode 100644 index 00000000..18a1b3d3 --- /dev/null +++ b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx @@ -0,0 +1,10 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const PopularProductSkeleton = () => ( + <div className='grid grid-cols-2 gap-x-3'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default PopularProductSkeleton diff --git a/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx new file mode 100644 index 00000000..00589342 --- /dev/null +++ b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx @@ -0,0 +1,12 @@ +import BrandSkeleton from '@/core/components/elements/Skeleton/BrandSkeleton' + +const PreferredBrandSkeleton = () => ( + <div className='grid grid-cols-4 gap-x-3'> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + </div> +) + +export default PreferredBrandSkeleton diff --git a/src/lib/home/hooks/useCategoryHome.js b/src/lib/home/hooks/useCategoryHome.js new file mode 100644 index 00000000..cfaa3d9c --- /dev/null +++ b/src/lib/home/hooks/useCategoryHome.js @@ -0,0 +1,13 @@ +import categoryHomeApi from '../api/categoryHomeApi' +import { useQuery } from 'react-query' + +const useCategoryHome = ({ id }) => { + const fetchCategoryHome = async () => await categoryHomeApi({ id }) + const { isLoading, data } = useQuery(`categoryHome-${id}`, fetchCategoryHome) + + return { + categoryHome: { data, isLoading } + } +} + +export default useCategoryHome diff --git a/src/lib/home/hooks/useCategoryHomeId.js b/src/lib/home/hooks/useCategoryHomeId.js new file mode 100644 index 00000000..c6953db7 --- /dev/null +++ b/src/lib/home/hooks/useCategoryHomeId.js @@ -0,0 +1,13 @@ +import categoryHomeIdApi from '../api/categoryHomeIdApi' +import { useQuery } from 'react-query' + +const useCategoryHomeId = () => { + const fetchCategoryHomeId = async () => await categoryHomeIdApi() + const { isLoading, data } = useQuery('categoryHomeId', fetchCategoryHomeId) + + return { + categoryHomeIds: { data, isLoading } + } +} + +export default useCategoryHomeId diff --git a/src/lib/home/hooks/useHeroBanner.js b/src/lib/home/hooks/useHeroBanner.js new file mode 100644 index 00000000..5d2b0512 --- /dev/null +++ b/src/lib/home/hooks/useHeroBanner.js @@ -0,0 +1,13 @@ +import heroBannerApi from '../api/heroBannerApi' +import { useQuery } from 'react-query' + +const useHeroBanner = () => { + const fetchHeroBanner = async () => await heroBannerApi() + const { isLoading, data } = useQuery('heroBanner', fetchHeroBanner) + + return { + heroBanners: { data, isLoading } + } +} + +export default useHeroBanner diff --git a/src/lib/home/hooks/usePopularProduct.js b/src/lib/home/hooks/usePopularProduct.js new file mode 100644 index 00000000..d0c34bb0 --- /dev/null +++ b/src/lib/home/hooks/usePopularProduct.js @@ -0,0 +1,13 @@ +import popularProductApi from '../api/popularProductApi' +import { useQuery } from 'react-query' + +const usePopularProduct = () => { + const fetchPopularProduct = async () => await popularProductApi() + const { data, isLoading } = useQuery('popularProduct', fetchPopularProduct) + + return { + popularProducts: { data, isLoading } + } +} + +export default usePopularProduct diff --git a/src/lib/home/hooks/usePreferredBrand.js b/src/lib/home/hooks/usePreferredBrand.js new file mode 100644 index 00000000..e56d361f --- /dev/null +++ b/src/lib/home/hooks/usePreferredBrand.js @@ -0,0 +1,13 @@ +import preferredBrandApi from '../api/preferredBrandApi' +import { useQuery } from 'react-query' + +const usePreferredBrand = () => { + const fetchPreferredBrand = async () => await preferredBrandApi() + const { data, isLoading } = useQuery('preferredBrand', fetchPreferredBrand) + + return { + preferredBrands: { data, isLoading } + } +} + +export default usePreferredBrand diff --git a/src/lib/invoice/api/invoiceApi.js b/src/lib/invoice/api/invoiceApi.js new file mode 100644 index 00000000..056df6c6 --- /dev/null +++ b/src/lib/invoice/api/invoiceApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoiceApi = async ({ id }) => { + const auth = getAuth() + const dataInvoice = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice/${id}`) + return dataInvoice +} + +export default invoiceApi diff --git a/src/lib/invoice/api/invoicesApi.js b/src/lib/invoice/api/invoicesApi.js new file mode 100644 index 00000000..622fe6ee --- /dev/null +++ b/src/lib/invoice/api/invoicesApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoicesApi = async ({ query }) => { + const auth = getAuth() + const dataInvoices = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice?${query}`) + return dataInvoices +} + +export default invoicesApi diff --git a/src/lib/invoice/components/Invoice.jsx b/src/lib/invoice/components/Invoice.jsx new file mode 100644 index 00000000..e34ad8c2 --- /dev/null +++ b/src/lib/invoice/components/Invoice.jsx @@ -0,0 +1,110 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useInvoice from '../hooks/useInvoice' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' +import Divider from '@/core/components/elements/Divider/Divider' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import currencyFormat from '@/core/utils/currencyFormat' + +const Invoice = ({ id }) => { + const { invoice } = useInvoice({ id }) + + if (invoice.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + const address = invoice.data?.customer + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + invoice.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='No Invoice'>{invoice.data?.name}</DescriptionRow> + <DescriptionRow label='Status Transaksi'> + {invoice.data?.amountResidual > 0 ? ( + <span className='badge-solid-red'>Belum Lunas</span> + ) : ( + <span className='badge-solid-green'>Lunas</span> + )} + </DescriptionRow> + <DescriptionRow label='Purchase Order'> + {invoice.data?.purchaseOrderName || '-'} + </DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'>{invoice.data?.paymentTerm}</DescriptionRow> + {invoice.data?.amountResidual > 0 && invoice.invoiceDate != invoice.invoiceDateDue && ( + <DescriptionRow label='Tanggal Jatuh Tempo'> + {invoice.data?.invoiceDateDue} + </DescriptionRow> + )} + <DescriptionRow label='Nama Sales'>{invoice.data?.sales}</DescriptionRow> + <DescriptionRow label='Tanggal Invoice'>{invoice.data?.invoiceDate}</DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Invoice</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadInvoice(invoice.data)} + > + Download + </button> + </div> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Faktur Pajak</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadTaxInvoice(invoice.data)} + disabled={!invoice.data?.efaktur} + > + Download + </button> + </div> + </div> + + <Divider /> + + <div className='p-4 font-medium'>Detail Penagihan</div> + + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address?.name}</DescriptionRow> + <DescriptionRow label='Email'>{address?.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address?.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={invoice.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(invoice.data?.amountTotal)}</p> + </div> + </div> + </> + ) + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Invoice diff --git a/src/lib/invoice/components/Invoices.jsx b/src/lib/invoice/components/Invoices.jsx new file mode 100644 index 00000000..ab318a3c --- /dev/null +++ b/src/lib/invoice/components/Invoices.jsx @@ -0,0 +1,177 @@ +import { + CheckIcon, + ClockIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon +} from '@heroicons/react/24/outline' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import { useRouter } from 'next/router' +import { useState } from 'react' +import useInvoices from '../hooks/useInvoices' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' + +const Invoices = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { invoices } = useInvoices({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + + const pageCount = Math.ceil(invoices?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/invoices?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Invoice...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {invoices.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!invoices.isLoading && invoices.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data invoice + </Alert> + )} + + {invoices.data?.invoices?.map((invoice, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/invoice/${invoice.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Invoice</span> + <h2 className='text-red_r-11 mt-1'>{invoice.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + {invoice.amountResidual > 0 ? ( + <div className='badge-solid-red h-fit ml-auto'>Belum Lunas</div> + ) : ( + <div className='badge-solid-green h-fit ml-auto'>Lunas</div> + )} + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(invoice)} + /> + </div> + </div> + <Link href={`/my/invoice/${invoice.id}`}> + <div className='grid grid-cols-2 text-caption-2 text-gray_r-11 mt-2 font-normal'> + <p>{invoice.invoiceDate}</p> + <p className='text-right'>{invoice.paymentTerm}</p> + </div> + <hr className='my-3' /> + <div className='grid grid-cols-2'> + <div> + <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span> + <p className='mt-1 font-medium text-gray_r-12'> + {invoice.purchaseOrderName || '-'} + </p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Invoice</span> + <p className='mt-1 font-medium text-gray_r-12'> + {currencyFormat(invoice.amountTotal)} + </p> + </div> + </div> + </Link> + {invoice.efaktur ? ( + <div className='badge-green h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <CheckIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + ) : ( + <div className='badge-red h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <ClockIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + )} + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/invoices${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + onClick={() => { + downloadInvoice(toOthers) + setToOthers(null) + }} + > + Download Invoice + </button> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.efaktur} + onClick={() => { + downloadTaxInvoice(toOthers) + setToOthers(null) + }} + > + Download Faktur Pajak + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Invoices diff --git a/src/lib/invoice/hooks/useInvoice.js b/src/lib/invoice/hooks/useInvoice.js new file mode 100644 index 00000000..2de5e91e --- /dev/null +++ b/src/lib/invoice/hooks/useInvoice.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import invoiceApi from '../api/invoiceApi' + +const useInvoice = ({ id }) => { + const fetchInvoice = async () => await invoiceApi({ id }) + const { data, isLoading, refetch } = useQuery(`invoice-${id}`, fetchInvoice) + + return { + invoice: { data, isLoading, refetch } + } +} + +export default useInvoice diff --git a/src/lib/invoice/hooks/useInvoices.js b/src/lib/invoice/hooks/useInvoices.js new file mode 100644 index 00000000..061626e4 --- /dev/null +++ b/src/lib/invoice/hooks/useInvoices.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import invoicesApi from '../api/invoicesApi' +import _ from 'lodash-contrib' + +const useInvoices = ({ query }) => { + const queryString = _.toQuery(query) + const fetchInvoices = async () => await invoicesApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`invoices-${queryString}`, fetchInvoices) + + return { + invoices: { data, isLoading, refetch } + } +} + +export default useInvoices diff --git a/src/lib/invoice/utils/invoices.js b/src/lib/invoice/utils/invoices.js new file mode 100644 index 00000000..221e53cf --- /dev/null +++ b/src/lib/invoice/utils/invoices.js @@ -0,0 +1,11 @@ +const downloadInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +const downloadTaxInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +export { downloadInvoice, downloadTaxInvoice } diff --git a/src/lib/product/api/productApi.js b/src/lib/product/api/productApi.js new file mode 100644 index 00000000..4fe4cd7d --- /dev/null +++ b/src/lib/product/api/productApi.js @@ -0,0 +1,9 @@ +import odooApi from '@/core/api/odooApi' + +const productApi = async ({ id }) => { + if (!id) return + const dataProduct = await odooApi('GET', `/api/v1/product/${id}`) + return dataProduct +} + +export default productApi diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js new file mode 100644 index 00000000..b9acd94b --- /dev/null +++ b/src/lib/product/api/productSearchApi.js @@ -0,0 +1,9 @@ +import _ from 'lodash-contrib' +import axios from 'axios' + +const productSearchApi = async ({ query }) => { + const dataProductSearch = await axios(`${process.env.SELF_HOST}/api/shop/search?${query}`) + return dataProductSearch.data +} + +export default productSearchApi diff --git a/src/lib/product/api/productSimilarApi.js b/src/lib/product/api/productSimilarApi.js new file mode 100644 index 00000000..7142fab4 --- /dev/null +++ b/src/lib/product/api/productSimilarApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const productSimilarApi = async ({ query }) => { + const dataProductSimilar = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=${query}&page=1&orderBy=popular` + ) + return dataProductSimilar.data.response +} + +export default productSimilarApi diff --git a/src/lib/product/components/Product.jsx b/src/lib/product/components/Product.jsx new file mode 100644 index 00000000..9e33316c --- /dev/null +++ b/src/lib/product/components/Product.jsx @@ -0,0 +1,315 @@ +import Badge from '@/core/components/elements/Badge/Badge' +import Divider from '@/core/components/elements/Divider/Divider' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { useEffect, useState } from 'react' +import Select from 'react-select' +import ProductSimilar from './ProductSimilar' +import LazyLoad from 'react-lazy-load' +import { toast } from 'react-hot-toast' +import { updateItemCart } from '@/core/utils/cart' +import useWishlist from '@/lib/wishlist/hooks/useWishlist' +import { HeartIcon } from '@heroicons/react/24/outline' +import useAuth from '@/core/hooks/useAuth' +import { useRouter } from 'next/router' +import createOrDeleteWishlistApi from '@/lib/wishlist/api/createOrDeleteWishlistApi' + +const informationTabOptions = [ + { value: 'specification', label: 'Spesifikasi' }, + { value: 'description', label: 'Deskripsi' }, + { value: 'important', label: 'Info Penting' } +] + +const Product = ({ product }) => { + const auth = useAuth() + const router = useRouter() + const { wishlist } = useWishlist({ productId: product?.id }) + const [quantity, setQuantity] = useState('1') + const [selectedVariant, setSelectedVariant] = useState(null) + const [informationTab, setInformationTab] = useState(null) + + const [activeVariant, setActiveVariant] = useState({ + id: product.id, + code: product.code, + name: product.name, + price: product.lowestPrice, + stock: product.stockTotal, + weight: product.weight + }) + + const variantOptions = product.variants?.map((variant) => ({ + value: variant.id, + label: + (variant.code ? `[${variant.code}] ` : '') + + (variant.attributes.length > 0 ? variant.attributes.join(', ') : product.name) + })) + + useEffect(() => { + if (!selectedVariant && variantOptions.length == 1) { + setSelectedVariant(variantOptions[0]) + } + }, [selectedVariant, variantOptions]) + + useEffect(() => { + if (selectedVariant) { + const variant = product.variants.find((variant) => variant.id == selectedVariant.value) + const variantAttributes = + variant.attributes.length > 0 ? ' - ' + variant.attributes.join(', ') : '' + + setActiveVariant({ + id: variant.id, + code: variant.code, + name: variant.parent.name + variantAttributes, + price: variant.price, + stock: variant.stock, + weight: variant.weight + }) + } + }, [selectedVariant, product]) + + useEffect(() => { + if (!informationTab) { + setInformationTab(informationTabOptions[0].value) + } + }, [informationTab]) + + const handleClickCart = () => { + if (!selectedVariant) { + toast.error('Pilih varian terlebih dahulu') + return + } + if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { + toast.error('Jumlah barang minimal 1') + return + } + updateItemCart({ + productId: activeVariant.id, + quantity + }) + toast.success('Berhasil menambahkan ke keranjang') + } + + const toggleWishlist = async () => { + if (!auth) { + router.push('/login') + return + } + const data = { product_id: product.id } + await createOrDeleteWishlistApi({ data }) + if (wishlist.data.productTotal > 0) { + toast.success('Berhasil menghapus dari wishlist') + } else { + toast.success('Berhasil menambahkan ke wishlist') + } + wishlist.refetch() + } + + return ( + <> + <Image + src={product.image} + alt={product.name} + className='h-72 object-contain object-center w-full border-b border-gray_r-4' + /> + + <div className='p-4'> + <div className='flex items-end mb-2'> + {product.manufacture?.name ? ( + <Link href='/'>{product.manufacture?.name}</Link> + ) : ( + <div>-</div> + )} + <button + type='button' + className='ml-auto' + onClick={toggleWishlist} + > + {wishlist.data?.productTotal > 0 ? ( + <HeartIcon className='w-6 fill-red_r-11 text-red_r-11' /> + ) : ( + <HeartIcon className='w-6' /> + )} + </button> + </div> + <h1 className='leading-6 font-medium'>{activeVariant?.name}</h1> + {activeVariant?.price?.discountPercentage > 0 && ( + <div className='flex gap-x-1 items-center mt-2'> + <div className='text-gray_r-11 line-through text-caption-1'> + {currencyFormat(activeVariant?.price?.price)} + </div> + <Badge type='solid-red'>{activeVariant?.price?.discountPercentage}%</Badge> + </div> + )} + <h3 className='text-red_r-11 font-semibold mt-1'> + {activeVariant?.price?.priceDiscount > 0 ? ( + currencyFormat(activeVariant?.price?.priceDiscount) + ) : ( + <span className='text-gray_r-11 leading-6 font-normal'> + Hubungi kami untuk dapatkan harga terbaik, + <a + href='https://wa.me/' + className='text-red_r-11 underline' + > + klik disini + </a> + </span> + )} + </h3> + </div> + + <Divider /> + + <div className='p-4'> + <div> + <label className='flex justify-between'> + Pilih Varian: + <span className='text-gray_r-11'>{product?.variantTotal} Varian</span> + </label> + <Select + name='variant' + classNamePrefix='form-select' + options={variantOptions} + className='mt-2' + value={selectedVariant} + onChange={(option) => setSelectedVariant(option)} + isSearchable={product.variantTotal > 10} + /> + </div> + <div className='mt-4 mb-2'>Jumlah</div> + <div className='flex gap-x-3'> + <div className='w-2/12'> + <input + name='quantity' + type='number' + className='form-input' + value={quantity} + onChange={(e) => setQuantity(e.target.value)} + /> + </div> + <button + type='button' + className='btn-yellow flex-1' + onClick={handleClickCart} + > + Keranjang + </button> + <button + type='button' + className='btn-solid-red flex-1' + > + Beli + </button> + </div> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold'>Informasi Produk</h2> + <div className='flex gap-x-4 mt-4 mb-3'> + {informationTabOptions.map((option) => ( + <TabButton + value={option.value} + key={option.value} + active={informationTab == option.value} + onClick={() => setInformationTab(option.value)} + > + {option.label} + </TabButton> + ))} + </div> + + <TabContent + active={informationTab == 'specification'} + className='rounded border border-gray_r-6 divide-y divide-gray_r-6' + > + <SpecificationContent label='Jumlah Varian'> + <span>{product?.variantTotal} Varian</span> + </SpecificationContent> + <SpecificationContent label='Nomor SKU'> + <span>SKU-{product?.id}</span> + </SpecificationContent> + <SpecificationContent label='Part Number'> + <span>{activeVariant?.code || '-'}</span> + </SpecificationContent> + <SpecificationContent label='Stok'> + {activeVariant?.stock > 0 && ( + <span className='flex gap-x-1.5'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{activeVariant?.stock > 5 ? '> 5' : '< 5'}</div> + </span> + )} + {activeVariant?.stock == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Stok + </a> + )} + </SpecificationContent> + <SpecificationContent label='Berat Barang'> + {activeVariant?.weight > 0 && <span>{activeVariant?.weight} KG</span>} + {activeVariant?.weight == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Berat + </a> + )} + </SpecificationContent> + </TabContent> + + <TabContent + active={informationTab == 'description'} + className='leading-6 text-gray_r-11' + dangerouslySetInnerHTML={{ + __html: product.description != '' ? product.description : 'Belum ada deskripsi produk.' + }} + /> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold mb-4'>Kamu Mungkin Juga Suka</h2> + <LazyLoad> + <ProductSimilar query={product?.name.split(' ').slice(1, 3).join(' ')} /> + </LazyLoad> + </div> + </> + ) +} + +const TabButton = ({ children, active, ...props }) => { + const activeClassName = active ? 'text-red_r-11 underline underline-offset-4' : 'text-gray_r-11' + return ( + <button + {...props} + type='button' + className={`font-medium pb-1 ${activeClassName}`} + > + {children} + </button> + ) +} + +const TabContent = ({ children, active, className, ...props }) => ( + <div + {...props} + className={`${active ? 'block' : 'hidden'} ${className}`} + > + {children} + </div> +) + +const SpecificationContent = ({ children, label }) => ( + <div className='flex justify-between p-3'> + <span className='text-gray_r-11'>{label}</span> + {children} + </div> +) + +export default Product diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx new file mode 100644 index 00000000..6b88a3bd --- /dev/null +++ b/src/lib/product/components/ProductCard.jsx @@ -0,0 +1,76 @@ +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { createSlug } from '@/core/utils/slug' + +const ProductCard = ({ product, simpleTitle }) => { + return ( + <> + <div className='rounded shadow-sm border border-gray_r-4 h-full bg-white'> + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className='border-b border-gray_r-4 relative' + > + <Image + src={product?.image} + alt={product?.name} + className='w-full object-contain object-center h-36' + /> + {product.variantTotal > 1 && ( + <div className='absolute badge-gray bottom-1.5 left-1.5'> + {product.variantTotal} Varian + </div> + )} + </Link> + <div className='p-2 pb-3 text-caption-2 leading-5'> + {product?.manufacture?.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product?.manufacture?.name, + product?.manufacture.id + )} + className='mb-1' + > + {product.manufacture.name} + </Link> + ) : ( + <div>-</div> + )} + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className={`font-medium mb-2 !text-gray_r-12 ${ + simpleTitle ? 'line-clamp-2' : 'line-clamp-3' + }`} + > + {product?.name} + </Link> + {product?.lowestPrice?.discountPercentage > 0 && ( + <div className='flex gap-x-1 mb-1 items-center'> + <div className='text-gray_r-11 line-through text-[11px]'> + {currencyFormat(product?.lowestPrice?.price)} + </div> + <div className='badge-solid-red'>{product?.lowestPrice?.discountPercentage}%</div> + </div> + )} + + <div className='text-red_r-11 font-semibold mb-2'> + {product?.lowestPrice?.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice?.priceDiscount) + ) : ( + <a href='https://wa.me/'>Call for price</a> + )} + </div> + {product?.stockTotal > 0 && ( + <div className='flex gap-x-1'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{product?.stockTotal > 5 ? '> 5' : '< 5'}</div> + </div> + )} + </div> + </div> + </> + ) +} + +export default ProductCard diff --git a/src/lib/product/components/ProductFilter.jsx b/src/lib/product/components/ProductFilter.jsx new file mode 100644 index 00000000..eca95f74 --- /dev/null +++ b/src/lib/product/components/ProductFilter.jsx @@ -0,0 +1,132 @@ +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { useRouter } from 'next/router' +import { useState } from 'react' +import _ from 'lodash' +import { toQuery } from 'lodash-contrib' + +const orderOptions = [ + { value: 'price-asc', label: 'Harga Terendah' }, + { value: 'price-desc', label: 'Harga Tertinggi' }, + { value: 'popular', label: 'Populer' }, + { value: 'stock', label: 'Ready Stock' } +] + +const ProductFilter = ({ active, close, brands, categories, prefixUrl, defaultBrand = null }) => { + const router = useRouter() + const { query } = router + const [order, setOrder] = useState(query?.orderBy) + const [brand, setBrand] = useState(query?.brand) + const [category, setCategory] = useState(query?.category) + const [priceFrom, setPriceFrom] = useState(query?.priceFrom) + const [priceTo, setPriceTo] = useState(query?.priceTo) + + const handleSubmit = () => { + let params = { + q: router.query.q, + orderBy: order, + brand, + category, + priceFrom, + priceTo + } + params = _.pickBy(params, _.identity) + params = toQuery(params) + router.push(`${prefixUrl}?${params}`) + } + + return ( + <BottomPopup + active={active} + close={close} + title='Filter Produk' + > + <div className='flex flex-col gap-y-4'> + {!defaultBrand && ( + <div> + <label>Brand</label> + <select + name='brand' + className='form-input mt-2' + value={brand} + onChange={(e) => setBrand(e.target.value)} + > + <option value=''>Pilih Brand...</option> + {brands.map((brand, index) => ( + <option + value={brand} + key={index} + > + {brand} + </option> + ))} + </select> + </div> + )} + <div> + <label>Kategori</label> + <select + name='category' + className='form-input mt-2' + value={category} + onChange={(e) => setCategory(e.target.value)} + > + <option value=''>Pilih Kategori...</option> + {categories.map((category, index) => ( + <option + value={category} + key={index} + > + {category} + </option> + ))} + </select> + </div> + <div> + <label>Urutkan</label> + <div className='flex mt-2 gap-x-2 overflow-x-auto'> + {orderOptions.map((orderOption) => ( + <button + key={orderOption.value} + className={`btn-light px-3 font-normal flex-shrink-0 ${ + order == orderOption.value ? 'bg-yellow_r-10' : 'bg-transparent' + }`} + onClick={() => setOrder(orderOption.value)} + > + {orderOption.label} + </button> + ))} + </div> + </div> + <div> + <label>Harga</label> + <div className='flex mt-2 gap-x-4 items-center'> + <input + type='number' + className='form-input' + placeholder='Dari' + value={priceFrom} + onChange={(e) => setPriceFrom(e.target.value)} + /> + <span>—</span> + <input + type='number' + className='form-input' + placeholder='Sampai' + value={priceTo} + onChange={(e) => setPriceTo(e.target.value)} + /> + </div> + </div> + <button + type='button' + className='btn-solid-red w-full mt-2' + onClick={handleSubmit} + > + Terapkan Filter + </button> + </div> + </BottomPopup> + ) +} + +export default ProductFilter diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx new file mode 100644 index 00000000..52bd5119 --- /dev/null +++ b/src/lib/product/components/ProductSearch.jsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import useProductSearch from '../hooks/useProductSearch' +import ProductCard from './ProductCard' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import ProductSearchSkeleton from './Skeleton/ProductSearchSkeleton' +import ProductFilter from './ProductFilter' +import useActive from '@/core/hooks/useActive' + +const ProductSearch = ({ query, prefixUrl, defaultBrand = null }) => { + const { page = 1 } = query + if (defaultBrand) query.brand = defaultBrand.toLowerCase() + const { productSearch } = useProductSearch({ query }) + const [products, setProducts] = useState(null) + const popup = useActive() + + const pageCount = Math.ceil( + productSearch.data?.response.numFound / productSearch.data?.responseHeader.params.rows + ) + const productStart = productSearch.data?.responseHeader.params.start + const productRows = productSearch.data?.responseHeader.params.rows + const productFound = productSearch.data?.response.numFound + + const brands = productSearch.data?.facetCounts?.facetFields?.brandStr?.filter((value, index) => { + if (index % 2 === 0) { + return true + } + }) + const categories = productSearch.data?.facetCounts?.facetFields?.categoryNameStr?.filter( + (value, index) => { + if (index % 2 === 0) { + return true + } + } + ) + + useEffect(() => { + if (!products) { + setProducts(productSearch.data?.response?.products) + } + }, [query, products, productSearch]) + + if (productSearch.isLoading) { + return <ProductSearchSkeleton /> + } + + return ( + <div className='p-4'> + <h1 className='mb-2 font-semibold text-h-sm'>Produk</h1> + + <div className='mb-2 leading-6 text-gray_r-11'> + {productFound > 0 ? ( + <> + Menampilkan + {pageCount > 1 ? ( + <> + {productStart + 1}- + {productStart + productRows > productFound + ? productFound + : productStart + productRows} + dari + </> + ) : ( + '' + )} + {productFound} + produk{' '} + {query.q && ( + <> + untuk pencarian <span className='font-semibold'>{query.q}</span> + </> + )} + </> + ) : ( + 'Mungkin yang anda cari' + )} + </div> + + <button + className='btn-light mb-6 py-2 px-5' + onClick={popup.activate} + > + Filter + </button> + + <div className='grid grid-cols-2 gap-3'> + {products && + products.map((product) => ( + <ProductCard + product={product} + key={product.id} + /> + ))} + </div> + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`${prefixUrl}?${toQuery(_.omit(query, ['page']))}`} + className='mt-6 mb-2' + /> + + <ProductFilter + active={popup.active} + close={popup.deactivate} + brands={brands || []} + categories={categories || []} + prefixUrl={prefixUrl} + defaultBrand={defaultBrand} + /> + </div> + ) +} + +export default ProductSearch diff --git a/src/lib/product/components/ProductSimilar.jsx b/src/lib/product/components/ProductSimilar.jsx new file mode 100644 index 00000000..63a33089 --- /dev/null +++ b/src/lib/product/components/ProductSimilar.jsx @@ -0,0 +1,15 @@ +import PopularProductSkeleton from '@/lib/home/components/Skeleton/PopularProductSkeleton' +import useProductSimilar from '../hooks/useProductSimilar' +import ProductSlider from './ProductSlider' + +const ProductSimilar = ({ query }) => { + const { productSimilar } = useProductSimilar({ query }) + + if (productSimilar.isLoading) { + return <PopularProductSkeleton /> + } + + return <ProductSlider products={productSimilar.data} /> +} + +export default ProductSimilar diff --git a/src/lib/product/components/ProductSlider.jsx b/src/lib/product/components/ProductSlider.jsx new file mode 100644 index 00000000..060d4638 --- /dev/null +++ b/src/lib/product/components/ProductSlider.jsx @@ -0,0 +1,64 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import { FreeMode } from 'swiper' +import ProductCard from './ProductCard' +import 'swiper/css' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { useRef } from 'react' + +const bannerClassName = + 'absolute rounded-r top-0 left-0 h-full max-w-[52%] idt-transition border border-gray_r-6' + +const ProductSlider = ({ products, simpleTitle = false, bannerMode = false }) => { + const bannerRef = useRef('') + + const changeBannerOpacity = (swiper) => { + if (!bannerMode) return + const calculateOpacity = (132 + swiper.translate) / 100 + bannerRef.current.style = `opacity: ${calculateOpacity > 0 ? calculateOpacity : 0}` + } + + return ( + <> + {bannerMode && ( + <div ref={bannerRef}> + <Image + src={products.banner.image} + alt={products.banner.name} + style={{ opacity: 1 }} + className={bannerClassName} + /> + </div> + )} + <Swiper + freeMode={{ enabled: true, sticky: false }} + slidesPerView={2.2} + spaceBetween={8} + onSliderMove={changeBannerOpacity} + onSlideChangeTransitionEnd={changeBannerOpacity} + onSlideChangeTransitionStart={changeBannerOpacity} + prefix='product' + modules={[FreeMode]} + > + {bannerMode && ( + <SwiperSlide> + <Link + href={products.banner.url} + className='w-full h-full block' + ></Link> + </SwiperSlide> + )} + {products?.products?.map((product, index) => ( + <SwiperSlide key={index}> + <ProductCard + product={product} + simpleTitle={simpleTitle} + /> + </SwiperSlide> + ))} + </Swiper> + </> + ) +} + +export default ProductSlider diff --git a/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx new file mode 100644 index 00000000..fa1e175d --- /dev/null +++ b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx @@ -0,0 +1,14 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const ProductSearchSkeleton = () => ( + <div className='p-4 grid grid-cols-2 gap-4'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default ProductSearchSkeleton diff --git a/src/lib/product/hooks/useProductSearch.js b/src/lib/product/hooks/useProductSearch.js new file mode 100644 index 00000000..0396caec --- /dev/null +++ b/src/lib/product/hooks/useProductSearch.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import productSearchApi from '../api/productSearchApi' +import _ from 'lodash-contrib' + +const useProductSearch = ({ query }) => { + const queryString = _.toQuery(query) + const fetchProductSearch = async () => await productSearchApi({ query: queryString }) + const { data, isLoading } = useQuery(`productSearch-${queryString}`, fetchProductSearch) + + return { + productSearch: { data, isLoading } + } +} + +export default useProductSearch diff --git a/src/lib/product/hooks/useProductSimilar.js b/src/lib/product/hooks/useProductSimilar.js new file mode 100644 index 00000000..d16e4c58 --- /dev/null +++ b/src/lib/product/hooks/useProductSimilar.js @@ -0,0 +1,13 @@ +import productSimilarApi from '../api/productSimilarApi' +import { useQuery } from 'react-query' + +const useProductSimilar = ({ query }) => { + const fetchProductSimilar = async () => await productSimilarApi({ query }) + const { data, isLoading } = useQuery(`productSimilar-${query}`, fetchProductSimilar) + + return { + productSimilar: { data, isLoading } + } +} + +export default useProductSimilar diff --git a/src/lib/quotation/components/Quotation.jsx b/src/lib/quotation/components/Quotation.jsx new file mode 100644 index 00000000..b6e276a3 --- /dev/null +++ b/src/lib/quotation/components/Quotation.jsx @@ -0,0 +1,167 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import CartApi from '@/lib/cart/api/CartApi' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import checkoutApi from '@/lib/checkout/api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Quotation = () => { + const router = useRouter() + const auth = useAuth() + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!products || products.length == 0) return + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: auth.partnerId, + partner_invoice_id: auth.partnerId, + order_line: JSON.stringify(productOrder) + } + const isSuccess = await checkoutApi({ data }) + setIsLoading(false) + if (isSuccess?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/quotation/finish?id=${isSuccess.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Penawaran</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Quotation'} + </button> + </div> + </> + ) +} + +export default Quotation diff --git a/src/lib/transaction/api/cancelTransactionApi.js b/src/lib/transaction/api/cancelTransactionApi.js new file mode 100644 index 00000000..1bba2bde --- /dev/null +++ b/src/lib/transaction/api/cancelTransactionApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const cancelTransactionApi = async ({ transaction }) => { + const auth = getAuth() + const dataCancelTransaction = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/cancel` + ) + return dataCancelTransaction +} + +export default cancelTransactionApi diff --git a/src/lib/transaction/api/checkoutPoApi.js b/src/lib/transaction/api/checkoutPoApi.js new file mode 100644 index 00000000..04421368 --- /dev/null +++ b/src/lib/transaction/api/checkoutPoApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutPoApi = async ({ id }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth?.partnerId}/sale_order/${id}/checkout` + ) + return dataCheckout +} + +export default checkoutPoApi diff --git a/src/lib/transaction/api/transactionApi.js b/src/lib/transaction/api/transactionApi.js new file mode 100644 index 00000000..e7c4c23f --- /dev/null +++ b/src/lib/transaction/api/transactionApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionApi = async ({ id }) => { + const auth = getAuth() + const dataTransaction = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/sale_order/${id}`) + return dataTransaction +} + +export default transactionApi diff --git a/src/lib/transaction/api/transactionsApi.js b/src/lib/transaction/api/transactionsApi.js new file mode 100644 index 00000000..f4e36e6f --- /dev/null +++ b/src/lib/transaction/api/transactionsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionsApi = async ({ query }) => { + const auth = getAuth() + const dataTransactions = await odooApi( + 'GET', + `/api/v1/partner/${auth.partnerId}/sale_order?${query}` + ) + return dataTransactions +} + +export default transactionsApi diff --git a/src/lib/transaction/api/uploadPoApi.js b/src/lib/transaction/api/uploadPoApi.js new file mode 100644 index 00000000..7feeff66 --- /dev/null +++ b/src/lib/transaction/api/uploadPoApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const uploadPoApi = async ({ id, data }) => { + const auth = getAuth() + const dataUploadPo = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${id}/upload_po`, + data + ) + return dataUploadPo +} + +export default uploadPoApi diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx new file mode 100644 index 00000000..7da33551 --- /dev/null +++ b/src/lib/transaction/components/Transaction.jsx @@ -0,0 +1,352 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useTransaction from '../hooks/useTransaction' +import TransactionStatusBadge from './TransactionStatusBadge' +import Divider from '@/core/components/elements/Divider/Divider' +import { useRef, useState } from 'react' +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import uploadPoApi from '../api/uploadPoApi' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import currencyFormat from '@/core/utils/currencyFormat' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import Link from '@/core/components/elements/Link/Link' +import Alert from '@/core/components/elements/Alert/Alert' +import checkoutPoApi from '../api/checkoutPoApi' +import cancelTransactionApi from '../api/cancelTransactionApi' + +const Transaction = ({ id }) => { + const { transaction } = useTransaction({ id }) + + const poNumber = useRef('') + const poFile = useRef('') + const [uploadPo, setUploadPo] = useState(false) + const openUploadPo = () => setUploadPo(true) + const closeUploadPo = () => setUploadPo(false) + const submitUploadPo = async () => { + const file = poFile.current.files[0] + const name = poNumber.current.value + if (typeof file === 'undefined' || !name) { + toast.error('Nomor dan Dokumen PO harus diisi', { position: 'bottom-center' }) + return + } + if (file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + const data = { name, file: await getFileBase64(file) } + const isUploaded = await uploadPoApi({ id, data }) + if (isUploaded) { + toast.success('Berhasil upload PO') + transaction.refetch() + closeUploadPo() + return + } + toast.error('Terjadi kesalahan internal, coba lagi nanti atau hubungi kami') + } + + const [cancelTransaction, setCancelTransaction] = useState(false) + const openCancelTransaction = () => setCancelTransaction(true) + const closeCancelTransaction = () => setCancelTransaction(false) + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ transaction: transaction.data }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transaction.refetch() + } + closeCancelTransaction() + } + + const checkout = async () => { + if (!transaction.data?.purchaseOrderFile) { + toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan') + return + } + await checkoutPoApi({ id }) + toast.success('Berhasil melanjutkan pesanan') + transaction.refetch() + } + + if (transaction.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + transaction.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='Status Transaksi'> + <div className='flex justify-end'> + <TransactionStatusBadge status={transaction.data?.status} /> + </div> + </DescriptionRow> + <DescriptionRow label='No Transaksi'>{transaction.data?.name}</DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'> + {transaction.data?.paymentTerm} + </DescriptionRow> + <DescriptionRow label='Nama Sales'>{transaction.data?.sales}</DescriptionRow> + <DescriptionRow label='Waktu Transaksi'>{transaction.data?.dateOrder}</DescriptionRow> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + <DescriptionRow label='Purchase Order'> + {transaction.data?.purchaseOrderName || '-'} + </DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Dokumen PO</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={ + transaction.data?.purchaseOrderFile + ? () => downloadPurchaseOrder(transaction.data) + : openUploadPo + } + > + {transaction.data?.purchaseOrderFile ? 'Download' : 'Upload'} + </button> + </div> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={transaction.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(transaction.data?.amountTotal)}</p> + </div> + </div> + + <Divider /> + + <SectionAddress address={transaction.data?.address} /> + + <Divider /> + + <div className='p-4'> + <p className='font-medium'>Invoice</p> + <div className='flex flex-col gap-y-3 mt-4'> + {transaction.data?.invoices?.map((invoice, index) => ( + <Link + href={`/my/invoice/${invoice.id}`} + key={index} + > + <div className='shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between'> + <div> + <p className='mb-2'>{invoice?.name}</p> + <div className='flex items-center gap-x-1'> + {invoice.amountResidual > 0 ? ( + <div className='badge-red'>Belum Lunas</div> + ) : ( + <div className='badge-green'>Lunas</div> + )} + <p className='text-caption-2 text-gray_r-11'> + {currencyFormat(invoice.amountTotal)} + </p> + </div> + </div> + <ChevronRightIcon className='w-5 stroke-2' /> + </div> + </Link> + ))} + {transaction.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Belum ada Invoice + </Alert> + )} + </div> + </div> + + <Divider /> + + <div className='p-4 pt-0'> + {transaction.data?.status == 'draft' && ( + <button + className='btn-yellow w-full mt-4' + onClick={checkout} + > + Lanjutkan Transaksi + </button> + )} + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'draft'} + onClick={downloadQuotation} + > + Download Quotation + </button> + {transaction.data?.status != 'draft' && ( + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'waiting'} + onClick={openCancelTransaction} + > + Batalkan Transaksi + </button> + )} + </div> + + <BottomPopup + active={cancelTransaction} + close={closeCancelTransaction} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{transaction.data?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={closeCancelTransaction} + > + Batal + </button> + </div> + </BottomPopup> + + <BottomPopup + title='Upload PO' + close={closeUploadPo} + active={uploadPo} + > + <div> + <label>Nomor PO</label> + <input + type='text' + className='form-input mt-3' + ref={poNumber} + /> + </div> + <div className='mt-4'> + <label>Dokumen PO</label> + <input + type='file' + className='form-input mt-3 py-2' + ref={poFile} + /> + </div> + <div className='grid grid-cols-2 gap-x-3 mt-6'> + <button + type='button' + className='btn-light w-full' + onClick={closeUploadPo} + > + Batal + </button> + <button + type='button' + className='btn-solid-red w-full' + onClick={submitUploadPo} + > + Upload + </button> + </div> + </BottomPopup> + </> + ) + ) +} + +const SectionAddress = ({ address }) => { + const [section, setSection] = useState({ + customer: false, + invoice: false, + shipping: false + }) + const toggleSection = (name) => { + setSection({ ...section, [name]: !section[name] }) + } + + return ( + <> + <SectionButton + label='Detail Pelanggan' + active={section.customer} + toggle={() => toggleSection('customer')} + /> + + {section.customer && <SectionContent address={address?.customer} />} + + <Divider /> + + <SectionButton + label='Detail Pengiriman' + active={section.shipping} + toggle={() => toggleSection('shipping')} + /> + + {section.shipping && <SectionContent address={address?.shipping} />} + + <Divider /> + + <SectionButton + label='Detail Penagihan' + active={section.invoice} + toggle={() => toggleSection('invoice')} + /> + {section.invoice && <SectionContent address={address?.invoice} />} + </> + ) +} + +const SectionButton = ({ label, active, toggle }) => ( + <button + className='p-4 font-medium flex justify-between w-full' + onClick={toggle} + > + <span>{label}</span> + {active ? <ChevronUpIcon className='w-5' /> : <ChevronDownIcon className='w-5' />} + </button> +) + +const SectionContent = ({ address }) => { + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address.name}</DescriptionRow> + <DescriptionRow label='Email'>{address.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Transaction diff --git a/src/lib/transaction/components/TransactionStatusBadge.jsx b/src/lib/transaction/components/TransactionStatusBadge.jsx new file mode 100644 index 00000000..7372e4da --- /dev/null +++ b/src/lib/transaction/components/TransactionStatusBadge.jsx @@ -0,0 +1,41 @@ +const TransactionStatusBadge = ({ status }) => { + let badgeProps = { + className: ['h-fit'], + text: '' + } + switch (status) { + case 'cancel': + badgeProps.className.push('badge-solid-red') + badgeProps.text = 'Pesanan batal' + break + case 'draft': + badgeProps.className.push('badge-red') + badgeProps.text = 'Pending quotation' + break + case 'waiting': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diterima' + break + case 'sale': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diproses' + break + case 'shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Pesanan dikirim' + break + case 'partial_shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Dikirim sebagian' + break + case 'done': + badgeProps.className.push('badge-solid-green') + badgeProps.text = 'Pesanan selesai' + break + } + badgeProps.className = badgeProps.className.join(' ') + + return <div className={badgeProps.className}>{badgeProps.text}</div> +} + +export default TransactionStatusBadge diff --git a/src/lib/transaction/components/Transactions.jsx b/src/lib/transaction/components/Transactions.jsx new file mode 100644 index 00000000..ccbdede2 --- /dev/null +++ b/src/lib/transaction/components/Transactions.jsx @@ -0,0 +1,216 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { EllipsisVerticalIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' + +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import useTransactions from '../hooks/useTransactions' +import currencyFormat from '@/core/utils/currencyFormat' +import cancelTransactionApi from '../api/cancelTransactionApi' +import TransactionStatusBadge from './TransactionStatusBadge' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Link from '@/core/components/elements/Link/Link' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import Alert from '@/core/components/elements/Alert/Alert' + +const Transactions = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { transactions } = useTransactions({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + const [toCancel, setToCancel] = useState(null) + + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ + transaction: toCancel + }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transactions.refetch() + } + setToCancel(null) + } + + const pageCount = Math.ceil(transactions?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/transactions?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Transaksi...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {transactions.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!transactions.isLoading && transactions.data?.saleOrders?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data transaksi + </Alert> + )} + + {transactions.data?.saleOrders?.map((saleOrder, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/transaction/${saleOrder.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Transaksi</span> + <h2 className='text-red_r-11 mt-1'>{saleOrder.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + <TransactionStatusBadge status={saleOrder.status} /> + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(saleOrder)} + /> + </div> + </div> + <Link href={`/my/transaction/${saleOrder.id}`}> + <div className='grid grid-cols-2 mt-3'> + <div> + <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span> + <p className='mt-1 font-medium text-gray_r-12'> + {saleOrder.purchaseOrderName || '-'} + </p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Invoice</span> + <p className='mt-1 font-medium text-gray_r-12'>{saleOrder.invoiceCount} Invoice</p> + </div> + </div> + <div className='grid grid-cols-2 mt-3'> + <div> + <span className='text-caption-2 text-gray_r-11'>Sales</span> + <p className='mt-1 font-medium text-gray_r-12'>{saleOrder.sales}</p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Harga</span> + <p className='mt-1 font-medium text-gray_r-12'> + {currencyFormat(saleOrder.amountTotal)} + </p> + </div> + </div> + </Link> + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/transactions${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.purchaseOrderFile} + onClick={() => { + downloadPurchaseOrder(toOthers) + setToOthers(null) + }} + > + Download PO + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'draft'} + onClick={() => { + downloadQuotation(toOthers) + setToOthers(null) + }} + > + Download Quotation + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'waiting'} + onClick={() => { + setToCancel(toOthers) + setToOthers(null) + }} + > + Batalkan Transaksi + </button> + </div> + </BottomPopup> + + <BottomPopup + active={toCancel} + close={() => setToCancel(null)} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{toCancel?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setToCancel(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Transactions diff --git a/src/lib/transaction/hooks/useTransaction.js b/src/lib/transaction/hooks/useTransaction.js new file mode 100644 index 00000000..6dda0573 --- /dev/null +++ b/src/lib/transaction/hooks/useTransaction.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import transactionApi from '../api/transactionApi' + +const useTransaction = ({ id }) => { + const fetchTransaction = async () => await transactionApi({ id }) + const { data, isLoading, refetch } = useQuery(`transaction-${id}`, fetchTransaction) + + return { + transaction: { data, isLoading, refetch } + } +} + +export default useTransaction diff --git a/src/lib/transaction/hooks/useTransactions.js b/src/lib/transaction/hooks/useTransactions.js new file mode 100644 index 00000000..5b40a05a --- /dev/null +++ b/src/lib/transaction/hooks/useTransactions.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import transactionsApi from '../api/transactionsApi' +import _ from 'lodash-contrib' + +const useTransactions = ({ query }) => { + const queryString = _.toQuery(query) + const fetchTransactions = async () => await transactionsApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`transactions-${queryString}`, fetchTransactions) + + return { + transactions: { data, isLoading, refetch } + } +} + +export default useTransactions diff --git a/src/lib/transaction/utils/transactions.js b/src/lib/transaction/utils/transactions.js new file mode 100644 index 00000000..4c7522be --- /dev/null +++ b/src/lib/transaction/utils/transactions.js @@ -0,0 +1,15 @@ +import { getAuth } from '@/core/utils/auth' + +const downloadPurchaseOrder = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download_po/${transaction.token}` + window.open(url, 'download') +} + +const downloadQuotation = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download/${transaction.token}` + window.open(url, 'download') +} + +export { downloadPurchaseOrder, downloadQuotation } diff --git a/src/lib/variant/components/VariantCard.jsx b/src/lib/variant/components/VariantCard.jsx new file mode 100644 index 00000000..6e7ea871 --- /dev/null +++ b/src/lib/variant/components/VariantCard.jsx @@ -0,0 +1,95 @@ +import { useRouter } from 'next/router' +import { toast } from 'react-hot-toast' + +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { createSlug } from '@/core/utils/slug' +import currencyFormat from '@/core/utils/currencyFormat' +import { updateItemCart } from '@/core/utils/cart' + +const VariantCard = ({ product, openOnClick = true, buyMore = false }) => { + const router = useRouter() + + const addItemToCart = () => { + toast.success('Berhasil menambahkan ke keranjang', { duration: 1500 }) + updateItemCart({ + productId: product.id, + quantity: 1 + }) + return + } + + const checkoutItem = () => { + router.push(`/shop/checkout?product_id=${product.id}&qty=${product.quantity}`) + } + + const Card = () => ( + <div className='flex gap-x-3'> + <div className='w-4/12 flex items-center gap-x-2'> + <Image + src={product.parent.image} + alt={product.parent.name} + className='object-contain object-center border border-gray_r-6 h-32 w-full rounded-md' + /> + </div> + <div className='w-8/12 flex flex-col'> + <p className='product-card__title wrap-line-ellipsis-2'>{product.parent.name}</p> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {product.code || '-'} + {product.attributes.length > 0 ? ` ・ ${product.attributes.join(', ')}` : ''} + </p> + <div className='flex flex-wrap gap-x-1 items-center mt-auto'> + {product.price.discountPercentage > 0 && ( + <> + <p className='text-caption-2 text-gray_r-11 line-through'> + {currencyFormat(product.price.price)} + </p> + <span className='badge-red'>{product.price.discountPercentage}%</span> + </> + )} + <p className='text-caption-2 text-gray_r-12'> + {currencyFormat(product.price.priceDiscount)} + </p> + </div> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {currencyFormat(product.price.priceDiscount)} × {product.quantity} Barang + </p> + <p className='text-caption-2 text-gray_r-12 font-bold mt-2'> + {currencyFormat(product.quantity * product.price.priceDiscount)} + </p> + </div> + </div> + ) + + if (openOnClick) { + return ( + <> + <Link href={createSlug('/shop/product/', product.parent.name, product.parent.id)}> + <Card /> + </Link> + {buyMore && ( + <div className='flex justify-end gap-x-2 mb-2'> + <button + type='button' + onClick={addItemToCart} + className='btn-yellow text-gray_r-12 py-2 px-3 text-caption-1' + > + Tambah Keranjang + </button> + <button + type='button' + onClick={checkoutItem} + className='btn-solid-red py-2 px-3 text-caption-1' + > + Beli Lagi + </button> + </div> + )} + </> + ) + } + + return <Card /> +} + +export default VariantCard diff --git a/src/lib/variant/components/VariantGroupCard.jsx b/src/lib/variant/components/VariantGroupCard.jsx new file mode 100644 index 00000000..e5f5c7fc --- /dev/null +++ b/src/lib/variant/components/VariantGroupCard.jsx @@ -0,0 +1,30 @@ +import { useState } from 'react' +import VariantCard from './VariantCard' + +const VariantGroupCard = ({ variants, ...props }) => { + const [showAll, setShowAll] = useState(false) + const variantsToShow = showAll ? variants : variants.slice(0, 2) + + return ( + <> + {variantsToShow?.map((variant, index) => ( + <VariantCard + key={index} + product={variant} + {...props} + /> + ))} + {variants.length > 2 && ( + <button + type='button' + className='btn-light py-2 w-full' + onClick={() => setShowAll(!showAll)} + > + {!showAll ? `Lihat Semua +${variants.length - variantsToShow.length}` : 'Tutup'} + </button> + )} + </> + ) +} + +export default VariantGroupCard diff --git a/src/lib/wishlist/api/createOrDeleteWishlistApi.js b/src/lib/wishlist/api/createOrDeleteWishlistApi.js new file mode 100644 index 00000000..617d139d --- /dev/null +++ b/src/lib/wishlist/api/createOrDeleteWishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const createOrDeleteWishlistApi = async ({ data }) => { + const auth = getAuth() + const dataWishlist = await odooApi( + 'POST', + `/api/v1/user/${auth.id}/wishlist/create-or-delete`, + data + ) + return dataWishlist +} + +export default createOrDeleteWishlistApi diff --git a/src/lib/wishlist/api/wishlistApi.js b/src/lib/wishlist/api/wishlistApi.js new file mode 100644 index 00000000..a8906dd4 --- /dev/null +++ b/src/lib/wishlist/api/wishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistApi = async ({ productId }) => { + const auth = getAuth() + if (!auth) return { productTotal: 0, products: [] } + const dataWishlist = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?product_id=${productId}` + ) + return dataWishlist +} + +export default wishlistApi diff --git a/src/lib/wishlist/api/wishlistsApi.js b/src/lib/wishlist/api/wishlistsApi.js new file mode 100644 index 00000000..dfcce028 --- /dev/null +++ b/src/lib/wishlist/api/wishlistsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistsApi = async ({ limit, offset }) => { + const auth = getAuth() + const dataWishlists = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?limit=${limit}&offset=${offset}` + ) + return dataWishlists +} + +export default wishlistsApi diff --git a/src/lib/wishlist/components/Wishlists.jsx b/src/lib/wishlist/components/Wishlists.jsx new file mode 100644 index 00000000..e61efcc3 --- /dev/null +++ b/src/lib/wishlist/components/Wishlists.jsx @@ -0,0 +1,55 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import ProductCard from '@/lib/product/components/ProductCard' +import { useRouter } from 'next/router' +import useWishlists from '../hooks/useWishlists' + +const Wishlists = () => { + const router = useRouter() + const { page = 1 } = router.query + const limit = 30 + const { wishlists } = useWishlists({ page, limit }) + + const pageCount = Math.ceil(wishlists.data?.productTotal / limit) + + if (wishlists.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='px-4 py-6'> + {wishlists.data?.products?.length == 0 && ( + <Alert + type='info' + className='text-center' + > + Wishlist anda masih kosong + </Alert> + )} + + <div className='grid grid-cols-2 gap-3'> + {wishlists.data?.products.map((product) => ( + <ProductCard + key={product.id} + product={product} + /> + ))} + </div> + + <div className='mt-6'> + <Pagination + currentPage={page} + pageCount={pageCount} + url={`/my/wishlist`} + /> + </div> + </div> + ) +} + +export default Wishlists diff --git a/src/lib/wishlist/hooks/useWishlist.js b/src/lib/wishlist/hooks/useWishlist.js new file mode 100644 index 00000000..8580a19d --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlist.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import wishlistApi from '../api/wishlistApi' + +const useWishlist = ({ productId }) => { + const fetchWishlist = async () => await wishlistApi({ productId }) + const { data, isLoading, refetch } = useQuery(`wishlist-${productId}`, fetchWishlist) + + return { + wishlist: { data, isLoading, refetch } + } +} + +export default useWishlist diff --git a/src/lib/wishlist/hooks/useWishlists.js b/src/lib/wishlist/hooks/useWishlists.js new file mode 100644 index 00000000..169fdf46 --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlists.js @@ -0,0 +1,14 @@ +import { useQuery } from 'react-query' +import wishlistsApi from '../api/wishlistsApi' + +const useWishlists = ({ page, limit }) => { + const offset = (page - 1) * limit + const fetchWishlists = async () => await wishlistsApi({ limit, offset }) + const { data, isLoading } = useQuery(`wishlists-${limit}-${offset}`, fetchWishlists) + + return { + wishlists: { data, isLoading } + } +} + +export default useWishlists diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx new file mode 100644 index 00000000..e32efc19 --- /dev/null +++ b/src/pages/_app.jsx @@ -0,0 +1,42 @@ +import '../styles/globals.css' +import NextProgress from 'next-progress' +import { useRouter } from 'next/router' +import { AnimatePresence } from 'framer-motion' +import { Toaster } from 'react-hot-toast' +import { QueryClient, QueryClientProvider } from 'react-query' + +const queryClient = new QueryClient() + +function MyApp({ Component, pageProps }) { + const router = useRouter() + + return ( + <> + <Toaster + position='top-center' + toastOptions={{ + duration: 3000, + className: 'border border-gray_r-8' + }} + /> + <NextProgress + color='#F01C21' + options={{ showSpinner: false }} + /> + <QueryClientProvider client={queryClient}> + <AnimatePresence + mode='wait' + initial={false} + onExitComplete={() => window.scrollTo(0, 0)} + > + <Component + {...pageProps} + key={router.asPath} + /> + </AnimatePresence> + </QueryClientProvider> + </> + ) +} + +export default MyApp diff --git a/src/pages/activate.jsx b/src/pages/activate.jsx new file mode 100644 index 00000000..cbd10ac2 --- /dev/null +++ b/src/pages/activate.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import ActivateComponent from '@/lib/auth/components/Activate' + +export default function Activate() { + return ( + <> + <ActivateComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/api/activation-request.js b/src/pages/api/activation-request.js index 3f33875c..7fae2fd1 100644 --- a/src/pages/api/activation-request.js +++ b/src/pages/api/activation-request.js @@ -1,15 +1,11 @@ -import apiOdoo from "@/core/utils/apiOdoo"; -import mailer from "@/core/utils/mailer"; +import odooApi from '@/core/api/odooApi' +import mailer from '@/core/utils/mailer' export default async function handler(req, res) { try { - const { email } = req.body; - let result = await apiOdoo( - 'POST', - '/api/v1/user/activation-request', - {email} - ); - if (result.activation_request) { + const { email } = req.body + let result = await odooApi('POST', '/api/v1/user/activation-request', { email }) + if (result.activationRequest) { mailer.sendMail({ from: 'sales@indoteknik.com', to: result.user.email, @@ -19,13 +15,13 @@ export default async function handler(req, res) { <br> <p>Aktivasi akun anda melalui link berikut: <a href="${process.env.SELF_HOST}/activate?token=${result.token}">Aktivasi Akun</a></p> ` - }); + }) } - delete result.user; - delete result.token; - res.status(200).json(result); + delete result.user + delete result.token + res.status(200).json(result) } catch (error) { - console.log(error); - res.status(400).json({ error: error.message }); + console.log(error) + res.status(400).json({ error: error.message }) } -}
\ No newline at end of file +} diff --git a/src/pages/api/shop/search.js b/src/pages/api/shop/search.js index ad986c86..c1e00d16 100644 --- a/src/pages/api/shop/search.js +++ b/src/pages/api/shop/search.js @@ -1,4 +1,5 @@ -import axios from "axios"; +import axios from 'axios' +import camelcaseObjectDeep from 'camelcase-object-deep' const productResponseMap = (products) => { return products.map((product) => { @@ -7,65 +8,65 @@ const productResponseMap = (products) => { image: product.image ? product.image[0] : '', code: product.default_code ? product.default_code[0] : '', name: product.product_name ? product.product_name[0] : '', - lowest_price: { + lowestPrice: { price: product.price ? product.price[0] : 0, - price_discount: product.price_discount ? product.price_discount[0] : 0, - discount_percentage: product.discount ? product.discount[0] : 0, + priceDiscount: product.price_discount ? product.price_discount[0] : 0, + discountPercentage: product.discount ? product.discount[0] : 0 }, - variant_total: product.variant_total ? product.variant_total[0] : 0, - stock_total: product.stock_total ? product.stock_total[0] : 0, + variantTotal: product.variant_total ? product.variant_total[0] : 0, + stockTotal: product.stock_total ? product.stock_total[0] : 0, weight: product.weight ? product.weight[0] : 0, manufacture: {}, - categories: [], - }; + categories: [] + } if (product.manufacture_id && product.brand) { productMapped.manufacture = { id: product.manufacture_id ? product.manufacture_id[0] : '', - name: product.brand ? product.brand[0] : '', - }; + name: product.brand ? product.brand[0] : '' + } } productMapped.categories = [ { id: product.category_id ? product.category_id[0] : '', - name: product.category_name ? product.category_name[0] : '', + name: product.category_name ? product.category_name[0] : '' } - ]; + ] - return productMapped; - }); + return productMapped + }) } export default async function handler(req, res) { - const { - q, + const { + q = '*', page = 1, brand = '', category = '', - price_from = 0, - price_to = 0, - order_by = '' - } = req.query; + priceFrom = 0, + priceTo = 0, + orderBy = '' + } = req.query - let paramOrderBy = ''; - switch (order_by) { + let paramOrderBy = '' + switch (orderBy) { case 'price-asc': - paramOrderBy = ', price_discount ASC'; - break; + paramOrderBy = ', price_discount ASC' + break case 'price-desc': - paramOrderBy = ', price_discount DESC'; - break; + paramOrderBy = ', price_discount DESC' + break case 'popular': - paramOrderBy = ', search_rank DESC'; - break; + paramOrderBy = ', search_rank DESC' + break case 'stock': - paramOrderBy = ', stock_total DESC'; - break; + paramOrderBy = ', stock_total DESC' + break } - let limit = 30; - let offset = (page - 1) * limit; + let limit = 30 + let offset = (page - 1) * limit let parameter = [ `facet.query=${q}`, 'facet=true', @@ -77,20 +78,21 @@ export default async function handler(req, res) { `start=${offset}`, `rows=${limit}`, `sort=product_rating DESC ${paramOrderBy}`, - `fq=price_discount:[${price_from == '' ? '*' : price_from} TO ${price_to == '' ? '*' : price_to}]` - ]; + `fq=price_discount:[${priceFrom == '' ? '*' : priceFrom} TO ${priceTo == '' ? '*' : priceTo}]` + ] - if (brand) parameter.push(`fq=brand:${brand}`); - if (category) parameter.push(`fq=category_name:${category}`); - - let result = await axios(process.env.SOLR_HOST + '/solr/products/select?' + parameter.join('&')); + if (brand) parameter.push(`fq=brand:${brand}`) + if (category) parameter.push(`fq=category_name:${category}`) + + let result = await axios(process.env.SOLR_HOST + '/solr/products/select?' + parameter.join('&')) try { - result.data.response.products = productResponseMap(result.data.response.docs); - result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start); - result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows); - delete result.data.response.docs; - res.status(200).json(result.data); + result.data.response.products = productResponseMap(result.data.response.docs) + result.data.responseHeader.params.start = parseInt(result.data.responseHeader.params.start) + result.data.responseHeader.params.rows = parseInt(result.data.responseHeader.params.rows) + delete result.data.response.docs + result.data = camelcaseObjectDeep(result.data) + res.status(200).json(result.data) } catch (error) { - res.status(400).json({ error: error.message }); + res.status(400).json({ error: error.message }) } -}
\ No newline at end of file +} diff --git a/src/pages/api/shop/suggest.js b/src/pages/api/shop/suggest.js index 6db1a851..cc0ff2b3 100644 --- a/src/pages/api/shop/suggest.js +++ b/src/pages/api/shop/suggest.js @@ -1,12 +1,18 @@ -import axios from "axios"; +import axios from 'axios' export default async function handler(req, res) { - const { q } = req.query; + const { q = '' } = req.query - let result = await axios(process.env.SOLR_HOST + `/solr/products/suggest?suggest=true&suggest.dictionary=mySuggester&suggest.q=${q}`); + let result = await axios( + process.env.SOLR_HOST + + `/solr/products/suggest?suggest=true&suggest.dictionary=mySuggester&suggest.q=${q}` + ) try { - res.status(200).json(result.data); + res.status(200).json(result.data.suggest.mySuggester[q]) } catch (error) { - res.status(400).json({ error: error.message }); + res.status(400).json({ + numFound: 0, + suggestions: [] + }) } -}
\ No newline at end of file +} diff --git a/src/pages/faqs.jsx b/src/pages/faqs.jsx new file mode 100644 index 00000000..ddbb4b0a --- /dev/null +++ b/src/pages/faqs.jsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import AppLayout from '@/core/components/layouts/AppLayout' + +const dataFaqs = [ + { + id: 1, + name: 'Akun', + description: 'Bantuan tentang pengelolaan fitur dan akun' + }, + { + id: 2, + name: 'Pembelian', + description: + 'Bantuan seputar status stock, layanan pengiriman & asuransi hingga seluruh indonesia' + }, + { + id: 3, + name: 'Metode Pembayaran', + description: 'Bantuan terkait layanan metode pembayaran' + }, + { + id: 4, + name: 'Quotation', + description: 'Bantuan fitur RFQ & quotation Express' + }, + { + id: 5, + name: 'Faktur Pajak & Invoice', + description: 'Bantuan seputar layanan terbit faktur pajak & invoice' + }, + { + id: 6, + name: 'Pengembalian & Garansi', + description: 'Bantuan cara pengembalian produk & garansi produk' + } +] + +export default function Faqs() { + const [faqs, setFaqs] = useState([]) + + useEffect(() => { + if (faqs.length == 0) { + setFaqs( + dataFaqs.map((dataFaq) => ({ + ...dataFaq, + isOpen: false + })) + ) + } + }, [faqs]) + + const toggleFaq = (id) => { + const faqsToUpdate = faqs.map((faq) => { + if (faq.id == id) faq.isOpen = !faq.isOpen + return faq + }) + setFaqs(faqsToUpdate) + } + + return ( + <AppLayout title='FAQs'> + <div className='divide-y divide-gray_r-6'> + {faqs.map((faq, index) => ( + <div + className='p-4' + key={index} + > + <div className='flex gap-x-3 items-center'> + <div className='flex-1'> + <p className='font-medium mb-1'>{faq.name}</p> + <p className='text-caption-1 text-gray_r-11'>{faq.description}</p> + </div> + <button + type='button' + className='p-2 rounded bg-gray_r-4 h-fit' + onClick={() => toggleFaq(faq.id)} + > + {faq.isOpen ? ( + <ChevronUpIcon className='w-5' /> + ) : ( + <ChevronDownIcon className='w-5' /> + )} + </button> + </div> + {faq.isOpen && ( + <p className='text-caption-1 text-gray_r-11 leading-7 mt-4'> + {faq?.content || + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.'} + </p> + )} + </div> + ))} + </div> + </AppLayout> + ) +} diff --git a/src/pages/index.jsx b/src/pages/index.jsx new file mode 100644 index 00000000..19d3e59c --- /dev/null +++ b/src/pages/index.jsx @@ -0,0 +1,36 @@ +import dynamic from 'next/dynamic' +import Seo from '@/core/components/Seo' +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' +import PopularProductSkeleton from '@/lib/home/components/Skeleton/PopularProductSkeleton' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) + +const HeroBanner = dynamic(() => import('@/lib/home/components/HeroBanner'), { + loading: () => <ImageSkeleton /> +}) + +const PreferredBrand = dynamic(() => import('@/lib/home/components/PreferredBrand'), { + loading: () => <PopularProductSkeleton /> +}) + +const PopularProduct = dynamic(() => import('@/lib/home/components/PopularProduct'), { + loading: () => <PopularProductSkeleton /> +}) + +const CategoryHomeId = dynamic(() => import('@/lib/home/components/CategoryHomeId'), { + loading: () => <PopularProductSkeleton /> +}) + +export default function Home() { + return ( + <BasicLayout> + <Seo title='Beranda - Indoteknik' /> + <HeroBanner /> + <div className='flex flex-col gap-y-6 my-6'> + <PreferredBrand /> + <PopularProduct /> + <CategoryHomeId /> + </div> + </BasicLayout> + ) +} diff --git a/src/pages/login.jsx b/src/pages/login.jsx new file mode 100644 index 00000000..03509e93 --- /dev/null +++ b/src/pages/login.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import LoginComponent from '@/lib/auth/components/Login' + +export default function Login() { + return ( + <> + <LoginComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/my/address/[id]/edit.jsx b/src/pages/my/address/[id]/edit.jsx new file mode 100644 index 00000000..bc5f3471 --- /dev/null +++ b/src/pages/my/address/[id]/edit.jsx @@ -0,0 +1,36 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import addressApi from '@/lib/address/api/addressApi' +import EditAddressComponent from '@/lib/address/components/EditAddress' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function EditAddress({ id, defaultValues }) { + return ( + <IsAuth> + <AppLayout title='Ubah Alamat'> + <EditAddressComponent + id={id} + defaultValues={defaultValues} + /> + </AppLayout> + </IsAuth> + ) +} + +export async function getServerSideProps(context) { + const { id } = context.query + const address = await addressApi({ id }) + const defaultValues = { + type: address.type, + name: address.name, + email: address.email, + mobile: address.mobile, + street: address.street, + zip: address.zip, + city: address.city?.id || '', + oldDistrict: address.district?.id || '', + district: '', + oldSubDistrict: address.subDistrict?.id || '', + subDistrict: '' + } + return { props: { id, defaultValues } } +} diff --git a/src/pages/my/address/create.jsx b/src/pages/my/address/create.jsx new file mode 100644 index 00000000..ec17f987 --- /dev/null +++ b/src/pages/my/address/create.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import CreateAddressComponent from '@/lib/address/components/CreateAddress' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function CreateAddress() { + return ( + <IsAuth> + <AppLayout title='Tambah Alamat'> + <CreateAddressComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/address/index.jsx b/src/pages/my/address/index.jsx new file mode 100644 index 00000000..93ed40b0 --- /dev/null +++ b/src/pages/my/address/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import AddressesComponent from '@/lib/address/components/Addresses' +import IsAuth from '@/lib/auth/components/IsAuth' + +export default function Addresses() { + return ( + <IsAuth> + <AppLayout title='Daftar Alamat'> + <AddressesComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/invoice/[id].jsx b/src/pages/my/invoice/[id].jsx new file mode 100644 index 00000000..4938d8f8 --- /dev/null +++ b/src/pages/my/invoice/[id].jsx @@ -0,0 +1,16 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import InvoiceComponent from '@/lib/invoice/components/Invoice' +import { useRouter } from 'next/router' + +export default function Invoice() { + const router = useRouter() + + return ( + <IsAuth> + <AppLayout title='Invoice & Faktur Pajak'> + <InvoiceComponent id={router.query.id} /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/invoices.jsx b/src/pages/my/invoices.jsx new file mode 100644 index 00000000..12a5ff7e --- /dev/null +++ b/src/pages/my/invoices.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import InvoicesComponent from '@/lib/invoice/components/Invoices' + +export default function Invoices() { + return ( + <IsAuth> + <AppLayout title='Invoice & Faktur Pajak'> + <InvoicesComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/menu.jsx b/src/pages/my/menu.jsx new file mode 100644 index 00000000..b9fd30ee --- /dev/null +++ b/src/pages/my/menu.jsx @@ -0,0 +1,100 @@ +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import AppLayout from '@/core/components/layouts/AppLayout' +import useAuth from '@/core/hooks/useAuth' +import { deleteAuth } from '@/core/utils/auth' +import IsAuth from '@/lib/auth/components/IsAuth' +import { ChevronRightIcon, UserIcon } from '@heroicons/react/24/solid' +import { useRouter } from 'next/router' + +export default function Menu() { + const auth = useAuth() + const router = useRouter() + + const logout = () => { + deleteAuth() + router.push('/login') + } + + return ( + <IsAuth> + <AppLayout title='Menu Utama'> + <Link + href='/my/profile' + className='p-4 flex items-center' + > + <div className='rounded-full p-3 bg-gray_r-6 text-gray_r-12/80'> + <UserIcon className='w-5' /> + </div> + <div className='ml-4'> + <div className='font-semibold text-gray_r-12'>{auth?.name}</div> + {auth?.company && <div className='badge-solid-red mt-1'>Akun Bisnis</div>} + {!auth?.company && <div className='badge-gray mt-1'>Akun Individu</div>} + </div> + <div className='ml-auto !text-gray_r-12'> + <ChevronRightIcon className='w-6' /> + </div> + </Link> + + <Divider /> + + <div className='flex flex-col gap-y-6 py-6'> + <div> + <MenuHeader>Aktivitas Pembelian</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/my/transactions'>Daftar Transaksi</LinkItem> + <LinkItem href='/my/invoices'>Invoice & Faktur Pajak</LinkItem> + <LinkItem href='/my/wishlist'>Wishlist</LinkItem> + </div> + </div> + + <div> + <MenuHeader>Pusat Bantuan</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/'>Customer Support</LinkItem> + <LinkItem href='/faqs'>F.A.Q</LinkItem> + </div> + </div> + + <div> + <MenuHeader>Pengaturan Akun</MenuHeader> + + <div className='divide-y divide-gray_r-6 border-y border-gray_r-6 mt-4'> + <LinkItem href='/my/address'>Daftar Alamat</LinkItem> + </div> + + <div + onClick={logout} + className='p-4 mt-2' + > + <button className='w-full btn-red'>Keluar Akun</button> + </div> + </div> + </div> + </AppLayout> + </IsAuth> + ) +} + +const MenuHeader = ({ children, ...props }) => ( + <div + {...props} + className='font-medium px-4 flex' + > + {children} + </div> +) + +const LinkItem = ({ children, ...props }) => ( + <Link + {...props} + className='!text-gray_r-12/70 !font-normal p-4 flex items-center' + > + {children} + <div className='ml-auto !text-gray_r-11'> + <ChevronRightIcon className='w-5' /> + </div> + </Link> +) diff --git a/src/pages/my/profile.jsx b/src/pages/my/profile.jsx new file mode 100644 index 00000000..72a1ee3c --- /dev/null +++ b/src/pages/my/profile.jsx @@ -0,0 +1,19 @@ +import Divider from '@/core/components/elements/Divider/Divider' +import AppLayout from '@/core/components/layouts/AppLayout' +import useAuth from '@/core/hooks/useAuth' +import CompanyProfile from '@/lib/auth/components/CompanyProfile' +import IsAuth from '@/lib/auth/components/IsAuth' +import PersonalProfile from '@/lib/auth/components/PersonalProfile' + +export default function Profile() { + const auth = useAuth() + return ( + <IsAuth> + <AppLayout title='Akun Saya'> + <PersonalProfile /> + <Divider /> + {auth?.parentId && <CompanyProfile />} + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/transaction/[id].jsx b/src/pages/my/transaction/[id].jsx new file mode 100644 index 00000000..5167748c --- /dev/null +++ b/src/pages/my/transaction/[id].jsx @@ -0,0 +1,16 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import TransactionComponent from '@/lib/transaction/components/Transaction' +import { useRouter } from 'next/router' + +export default function Transaction() { + const router = useRouter() + + return ( + <IsAuth> + <AppLayout title='Transaksi'> + <TransactionComponent id={router.query.id} /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/transactions.jsx b/src/pages/my/transactions.jsx new file mode 100644 index 00000000..30b9be07 --- /dev/null +++ b/src/pages/my/transactions.jsx @@ -0,0 +1,15 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import dynamic from 'next/dynamic' + +const TransactionsComponent = dynamic(() => import('@/lib/transaction/components/Transactions')) + +export default function Transactions() { + return ( + <IsAuth> + <AppLayout title='Transaksi'> + <TransactionsComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/my/wishlist.jsx b/src/pages/my/wishlist.jsx new file mode 100644 index 00000000..196adf50 --- /dev/null +++ b/src/pages/my/wishlist.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import Wishlists from '@/lib/wishlist/components/Wishlists' + +export default function Wishlist() { + return ( + <IsAuth> + <AppLayout title='Wishlist'> + <Wishlists /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/register.jsx b/src/pages/register.jsx new file mode 100644 index 00000000..0ca1e81e --- /dev/null +++ b/src/pages/register.jsx @@ -0,0 +1,11 @@ +import SimpleFooter from '@/core/components/elements/Footer/SimpleFooter' +import RegisterComponent from '@/lib/auth/components/Register' + +export default function Register() { + return ( + <> + <RegisterComponent /> + <SimpleFooter /> + </> + ) +} diff --git a/src/pages/shop/brands/[slug].jsx b/src/pages/shop/brands/[slug].jsx new file mode 100644 index 00000000..33f81fa2 --- /dev/null +++ b/src/pages/shop/brands/[slug].jsx @@ -0,0 +1,25 @@ +import dynamic from 'next/dynamic' +import { getIdFromSlug, getNameFromSlug } from '@/core/utils/slug' +import { useRouter } from 'next/router' +import _ from 'lodash' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const ProductSearch = dynamic(() => import('@/lib/product/components/ProductSearch')) +const Brand = dynamic(() => import('@/lib/brand/components/Brand')) + +export default function BrandDetail() { + const router = useRouter() + const { slug = '' } = router.query + return ( + <BasicLayout> + <Brand id={getIdFromSlug(slug)} /> + {!_.isEmpty(router.query) && ( + <ProductSearch + query={_.omit(router.query, 'slug')} + prefixUrl={`/shop/brands/${slug}`} + defaultBrand={getNameFromSlug(slug)} + /> + )} + </BasicLayout> + ) +} diff --git a/src/pages/shop/brands/index.jsx b/src/pages/shop/brands/index.jsx new file mode 100644 index 00000000..5c01fd19 --- /dev/null +++ b/src/pages/shop/brands/index.jsx @@ -0,0 +1,10 @@ +import BasicLayout from '@/core/components/layouts/BasicLayout' +import BrandsComponent from '@/lib/brand/components/Brands' + +export default function Brands() { + return ( + <BasicLayout> + <BrandsComponent /> + </BasicLayout> + ) +} diff --git a/src/pages/shop/cart.jsx b/src/pages/shop/cart.jsx new file mode 100644 index 00000000..97f98843 --- /dev/null +++ b/src/pages/shop/cart.jsx @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic' + +const AppLayout = dynamic(() => import('@/core/components/layouts/AppLayout')) +const CartComponent = dynamic(() => import('@/lib/cart/components/Cart')) + +export default function Cart() { + return ( + <AppLayout title='Keranjang'> + <CartComponent /> + </AppLayout> + ) +} diff --git a/src/pages/shop/checkout/finish.jsx b/src/pages/shop/checkout/finish.jsx new file mode 100644 index 00000000..cc64199f --- /dev/null +++ b/src/pages/shop/checkout/finish.jsx @@ -0,0 +1,16 @@ +import BasicLayout from '@/core/components/layouts/BasicLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import FinishCheckoutComponent from '@/lib/checkout/components/FinishCheckout' +import { useRouter } from 'next/router' + +export default function Finish() { + const router = useRouter() + + return ( + <IsAuth> + <BasicLayout> + <FinishCheckoutComponent id={router.query.id || 0} /> + </BasicLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/checkout/index.jsx b/src/pages/shop/checkout/index.jsx new file mode 100644 index 00000000..015a11b3 --- /dev/null +++ b/src/pages/shop/checkout/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import CheckoutComponent from '@/lib/checkout/components/Checkout' + +export default function Checkout() { + return ( + <IsAuth> + <AppLayout title='Checkout'> + <CheckoutComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/product/[slug].jsx b/src/pages/shop/product/[slug].jsx new file mode 100644 index 00000000..cc6924a3 --- /dev/null +++ b/src/pages/shop/product/[slug].jsx @@ -0,0 +1,31 @@ +import Seo from '@/core/components/Seo' +import { getIdFromSlug } from '@/core/utils/slug' +import productApi from '@/lib/product/api/productApi' +import dynamic from 'next/dynamic' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const Product = dynamic(() => import('@/lib/product/components/Product')) + +export async function getServerSideProps(context) { + const { slug } = context.query + let product = await productApi({ id: getIdFromSlug(slug) }) + if (product?.length == 1) { + product = product[0] + product.description = product.description.replaceAll('<p>', '||p||') + product.description = product.description.replaceAll('</p>', '||/p||') + product.description = product.description.replace(/(<([^>]+)>)/gi, ' ') + product.description = product.description.replaceAll('||p||', '<p>') + product.description = product.description.replaceAll('||/p||', '</p>') + product.description = product.description.trim() + } + return { props: { product } } +} + +export default function ProductDetail({ product }) { + return ( + <BasicLayout> + <Seo title={product?.name} /> + <Product product={product} /> + </BasicLayout> + ) +} diff --git a/src/pages/shop/quotation/finish.jsx b/src/pages/shop/quotation/finish.jsx new file mode 100644 index 00000000..15881ea0 --- /dev/null +++ b/src/pages/shop/quotation/finish.jsx @@ -0,0 +1,44 @@ +import Link from '@/core/components/elements/Link/Link' +import BasicLayout from '@/core/components/layouts/BasicLayout' +import useAuth from '@/core/hooks/useAuth' +import IsAuth from '@/lib/auth/components/IsAuth' +import { EnvelopeIcon } from '@heroicons/react/24/outline' +import { useRouter } from 'next/router' + +export default function FinishQuotation() { + const auth = useAuth() + const router = useRouter() + const { id } = router.query + return ( + <IsAuth> + <BasicLayout> + <div className='m-4 px-4 py-6 shadow-md border border-gray_r-3'> + <div className='flex'> + <span className='p-3 mx-auto bg-yellow_r-3 border border-yellow_r-6 rounded'> + <EnvelopeIcon className='w-8 text-yellow_r-11' /> + </span> + </div> + <p className='h2 text-center mt-6'>Terima Kasih {auth?.name}</p> + <p className='text-center mt-3 leading-6 text-gray_r-11'> + Penawaran harga kamu di Indoteknik.com berhasil dikirimkan, tim kami akan segera + menghubungi anda. + </p> + {id && ( + <Link + href={`/my/transaction/${id}`} + className='btn-yellow !text-gray_r-12 mt-6 w-full' + > + Lihat Penawaran + </Link> + )} + <Link + href='/' + className='btn-light !text-gray_r-12 mt-2 w-full' + > + Ke Halaman Utama + </Link> + </div> + </BasicLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/quotation/index.jsx b/src/pages/shop/quotation/index.jsx new file mode 100644 index 00000000..ff8b8644 --- /dev/null +++ b/src/pages/shop/quotation/index.jsx @@ -0,0 +1,13 @@ +import AppLayout from '@/core/components/layouts/AppLayout' +import IsAuth from '@/lib/auth/components/IsAuth' +import QuotationComponent from '@/lib/quotation/components/Quotation' + +export default function Quotation() { + return ( + <IsAuth> + <AppLayout title='Quotation'> + <QuotationComponent /> + </AppLayout> + </IsAuth> + ) +} diff --git a/src/pages/shop/search.jsx b/src/pages/shop/search.jsx new file mode 100644 index 00000000..345b715a --- /dev/null +++ b/src/pages/shop/search.jsx @@ -0,0 +1,21 @@ +import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' +import _ from 'lodash-contrib' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout')) +const ProductSearch = dynamic(() => import('@/lib/product/components/ProductSearch')) + +export default function Search() { + const router = useRouter() + + return ( + <BasicLayout> + {!_.isEmpty(router.query) && ( + <ProductSearch + query={router.query} + prefixUrl='/shop/search' + /> + )} + </BasicLayout> + ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css index b871a325..0137351e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -8,47 +8,32 @@ -webkit-tap-highlight-color: transparent; } -html, body { - @apply - w-screen +html, +body { + @apply w-screen text-body-2 text-gray_r-12 bg-gray_r-1 - overflow-x-clip - ; + overflow-x-clip; +} + +#__next main { + @apply min-h-screen +} + +button { + @apply block; } @layer base { - input[type="number"]::-webkit-inner-spin-button, - input[type="number"]::-webkit-outer-spin-button { + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } - input[type=number] { - -moz-appearance:textfield; - } - - h1, .h1 { - @apply - text-h-md - font-semibold - ; - } - - h2, .h2 { - @apply - text-body-2 - font-semibold - leading-6 - ; - } - - a { - @apply - font-medium - text-red_r-11 - ; + input[type='number'] { + -moz-appearance: textfield; } } @@ -60,94 +45,73 @@ html, body { .badge-blue, .badge-green, .badge-solid-green { - @apply - text-caption-2 + @apply text-[11px] leading-none font-medium px-1 py-1 rounded - w-fit - ; + w-fit; } .badge-red { - @apply - bg-red_r-5 - text-red_r-10 - ; + @apply bg-red_r-5 + text-red_r-10; } .badge-solid-red { - @apply - bg-red_r-10 - text-white - ; + @apply bg-red_r-10 + text-white; } .badge-gray { - @apply - bg-gray_r-5 - text-gray_r-10 - ; + @apply bg-gray_r-5 + text-gray_r-10; } .badge-yellow { - @apply - bg-yellow_r-3 - text-yellow_r-11 - ; + @apply bg-yellow_r-3 + text-yellow_r-11; } .badge-blue { - @apply - bg-blue-200 - text-blue-600 - ; + @apply bg-blue-200 + text-blue-600; } .badge-green { - @apply - bg-green_r-5 - text-green_r-10 - ; + @apply bg-green_r-5 + text-green_r-10; } - + .badge-solid-green { - @apply - bg-green_r-10 - text-white - ; + @apply bg-green_r-10 + text-white; } .form-label { - @apply - font-medium - block - ; + @apply font-medium + block; } .form-input { - @apply - p-3 + @apply p-3 rounded border text-gray_r-12 border-gray_r-7 + bg-white bg-transparent w-full leading-none focus:outline-none focus:border-yellow_r-9 - disabled:bg-gray_r-5 - ; + disabled:bg-gray_r-5; } .form-input[aria-invalid] { - @apply - border-red_r-10 - focus:border-red_r-10 - ; + @apply border-red_r-10 + focus:border-red_r-10; } .btn-yellow, @@ -155,8 +119,7 @@ html, body { .btn-red, .btn-solid-red, .btn-green { - @apply - block + @apply block w-fit py-3 px-6 @@ -165,63 +128,51 @@ html, body { text-center font-medium ease-linear - duration-150 - ; + duration-150; } .btn-yellow { - @apply - bg-yellow_r-9 + @apply bg-yellow_r-9 border-yellow_r-9 disabled:text-gray_r-10 disabled:bg-yellow_r-7 - disabled:border-yellow_r-7 - ; + disabled:border-yellow_r-7; } .btn-red { - @apply - bg-red_r-3 + @apply bg-red_r-3 border-red_r-6 text-red_r-11 disabled:text-red_r-10 - disabled:bg-red_r-6 - ; + disabled:bg-red_r-6; } .btn-solid-red { - @apply - bg-red_r-11 + @apply bg-red_r-11 border-red_r-11 text-gray_r-1 disabled:text-gray_r-1 disabled:bg-red_r-8 - disabled:border-red_r-8 - ; + disabled:border-red_r-8; } .btn-green { - @apply - bg-green_r-3 + @apply bg-green_r-3 border-green_r-6 text-green_r-11 disabled:text-green_r-10 - disabled:bg-green_r-6 - ; + disabled:bg-green_r-6; } .btn-light { - @apply - bg-gray_r-3 + @apply bg-gray_r-3 border-gray_r-6 disabled:text-gray_r-10 - disabled:bg-gray_r-6 - ; + disabled:bg-gray_r-6; } .product-card { - @apply - w-full + @apply w-full h-full border border-gray_r-3 @@ -230,43 +181,34 @@ html, body { rounded relative flex - flex-col - ; + flex-col; } .product-card__image { - @apply - w-full + @apply w-full h-[160px] object-contain object-center border-b - border-gray_r-6 - ; + border-gray_r-6; } .product-card__content { - @apply - p-2 + @apply p-2 pb-3 - flex-1 - ; + flex-1; } .product-card__title { - @apply - text-caption-1 + @apply text-caption-1 text-gray_r-12 - leading-5 - ; + leading-5; } .product-card__brand { - @apply - text-caption-1 + @apply text-caption-1 mb-1 - block - ; + block; } } @@ -287,15 +229,14 @@ html, body { .wrap-line-ellipsis-2 { -webkit-line-clamp: 2; } - + .wrap-line-ellipsis-3 { -webkit-line-clamp: 3; } } .menu-wrapper { - @apply - fixed + @apply fixed top-0 left-0 bg-white @@ -305,29 +246,25 @@ html, body { overflow-y-auto translate-x-[-100%] ease-linear - duration-150 - ; + duration-150; } -.menu-wrapper.active{ +.menu-wrapper.active { @apply translate-x-0; } -.menu-overlay { - @apply - fixed +.overlay { + @apply fixed top-0 left-0 w-full h-full z-[55] - bg-gray_r-12/40 - ; + bg-gray_r-12/40; } .sticky-header { - @apply - px-4 + @apply px-4 py-3 bg-gray_r-1/90 backdrop-blur-lg @@ -335,20 +272,16 @@ html, body { top-0 border-b border-gray_r-7 - z-50 - ; + z-50; } .content-container { - @apply - max-w-full - overflow-x-hidden - ; + @apply max-w-full + overflow-x-hidden; } #indoteknik_toast { - @apply - fixed + @apply fixed bottom-4 translate-y-[200%] left-[50%] @@ -366,8 +299,7 @@ html, body { rounded-lg shadow ease-linear - duration-300 - ; + duration-300; } #indoteknik_toast.active { @@ -383,10 +315,8 @@ html, body { } .lazy-load-image-background { - @apply - !block - w-full - ; + @apply !block + w-full; } .swiper-pagination-bullet-active { @@ -394,16 +324,13 @@ html, body { } .pagination { - @apply - flex + @apply flex justify-center - gap-x-1 - ; + gap-x-1; } .pagination-item { - @apply - p-1 + @apply p-1 flex justify-center items-center @@ -415,20 +342,16 @@ html, body { border-gray_r-6 bg-gray_r-3 hover:bg-gray_r-5 - text-gray_r-12 - ; + text-gray_r-12; } .pagination-item--active { - @apply - border-yellow_r-9 - bg-yellow_r-9 - ; + @apply border-yellow_r-9 + bg-yellow_r-9; } .pagination-dots { - @apply - p-1 + @apply p-1 flex justify-center items-end @@ -436,33 +359,24 @@ html, body { rounded ease-linear bg-gray_r-3 - text-caption-2 - ; + text-caption-2; } .idt-transition { - @apply - transition-all - ease-linear - duration-300 - ; + @apply transition-all + ease-out + duration-200; } .form-select__placeholder { - @apply - !text-gray_r-9 - ; + @apply !text-gray_r-9; } .form-select__control { - @apply - !shadow-none - !border-gray_r-7 - ; + @apply !shadow-none + !border-gray_r-7; } .form-select__control--menu-is-open { - @apply - !border-yellow_r-9 - ; -}
\ No newline at end of file + @apply !border-yellow_r-9; +} diff --git a/src/components/auth/WithAuth.js b/src2/components/auth/WithAuth.js index ef975873..ef975873 100644 --- a/src/components/auth/WithAuth.js +++ b/src2/components/auth/WithAuth.js diff --git a/src/components/elements/Alert.js b/src2/components/elements/Alert.js index 914d1590..914d1590 100644 --- a/src/components/elements/Alert.js +++ b/src2/components/elements/Alert.js diff --git a/src/components/elements/BottomPopup.js b/src2/components/elements/BottomPopup.js index c1a56e10..c1a56e10 100644 --- a/src/components/elements/BottomPopup.js +++ b/src2/components/elements/BottomPopup.js diff --git a/src/components/elements/ConfirmAlert.js b/src2/components/elements/ConfirmAlert.js index d33abb89..d33abb89 100644 --- a/src/components/elements/ConfirmAlert.js +++ b/src2/components/elements/ConfirmAlert.js diff --git a/src/components/elements/DescriptionRow.js b/src2/components/elements/DescriptionRow.js index 7fe9e3a1..7fe9e3a1 100644 --- a/src/components/elements/DescriptionRow.js +++ b/src2/components/elements/DescriptionRow.js diff --git a/src/components/elements/Disclosure.js b/src2/components/elements/Disclosure.js index 1f334be3..1f334be3 100644 --- a/src/components/elements/Disclosure.js +++ b/src2/components/elements/Disclosure.js diff --git a/src/components/elements/Fields.js b/src2/components/elements/Fields.js index 586a6a22..586a6a22 100644 --- a/src/components/elements/Fields.js +++ b/src2/components/elements/Fields.js diff --git a/src/components/elements/Filter.js b/src2/components/elements/Filter.js index f2051ba8..f2051ba8 100644 --- a/src/components/elements/Filter.js +++ b/src2/components/elements/Filter.js diff --git a/src/components/elements/Image.js b/src2/components/elements/Image.js index 60e249b9..60e249b9 100644 --- a/src/components/elements/Image.js +++ b/src2/components/elements/Image.js diff --git a/src/components/elements/LineDivider.js b/src2/components/elements/LineDivider.js index 4e8c7b52..4e8c7b52 100644 --- a/src/components/elements/LineDivider.js +++ b/src2/components/elements/LineDivider.js diff --git a/src/components/elements/Link.js b/src2/components/elements/Link.js index 065b5c9e..065b5c9e 100644 --- a/src/components/elements/Link.js +++ b/src2/components/elements/Link.js diff --git a/src/components/elements/Pagination.js b/src2/components/elements/Pagination.js index ff2a8462..ff2a8462 100644 --- a/src/components/elements/Pagination.js +++ b/src2/components/elements/Pagination.js diff --git a/src/components/elements/ProgressBar.js b/src2/components/elements/ProgressBar.js index 0adedcdf..0adedcdf 100644 --- a/src/components/elements/ProgressBar.js +++ b/src2/components/elements/ProgressBar.js diff --git a/src/components/elements/Skeleton.js b/src2/components/elements/Skeleton.js index fbdbc245..fbdbc245 100644 --- a/src/components/elements/Skeleton.js +++ b/src2/components/elements/Skeleton.js diff --git a/src/components/elements/Spinner.js b/src2/components/elements/Spinner.js index 21006ecd..21006ecd 100644 --- a/src/components/elements/Spinner.js +++ b/src2/components/elements/Spinner.js diff --git a/src/components/layouts/AppBar.js b/src2/components/layouts/AppBar.js index fe74c940..fe74c940 100644 --- a/src/components/layouts/AppBar.js +++ b/src2/components/layouts/AppBar.js diff --git a/src/components/layouts/Footer.js b/src2/components/layouts/Footer.js index d173a525..d173a525 100644 --- a/src/components/layouts/Footer.js +++ b/src2/components/layouts/Footer.js diff --git a/src/components/layouts/Header.js b/src2/components/layouts/Header.js index 23fda642..23fda642 100644 --- a/src/components/layouts/Header.js +++ b/src2/components/layouts/Header.js diff --git a/src/components/layouts/Layout.js b/src2/components/layouts/Layout.js index fd507963..fd507963 100644 --- a/src/components/layouts/Layout.js +++ b/src2/components/layouts/Layout.js diff --git a/src/components/manufactures/ManufactureCard.js b/src2/components/manufactures/ManufactureCard.js index 73a96902..73a96902 100644 --- a/src/components/manufactures/ManufactureCard.js +++ b/src2/components/manufactures/ManufactureCard.js diff --git a/src/components/products/ProductCard.js b/src2/components/products/ProductCard.js index c79a4900..c79a4900 100644 --- a/src/components/products/ProductCard.js +++ b/src2/components/products/ProductCard.js diff --git a/src/components/products/ProductCategories.js b/src2/components/products/ProductCategories.js index 3b671f29..3b671f29 100644 --- a/src/components/products/ProductCategories.js +++ b/src2/components/products/ProductCategories.js diff --git a/src/components/products/ProductSimilar.js b/src2/components/products/ProductSimilar.js index 9e2292cb..9e2292cb 100644 --- a/src/components/products/ProductSimilar.js +++ b/src2/components/products/ProductSimilar.js diff --git a/src/components/products/ProductSlider.js b/src2/components/products/ProductSlider.js index 662a6511..662a6511 100644 --- a/src/components/products/ProductSlider.js +++ b/src2/components/products/ProductSlider.js diff --git a/src/components/transactions/TransactionDetail.js b/src2/components/transactions/TransactionDetail.js index 295a4f9f..295a4f9f 100644 --- a/src/components/transactions/TransactionDetail.js +++ b/src2/components/transactions/TransactionDetail.js diff --git a/src/components/transactions/TransactionStatusBadge.js b/src2/components/transactions/TransactionStatusBadge.js index f94fd3fd..f94fd3fd 100644 --- a/src/components/transactions/TransactionStatusBadge.js +++ b/src2/components/transactions/TransactionStatusBadge.js diff --git a/src/components/variants/VariantCard.js b/src2/components/variants/VariantCard.js index a821480c..a821480c 100644 --- a/src/components/variants/VariantCard.js +++ b/src2/components/variants/VariantCard.js diff --git a/src/components/variants/VariantGroupCard.js b/src2/components/variants/VariantGroupCard.js index 462c63cf..462c63cf 100644 --- a/src/components/variants/VariantGroupCard.js +++ b/src2/components/variants/VariantGroupCard.js 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/src/core/utils/apiOdoo.js b/src2/core/utils/apiOdoo.js index 4d0adae3..4d0adae3 100644 --- a/src/core/utils/apiOdoo.js +++ b/src2/core/utils/apiOdoo.js 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/src/core/utils/convertToOption.js b/src2/core/utils/convertToOption.js index 08fec08f..08fec08f 100644 --- a/src/core/utils/convertToOption.js +++ b/src2/core/utils/convertToOption.js 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/src/core/utils/formValidation.js b/src2/core/utils/formValidation.js index 0e83f4cc..0e83f4cc 100644 --- a/src/core/utils/formValidation.js +++ b/src2/core/utils/formValidation.js 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/src/icons/chevron-left.svg b/src2/icons/chevron-left.svg index a22ce386..a22ce386 100644 --- a/src/icons/chevron-left.svg +++ b/src2/icons/chevron-left.svg diff --git a/src/icons/chevron-right.svg b/src2/icons/chevron-right.svg index eb58f2f2..eb58f2f2 100644 --- a/src/icons/chevron-right.svg +++ b/src2/icons/chevron-right.svg diff --git a/src/icons/close.svg b/src2/icons/close.svg index 50e0589d..50e0589d 100644 --- a/src/icons/close.svg +++ b/src2/icons/close.svg diff --git a/src/icons/filter.svg b/src2/icons/filter.svg index c15ce7b9..c15ce7b9 100644 --- a/src/icons/filter.svg +++ b/src2/icons/filter.svg diff --git a/src/icons/image-placeholder.svg b/src2/icons/image-placeholder.svg index 935e1097..935e1097 100644 --- a/src/icons/image-placeholder.svg +++ b/src2/icons/image-placeholder.svg diff --git a/src/icons/instagram.svg b/src2/icons/instagram.svg index d90842c6..d90842c6 100644 --- a/src/icons/instagram.svg +++ b/src2/icons/instagram.svg diff --git a/src/icons/linkedin.svg b/src2/icons/linkedin.svg index a68aec96..a68aec96 100644 --- a/src/icons/linkedin.svg +++ b/src2/icons/linkedin.svg diff --git a/src/icons/menu.svg b/src2/icons/menu.svg index 5d067e8e..5d067e8e 100644 --- a/src/icons/menu.svg +++ b/src2/icons/menu.svg diff --git a/src/icons/minus.svg b/src2/icons/minus.svg index 12a10199..12a10199 100644 --- a/src/icons/minus.svg +++ b/src2/icons/minus.svg diff --git a/src/icons/plus.svg b/src2/icons/plus.svg index 2923c684..2923c684 100644 --- a/src/icons/plus.svg +++ b/src2/icons/plus.svg diff --git a/src/icons/search.svg b/src2/icons/search.svg index 6de1cdfa..6de1cdfa 100644 --- a/src/icons/search.svg +++ b/src2/icons/search.svg diff --git a/src/icons/shopping-cart.svg b/src2/icons/shopping-cart.svg index 09f14ca6..09f14ca6 100644 --- a/src/icons/shopping-cart.svg +++ b/src2/icons/shopping-cart.svg diff --git a/src/icons/trash.svg b/src2/icons/trash.svg index e23673ee..e23673ee 100644 --- a/src/icons/trash.svg +++ b/src2/icons/trash.svg diff --git a/src2/images/logo.png b/src2/images/logo.png Binary files differnew file mode 100644 index 00000000..87c696aa --- /dev/null +++ b/src2/images/logo.png diff --git a/src/images/page-not-found.png b/src2/images/page-not-found.png Binary files differindex 296c0443..296c0443 100644 --- a/src/images/page-not-found.png +++ b/src2/images/page-not-found.png diff --git a/src/lib/elements/hooks/useBottomPopup.js b/src2/lib/elements/hooks/useBottomPopup.js index 88b72316..88b72316 100644 --- a/src/lib/elements/hooks/useBottomPopup.js +++ b/src2/lib/elements/hooks/useBottomPopup.js diff --git a/src/lib/elements/hooks/useConfirmAlert.js b/src2/lib/elements/hooks/useConfirmAlert.js index 4975c57d..4975c57d 100644 --- a/src/lib/elements/hooks/useConfirmAlert.js +++ b/src2/lib/elements/hooks/useConfirmAlert.js diff --git a/src/pages/404.js b/src2/pages/404.js index 1e1850f2..1e1850f2 100644 --- a/src/pages/404.js +++ b/src2/pages/404.js diff --git a/src/pages/_app.js b/src2/pages/_app.js index 6a40f4e6..6a40f4e6 100644 --- a/src/pages/_app.js +++ b/src2/pages/_app.js diff --git a/src/pages/_error.js b/src2/pages/_error.js index 107ddf46..107ddf46 100644 --- a/src/pages/_error.js +++ b/src2/pages/_error.js diff --git a/src/pages/activate.js b/src2/pages/activate.js index d9b41bf4..d9b41bf4 100644 --- a/src/pages/activate.js +++ b/src2/pages/activate.js 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: ` + <h1>Permintaan Aktivasi Akun Indoteknik</h1> + <br> + <p>Aktivasi akun anda melalui link berikut: <a href="${process.env.SELF_HOST}/activate?token=${result.token}">Aktivasi Akun</a></p> + ` + }); + } + delete result.user; + delete result.token; + res.status(200).json(result); + } catch (error) { + console.log(error); + res.status(400).json({ error: error.message }); + } +}
\ No newline at end of file diff --git a/src/pages/api/activation.js b/src2/pages/api/activation.js index 8b22af8d..8b22af8d 100644 --- a/src/pages/api/activation.js +++ b/src2/pages/api/activation.js diff --git a/src/pages/api/login.js b/src2/pages/api/login.js index e02a73cb..e02a73cb 100644 --- a/src/pages/api/login.js +++ b/src2/pages/api/login.js diff --git a/src/pages/api/register.js b/src2/pages/api/register.js index 7c8d8b39..7c8d8b39 100644 --- a/src/pages/api/register.js +++ b/src2/pages/api/register.js 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/src/pages/api/token.js b/src2/pages/api/token.js index ec048158..ec048158 100644 --- a/src/pages/api/token.js +++ b/src2/pages/api/token.js diff --git a/src/pages/faqs.js b/src2/pages/faqs.js index cdb8ef52..cdb8ef52 100644 --- a/src/pages/faqs.js +++ b/src2/pages/faqs.js diff --git a/src/pages/index.js b/src2/pages/index.js index 65999ff6..65999ff6 100644 --- a/src/pages/index.js +++ b/src2/pages/index.js diff --git a/src/pages/login.js b/src2/pages/login.js index e80de44e..e80de44e 100644 --- a/src/pages/login.js +++ b/src2/pages/login.js diff --git a/src/pages/logout.js b/src2/pages/logout.js index 8ea21fab..8ea21fab 100644 --- a/src/pages/logout.js +++ b/src2/pages/logout.js diff --git a/src/pages/my/address/[id]/edit.js b/src2/pages/my/address/[id]/edit.js index 838d39e7..838d39e7 100644 --- a/src/pages/my/address/[id]/edit.js +++ b/src2/pages/my/address/[id]/edit.js diff --git a/src/pages/my/address/create.js b/src2/pages/my/address/create.js index 42cd117c..42cd117c 100644 --- a/src/pages/my/address/create.js +++ b/src2/pages/my/address/create.js diff --git a/src/pages/my/address/index.js b/src2/pages/my/address/index.js index 5cad4410..5cad4410 100644 --- a/src/pages/my/address/index.js +++ b/src2/pages/my/address/index.js diff --git a/src/pages/my/invoice/[id].js b/src2/pages/my/invoice/[id].js index 820c9af8..820c9af8 100644 --- a/src/pages/my/invoice/[id].js +++ b/src2/pages/my/invoice/[id].js diff --git a/src/pages/my/invoices.js b/src2/pages/my/invoices.js index 9b2e77dc..9b2e77dc 100644 --- a/src/pages/my/invoices.js +++ b/src2/pages/my/invoices.js diff --git a/src/pages/my/menu.js b/src2/pages/my/menu.js index ae6c2af8..ae6c2af8 100644 --- a/src/pages/my/menu.js +++ b/src2/pages/my/menu.js diff --git a/src/pages/my/profile.js b/src2/pages/my/profile.js index 97891259..97891259 100644 --- a/src/pages/my/profile.js +++ b/src2/pages/my/profile.js diff --git a/src/pages/my/transaction/[id].js b/src2/pages/my/transaction/[id].js index fb806aa4..fb806aa4 100644 --- a/src/pages/my/transaction/[id].js +++ b/src2/pages/my/transaction/[id].js diff --git a/src/pages/my/transactions.js b/src2/pages/my/transactions.js index 8be43af7..8be43af7 100644 --- a/src/pages/my/transactions.js +++ b/src2/pages/my/transactions.js diff --git a/src/pages/my/wishlist.js b/src2/pages/my/wishlist.js index 3d479802..3d479802 100644 --- a/src/pages/my/wishlist.js +++ b/src2/pages/my/wishlist.js diff --git a/src/pages/register.js b/src2/pages/register.js index 39bd137f..39bd137f 100644 --- a/src/pages/register.js +++ b/src2/pages/register.js diff --git a/src/pages/shop/brands/[slug].js b/src2/pages/shop/brands/[slug].js index a387e55d..a387e55d 100644 --- a/src/pages/shop/brands/[slug].js +++ b/src2/pages/shop/brands/[slug].js diff --git a/src/pages/shop/brands/index.js b/src2/pages/shop/brands/index.js index bfdcd403..bfdcd403 100644 --- a/src/pages/shop/brands/index.js +++ b/src2/pages/shop/brands/index.js diff --git a/src/pages/shop/cart.js b/src2/pages/shop/cart.js index 1178781b..1178781b 100644 --- a/src/pages/shop/cart.js +++ b/src2/pages/shop/cart.js diff --git a/src/pages/shop/checkout/finish.js b/src2/pages/shop/checkout/finish.js index df284f8a..df284f8a 100644 --- a/src/pages/shop/checkout/finish.js +++ b/src2/pages/shop/checkout/finish.js diff --git a/src/pages/shop/checkout/index.js b/src2/pages/shop/checkout/index.js index 0a77ebed..0a77ebed 100644 --- a/src/pages/shop/checkout/index.js +++ b/src2/pages/shop/checkout/index.js diff --git a/src/pages/shop/product/[slug].js b/src2/pages/shop/product/[slug].js index 61692c1c..61692c1c 100644 --- a/src/pages/shop/product/[slug].js +++ b/src2/pages/shop/product/[slug].js diff --git a/src/pages/shop/quotation/finish.js b/src2/pages/shop/quotation/finish.js index f7983fef..f7983fef 100644 --- a/src/pages/shop/quotation/finish.js +++ b/src2/pages/shop/quotation/finish.js diff --git a/src/pages/shop/quotation/index.js b/src2/pages/shop/quotation/index.js index e1c196db..e1c196db 100644 --- a/src/pages/shop/quotation/index.js +++ b/src2/pages/shop/quotation/index.js diff --git a/src/pages/shop/search.js b/src2/pages/shop/search.js index 4152bd43..4152bd43 100644 --- a/src/pages/shop/search.js +++ b/src2/pages/shop/search.js diff --git a/src2/styles/globals.css b/src2/styles/globals.css new file mode 100644 index 00000000..b871a325 --- /dev/null +++ b/src2/styles/globals.css @@ -0,0 +1,468 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + -webkit-tap-highlight-color: transparent; +} + +html, body { + @apply + w-screen + text-body-2 + text-gray_r-12 + bg-gray_r-1 + overflow-x-clip + ; +} + +@layer base { + input[type="number"]::-webkit-inner-spin-button, + input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type=number] { + -moz-appearance:textfield; + } + + h1, .h1 { + @apply + text-h-md + font-semibold + ; + } + + h2, .h2 { + @apply + text-body-2 + font-semibold + leading-6 + ; + } + + a { + @apply + font-medium + text-red_r-11 + ; + } +} + +@layer components { + .badge-red, + .badge-solid-red, + .badge-gray, + .badge-yellow, + .badge-blue, + .badge-green, + .badge-solid-green { + @apply + text-caption-2 + leading-none + font-medium + px-1 + py-1 + rounded + w-fit + ; + } + + .badge-red { + @apply + bg-red_r-5 + text-red_r-10 + ; + } + + .badge-solid-red { + @apply + bg-red_r-10 + text-white + ; + } + + .badge-gray { + @apply + bg-gray_r-5 + text-gray_r-10 + ; + } + + .badge-yellow { + @apply + bg-yellow_r-3 + text-yellow_r-11 + ; + } + + .badge-blue { + @apply + bg-blue-200 + text-blue-600 + ; + } + + .badge-green { + @apply + bg-green_r-5 + text-green_r-10 + ; + } + + .badge-solid-green { + @apply + bg-green_r-10 + text-white + ; + } + + .form-label { + @apply + font-medium + block + ; + } + + .form-input { + @apply + p-3 + rounded + border + text-gray_r-12 + border-gray_r-7 + bg-transparent + w-full + leading-none + focus:outline-none + focus:border-yellow_r-9 + disabled:bg-gray_r-5 + ; + } + + .form-input[aria-invalid] { + @apply + border-red_r-10 + focus:border-red_r-10 + ; + } + + .btn-yellow, + .btn-light, + .btn-red, + .btn-solid-red, + .btn-green { + @apply + block + w-fit + py-3 + px-6 + rounded + border + text-center + font-medium + ease-linear + duration-150 + ; + } + + .btn-yellow { + @apply + bg-yellow_r-9 + border-yellow_r-9 + disabled:text-gray_r-10 + disabled:bg-yellow_r-7 + disabled:border-yellow_r-7 + ; + } + + .btn-red { + @apply + bg-red_r-3 + border-red_r-6 + text-red_r-11 + disabled:text-red_r-10 + disabled:bg-red_r-6 + ; + } + + .btn-solid-red { + @apply + bg-red_r-11 + border-red_r-11 + text-gray_r-1 + disabled:text-gray_r-1 + disabled:bg-red_r-8 + disabled:border-red_r-8 + ; + } + + .btn-green { + @apply + bg-green_r-3 + border-green_r-6 + text-green_r-11 + disabled:text-green_r-10 + disabled:bg-green_r-6 + ; + } + + .btn-light { + @apply + bg-gray_r-3 + border-gray_r-6 + disabled:text-gray_r-10 + disabled:bg-gray_r-6 + ; + } + + .product-card { + @apply + w-full + h-full + border + border-gray_r-3 + shadow + bg-white + rounded + relative + flex + flex-col + ; + } + + .product-card__image { + @apply + w-full + h-[160px] + object-contain + object-center + border-b + border-gray_r-6 + ; + } + + .product-card__content { + @apply + p-2 + pb-3 + flex-1 + ; + } + + .product-card__title { + @apply + text-caption-1 + text-gray_r-12 + leading-5 + ; + } + + .product-card__brand { + @apply + text-caption-1 + mb-1 + block + ; + } +} + +@layer utilities { + .wrap-line-ellipsis-1, + .wrap-line-ellipsis-2, + .wrap-line-ellipsis-3 { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .wrap-line-ellipsis-1 { + -webkit-line-clamp: 1; + } + + .wrap-line-ellipsis-2 { + -webkit-line-clamp: 2; + } + + .wrap-line-ellipsis-3 { + -webkit-line-clamp: 3; + } +} + +.menu-wrapper { + @apply + fixed + top-0 + left-0 + bg-white + w-[80%] + h-full + z-[60] + overflow-y-auto + translate-x-[-100%] + ease-linear + duration-150 + ; +} + +.menu-wrapper.active{ + @apply translate-x-0; +} + +.menu-overlay { + @apply + fixed + top-0 + left-0 + w-full + h-full + z-[55] + bg-gray_r-12/40 + ; +} + +.sticky-header { + @apply + px-4 + py-3 + bg-gray_r-1/90 + backdrop-blur-lg + sticky + top-0 + border-b + border-gray_r-7 + z-50 + ; +} + +.content-container { + @apply + max-w-full + overflow-x-hidden + ; +} + +#indoteknik_toast { + @apply + fixed + bottom-4 + translate-y-[200%] + left-[50%] + translate-x-[-50%] + z-[100] + flex + items-center + p-4 + mb-4 + w-[90%] + text-gray-500 + bg-white + border + border-gray-300 + rounded-lg + shadow + ease-linear + duration-300 + ; +} + +#indoteknik_toast.active { + @apply translate-y-0; +} + +.category-menu { + @apply hidden; +} + +.swiper-slide { + @apply !h-auto; +} + +.lazy-load-image-background { + @apply + !block + w-full + ; +} + +.swiper-pagination-bullet-active { + @apply !bg-red_r-11; +} + +.pagination { + @apply + flex + justify-center + gap-x-1 + ; +} + +.pagination-item { + @apply + p-1 + flex + justify-center + items-center + w-10 + rounded + ease-linear + duration-150 + border + border-gray_r-6 + bg-gray_r-3 + hover:bg-gray_r-5 + text-gray_r-12 + ; +} + +.pagination-item--active { + @apply + border-yellow_r-9 + bg-yellow_r-9 + ; +} + +.pagination-dots { + @apply + p-1 + flex + justify-center + items-end + w-10 + rounded + ease-linear + bg-gray_r-3 + text-caption-2 + ; +} + +.idt-transition { + @apply + transition-all + ease-linear + duration-300 + ; +} + +.form-select__placeholder { + @apply + !text-gray_r-9 + ; +} + +.form-select__control { + @apply + !shadow-none + !border-gray_r-7 + ; +} + +.form-select__control--menu-is-open { + @apply + !border-yellow_r-9 + ; +}
\ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 03b05823..e25c9203 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,14 +1,10 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ - "./src/pages/**/*.{js,ts,tsx}", - "./src/components/**/*.{js,ts,tsx}", - "./src/helpers/**/*.{js,ts,tsx}", - ], + content: ['./src/**/*.{js,ts,jsx,tsx}'], theme: { extend: { fontFamily: { - 'sans': ['Inter', 'sans-serif'] + sans: ['Inter', 'sans-serif'] }, fontSize: { 'display-lg': ['56px', '100%'], @@ -25,10 +21,10 @@ module.exports = { 'paragraph-1': ['15px', '140%'], 'paragraph-2': ['14px', '140%'], 'caption-1': ['13px', '121%'], - 'caption-2': ['12px', '121%'], + 'caption-2': ['12px', '121%'] }, colors: { - 'green_r': { + green_r: { 1: '#fbfefc', 2: '#f2fcf5', 3: '#e9f9ee', @@ -40,9 +36,9 @@ module.exports = { 9: '#30a46c', 10: '#299764', 11: '#18794e', - 12: '#153226', + 12: '#153226' }, - 'yellow_r': { + yellow_r: { 1: '#fdfdf9', 2: '#fffce8', 3: '#fffbd1', @@ -54,9 +50,9 @@ module.exports = { 9: '#f5d90a', 10: '#f7ce00', 11: '#946800', - 12: '#35290f', + 12: '#35290f' }, - 'gray_r': { + gray_r: { 1: '#fcfcfc', 2: '#f8f8f8', 3: '#f3f3f3', @@ -68,9 +64,9 @@ module.exports = { 9: '#8f8f8f', 10: '#858585', 11: '#6f6f6f', - 12: '#171717', + 12: '#171717' }, - 'red_r': { + red_r: { 1: '#fffcfc', 2: '#fff8f8', 3: '#ffefef', @@ -82,9 +78,9 @@ module.exports = { 9: '#e5484d', 10: '#dc3d43', 11: '#cd2b31', - 12: '#381316', + 12: '#381316' }, - 'sand_r': { + sand_r: { 1: '#fdfdfc', 2: '#f9f9f8', 3: '#f3f3f2', @@ -96,10 +92,10 @@ module.exports = { 9: '#90908c', 10: '#868682', 11: '#706f6c', - 12: '#1b1b18', + 12: '#1b1b18' } } - }, + } }, - plugins: [], + plugins: [require('@tailwindcss/line-clamp')] } |
