summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorIT Fixcomart <it@fixcomart.co.id>2023-03-01 09:18:52 +0000
committerIT Fixcomart <it@fixcomart.co.id>2023-03-01 09:18:52 +0000
commita7abbf4ddc70068620e9f44b74dc162ce2e16ee2 (patch)
tree74f66253717515d364ce74bd8275015c1f829cbc /src/lib
parent90e1edab9b6a8ccc09a49fed3addbec2cbc4e4c3 (diff)
parenta1b9b647a6c4bda1f5db63879639d44543f9557e (diff)
Merged in refactor (pull request #1)
Refactor
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/address/api/addressApi.js8
-rw-r--r--src/lib/address/api/addressesApi.js10
-rw-r--r--src/lib/address/api/cityApi.js8
-rw-r--r--src/lib/address/api/createAddressApi.js8
-rw-r--r--src/lib/address/api/districtApi.js8
-rw-r--r--src/lib/address/api/editAddressApi.js8
-rw-r--r--src/lib/address/api/subDistrictApi.js8
-rw-r--r--src/lib/address/components/Addresses.jsx70
-rw-r--r--src/lib/address/components/CreateAddress.jsx250
-rw-r--r--src/lib/address/components/EditAddress.jsx252
-rw-r--r--src/lib/address/hooks/useAddresses.js13
-rw-r--r--src/lib/auth/api/editPersonalProfileApi.js10
-rw-r--r--src/lib/auth/api/loginApi.js8
-rw-r--r--src/lib/auth/api/registerApi.js8
-rw-r--r--src/lib/auth/components/Activate.jsx166
-rw-r--r--src/lib/auth/components/CompanyProfile.jsx158
-rw-r--r--src/lib/auth/components/IsAuth.jsx20
-rw-r--r--src/lib/auth/components/Login.jsx125
-rw-r--r--src/lib/auth/components/PersonalProfile.jsx118
-rw-r--r--src/lib/auth/components/Register.jsx151
-rw-r--r--src/lib/brand/api/BrandApi.js8
-rw-r--r--src/lib/brand/components/Brand.jsx70
-rw-r--r--src/lib/brand/components/BrandCard.jsx30
-rw-r--r--src/lib/brand/components/Brands.jsx80
-rw-r--r--src/lib/brand/hooks/useBrand.js13
-rw-r--r--src/lib/cart/api/CartApi.js11
-rw-r--r--src/lib/cart/components/Cart.jsx289
-rw-r--r--src/lib/cart/hooks/useCart.js17
-rw-r--r--src/lib/checkout/api/checkoutApi.js14
-rw-r--r--src/lib/checkout/components/Checkout.jsx323
-rw-r--r--src/lib/checkout/components/FinishCheckout.jsx30
-rw-r--r--src/lib/elements/hooks/useBottomPopup.js40
-rw-r--r--src/lib/elements/hooks/useConfirmAlert.js49
-rw-r--r--src/lib/home/api/categoryHomeApi.js8
-rw-r--r--src/lib/home/api/categoryHomeIdApi.js8
-rw-r--r--src/lib/home/api/heroBannerApi.js8
-rw-r--r--src/lib/home/api/popularProductApi.js10
-rw-r--r--src/lib/home/api/preferredBrandApi.js8
-rw-r--r--src/lib/home/components/CategoryHome.jsx30
-rw-r--r--src/lib/home/components/CategoryHomeId.jsx19
-rw-r--r--src/lib/home/components/HeroBanner.jsx50
-rw-r--r--src/lib/home/components/PopularProduct.jsx24
-rw-r--r--src/lib/home/components/PreferredBrand.jsx30
-rw-r--r--src/lib/home/components/Skeleton/PopularProductSkeleton.jsx10
-rw-r--r--src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx12
-rw-r--r--src/lib/home/hooks/useCategoryHome.js13
-rw-r--r--src/lib/home/hooks/useCategoryHomeId.js13
-rw-r--r--src/lib/home/hooks/useHeroBanner.js13
-rw-r--r--src/lib/home/hooks/usePopularProduct.js13
-rw-r--r--src/lib/home/hooks/usePreferredBrand.js13
-rw-r--r--src/lib/invoice/api/invoiceApi.js10
-rw-r--r--src/lib/invoice/api/invoicesApi.js10
-rw-r--r--src/lib/invoice/components/Invoice.jsx110
-rw-r--r--src/lib/invoice/components/Invoices.jsx177
-rw-r--r--src/lib/invoice/hooks/useInvoice.js13
-rw-r--r--src/lib/invoice/hooks/useInvoices.js15
-rw-r--r--src/lib/invoice/utils/invoices.js11
-rw-r--r--src/lib/product/api/productApi.js9
-rw-r--r--src/lib/product/api/productSearchApi.js9
-rw-r--r--src/lib/product/api/productSimilarApi.js10
-rw-r--r--src/lib/product/components/Product.jsx315
-rw-r--r--src/lib/product/components/ProductCard.jsx76
-rw-r--r--src/lib/product/components/ProductFilter.jsx132
-rw-r--r--src/lib/product/components/ProductSearch.jsx116
-rw-r--r--src/lib/product/components/ProductSimilar.jsx15
-rw-r--r--src/lib/product/components/ProductSlider.jsx64
-rw-r--r--src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx14
-rw-r--r--src/lib/product/hooks/useProductSearch.js15
-rw-r--r--src/lib/product/hooks/useProductSimilar.js13
-rw-r--r--src/lib/quotation/components/Quotation.jsx167
-rw-r--r--src/lib/transaction/api/cancelTransactionApi.js13
-rw-r--r--src/lib/transaction/api/checkoutPoApi.js13
-rw-r--r--src/lib/transaction/api/transactionApi.js10
-rw-r--r--src/lib/transaction/api/transactionsApi.js13
-rw-r--r--src/lib/transaction/api/uploadPoApi.js14
-rw-r--r--src/lib/transaction/components/Transaction.jsx352
-rw-r--r--src/lib/transaction/components/TransactionStatusBadge.jsx41
-rw-r--r--src/lib/transaction/components/Transactions.jsx216
-rw-r--r--src/lib/transaction/hooks/useTransaction.js13
-rw-r--r--src/lib/transaction/hooks/useTransactions.js15
-rw-r--r--src/lib/transaction/utils/transactions.js15
-rw-r--r--src/lib/variant/components/VariantCard.jsx95
-rw-r--r--src/lib/variant/components/VariantGroupCard.jsx30
-rw-r--r--src/lib/wishlist/api/createOrDeleteWishlistApi.js14
-rw-r--r--src/lib/wishlist/api/wishlistApi.js14
-rw-r--r--src/lib/wishlist/api/wishlistsApi.js13
-rw-r--r--src/lib/wishlist/components/Wishlists.jsx55
-rw-r--r--src/lib/wishlist/hooks/useWishlist.js13
-rw-r--r--src/lib/wishlist/hooks/useWishlists.js14
89 files changed, 4838 insertions, 89 deletions
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'>
+ &nbsp;
+ {selectedProduct().length > 0
+ ? currencyFormat(totalPriceBeforeTax - totalDiscountAmount + totalTaxAmount)
+ : '-'}
+ </span>
+ </div>
+ </div>
+ <div className='flex gap-x-3'>
+ <button
+ type='button'
+ className='btn-yellow flex-1'
+ disabled={selectedProduct().length == 0}
+ onClick={() => router.push('/shop/quotation')}
+ >
+ Quotation
+ </button>
+ <button
+ type='button'
+ className='btn-solid-red flex-1'
+ disabled={selectedProduct().length == 0}
+ onClick={() => router.push('/shop/checkout')}
+ >
+ Checkout
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <BottomPopup
+ active={deleteConfirmation}
+ close={() => setDeleteConfirmation(null)}
+ title='Hapus dari Keranjang'
+ >
+ <div className='leading-7 text-gray_r-12/80'>
+ Apakah anda yakin menghapus barang{' '}
+ <span className='underline'>{deleteConfirmation?.name}</span> dari keranjang?
+ </div>
+ <div className='flex mt-6 gap-x-4'>
+ <button
+ className='btn-solid-red flex-1'
+ type='button'
+ onClick={() => deleteProduct(deleteConfirmation?.id)}
+ >
+ Ya, Hapus
+ </button>
+ <button
+ className='btn-light flex-1'
+ type='button'
+ onClick={() => setDeleteConfirmation(null)}
+ >
+ Batal
+ </button>
+ </div>
+ </BottomPopup>
+ </div>
+ )
+}
+
+export default Cart
diff --git a/src/lib/cart/hooks/useCart.js b/src/lib/cart/hooks/useCart.js
new file mode 100644
index 00000000..bc1ea7ea
--- /dev/null
+++ b/src/lib/cart/hooks/useCart.js
@@ -0,0 +1,17 @@
+import { getCart } from '@/core/utils/cart'
+import { useQuery } from 'react-query'
+import _ from 'lodash'
+import CartApi from '../api/CartApi'
+
+const useCart = ({ enabled }) => {
+ const cart = getCart()
+ const variantIds = _.keys(cart).join(',')
+ const fetchCart = async () => CartApi({ variantIds })
+ const { data, isLoading } = useQuery('cart', fetchCart, { enabled })
+
+ return {
+ cart: { data, isLoading }
+ }
+}
+
+export default useCart
diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js
new file mode 100644
index 00000000..b76c9b7f
--- /dev/null
+++ b/src/lib/checkout/api/checkoutApi.js
@@ -0,0 +1,14 @@
+import odooApi from '@/core/api/odooApi'
+import { getAuth } from '@/core/utils/auth'
+
+const checkoutApi = async ({ data }) => {
+ const auth = getAuth()
+ const dataCheckout = await odooApi(
+ 'POST',
+ `/api/v1/partner/${auth.partnerId}/sale_order/checkout`,
+ data
+ )
+ return dataCheckout
+}
+
+export default checkoutApi
diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx
new file mode 100644
index 00000000..f6170b13
--- /dev/null
+++ b/src/lib/checkout/components/Checkout.jsx
@@ -0,0 +1,323 @@
+import Alert from '@/core/components/elements/Alert/Alert'
+import Divider from '@/core/components/elements/Divider/Divider'
+import Link from '@/core/components/elements/Link/Link'
+import useAuth from '@/core/hooks/useAuth'
+import { getItemAddress } from '@/core/utils/address'
+import addressesApi from '@/lib/address/api/addressesApi'
+import CartApi from '@/lib/cart/api/CartApi'
+import VariantCard from '@/lib/variant/components/VariantCard'
+import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
+import { useEffect, useRef, useState } from 'react'
+import _ from 'lodash'
+import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart'
+import currencyFormat from '@/core/utils/currencyFormat'
+import { toast } from 'react-hot-toast'
+import getFileBase64 from '@/core/utils/getFileBase64'
+import checkoutApi from '../api/checkoutApi'
+import { useRouter } from 'next/router'
+import VariantGroupCard from '@/lib/variant/components/VariantGroupCard'
+
+const Checkout = () => {
+ const router = useRouter()
+ const auth = useAuth()
+ const [selectedAddress, setSelectedAddress] = useState({
+ shipping: null,
+ invoicing: null
+ })
+ const [addresses, setAddresses] = useState(null)
+
+ useEffect(() => {
+ if (!auth) return
+
+ const getAddresses = async () => {
+ const dataAddresses = await addressesApi()
+ setAddresses(dataAddresses)
+ }
+
+ getAddresses()
+ }, [auth])
+
+ useEffect(() => {
+ if (!addresses) return
+
+ const matchAddress = (key) => {
+ const addressToMatch = getItemAddress(key)
+ const foundAddress = addresses.filter((address) => address.id == addressToMatch)
+ if (foundAddress.length > 0) {
+ return foundAddress[0]
+ }
+ return addresses[0]
+ }
+
+ setSelectedAddress({
+ shipping: matchAddress('shipping'),
+ invoicing: matchAddress('invoicing')
+ })
+ }, [addresses])
+
+ const [products, setProducts] = useState(null)
+ const [totalAmount, setTotalAmount] = useState(0)
+ const [totalDiscountAmount, setTotalDiscountAmount] = useState(0)
+
+ useEffect(() => {
+ const loadProducts = async () => {
+ const cart = getCart()
+ const variantIds = _.filter(cart, (o) => o.selected == true)
+ .map((o) => o.productId)
+ .join(',')
+ const dataProducts = await CartApi({ variantIds })
+ const dataProductsQuantity = _.map(dataProducts, (o) => ({
+ ...o,
+ quantity: getItemCart({ productId: o.id }).quantity
+ }))
+ setProducts(dataProductsQuantity)
+ }
+ loadProducts()
+ }, [])
+
+ useEffect(() => {
+ if (products) {
+ let calculateTotalAmount = 0
+ let calculateTotalDiscountAmount = 0
+ products.forEach((product) => {
+ calculateTotalAmount += product.price.price * product.quantity
+ calculateTotalDiscountAmount +=
+ (product.price.price - product.price.priceDiscount) * product.quantity
+ })
+ setTotalAmount(calculateTotalAmount)
+ setTotalDiscountAmount(calculateTotalDiscountAmount)
+ }
+ }, [products])
+
+ const [selectedPayment, setSelectedPayment] = useState(null)
+
+ const poNumber = useRef('')
+ const poFile = useRef('')
+
+ const [isLoading, setIsLoading] = useState(false)
+
+ const checkout = async () => {
+ if (!selectedPayment) {
+ toast.error('Pilih metode pembayaran', { position: 'bottom-center' })
+ return
+ }
+ const file = poFile.current.files[0]
+ if (typeof file !== 'undefined' && file.size > 5000000) {
+ toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' })
+ return
+ }
+ setIsLoading(true)
+ const productOrder = products.map((product) => ({
+ product_id: product.id,
+ quantity: product.quantity
+ }))
+ let data = {
+ partner_shipping_id: selectedAddress.shipping.id,
+ partner_invoice_id: selectedAddress.invoicing.id,
+ order_line: JSON.stringify(productOrder),
+ type: 'sale_order'
+ }
+ if (poNumber.current.value) data.po_number = poNumber.current.value
+ if (typeof file !== 'undefined') data.po_file = await getFileBase64(file)
+
+ const isCheckouted = await checkoutApi({ data })
+ setIsLoading(false)
+ if (isCheckouted?.id) {
+ for (const product of products) deleteItemCart({ productId: product.id })
+ router.push(`/shop/checkout/finish?id=${isCheckouted.id}`)
+ return
+ }
+ toast.error('Gagal melakukan transaksi, terjadi kesalahan internal')
+ }
+
+ return (
+ <>
+ <div className='p-4'>
+ <Alert
+ type='info'
+ className='text-caption-2 flex gap-x-3'
+ >
+ <div>
+ <ExclamationCircleIcon className='w-7 text-blue-700' />
+ </div>
+ <span className='leading-5'>
+ Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami
+ disini
+ </span>
+ </Alert>
+ </div>
+
+ <Divider />
+
+ <SectionAddress
+ label='Alamat Pengiriman'
+ url='/my/address?select=shipping'
+ address={selectedAddress.shipping}
+ />
+
+ <Divider />
+
+ <div className='p-4 flex flex-col gap-y-4'>
+ {products && (
+ <VariantGroupCard
+ openOnClick={false}
+ variants={products}
+ />
+ )}
+ </div>
+
+ <Divider />
+
+ <div className='p-4'>
+ <div className='flex justify-between items-center'>
+ <div className='font-medium'>Ringkasan Pesanan</div>
+ <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div>
+ </div>
+ <hr className='my-4 border-gray_r-6' />
+ <div className='flex flex-col gap-y-4'>
+ <div className='flex gap-x-2 justify-between'>
+ <div className='text-gray_r-11'>Total Belanja</div>
+ <div>{currencyFormat(totalAmount)}</div>
+ </div>
+ <div className='flex gap-x-2 justify-between'>
+ <div className='text-gray_r-11'>Total Diskon</div>
+ <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div>
+ </div>
+ <div className='flex gap-x-2 justify-between'>
+ <div className='text-gray_r-11'>Subtotal</div>
+ <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div>
+ </div>
+ <div className='flex gap-x-2 justify-between'>
+ <div className='text-gray_r-11'>PPN 11% (Incl.)</div>
+ <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div>
+ </div>
+ </div>
+ <hr className='my-4 border-gray_r-6' />
+ <div className='flex gap-x-2 justify-between mb-4'>
+ <div>Grand Total</div>
+ <div className='font-semibold text-gray_r-12'>
+ {currencyFormat(totalAmount - totalDiscountAmount)}
+ </div>
+ </div>
+ <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p>
+ <p className='text-caption-2 text-gray_r-10 leading-5'>
+ Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '}
+ <Link
+ href='/'
+ className='inline font-normal'
+ >
+ Syarat & Ketentuan
+ </Link>{' '}
+ yang berlaku
+ </p>
+ </div>
+
+ <Divider />
+
+ <SectionAddress
+ label='Alamat Penagihan'
+ url='/my/address?select=invoicing'
+ address={selectedAddress.invoicing}
+ />
+
+ <Divider />
+
+ <div className='p-4'>
+ <div className='font-medium'>
+ Metode Pembayaran <span className='font-normal text-gray_r-11'>(Wajib dipilih)</span>
+ </div>
+ <div className='grid gap-y-3 mt-4'>
+ {payments.map((payment, index) => (
+ <button
+ type='button'
+ className={
+ 'text-left border border-gray_r-6 rounded-md p-3 ' +
+ (selectedPayment == payment.name && 'border-yellow_r-10 bg-yellow_r-3')
+ }
+ onClick={() => setSelectedPayment(payment.name)}
+ key={index}
+ >
+ <p>
+ {payment.name} - {payment.number}
+ </p>
+ <p className='mt-1 text-gray_r-11'>PT. Indoteknik Dotcom Gemilang</p>
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <Divider />
+
+ <div className='p-4'>
+ <div className='font-medium'>Purchase Order</div>
+
+ <div className='mt-4 flex gap-x-3'>
+ <div className='w-6/12'>
+ <label className='form-label font-normal'>Dokumen PO</label>
+ <input
+ type='file'
+ className='form-input mt-2 h-12'
+ accept='image/*,application/pdf'
+ ref={poFile}
+ />
+ </div>
+ <div className='w-6/12'>
+ <label className='form-label font-normal'>Nomor PO</label>
+ <input
+ type='text'
+ className='form-input mt-2 h-12'
+ ref={poNumber}
+ />
+ </div>
+ </div>
+ <p className='text-caption-2 text-gray_r-11 mt-2'>Ukuran dokumen PO Maksimal 5MB</p>
+ </div>
+
+ <Divider />
+
+ <div className='flex gap-x-3 p-4'>
+ <button
+ className='flex-1 btn-yellow'
+ onClick={checkout}
+ disabled={isLoading}
+ >
+ {isLoading ? 'Loading...' : 'Bayar'}
+ </button>
+ </div>
+ </>
+ )
+}
+
+const payments = [
+ { name: 'BCA', number: '8870-4000-81' },
+ { name: 'MANDIRI', number: '155-0067-6869-75' }
+]
+
+const SectionAddress = ({ address, label, url }) => (
+ <div className='p-4'>
+ <div className='flex justify-between items-center'>
+ <div className='font-medium'>{label}</div>
+ <Link
+ className='text-caption-1'
+ href={url}
+ >
+ Pilih Alamat Lain
+ </Link>
+ </div>
+
+ {address && (
+ <div className='mt-4 text-caption-1'>
+ <div className='badge-red mb-2'>
+ {address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address'}
+ </div>
+ <p className='font-medium'>{address.name}</p>
+ <p className='mt-2 text-gray_r-11'>{address.mobile}</p>
+ <p className='mt-1 text-gray_r-11'>
+ {address.street}, {address?.city?.name}
+ </p>
+ </div>
+ )}
+ </div>
+)
+
+export default Checkout
diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx
new file mode 100644
index 00000000..a7d65dd0
--- /dev/null
+++ b/src/lib/checkout/components/FinishCheckout.jsx
@@ -0,0 +1,30 @@
+import Link from '@/core/components/elements/Link/Link'
+import useTransaction from '@/lib/transaction/hooks/useTransaction'
+
+const FinishCheckout = ({ id }) => {
+ const { transaction } = useTransaction({ id })
+
+ return (
+ <div className='p-4'>
+ <div className='rounded-xl bg-yellow_r-4 text-center border border-yellow_r-7'>
+ <div className='px-4 py-6 text-yellow_r-12'>
+ <p className='font-semibold mb-2'>Terima Kasih atas Pembelian Anda</p>
+ <p className='text-yellow_r-11 mb-4 leading-6'>
+ Rincian belanja sudah kami kirimkan ke email anda. Mohon dicek kembali. jika tidak
+ menerima email, anda dapat menghubungi kami disini.
+ </p>
+ <p className='mb-2 font-medium'>{transaction.data?.name}</p>
+ <p className='text-caption-2 text-yellow_r-11'>No. Transaksi</p>
+ </div>
+ <Link
+ href={transaction.data?.id ? `/my/transaction/${transaction.data.id}` : '/'}
+ className='bg-yellow_r-6 text-yellow_r-12 rounded-b-xl py-4 block'
+ >
+ Lihat detail pembelian Anda disini
+ </Link>
+ </div>
+ </div>
+ )
+}
+
+export default FinishCheckout
diff --git a/src/lib/elements/hooks/useBottomPopup.js b/src/lib/elements/hooks/useBottomPopup.js
deleted file mode 100644
index 88b72316..00000000
--- a/src/lib/elements/hooks/useBottomPopup.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useState } from "react";
-import dynamic from "next/dynamic";
-
-const DynamicBottomPopup = dynamic(() => import('@/components/elements/BottomPopup'));
-
-const useBottomPopup = ({
- title,
- children
-}) => {
- const [ isOpen, setIsOpen ] = useState(false);
- const [ dataPopup, setDataPopup ] = useState(null);
-
- const closePopup = () => {
- setIsOpen(false);
- setDataPopup(null);
- };
- const openPopup = ( data = null ) => {
- setIsOpen(true);
- setDataPopup(data);
- };
-
- const BottomPopup = (
- <DynamicBottomPopup
- title={title}
- active={isOpen}
- closePopup={closePopup}
- >
- { children(dataPopup) }
- </DynamicBottomPopup>
- );
-
- return {
- dataPopup,
- BottomPopup,
- closePopup,
- openPopup
- }
-}
-
-export default useBottomPopup; \ No newline at end of file
diff --git a/src/lib/elements/hooks/useConfirmAlert.js b/src/lib/elements/hooks/useConfirmAlert.js
deleted file mode 100644
index 4975c57d..00000000
--- a/src/lib/elements/hooks/useConfirmAlert.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useState } from "react";
-import dynamic from "next/dynamic";
-
-const DynamicConfirmAlert = dynamic(() => import('@/components/elements/ConfirmAlert'));
-
-const useConfirmAlert = ({
- title,
- caption,
- closeText,
- submitText,
- onSubmit,
-}) => {
- const [ isOpen, setIsOpen ] = useState(false);
- const [ data, setData ] = useState(null);
-
- const closeConfirmAlert = () => {
- setIsOpen(false);
- setData(null);
- };
- const openConfirmAlert = ( data = null ) => {
- setIsOpen(true);
- setData(data);
- };
- const handleSubmit = async () => {
- await onSubmit(data);
- closeConfirmAlert();
- };
-
- const ConfirmAlert = (
- <DynamicConfirmAlert
- title={title}
- caption={caption}
- closeText={closeText}
- submitText={submitText}
- onClose={closeConfirmAlert}
- onSubmit={handleSubmit}
- show={isOpen}
- />
- );
-
- return {
- isOpen,
- closeConfirmAlert,
- openConfirmAlert,
- ConfirmAlert
- };
-}
-
-export default useConfirmAlert; \ No newline at end of file
diff --git a/src/lib/home/api/categoryHomeApi.js b/src/lib/home/api/categoryHomeApi.js
new file mode 100644
index 00000000..81909d7b
--- /dev/null
+++ b/src/lib/home/api/categoryHomeApi.js
@@ -0,0 +1,8 @@
+import odooApi from '@/core/api/odooApi'
+
+const categoryHomeIdApi = async ({ id }) => {
+ const dataCategoryHomeId = await odooApi('GET', `/api/v1/categories_homepage?id=${id}`)
+ return dataCategoryHomeId
+}
+
+export default categoryHomeIdApi
diff --git a/src/lib/home/api/categoryHomeIdApi.js b/src/lib/home/api/categoryHomeIdApi.js
new file mode 100644
index 00000000..6b820fd3
--- /dev/null
+++ b/src/lib/home/api/categoryHomeIdApi.js
@@ -0,0 +1,8 @@
+import odooApi from '@/core/api/odooApi'
+
+const categoryHomeIdApi = async () => {
+ const dataCategoryHomeIds = await odooApi('GET', '/api/v1/categories_homepage/ids')
+ return dataCategoryHomeIds
+}
+
+export default categoryHomeIdApi
diff --git a/src/lib/home/api/heroBannerApi.js b/src/lib/home/api/heroBannerApi.js
new file mode 100644
index 00000000..60a0702a
--- /dev/null
+++ b/src/lib/home/api/heroBannerApi.js
@@ -0,0 +1,8 @@
+import odooApi from '@/core/api/odooApi'
+
+const heroBannerApi = async () => {
+ const dataHeroBanners = await odooApi('GET', '/api/v1/banner?type=index-a-1')
+ return dataHeroBanners
+}
+
+export default heroBannerApi
diff --git a/src/lib/home/api/popularProductApi.js b/src/lib/home/api/popularProductApi.js
new file mode 100644
index 00000000..5a6d3212
--- /dev/null
+++ b/src/lib/home/api/popularProductApi.js
@@ -0,0 +1,10 @@
+import axios from 'axios'
+
+const popularProductApi = async () => {
+ const dataPopularProducts = await axios(
+ `${process.env.SELF_HOST}/api/shop/search?q=*&page=1&orderBy=popular`
+ )
+ return dataPopularProducts.data.response
+}
+
+export default popularProductApi
diff --git a/src/lib/home/api/preferredBrandApi.js b/src/lib/home/api/preferredBrandApi.js
new file mode 100644
index 00000000..0e3200e0
--- /dev/null
+++ b/src/lib/home/api/preferredBrandApi.js
@@ -0,0 +1,8 @@
+import odooApi from '@/core/api/odooApi'
+
+const preferredBrandApi = async () => {
+ const dataPreferredBrands = await odooApi('GET', '/api/v1/manufacture?level=prioritas')
+ return dataPreferredBrands
+}
+
+export default preferredBrandApi
diff --git a/src/lib/home/components/CategoryHome.jsx b/src/lib/home/components/CategoryHome.jsx
new file mode 100644
index 00000000..ac43e2bc
--- /dev/null
+++ b/src/lib/home/components/CategoryHome.jsx
@@ -0,0 +1,30 @@
+import ProductSlider from '@/lib/product/components/ProductSlider'
+import useCategoryHome from '../hooks/useCategoryHome'
+import PopularProductSkeleton from './Skeleton/PopularProductSkeleton'
+
+const CategoryHome = ({ id }) => {
+ const { categoryHome } = useCategoryHome({ id })
+
+ return (
+ <div className='p-4 relative bg-yellow_r-2'>
+ {categoryHome.data ? (
+ <ProductSlider
+ products={{
+ products: categoryHome.data?.[0].products,
+ banner: {
+ image: categoryHome.data?.[0].image,
+ name: categoryHome.data?.[0].name,
+ url: `/shop/search?category=${categoryHome.data?.[0].name}`
+ }
+ }}
+ simpleTitle
+ bannerMode
+ />
+ ) : (
+ <PopularProductSkeleton />
+ )}
+ </div>
+ )
+}
+
+export default CategoryHome
diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx
new file mode 100644
index 00000000..c37a6af7
--- /dev/null
+++ b/src/lib/home/components/CategoryHomeId.jsx
@@ -0,0 +1,19 @@
+import { LazyLoadComponent } from 'react-lazy-load-image-component'
+import useCategoryHomeId from '../hooks/useCategoryHomeId'
+import CategoryHome from './CategoryHome'
+
+const CategoryHomeId = () => {
+ const { categoryHomeIds } = useCategoryHomeId()
+
+ return (
+ <div className='flex flex-col gap-y-6'>
+ {categoryHomeIds.data?.map((id) => (
+ <LazyLoadComponent key={id}>
+ <CategoryHome id={id} />
+ </LazyLoadComponent>
+ ))}
+ </div>
+ )
+}
+
+export default CategoryHomeId
diff --git a/src/lib/home/components/HeroBanner.jsx b/src/lib/home/components/HeroBanner.jsx
new file mode 100644
index 00000000..0ac14bad
--- /dev/null
+++ b/src/lib/home/components/HeroBanner.jsx
@@ -0,0 +1,50 @@
+import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton'
+import useHeroBanner from '../hooks/useHeroBanner'
+import Image from '@/core/components/elements/Image/Image'
+
+// Swiper
+import { Swiper, SwiperSlide } from 'swiper/react'
+import { Pagination, Autoplay } from 'swiper'
+import 'swiper/css'
+import 'swiper/css/pagination'
+import 'swiper/css/autoplay'
+
+const swiperBanner = {
+ pagination: { dynamicBullets: true },
+ autoplay: {
+ delay: 6000,
+ disableOnInteraction: false
+ },
+ modules: [Pagination, Autoplay]
+}
+
+const HeroBanner = () => {
+ const { heroBanners } = useHeroBanner()
+
+ return (
+ <div className='min-h-[200px]'>
+ {heroBanners.isLoading && <ImageSkeleton />}
+ {!heroBanners.isLoading && (
+ <Swiper
+ slidesPerView={1}
+ pagination={swiperBanner.pagination}
+ modules={swiperBanner.modules}
+ autoplay={swiperBanner.autoplay}
+ className='border-b border-gray_r-6'
+ >
+ {heroBanners.data?.map((banner, index) => (
+ <SwiperSlide key={index}>
+ <Image
+ src={banner.image}
+ alt={banner.name}
+ className='w-full h-auto'
+ />
+ </SwiperSlide>
+ ))}
+ </Swiper>
+ )}
+ </div>
+ )
+}
+
+export default HeroBanner
diff --git a/src/lib/home/components/PopularProduct.jsx b/src/lib/home/components/PopularProduct.jsx
new file mode 100644
index 00000000..d23275f7
--- /dev/null
+++ b/src/lib/home/components/PopularProduct.jsx
@@ -0,0 +1,24 @@
+import { Swiper, SwiperSlide } from 'swiper/react'
+import usePopularProduct from '../hooks/usePopularProduct'
+import ProductCard from '@/lib/product/components/ProductCard'
+import PopularProductSkeleton from './Skeleton/PopularProductSkeleton'
+import ProductSlider from '@/lib/product/components/ProductSlider'
+
+const PopularProduct = () => {
+ const { popularProducts } = usePopularProduct()
+
+ return (
+ <div className='px-4'>
+ <div className='font-medium mb-4'>Produk Populer</div>
+ {popularProducts.isLoading && <PopularProductSkeleton />}
+ {!popularProducts.isLoading && (
+ <ProductSlider
+ products={popularProducts.data}
+ simpleTitle
+ />
+ )}
+ </div>
+ )
+}
+
+export default PopularProduct
diff --git a/src/lib/home/components/PreferredBrand.jsx b/src/lib/home/components/PreferredBrand.jsx
new file mode 100644
index 00000000..3df3cdb7
--- /dev/null
+++ b/src/lib/home/components/PreferredBrand.jsx
@@ -0,0 +1,30 @@
+import { Swiper, SwiperSlide } from 'swiper/react'
+import usePreferredBrand from '../hooks/usePreferredBrand'
+import PreferredBrandSkeleton from './Skeleton/PreferredBrandSkeleton'
+import BrandCard from '@/lib/brand/components/BrandCard'
+
+const PreferredBrand = () => {
+ const { preferredBrands } = usePreferredBrand()
+
+ return (
+ <div className='px-4'>
+ <div className='font-medium mb-4'>Brand Pilihan</div>
+ {preferredBrands.isLoading && <PreferredBrandSkeleton />}
+ {!preferredBrands.isLoading && (
+ <Swiper
+ slidesPerView={3.5}
+ spaceBetween={12}
+ freeMode
+ >
+ {preferredBrands.data?.manufactures.map((brand) => (
+ <SwiperSlide key={brand.id}>
+ <BrandCard brand={brand} />
+ </SwiperSlide>
+ ))}
+ </Swiper>
+ )}
+ </div>
+ )
+}
+
+export default PreferredBrand
diff --git a/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx
new file mode 100644
index 00000000..18a1b3d3
--- /dev/null
+++ b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx
@@ -0,0 +1,10 @@
+import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton'
+
+const PopularProductSkeleton = () => (
+ <div className='grid grid-cols-2 gap-x-3'>
+ <ProductCardSkeleton />
+ <ProductCardSkeleton />
+ </div>
+)
+
+export default PopularProductSkeleton
diff --git a/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx
new file mode 100644
index 00000000..00589342
--- /dev/null
+++ b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx
@@ -0,0 +1,12 @@
+import BrandSkeleton from '@/core/components/elements/Skeleton/BrandSkeleton'
+
+const PreferredBrandSkeleton = () => (
+ <div className='grid grid-cols-4 gap-x-3'>
+ <BrandSkeleton />
+ <BrandSkeleton />
+ <BrandSkeleton />
+ <BrandSkeleton />
+ </div>
+)
+
+export default PreferredBrandSkeleton
diff --git a/src/lib/home/hooks/useCategoryHome.js b/src/lib/home/hooks/useCategoryHome.js
new file mode 100644
index 00000000..cfaa3d9c
--- /dev/null
+++ b/src/lib/home/hooks/useCategoryHome.js
@@ -0,0 +1,13 @@
+import categoryHomeApi from '../api/categoryHomeApi'
+import { useQuery } from 'react-query'
+
+const useCategoryHome = ({ id }) => {
+ const fetchCategoryHome = async () => await categoryHomeApi({ id })
+ const { isLoading, data } = useQuery(`categoryHome-${id}`, fetchCategoryHome)
+
+ return {
+ categoryHome: { data, isLoading }
+ }
+}
+
+export default useCategoryHome
diff --git a/src/lib/home/hooks/useCategoryHomeId.js b/src/lib/home/hooks/useCategoryHomeId.js
new file mode 100644
index 00000000..c6953db7
--- /dev/null
+++ b/src/lib/home/hooks/useCategoryHomeId.js
@@ -0,0 +1,13 @@
+import categoryHomeIdApi from '../api/categoryHomeIdApi'
+import { useQuery } from 'react-query'
+
+const useCategoryHomeId = () => {
+ const fetchCategoryHomeId = async () => await categoryHomeIdApi()
+ const { isLoading, data } = useQuery('categoryHomeId', fetchCategoryHomeId)
+
+ return {
+ categoryHomeIds: { data, isLoading }
+ }
+}
+
+export default useCategoryHomeId
diff --git a/src/lib/home/hooks/useHeroBanner.js b/src/lib/home/hooks/useHeroBanner.js
new file mode 100644
index 00000000..5d2b0512
--- /dev/null
+++ b/src/lib/home/hooks/useHeroBanner.js
@@ -0,0 +1,13 @@
+import heroBannerApi from '../api/heroBannerApi'
+import { useQuery } from 'react-query'
+
+const useHeroBanner = () => {
+ const fetchHeroBanner = async () => await heroBannerApi()
+ const { isLoading, data } = useQuery('heroBanner', fetchHeroBanner)
+
+ return {
+ heroBanners: { data, isLoading }
+ }
+}
+
+export default useHeroBanner
diff --git a/src/lib/home/hooks/usePopularProduct.js b/src/lib/home/hooks/usePopularProduct.js
new file mode 100644
index 00000000..d0c34bb0
--- /dev/null
+++ b/src/lib/home/hooks/usePopularProduct.js
@@ -0,0 +1,13 @@
+import popularProductApi from '../api/popularProductApi'
+import { useQuery } from 'react-query'
+
+const usePopularProduct = () => {
+ const fetchPopularProduct = async () => await popularProductApi()
+ const { data, isLoading } = useQuery('popularProduct', fetchPopularProduct)
+
+ return {
+ popularProducts: { data, isLoading }
+ }
+}
+
+export default usePopularProduct
diff --git a/src/lib/home/hooks/usePreferredBrand.js b/src/lib/home/hooks/usePreferredBrand.js
new file mode 100644
index 00000000..e56d361f
--- /dev/null
+++ b/src/lib/home/hooks/usePreferredBrand.js
@@ -0,0 +1,13 @@
+import preferredBrandApi from '../api/preferredBrandApi'
+import { useQuery } from 'react-query'
+
+const usePreferredBrand = () => {
+ const fetchPreferredBrand = async () => await preferredBrandApi()
+ const { data, isLoading } = useQuery('preferredBrand', fetchPreferredBrand)
+
+ return {
+ preferredBrands: { data, isLoading }
+ }
+}
+
+export default usePreferredBrand
diff --git a/src/lib/invoice/api/invoiceApi.js b/src/lib/invoice/api/invoiceApi.js
new file mode 100644
index 00000000..056df6c6
--- /dev/null
+++ b/src/lib/invoice/api/invoiceApi.js
@@ -0,0 +1,10 @@
+import odooApi from '@/core/api/odooApi'
+import { getAuth } from '@/core/utils/auth'
+
+const invoiceApi = async ({ id }) => {
+ const auth = getAuth()
+ const dataInvoice = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice/${id}`)
+ return dataInvoice
+}
+
+export default invoiceApi
diff --git a/src/lib/invoice/api/invoicesApi.js b/src/lib/invoice/api/invoicesApi.js
new file mode 100644
index 00000000..622fe6ee
--- /dev/null
+++ b/src/lib/invoice/api/invoicesApi.js
@@ -0,0 +1,10 @@
+import odooApi from '@/core/api/odooApi'
+import { getAuth } from '@/core/utils/auth'
+
+const invoicesApi = async ({ query }) => {
+ const auth = getAuth()
+ const dataInvoices = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice?${query}`)
+ return dataInvoices
+}
+
+export default invoicesApi
diff --git a/src/lib/invoice/components/Invoice.jsx b/src/lib/invoice/components/Invoice.jsx
new file mode 100644
index 00000000..e34ad8c2
--- /dev/null
+++ b/src/lib/invoice/components/Invoice.jsx
@@ -0,0 +1,110 @@
+import Spinner from '@/core/components/elements/Spinner/Spinner'
+import useInvoice from '../hooks/useInvoice'
+import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices'
+import Divider from '@/core/components/elements/Divider/Divider'
+import VariantGroupCard from '@/lib/variant/components/VariantGroupCard'
+import currencyFormat from '@/core/utils/currencyFormat'
+
+const Invoice = ({ id }) => {
+ const { invoice } = useInvoice({ id })
+
+ if (invoice.isLoading) {
+ return (
+ <div className='flex justify-center my-6'>
+ <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' />
+ </div>
+ )
+ }
+
+ const address = invoice.data?.customer
+ let fullAddress = []
+ if (address?.street) fullAddress.push(address.street)
+ if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name)
+ if (address?.district?.name) fullAddress.push(address.district.name)
+ if (address?.city?.name) fullAddress.push(address.city.name)
+ fullAddress = fullAddress.join(', ')
+
+ return (
+ invoice.data?.name && (
+ <>
+ <div className='flex flex-col gap-y-4 p-4'>
+ <DescriptionRow label='No Invoice'>{invoice.data?.name}</DescriptionRow>
+ <DescriptionRow label='Status Transaksi'>
+ {invoice.data?.amountResidual > 0 ? (
+ <span className='badge-solid-red'>Belum Lunas</span>
+ ) : (
+ <span className='badge-solid-green'>Lunas</span>
+ )}
+ </DescriptionRow>
+ <DescriptionRow label='Purchase Order'>
+ {invoice.data?.purchaseOrderName || '-'}
+ </DescriptionRow>
+ <DescriptionRow label='Ketentuan Pembayaran'>{invoice.data?.paymentTerm}</DescriptionRow>
+ {invoice.data?.amountResidual > 0 && invoice.invoiceDate != invoice.invoiceDateDue && (
+ <DescriptionRow label='Tanggal Jatuh Tempo'>
+ {invoice.data?.invoiceDateDue}
+ </DescriptionRow>
+ )}
+ <DescriptionRow label='Nama Sales'>{invoice.data?.sales}</DescriptionRow>
+ <DescriptionRow label='Tanggal Invoice'>{invoice.data?.invoiceDate}</DescriptionRow>
+ <div className='flex items-center'>
+ <p className='text-gray_r-11 leading-none'>Invoice</p>
+ <button
+ type='button'
+ className='btn-light py-1.5 px-3 ml-auto'
+ onClick={() => downloadInvoice(invoice.data)}
+ >
+ Download
+ </button>
+ </div>
+ <div className='flex items-center'>
+ <p className='text-gray_r-11 leading-none'>Faktur Pajak</p>
+ <button
+ type='button'
+ className='btn-light py-1.5 px-3 ml-auto'
+ onClick={() => downloadTaxInvoice(invoice.data)}
+ disabled={!invoice.data?.efaktur}
+ >
+ Download
+ </button>
+ </div>
+ </div>
+
+ <Divider />
+
+ <div className='p-4 font-medium'>Detail Penagihan</div>
+
+ <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'>
+ <DescriptionRow label='Nama'>{address?.name}</DescriptionRow>
+ <DescriptionRow label='Email'>{address?.email || '-'}</DescriptionRow>
+ <DescriptionRow label='No Telepon'>{address?.mobile || '-'}</DescriptionRow>
+ <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow>
+ </div>
+
+ <Divider />
+
+ <div className='font-medium p-4'>Detail Produk</div>
+
+ <div className='p-4 pt-0 flex flex-col gap-y-3'>
+ <VariantGroupCard
+ variants={invoice.data?.products}
+ buyMore
+ />
+ <div className='flex justify-between mt-3 font-medium'>
+ <p>Total Belanja</p>
+ <p>{currencyFormat(invoice.data?.amountTotal)}</p>
+ </div>
+ </div>
+ </>
+ )
+ )
+}
+
+const DescriptionRow = ({ children, label }) => (
+ <div className='grid grid-cols-2'>
+ <span className='text-gray_r-11'>{label}</span>
+ <span className='text-right'>{children}</span>
+ </div>
+)
+
+export default Invoice
diff --git a/src/lib/invoice/components/Invoices.jsx b/src/lib/invoice/components/Invoices.jsx
new file mode 100644
index 00000000..ab318a3c
--- /dev/null
+++ b/src/lib/invoice/components/Invoices.jsx
@@ -0,0 +1,177 @@
+import {
+ CheckIcon,
+ ClockIcon,
+ EllipsisVerticalIcon,
+ MagnifyingGlassIcon
+} from '@heroicons/react/24/outline'
+import { toQuery } from 'lodash-contrib'
+import _ from 'lodash'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+import useInvoices from '../hooks/useInvoices'
+import Spinner from '@/core/components/elements/Spinner/Spinner'
+import Alert from '@/core/components/elements/Alert/Alert'
+import Pagination from '@/core/components/elements/Pagination/Pagination'
+import Link from '@/core/components/elements/Link/Link'
+import currencyFormat from '@/core/utils/currencyFormat'
+import BottomPopup from '@/core/components/elements/Popup/BottomPopup'
+import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices'
+
+const Invoices = () => {
+ const router = useRouter()
+ const { q = '', page = 1 } = router.query
+
+ const limit = 10
+
+ const query = {
+ name: q,
+ offset: (page - 1) * limit,
+ limit
+ }
+ const { invoices } = useInvoices({ query })
+
+ const [inputQuery, setInputQuery] = useState(q)
+ const [toOthers, setToOthers] = useState(null)
+
+ const pageCount = Math.ceil(invoices?.data?.saleOrderTotal / limit)
+ let pageQuery = _.omit(query, ['limit', 'offset'])
+ pageQuery = _.pickBy(pageQuery, _.identity)
+ pageQuery = toQuery(pageQuery)
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ router.push(`/my/invoices?q=${inputQuery}`)
+ }
+
+ return (
+ <div className='p-4 flex flex-col gap-y-4'>
+ <form
+ className='flex gap-x-3'
+ onSubmit={handleSubmit}
+ >
+ <input
+ type='text'
+ className='form-input'
+ placeholder='Cari Invoice...'
+ value={inputQuery}
+ onChange={(e) => setInputQuery(e.target.value)}
+ />
+ <button
+ className='btn-light bg-transparent px-3'
+ type='submit'
+ >
+ <MagnifyingGlassIcon className='w-6' />
+ </button>
+ </form>
+
+ {invoices.isLoading && (
+ <div className='flex justify-center my-4'>
+ <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' />
+ </div>
+ )}
+
+ {!invoices.isLoading && invoices.data?.invoices?.length === 0 && (
+ <Alert
+ type='info'
+ className='text-center'
+ >
+ Tidak ada data invoice
+ </Alert>
+ )}
+
+ {invoices.data?.invoices?.map((invoice, index) => (
+ <div
+ className='p-4 shadow border border-gray_r-3 rounded-md'
+ key={index}
+ >
+ <div className='grid grid-cols-2'>
+ <Link href={`/my/invoice/${invoice.id}`}>
+ <span className='text-caption-2 text-gray_r-11'>No. Invoice</span>
+ <h2 className='text-red_r-11 mt-1'>{invoice.name}</h2>
+ </Link>
+ <div className='flex gap-x-1 justify-end'>
+ {invoice.amountResidual > 0 ? (
+ <div className='badge-solid-red h-fit ml-auto'>Belum Lunas</div>
+ ) : (
+ <div className='badge-solid-green h-fit ml-auto'>Lunas</div>
+ )}
+ <EllipsisVerticalIcon
+ className='w-5 h-5'
+ onClick={() => setToOthers(invoice)}
+ />
+ </div>
+ </div>
+ <Link href={`/my/invoice/${invoice.id}`}>
+ <div className='grid grid-cols-2 text-caption-2 text-gray_r-11 mt-2 font-normal'>
+ <p>{invoice.invoiceDate}</p>
+ <p className='text-right'>{invoice.paymentTerm}</p>
+ </div>
+ <hr className='my-3' />
+ <div className='grid grid-cols-2'>
+ <div>
+ <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span>
+ <p className='mt-1 font-medium text-gray_r-12'>
+ {invoice.purchaseOrderName || '-'}
+ </p>
+ </div>
+ <div className='text-right'>
+ <span className='text-caption-2 text-gray_r-11'>Total Invoice</span>
+ <p className='mt-1 font-medium text-gray_r-12'>
+ {currencyFormat(invoice.amountTotal)}
+ </p>
+ </div>
+ </div>
+ </Link>
+ {invoice.efaktur ? (
+ <div className='badge-green h-fit mt-3 ml-auto flex items-center gap-x-0.5'>
+ <CheckIcon className='w-4 stroke-2' />
+ Faktur Pajak
+ </div>
+ ) : (
+ <div className='badge-red h-fit mt-3 ml-auto flex items-center gap-x-0.5'>
+ <ClockIcon className='w-4 stroke-2' />
+ Faktur Pajak
+ </div>
+ )}
+ </div>
+ ))}
+
+ <Pagination
+ pageCount={pageCount}
+ currentPage={parseInt(page)}
+ url={`/my/invoices${pageQuery}`}
+ className='mt-2 mb-2'
+ />
+
+ <BottomPopup
+ title='Lainnya'
+ active={toOthers}
+ close={() => setToOthers(null)}
+ >
+ <div className='flex flex-col gap-y-4 mt-2'>
+ <button
+ className='text-left disabled:opacity-60'
+ onClick={() => {
+ downloadInvoice(toOthers)
+ setToOthers(null)
+ }}
+ >
+ Download Invoice
+ </button>
+ <button
+ className='text-left disabled:opacity-60'
+ disabled={!toOthers?.efaktur}
+ onClick={() => {
+ downloadTaxInvoice(toOthers)
+ setToOthers(null)
+ }}
+ >
+ Download Faktur Pajak
+ </button>
+ </div>
+ </BottomPopup>
+ </div>
+ )
+}
+
+export default Invoices
diff --git a/src/lib/invoice/hooks/useInvoice.js b/src/lib/invoice/hooks/useInvoice.js
new file mode 100644
index 00000000..2de5e91e
--- /dev/null
+++ b/src/lib/invoice/hooks/useInvoice.js
@@ -0,0 +1,13 @@
+import { useQuery } from 'react-query'
+import invoiceApi from '../api/invoiceApi'
+
+const useInvoice = ({ id }) => {
+ const fetchInvoice = async () => await invoiceApi({ id })
+ const { data, isLoading, refetch } = useQuery(`invoice-${id}`, fetchInvoice)
+
+ return {
+ invoice: { data, isLoading, refetch }
+ }
+}
+
+export default useInvoice
diff --git a/src/lib/invoice/hooks/useInvoices.js b/src/lib/invoice/hooks/useInvoices.js
new file mode 100644
index 00000000..061626e4
--- /dev/null
+++ b/src/lib/invoice/hooks/useInvoices.js
@@ -0,0 +1,15 @@
+import { useQuery } from 'react-query'
+import invoicesApi from '../api/invoicesApi'
+import _ from 'lodash-contrib'
+
+const useInvoices = ({ query }) => {
+ const queryString = _.toQuery(query)
+ const fetchInvoices = async () => await invoicesApi({ query: queryString })
+ const { data, isLoading, refetch } = useQuery(`invoices-${queryString}`, fetchInvoices)
+
+ return {
+ invoices: { data, isLoading, refetch }
+ }
+}
+
+export default useInvoices
diff --git a/src/lib/invoice/utils/invoices.js b/src/lib/invoice/utils/invoices.js
new file mode 100644
index 00000000..221e53cf
--- /dev/null
+++ b/src/lib/invoice/utils/invoices.js
@@ -0,0 +1,11 @@
+const downloadInvoice = (invoice) => {
+ const url = `${process.env.ODOO_HOST}/api/v1/download/invoice/${invoice.id}/${invoice.token}`
+ window.open(url, 'download')
+}
+
+const downloadTaxInvoice = (invoice) => {
+ const url = `${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${invoice.id}/${invoice.token}`
+ window.open(url, 'download')
+}
+
+export { downloadInvoice, downloadTaxInvoice }
diff --git a/src/lib/product/api/productApi.js b/src/lib/product/api/productApi.js
new file mode 100644
index 00000000..4fe4cd7d
--- /dev/null
+++ b/src/lib/product/api/productApi.js
@@ -0,0 +1,9 @@
+import odooApi from '@/core/api/odooApi'
+
+const productApi = async ({ id }) => {
+ if (!id) return
+ const dataProduct = await odooApi('GET', `/api/v1/product/${id}`)
+ return dataProduct
+}
+
+export default productApi
diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js
new file mode 100644
index 00000000..b9acd94b
--- /dev/null
+++ b/src/lib/product/api/productSearchApi.js
@@ -0,0 +1,9 @@
+import _ from 'lodash-contrib'
+import axios from 'axios'
+
+const productSearchApi = async ({ query }) => {
+ const dataProductSearch = await axios(`${process.env.SELF_HOST}/api/shop/search?${query}`)
+ return dataProductSearch.data
+}
+
+export default productSearchApi
diff --git a/src/lib/product/api/productSimilarApi.js b/src/lib/product/api/productSimilarApi.js
new file mode 100644
index 00000000..7142fab4
--- /dev/null
+++ b/src/lib/product/api/productSimilarApi.js
@@ -0,0 +1,10 @@
+import axios from 'axios'
+
+const productSimilarApi = async ({ query }) => {
+ const dataProductSimilar = await axios(
+ `${process.env.SELF_HOST}/api/shop/search?q=${query}&page=1&orderBy=popular`
+ )
+ return dataProductSimilar.data.response
+}
+
+export default productSimilarApi
diff --git a/src/lib/product/components/Product.jsx b/src/lib/product/components/Product.jsx
new file mode 100644
index 00000000..9e33316c
--- /dev/null
+++ b/src/lib/product/components/Product.jsx
@@ -0,0 +1,315 @@
+import Badge from '@/core/components/elements/Badge/Badge'
+import Divider from '@/core/components/elements/Divider/Divider'
+import Image from '@/core/components/elements/Image/Image'
+import Link from '@/core/components/elements/Link/Link'
+import currencyFormat from '@/core/utils/currencyFormat'
+import { useEffect, useState } from 'react'
+import Select from 'react-select'
+import ProductSimilar from './ProductSimilar'
+import LazyLoad from 'react-lazy-load'
+import { toast } from 'react-hot-toast'
+import { updateItemCart } from '@/core/utils/cart'
+import useWishlist from '@/lib/wishlist/hooks/useWishlist'
+import { HeartIcon } from '@heroicons/react/24/outline'
+import useAuth from '@/core/hooks/useAuth'
+import { useRouter } from 'next/router'
+import createOrDeleteWishlistApi from '@/lib/wishlist/api/createOrDeleteWishlistApi'
+
+const informationTabOptions = [
+ { value: 'specification', label: 'Spesifikasi' },
+ { value: 'description', label: 'Deskripsi' },
+ { value: 'important', label: 'Info Penting' }
+]
+
+const Product = ({ product }) => {
+ const auth = useAuth()
+ const router = useRouter()
+ const { wishlist } = useWishlist({ productId: product?.id })
+ const [quantity, setQuantity] = useState('1')
+ const [selectedVariant, setSelectedVariant] = useState(null)
+ const [informationTab, setInformationTab] = useState(null)
+
+ const [activeVariant, setActiveVariant] = useState({
+ id: product.id,
+ code: product.code,
+ name: product.name,
+ price: product.lowestPrice,
+ stock: product.stockTotal,
+ weight: product.weight
+ })
+
+ const variantOptions = product.variants?.map((variant) => ({
+ value: variant.id,
+ label:
+ (variant.code ? `[${variant.code}] ` : '') +
+ (variant.attributes.length > 0 ? variant.attributes.join(', ') : product.name)
+ }))
+
+ useEffect(() => {
+ if (!selectedVariant && variantOptions.length == 1) {
+ setSelectedVariant(variantOptions[0])
+ }
+ }, [selectedVariant, variantOptions])
+
+ useEffect(() => {
+ if (selectedVariant) {
+ const variant = product.variants.find((variant) => variant.id == selectedVariant.value)
+ const variantAttributes =
+ variant.attributes.length > 0 ? ' - ' + variant.attributes.join(', ') : ''
+
+ setActiveVariant({
+ id: variant.id,
+ code: variant.code,
+ name: variant.parent.name + variantAttributes,
+ price: variant.price,
+ stock: variant.stock,
+ weight: variant.weight
+ })
+ }
+ }, [selectedVariant, product])
+
+ useEffect(() => {
+ if (!informationTab) {
+ setInformationTab(informationTabOptions[0].value)
+ }
+ }, [informationTab])
+
+ const handleClickCart = () => {
+ if (!selectedVariant) {
+ toast.error('Pilih varian terlebih dahulu')
+ return
+ }
+ if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) {
+ toast.error('Jumlah barang minimal 1')
+ return
+ }
+ updateItemCart({
+ productId: activeVariant.id,
+ quantity
+ })
+ toast.success('Berhasil menambahkan ke keranjang')
+ }
+
+ const toggleWishlist = async () => {
+ if (!auth) {
+ router.push('/login')
+ return
+ }
+ const data = { product_id: product.id }
+ await createOrDeleteWishlistApi({ data })
+ if (wishlist.data.productTotal > 0) {
+ toast.success('Berhasil menghapus dari wishlist')
+ } else {
+ toast.success('Berhasil menambahkan ke wishlist')
+ }
+ wishlist.refetch()
+ }
+
+ return (
+ <>
+ <Image
+ src={product.image}
+ alt={product.name}
+ className='h-72 object-contain object-center w-full border-b border-gray_r-4'
+ />
+
+ <div className='p-4'>
+ <div className='flex items-end mb-2'>
+ {product.manufacture?.name ? (
+ <Link href='/'>{product.manufacture?.name}</Link>
+ ) : (
+ <div>-</div>
+ )}
+ <button
+ type='button'
+ className='ml-auto'
+ onClick={toggleWishlist}
+ >
+ {wishlist.data?.productTotal > 0 ? (
+ <HeartIcon className='w-6 fill-red_r-11 text-red_r-11' />
+ ) : (
+ <HeartIcon className='w-6' />
+ )}
+ </button>
+ </div>
+ <h1 className='leading-6 font-medium'>{activeVariant?.name}</h1>
+ {activeVariant?.price?.discountPercentage > 0 && (
+ <div className='flex gap-x-1 items-center mt-2'>
+ <div className='text-gray_r-11 line-through text-caption-1'>
+ {currencyFormat(activeVariant?.price?.price)}
+ </div>
+ <Badge type='solid-red'>{activeVariant?.price?.discountPercentage}%</Badge>
+ </div>
+ )}
+ <h3 className='text-red_r-11 font-semibold mt-1'>
+ {activeVariant?.price?.priceDiscount > 0 ? (
+ currencyFormat(activeVariant?.price?.priceDiscount)
+ ) : (
+ <span className='text-gray_r-11 leading-6 font-normal'>
+ Hubungi kami untuk dapatkan harga terbaik,&nbsp;
+ <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&nbsp;
+ {pageCount > 1 ? (
+ <>
+ {productStart + 1}-
+ {productStart + productRows > productFound
+ ? productFound
+ : productStart + productRows}
+ &nbsp;dari&nbsp;
+ </>
+ ) : (
+ ''
+ )}
+ {productFound}
+ &nbsp;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