diff options
Diffstat (limited to 'src/lib')
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'> + + {selectedProduct().length > 0 + ? currencyFormat(totalPriceBeforeTax - totalDiscountAmount + totalTaxAmount) + : '-'} + </span> + </div> + </div> + <div className='flex gap-x-3'> + <button + type='button' + className='btn-yellow flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/quotation')} + > + Quotation + </button> + <button + type='button' + className='btn-solid-red flex-1' + disabled={selectedProduct().length == 0} + onClick={() => router.push('/shop/checkout')} + > + Checkout + </button> + </div> + </div> + </div> + + <BottomPopup + active={deleteConfirmation} + close={() => setDeleteConfirmation(null)} + title='Hapus dari Keranjang' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin menghapus barang{' '} + <span className='underline'>{deleteConfirmation?.name}</span> dari keranjang? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={() => deleteProduct(deleteConfirmation?.id)} + > + Ya, Hapus + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setDeleteConfirmation(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Cart diff --git a/src/lib/cart/hooks/useCart.js b/src/lib/cart/hooks/useCart.js new file mode 100644 index 00000000..bc1ea7ea --- /dev/null +++ b/src/lib/cart/hooks/useCart.js @@ -0,0 +1,17 @@ +import { getCart } from '@/core/utils/cart' +import { useQuery } from 'react-query' +import _ from 'lodash' +import CartApi from '../api/CartApi' + +const useCart = ({ enabled }) => { + const cart = getCart() + const variantIds = _.keys(cart).join(',') + const fetchCart = async () => CartApi({ variantIds }) + const { data, isLoading } = useQuery('cart', fetchCart, { enabled }) + + return { + cart: { data, isLoading } + } +} + +export default useCart diff --git a/src/lib/checkout/api/checkoutApi.js b/src/lib/checkout/api/checkoutApi.js new file mode 100644 index 00000000..b76c9b7f --- /dev/null +++ b/src/lib/checkout/api/checkoutApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutApi = async ({ data }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/checkout`, + data + ) + return dataCheckout +} + +export default checkoutApi diff --git a/src/lib/checkout/components/Checkout.jsx b/src/lib/checkout/components/Checkout.jsx new file mode 100644 index 00000000..f6170b13 --- /dev/null +++ b/src/lib/checkout/components/Checkout.jsx @@ -0,0 +1,323 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import { getItemAddress } from '@/core/utils/address' +import addressesApi from '@/lib/address/api/addressesApi' +import CartApi from '@/lib/cart/api/CartApi' +import VariantCard from '@/lib/variant/components/VariantCard' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useRef, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import checkoutApi from '../api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Checkout = () => { + const router = useRouter() + const auth = useAuth() + const [selectedAddress, setSelectedAddress] = useState({ + shipping: null, + invoicing: null + }) + const [addresses, setAddresses] = useState(null) + + useEffect(() => { + if (!auth) return + + const getAddresses = async () => { + const dataAddresses = await addressesApi() + setAddresses(dataAddresses) + } + + getAddresses() + }, [auth]) + + useEffect(() => { + if (!addresses) return + + const matchAddress = (key) => { + const addressToMatch = getItemAddress(key) + const foundAddress = addresses.filter((address) => address.id == addressToMatch) + if (foundAddress.length > 0) { + return foundAddress[0] + } + return addresses[0] + } + + setSelectedAddress({ + shipping: matchAddress('shipping'), + invoicing: matchAddress('invoicing') + }) + }, [addresses]) + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [selectedPayment, setSelectedPayment] = useState(null) + + const poNumber = useRef('') + const poFile = useRef('') + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!selectedPayment) { + toast.error('Pilih metode pembayaran', { position: 'bottom-center' }) + return + } + const file = poFile.current.files[0] + if (typeof file !== 'undefined' && file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: selectedAddress.shipping.id, + partner_invoice_id: selectedAddress.invoicing.id, + order_line: JSON.stringify(productOrder), + type: 'sale_order' + } + if (poNumber.current.value) data.po_number = poNumber.current.value + if (typeof file !== 'undefined') data.po_file = await getFileBase64(file) + + const isCheckouted = await checkoutApi({ data }) + setIsLoading(false) + if (isCheckouted?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/checkout/finish?id=${isCheckouted.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Pengiriman' + url='/my/address?select=shipping' + address={selectedAddress.shipping} + /> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Pesanan</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <SectionAddress + label='Alamat Penagihan' + url='/my/address?select=invoicing' + address={selectedAddress.invoicing} + /> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'> + Metode Pembayaran <span className='font-normal text-gray_r-11'>(Wajib dipilih)</span> + </div> + <div className='grid gap-y-3 mt-4'> + {payments.map((payment, index) => ( + <button + type='button' + className={ + 'text-left border border-gray_r-6 rounded-md p-3 ' + + (selectedPayment == payment.name && 'border-yellow_r-10 bg-yellow_r-3') + } + onClick={() => setSelectedPayment(payment.name)} + key={index} + > + <p> + {payment.name} - {payment.number} + </p> + <p className='mt-1 text-gray_r-11'>PT. Indoteknik Dotcom Gemilang</p> + </button> + ))} + </div> + </div> + + <Divider /> + + <div className='p-4'> + <div className='font-medium'>Purchase Order</div> + + <div className='mt-4 flex gap-x-3'> + <div className='w-6/12'> + <label className='form-label font-normal'>Dokumen PO</label> + <input + type='file' + className='form-input mt-2 h-12' + accept='image/*,application/pdf' + ref={poFile} + /> + </div> + <div className='w-6/12'> + <label className='form-label font-normal'>Nomor PO</label> + <input + type='text' + className='form-input mt-2 h-12' + ref={poNumber} + /> + </div> + </div> + <p className='text-caption-2 text-gray_r-11 mt-2'>Ukuran dokumen PO Maksimal 5MB</p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Bayar'} + </button> + </div> + </> + ) +} + +const payments = [ + { name: 'BCA', number: '8870-4000-81' }, + { name: 'MANDIRI', number: '155-0067-6869-75' } +] + +const SectionAddress = ({ address, label, url }) => ( + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>{label}</div> + <Link + className='text-caption-1' + href={url} + > + Pilih Alamat Lain + </Link> + </div> + + {address && ( + <div className='mt-4 text-caption-1'> + <div className='badge-red mb-2'> + {address.type.charAt(0).toUpperCase() + address.type.slice(1) + ' Address'} + </div> + <p className='font-medium'>{address.name}</p> + <p className='mt-2 text-gray_r-11'>{address.mobile}</p> + <p className='mt-1 text-gray_r-11'> + {address.street}, {address?.city?.name} + </p> + </div> + )} + </div> +) + +export default Checkout diff --git a/src/lib/checkout/components/FinishCheckout.jsx b/src/lib/checkout/components/FinishCheckout.jsx new file mode 100644 index 00000000..a7d65dd0 --- /dev/null +++ b/src/lib/checkout/components/FinishCheckout.jsx @@ -0,0 +1,30 @@ +import Link from '@/core/components/elements/Link/Link' +import useTransaction from '@/lib/transaction/hooks/useTransaction' + +const FinishCheckout = ({ id }) => { + const { transaction } = useTransaction({ id }) + + return ( + <div className='p-4'> + <div className='rounded-xl bg-yellow_r-4 text-center border border-yellow_r-7'> + <div className='px-4 py-6 text-yellow_r-12'> + <p className='font-semibold mb-2'>Terima Kasih atas Pembelian Anda</p> + <p className='text-yellow_r-11 mb-4 leading-6'> + Rincian belanja sudah kami kirimkan ke email anda. Mohon dicek kembali. jika tidak + menerima email, anda dapat menghubungi kami disini. + </p> + <p className='mb-2 font-medium'>{transaction.data?.name}</p> + <p className='text-caption-2 text-yellow_r-11'>No. Transaksi</p> + </div> + <Link + href={transaction.data?.id ? `/my/transaction/${transaction.data.id}` : '/'} + className='bg-yellow_r-6 text-yellow_r-12 rounded-b-xl py-4 block' + > + Lihat detail pembelian Anda disini + </Link> + </div> + </div> + ) +} + +export default FinishCheckout diff --git a/src/lib/elements/hooks/useBottomPopup.js b/src/lib/elements/hooks/useBottomPopup.js deleted file mode 100644 index 88b72316..00000000 --- a/src/lib/elements/hooks/useBottomPopup.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useState } from "react"; -import dynamic from "next/dynamic"; - -const DynamicBottomPopup = dynamic(() => import('@/components/elements/BottomPopup')); - -const useBottomPopup = ({ - title, - children -}) => { - const [ isOpen, setIsOpen ] = useState(false); - const [ dataPopup, setDataPopup ] = useState(null); - - const closePopup = () => { - setIsOpen(false); - setDataPopup(null); - }; - const openPopup = ( data = null ) => { - setIsOpen(true); - setDataPopup(data); - }; - - const BottomPopup = ( - <DynamicBottomPopup - title={title} - active={isOpen} - closePopup={closePopup} - > - { children(dataPopup) } - </DynamicBottomPopup> - ); - - return { - dataPopup, - BottomPopup, - closePopup, - openPopup - } -} - -export default useBottomPopup;
\ No newline at end of file diff --git a/src/lib/elements/hooks/useConfirmAlert.js b/src/lib/elements/hooks/useConfirmAlert.js deleted file mode 100644 index 4975c57d..00000000 --- a/src/lib/elements/hooks/useConfirmAlert.js +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from "react"; -import dynamic from "next/dynamic"; - -const DynamicConfirmAlert = dynamic(() => import('@/components/elements/ConfirmAlert')); - -const useConfirmAlert = ({ - title, - caption, - closeText, - submitText, - onSubmit, -}) => { - const [ isOpen, setIsOpen ] = useState(false); - const [ data, setData ] = useState(null); - - const closeConfirmAlert = () => { - setIsOpen(false); - setData(null); - }; - const openConfirmAlert = ( data = null ) => { - setIsOpen(true); - setData(data); - }; - const handleSubmit = async () => { - await onSubmit(data); - closeConfirmAlert(); - }; - - const ConfirmAlert = ( - <DynamicConfirmAlert - title={title} - caption={caption} - closeText={closeText} - submitText={submitText} - onClose={closeConfirmAlert} - onSubmit={handleSubmit} - show={isOpen} - /> - ); - - return { - isOpen, - closeConfirmAlert, - openConfirmAlert, - ConfirmAlert - }; -} - -export default useConfirmAlert;
\ No newline at end of file diff --git a/src/lib/home/api/categoryHomeApi.js b/src/lib/home/api/categoryHomeApi.js new file mode 100644 index 00000000..81909d7b --- /dev/null +++ b/src/lib/home/api/categoryHomeApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async ({ id }) => { + const dataCategoryHomeId = await odooApi('GET', `/api/v1/categories_homepage?id=${id}`) + return dataCategoryHomeId +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/categoryHomeIdApi.js b/src/lib/home/api/categoryHomeIdApi.js new file mode 100644 index 00000000..6b820fd3 --- /dev/null +++ b/src/lib/home/api/categoryHomeIdApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const categoryHomeIdApi = async () => { + const dataCategoryHomeIds = await odooApi('GET', '/api/v1/categories_homepage/ids') + return dataCategoryHomeIds +} + +export default categoryHomeIdApi diff --git a/src/lib/home/api/heroBannerApi.js b/src/lib/home/api/heroBannerApi.js new file mode 100644 index 00000000..60a0702a --- /dev/null +++ b/src/lib/home/api/heroBannerApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const heroBannerApi = async () => { + const dataHeroBanners = await odooApi('GET', '/api/v1/banner?type=index-a-1') + return dataHeroBanners +} + +export default heroBannerApi diff --git a/src/lib/home/api/popularProductApi.js b/src/lib/home/api/popularProductApi.js new file mode 100644 index 00000000..5a6d3212 --- /dev/null +++ b/src/lib/home/api/popularProductApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const popularProductApi = async () => { + const dataPopularProducts = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=*&page=1&orderBy=popular` + ) + return dataPopularProducts.data.response +} + +export default popularProductApi diff --git a/src/lib/home/api/preferredBrandApi.js b/src/lib/home/api/preferredBrandApi.js new file mode 100644 index 00000000..0e3200e0 --- /dev/null +++ b/src/lib/home/api/preferredBrandApi.js @@ -0,0 +1,8 @@ +import odooApi from '@/core/api/odooApi' + +const preferredBrandApi = async () => { + const dataPreferredBrands = await odooApi('GET', '/api/v1/manufacture?level=prioritas') + return dataPreferredBrands +} + +export default preferredBrandApi diff --git a/src/lib/home/components/CategoryHome.jsx b/src/lib/home/components/CategoryHome.jsx new file mode 100644 index 00000000..ac43e2bc --- /dev/null +++ b/src/lib/home/components/CategoryHome.jsx @@ -0,0 +1,30 @@ +import ProductSlider from '@/lib/product/components/ProductSlider' +import useCategoryHome from '../hooks/useCategoryHome' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' + +const CategoryHome = ({ id }) => { + const { categoryHome } = useCategoryHome({ id }) + + return ( + <div className='p-4 relative bg-yellow_r-2'> + {categoryHome.data ? ( + <ProductSlider + products={{ + products: categoryHome.data?.[0].products, + banner: { + image: categoryHome.data?.[0].image, + name: categoryHome.data?.[0].name, + url: `/shop/search?category=${categoryHome.data?.[0].name}` + } + }} + simpleTitle + bannerMode + /> + ) : ( + <PopularProductSkeleton /> + )} + </div> + ) +} + +export default CategoryHome diff --git a/src/lib/home/components/CategoryHomeId.jsx b/src/lib/home/components/CategoryHomeId.jsx new file mode 100644 index 00000000..c37a6af7 --- /dev/null +++ b/src/lib/home/components/CategoryHomeId.jsx @@ -0,0 +1,19 @@ +import { LazyLoadComponent } from 'react-lazy-load-image-component' +import useCategoryHomeId from '../hooks/useCategoryHomeId' +import CategoryHome from './CategoryHome' + +const CategoryHomeId = () => { + const { categoryHomeIds } = useCategoryHomeId() + + return ( + <div className='flex flex-col gap-y-6'> + {categoryHomeIds.data?.map((id) => ( + <LazyLoadComponent key={id}> + <CategoryHome id={id} /> + </LazyLoadComponent> + ))} + </div> + ) +} + +export default CategoryHomeId diff --git a/src/lib/home/components/HeroBanner.jsx b/src/lib/home/components/HeroBanner.jsx new file mode 100644 index 00000000..0ac14bad --- /dev/null +++ b/src/lib/home/components/HeroBanner.jsx @@ -0,0 +1,50 @@ +import ImageSkeleton from '@/core/components/elements/Skeleton/ImageSkeleton' +import useHeroBanner from '../hooks/useHeroBanner' +import Image from '@/core/components/elements/Image/Image' + +// Swiper +import { Swiper, SwiperSlide } from 'swiper/react' +import { Pagination, Autoplay } from 'swiper' +import 'swiper/css' +import 'swiper/css/pagination' +import 'swiper/css/autoplay' + +const swiperBanner = { + pagination: { dynamicBullets: true }, + autoplay: { + delay: 6000, + disableOnInteraction: false + }, + modules: [Pagination, Autoplay] +} + +const HeroBanner = () => { + const { heroBanners } = useHeroBanner() + + return ( + <div className='min-h-[200px]'> + {heroBanners.isLoading && <ImageSkeleton />} + {!heroBanners.isLoading && ( + <Swiper + slidesPerView={1} + pagination={swiperBanner.pagination} + modules={swiperBanner.modules} + autoplay={swiperBanner.autoplay} + className='border-b border-gray_r-6' + > + {heroBanners.data?.map((banner, index) => ( + <SwiperSlide key={index}> + <Image + src={banner.image} + alt={banner.name} + className='w-full h-auto' + /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default HeroBanner diff --git a/src/lib/home/components/PopularProduct.jsx b/src/lib/home/components/PopularProduct.jsx new file mode 100644 index 00000000..d23275f7 --- /dev/null +++ b/src/lib/home/components/PopularProduct.jsx @@ -0,0 +1,24 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePopularProduct from '../hooks/usePopularProduct' +import ProductCard from '@/lib/product/components/ProductCard' +import PopularProductSkeleton from './Skeleton/PopularProductSkeleton' +import ProductSlider from '@/lib/product/components/ProductSlider' + +const PopularProduct = () => { + const { popularProducts } = usePopularProduct() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Produk Populer</div> + {popularProducts.isLoading && <PopularProductSkeleton />} + {!popularProducts.isLoading && ( + <ProductSlider + products={popularProducts.data} + simpleTitle + /> + )} + </div> + ) +} + +export default PopularProduct diff --git a/src/lib/home/components/PreferredBrand.jsx b/src/lib/home/components/PreferredBrand.jsx new file mode 100644 index 00000000..3df3cdb7 --- /dev/null +++ b/src/lib/home/components/PreferredBrand.jsx @@ -0,0 +1,30 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import usePreferredBrand from '../hooks/usePreferredBrand' +import PreferredBrandSkeleton from './Skeleton/PreferredBrandSkeleton' +import BrandCard from '@/lib/brand/components/BrandCard' + +const PreferredBrand = () => { + const { preferredBrands } = usePreferredBrand() + + return ( + <div className='px-4'> + <div className='font-medium mb-4'>Brand Pilihan</div> + {preferredBrands.isLoading && <PreferredBrandSkeleton />} + {!preferredBrands.isLoading && ( + <Swiper + slidesPerView={3.5} + spaceBetween={12} + freeMode + > + {preferredBrands.data?.manufactures.map((brand) => ( + <SwiperSlide key={brand.id}> + <BrandCard brand={brand} /> + </SwiperSlide> + ))} + </Swiper> + )} + </div> + ) +} + +export default PreferredBrand diff --git a/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx new file mode 100644 index 00000000..18a1b3d3 --- /dev/null +++ b/src/lib/home/components/Skeleton/PopularProductSkeleton.jsx @@ -0,0 +1,10 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const PopularProductSkeleton = () => ( + <div className='grid grid-cols-2 gap-x-3'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default PopularProductSkeleton diff --git a/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx new file mode 100644 index 00000000..00589342 --- /dev/null +++ b/src/lib/home/components/Skeleton/PreferredBrandSkeleton.jsx @@ -0,0 +1,12 @@ +import BrandSkeleton from '@/core/components/elements/Skeleton/BrandSkeleton' + +const PreferredBrandSkeleton = () => ( + <div className='grid grid-cols-4 gap-x-3'> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + <BrandSkeleton /> + </div> +) + +export default PreferredBrandSkeleton diff --git a/src/lib/home/hooks/useCategoryHome.js b/src/lib/home/hooks/useCategoryHome.js new file mode 100644 index 00000000..cfaa3d9c --- /dev/null +++ b/src/lib/home/hooks/useCategoryHome.js @@ -0,0 +1,13 @@ +import categoryHomeApi from '../api/categoryHomeApi' +import { useQuery } from 'react-query' + +const useCategoryHome = ({ id }) => { + const fetchCategoryHome = async () => await categoryHomeApi({ id }) + const { isLoading, data } = useQuery(`categoryHome-${id}`, fetchCategoryHome) + + return { + categoryHome: { data, isLoading } + } +} + +export default useCategoryHome diff --git a/src/lib/home/hooks/useCategoryHomeId.js b/src/lib/home/hooks/useCategoryHomeId.js new file mode 100644 index 00000000..c6953db7 --- /dev/null +++ b/src/lib/home/hooks/useCategoryHomeId.js @@ -0,0 +1,13 @@ +import categoryHomeIdApi from '../api/categoryHomeIdApi' +import { useQuery } from 'react-query' + +const useCategoryHomeId = () => { + const fetchCategoryHomeId = async () => await categoryHomeIdApi() + const { isLoading, data } = useQuery('categoryHomeId', fetchCategoryHomeId) + + return { + categoryHomeIds: { data, isLoading } + } +} + +export default useCategoryHomeId diff --git a/src/lib/home/hooks/useHeroBanner.js b/src/lib/home/hooks/useHeroBanner.js new file mode 100644 index 00000000..5d2b0512 --- /dev/null +++ b/src/lib/home/hooks/useHeroBanner.js @@ -0,0 +1,13 @@ +import heroBannerApi from '../api/heroBannerApi' +import { useQuery } from 'react-query' + +const useHeroBanner = () => { + const fetchHeroBanner = async () => await heroBannerApi() + const { isLoading, data } = useQuery('heroBanner', fetchHeroBanner) + + return { + heroBanners: { data, isLoading } + } +} + +export default useHeroBanner diff --git a/src/lib/home/hooks/usePopularProduct.js b/src/lib/home/hooks/usePopularProduct.js new file mode 100644 index 00000000..d0c34bb0 --- /dev/null +++ b/src/lib/home/hooks/usePopularProduct.js @@ -0,0 +1,13 @@ +import popularProductApi from '../api/popularProductApi' +import { useQuery } from 'react-query' + +const usePopularProduct = () => { + const fetchPopularProduct = async () => await popularProductApi() + const { data, isLoading } = useQuery('popularProduct', fetchPopularProduct) + + return { + popularProducts: { data, isLoading } + } +} + +export default usePopularProduct diff --git a/src/lib/home/hooks/usePreferredBrand.js b/src/lib/home/hooks/usePreferredBrand.js new file mode 100644 index 00000000..e56d361f --- /dev/null +++ b/src/lib/home/hooks/usePreferredBrand.js @@ -0,0 +1,13 @@ +import preferredBrandApi from '../api/preferredBrandApi' +import { useQuery } from 'react-query' + +const usePreferredBrand = () => { + const fetchPreferredBrand = async () => await preferredBrandApi() + const { data, isLoading } = useQuery('preferredBrand', fetchPreferredBrand) + + return { + preferredBrands: { data, isLoading } + } +} + +export default usePreferredBrand diff --git a/src/lib/invoice/api/invoiceApi.js b/src/lib/invoice/api/invoiceApi.js new file mode 100644 index 00000000..056df6c6 --- /dev/null +++ b/src/lib/invoice/api/invoiceApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoiceApi = async ({ id }) => { + const auth = getAuth() + const dataInvoice = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice/${id}`) + return dataInvoice +} + +export default invoiceApi diff --git a/src/lib/invoice/api/invoicesApi.js b/src/lib/invoice/api/invoicesApi.js new file mode 100644 index 00000000..622fe6ee --- /dev/null +++ b/src/lib/invoice/api/invoicesApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const invoicesApi = async ({ query }) => { + const auth = getAuth() + const dataInvoices = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/invoice?${query}`) + return dataInvoices +} + +export default invoicesApi diff --git a/src/lib/invoice/components/Invoice.jsx b/src/lib/invoice/components/Invoice.jsx new file mode 100644 index 00000000..e34ad8c2 --- /dev/null +++ b/src/lib/invoice/components/Invoice.jsx @@ -0,0 +1,110 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useInvoice from '../hooks/useInvoice' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' +import Divider from '@/core/components/elements/Divider/Divider' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import currencyFormat from '@/core/utils/currencyFormat' + +const Invoice = ({ id }) => { + const { invoice } = useInvoice({ id }) + + if (invoice.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + const address = invoice.data?.customer + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + invoice.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='No Invoice'>{invoice.data?.name}</DescriptionRow> + <DescriptionRow label='Status Transaksi'> + {invoice.data?.amountResidual > 0 ? ( + <span className='badge-solid-red'>Belum Lunas</span> + ) : ( + <span className='badge-solid-green'>Lunas</span> + )} + </DescriptionRow> + <DescriptionRow label='Purchase Order'> + {invoice.data?.purchaseOrderName || '-'} + </DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'>{invoice.data?.paymentTerm}</DescriptionRow> + {invoice.data?.amountResidual > 0 && invoice.invoiceDate != invoice.invoiceDateDue && ( + <DescriptionRow label='Tanggal Jatuh Tempo'> + {invoice.data?.invoiceDateDue} + </DescriptionRow> + )} + <DescriptionRow label='Nama Sales'>{invoice.data?.sales}</DescriptionRow> + <DescriptionRow label='Tanggal Invoice'>{invoice.data?.invoiceDate}</DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Invoice</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadInvoice(invoice.data)} + > + Download + </button> + </div> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Faktur Pajak</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={() => downloadTaxInvoice(invoice.data)} + disabled={!invoice.data?.efaktur} + > + Download + </button> + </div> + </div> + + <Divider /> + + <div className='p-4 font-medium'>Detail Penagihan</div> + + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address?.name}</DescriptionRow> + <DescriptionRow label='Email'>{address?.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address?.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={invoice.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(invoice.data?.amountTotal)}</p> + </div> + </div> + </> + ) + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Invoice diff --git a/src/lib/invoice/components/Invoices.jsx b/src/lib/invoice/components/Invoices.jsx new file mode 100644 index 00000000..ab318a3c --- /dev/null +++ b/src/lib/invoice/components/Invoices.jsx @@ -0,0 +1,177 @@ +import { + CheckIcon, + ClockIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon +} from '@heroicons/react/24/outline' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import { useRouter } from 'next/router' +import { useState } from 'react' +import useInvoices from '../hooks/useInvoices' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { downloadInvoice, downloadTaxInvoice } from '../utils/invoices' + +const Invoices = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { invoices } = useInvoices({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + + const pageCount = Math.ceil(invoices?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/invoices?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Invoice...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {invoices.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!invoices.isLoading && invoices.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data invoice + </Alert> + )} + + {invoices.data?.invoices?.map((invoice, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/invoice/${invoice.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Invoice</span> + <h2 className='text-red_r-11 mt-1'>{invoice.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + {invoice.amountResidual > 0 ? ( + <div className='badge-solid-red h-fit ml-auto'>Belum Lunas</div> + ) : ( + <div className='badge-solid-green h-fit ml-auto'>Lunas</div> + )} + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(invoice)} + /> + </div> + </div> + <Link href={`/my/invoice/${invoice.id}`}> + <div className='grid grid-cols-2 text-caption-2 text-gray_r-11 mt-2 font-normal'> + <p>{invoice.invoiceDate}</p> + <p className='text-right'>{invoice.paymentTerm}</p> + </div> + <hr className='my-3' /> + <div className='grid grid-cols-2'> + <div> + <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span> + <p className='mt-1 font-medium text-gray_r-12'> + {invoice.purchaseOrderName || '-'} + </p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Invoice</span> + <p className='mt-1 font-medium text-gray_r-12'> + {currencyFormat(invoice.amountTotal)} + </p> + </div> + </div> + </Link> + {invoice.efaktur ? ( + <div className='badge-green h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <CheckIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + ) : ( + <div className='badge-red h-fit mt-3 ml-auto flex items-center gap-x-0.5'> + <ClockIcon className='w-4 stroke-2' /> + Faktur Pajak + </div> + )} + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/invoices${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + onClick={() => { + downloadInvoice(toOthers) + setToOthers(null) + }} + > + Download Invoice + </button> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.efaktur} + onClick={() => { + downloadTaxInvoice(toOthers) + setToOthers(null) + }} + > + Download Faktur Pajak + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Invoices diff --git a/src/lib/invoice/hooks/useInvoice.js b/src/lib/invoice/hooks/useInvoice.js new file mode 100644 index 00000000..2de5e91e --- /dev/null +++ b/src/lib/invoice/hooks/useInvoice.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import invoiceApi from '../api/invoiceApi' + +const useInvoice = ({ id }) => { + const fetchInvoice = async () => await invoiceApi({ id }) + const { data, isLoading, refetch } = useQuery(`invoice-${id}`, fetchInvoice) + + return { + invoice: { data, isLoading, refetch } + } +} + +export default useInvoice diff --git a/src/lib/invoice/hooks/useInvoices.js b/src/lib/invoice/hooks/useInvoices.js new file mode 100644 index 00000000..061626e4 --- /dev/null +++ b/src/lib/invoice/hooks/useInvoices.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import invoicesApi from '../api/invoicesApi' +import _ from 'lodash-contrib' + +const useInvoices = ({ query }) => { + const queryString = _.toQuery(query) + const fetchInvoices = async () => await invoicesApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`invoices-${queryString}`, fetchInvoices) + + return { + invoices: { data, isLoading, refetch } + } +} + +export default useInvoices diff --git a/src/lib/invoice/utils/invoices.js b/src/lib/invoice/utils/invoices.js new file mode 100644 index 00000000..221e53cf --- /dev/null +++ b/src/lib/invoice/utils/invoices.js @@ -0,0 +1,11 @@ +const downloadInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +const downloadTaxInvoice = (invoice) => { + const url = `${process.env.ODOO_HOST}/api/v1/download/tax-invoice/${invoice.id}/${invoice.token}` + window.open(url, 'download') +} + +export { downloadInvoice, downloadTaxInvoice } diff --git a/src/lib/product/api/productApi.js b/src/lib/product/api/productApi.js new file mode 100644 index 00000000..4fe4cd7d --- /dev/null +++ b/src/lib/product/api/productApi.js @@ -0,0 +1,9 @@ +import odooApi from '@/core/api/odooApi' + +const productApi = async ({ id }) => { + if (!id) return + const dataProduct = await odooApi('GET', `/api/v1/product/${id}`) + return dataProduct +} + +export default productApi diff --git a/src/lib/product/api/productSearchApi.js b/src/lib/product/api/productSearchApi.js new file mode 100644 index 00000000..b9acd94b --- /dev/null +++ b/src/lib/product/api/productSearchApi.js @@ -0,0 +1,9 @@ +import _ from 'lodash-contrib' +import axios from 'axios' + +const productSearchApi = async ({ query }) => { + const dataProductSearch = await axios(`${process.env.SELF_HOST}/api/shop/search?${query}`) + return dataProductSearch.data +} + +export default productSearchApi diff --git a/src/lib/product/api/productSimilarApi.js b/src/lib/product/api/productSimilarApi.js new file mode 100644 index 00000000..7142fab4 --- /dev/null +++ b/src/lib/product/api/productSimilarApi.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +const productSimilarApi = async ({ query }) => { + const dataProductSimilar = await axios( + `${process.env.SELF_HOST}/api/shop/search?q=${query}&page=1&orderBy=popular` + ) + return dataProductSimilar.data.response +} + +export default productSimilarApi diff --git a/src/lib/product/components/Product.jsx b/src/lib/product/components/Product.jsx new file mode 100644 index 00000000..9e33316c --- /dev/null +++ b/src/lib/product/components/Product.jsx @@ -0,0 +1,315 @@ +import Badge from '@/core/components/elements/Badge/Badge' +import Divider from '@/core/components/elements/Divider/Divider' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { useEffect, useState } from 'react' +import Select from 'react-select' +import ProductSimilar from './ProductSimilar' +import LazyLoad from 'react-lazy-load' +import { toast } from 'react-hot-toast' +import { updateItemCart } from '@/core/utils/cart' +import useWishlist from '@/lib/wishlist/hooks/useWishlist' +import { HeartIcon } from '@heroicons/react/24/outline' +import useAuth from '@/core/hooks/useAuth' +import { useRouter } from 'next/router' +import createOrDeleteWishlistApi from '@/lib/wishlist/api/createOrDeleteWishlistApi' + +const informationTabOptions = [ + { value: 'specification', label: 'Spesifikasi' }, + { value: 'description', label: 'Deskripsi' }, + { value: 'important', label: 'Info Penting' } +] + +const Product = ({ product }) => { + const auth = useAuth() + const router = useRouter() + const { wishlist } = useWishlist({ productId: product?.id }) + const [quantity, setQuantity] = useState('1') + const [selectedVariant, setSelectedVariant] = useState(null) + const [informationTab, setInformationTab] = useState(null) + + const [activeVariant, setActiveVariant] = useState({ + id: product.id, + code: product.code, + name: product.name, + price: product.lowestPrice, + stock: product.stockTotal, + weight: product.weight + }) + + const variantOptions = product.variants?.map((variant) => ({ + value: variant.id, + label: + (variant.code ? `[${variant.code}] ` : '') + + (variant.attributes.length > 0 ? variant.attributes.join(', ') : product.name) + })) + + useEffect(() => { + if (!selectedVariant && variantOptions.length == 1) { + setSelectedVariant(variantOptions[0]) + } + }, [selectedVariant, variantOptions]) + + useEffect(() => { + if (selectedVariant) { + const variant = product.variants.find((variant) => variant.id == selectedVariant.value) + const variantAttributes = + variant.attributes.length > 0 ? ' - ' + variant.attributes.join(', ') : '' + + setActiveVariant({ + id: variant.id, + code: variant.code, + name: variant.parent.name + variantAttributes, + price: variant.price, + stock: variant.stock, + weight: variant.weight + }) + } + }, [selectedVariant, product]) + + useEffect(() => { + if (!informationTab) { + setInformationTab(informationTabOptions[0].value) + } + }, [informationTab]) + + const handleClickCart = () => { + if (!selectedVariant) { + toast.error('Pilih varian terlebih dahulu') + return + } + if (!quantity || quantity < 1 || isNaN(parseInt(quantity))) { + toast.error('Jumlah barang minimal 1') + return + } + updateItemCart({ + productId: activeVariant.id, + quantity + }) + toast.success('Berhasil menambahkan ke keranjang') + } + + const toggleWishlist = async () => { + if (!auth) { + router.push('/login') + return + } + const data = { product_id: product.id } + await createOrDeleteWishlistApi({ data }) + if (wishlist.data.productTotal > 0) { + toast.success('Berhasil menghapus dari wishlist') + } else { + toast.success('Berhasil menambahkan ke wishlist') + } + wishlist.refetch() + } + + return ( + <> + <Image + src={product.image} + alt={product.name} + className='h-72 object-contain object-center w-full border-b border-gray_r-4' + /> + + <div className='p-4'> + <div className='flex items-end mb-2'> + {product.manufacture?.name ? ( + <Link href='/'>{product.manufacture?.name}</Link> + ) : ( + <div>-</div> + )} + <button + type='button' + className='ml-auto' + onClick={toggleWishlist} + > + {wishlist.data?.productTotal > 0 ? ( + <HeartIcon className='w-6 fill-red_r-11 text-red_r-11' /> + ) : ( + <HeartIcon className='w-6' /> + )} + </button> + </div> + <h1 className='leading-6 font-medium'>{activeVariant?.name}</h1> + {activeVariant?.price?.discountPercentage > 0 && ( + <div className='flex gap-x-1 items-center mt-2'> + <div className='text-gray_r-11 line-through text-caption-1'> + {currencyFormat(activeVariant?.price?.price)} + </div> + <Badge type='solid-red'>{activeVariant?.price?.discountPercentage}%</Badge> + </div> + )} + <h3 className='text-red_r-11 font-semibold mt-1'> + {activeVariant?.price?.priceDiscount > 0 ? ( + currencyFormat(activeVariant?.price?.priceDiscount) + ) : ( + <span className='text-gray_r-11 leading-6 font-normal'> + Hubungi kami untuk dapatkan harga terbaik, + <a + href='https://wa.me/' + className='text-red_r-11 underline' + > + klik disini + </a> + </span> + )} + </h3> + </div> + + <Divider /> + + <div className='p-4'> + <div> + <label className='flex justify-between'> + Pilih Varian: + <span className='text-gray_r-11'>{product?.variantTotal} Varian</span> + </label> + <Select + name='variant' + classNamePrefix='form-select' + options={variantOptions} + className='mt-2' + value={selectedVariant} + onChange={(option) => setSelectedVariant(option)} + isSearchable={product.variantTotal > 10} + /> + </div> + <div className='mt-4 mb-2'>Jumlah</div> + <div className='flex gap-x-3'> + <div className='w-2/12'> + <input + name='quantity' + type='number' + className='form-input' + value={quantity} + onChange={(e) => setQuantity(e.target.value)} + /> + </div> + <button + type='button' + className='btn-yellow flex-1' + onClick={handleClickCart} + > + Keranjang + </button> + <button + type='button' + className='btn-solid-red flex-1' + > + Beli + </button> + </div> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold'>Informasi Produk</h2> + <div className='flex gap-x-4 mt-4 mb-3'> + {informationTabOptions.map((option) => ( + <TabButton + value={option.value} + key={option.value} + active={informationTab == option.value} + onClick={() => setInformationTab(option.value)} + > + {option.label} + </TabButton> + ))} + </div> + + <TabContent + active={informationTab == 'specification'} + className='rounded border border-gray_r-6 divide-y divide-gray_r-6' + > + <SpecificationContent label='Jumlah Varian'> + <span>{product?.variantTotal} Varian</span> + </SpecificationContent> + <SpecificationContent label='Nomor SKU'> + <span>SKU-{product?.id}</span> + </SpecificationContent> + <SpecificationContent label='Part Number'> + <span>{activeVariant?.code || '-'}</span> + </SpecificationContent> + <SpecificationContent label='Stok'> + {activeVariant?.stock > 0 && ( + <span className='flex gap-x-1.5'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{activeVariant?.stock > 5 ? '> 5' : '< 5'}</div> + </span> + )} + {activeVariant?.stock == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Stok + </a> + )} + </SpecificationContent> + <SpecificationContent label='Berat Barang'> + {activeVariant?.weight > 0 && <span>{activeVariant?.weight} KG</span>} + {activeVariant?.weight == 0 && ( + <a + href='https://wa.me' + className='text-red_r-11 font-medium' + > + Tanya Berat + </a> + )} + </SpecificationContent> + </TabContent> + + <TabContent + active={informationTab == 'description'} + className='leading-6 text-gray_r-11' + dangerouslySetInnerHTML={{ + __html: product.description != '' ? product.description : 'Belum ada deskripsi produk.' + }} + /> + </div> + + <Divider /> + + <div className='p-4'> + <h2 className='font-semibold mb-4'>Kamu Mungkin Juga Suka</h2> + <LazyLoad> + <ProductSimilar query={product?.name.split(' ').slice(1, 3).join(' ')} /> + </LazyLoad> + </div> + </> + ) +} + +const TabButton = ({ children, active, ...props }) => { + const activeClassName = active ? 'text-red_r-11 underline underline-offset-4' : 'text-gray_r-11' + return ( + <button + {...props} + type='button' + className={`font-medium pb-1 ${activeClassName}`} + > + {children} + </button> + ) +} + +const TabContent = ({ children, active, className, ...props }) => ( + <div + {...props} + className={`${active ? 'block' : 'hidden'} ${className}`} + > + {children} + </div> +) + +const SpecificationContent = ({ children, label }) => ( + <div className='flex justify-between p-3'> + <span className='text-gray_r-11'>{label}</span> + {children} + </div> +) + +export default Product diff --git a/src/lib/product/components/ProductCard.jsx b/src/lib/product/components/ProductCard.jsx new file mode 100644 index 00000000..6b88a3bd --- /dev/null +++ b/src/lib/product/components/ProductCard.jsx @@ -0,0 +1,76 @@ +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import currencyFormat from '@/core/utils/currencyFormat' +import { createSlug } from '@/core/utils/slug' + +const ProductCard = ({ product, simpleTitle }) => { + return ( + <> + <div className='rounded shadow-sm border border-gray_r-4 h-full bg-white'> + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className='border-b border-gray_r-4 relative' + > + <Image + src={product?.image} + alt={product?.name} + className='w-full object-contain object-center h-36' + /> + {product.variantTotal > 1 && ( + <div className='absolute badge-gray bottom-1.5 left-1.5'> + {product.variantTotal} Varian + </div> + )} + </Link> + <div className='p-2 pb-3 text-caption-2 leading-5'> + {product?.manufacture?.name ? ( + <Link + href={createSlug( + '/shop/brands/', + product?.manufacture?.name, + product?.manufacture.id + )} + className='mb-1' + > + {product.manufacture.name} + </Link> + ) : ( + <div>-</div> + )} + <Link + href={createSlug('/shop/product/', product?.name, product?.id)} + className={`font-medium mb-2 !text-gray_r-12 ${ + simpleTitle ? 'line-clamp-2' : 'line-clamp-3' + }`} + > + {product?.name} + </Link> + {product?.lowestPrice?.discountPercentage > 0 && ( + <div className='flex gap-x-1 mb-1 items-center'> + <div className='text-gray_r-11 line-through text-[11px]'> + {currencyFormat(product?.lowestPrice?.price)} + </div> + <div className='badge-solid-red'>{product?.lowestPrice?.discountPercentage}%</div> + </div> + )} + + <div className='text-red_r-11 font-semibold mb-2'> + {product?.lowestPrice?.priceDiscount > 0 ? ( + currencyFormat(product?.lowestPrice?.priceDiscount) + ) : ( + <a href='https://wa.me/'>Call for price</a> + )} + </div> + {product?.stockTotal > 0 && ( + <div className='flex gap-x-1'> + <div className='badge-solid-red'>Ready Stock</div> + <div className='badge-gray'>{product?.stockTotal > 5 ? '> 5' : '< 5'}</div> + </div> + )} + </div> + </div> + </> + ) +} + +export default ProductCard diff --git a/src/lib/product/components/ProductFilter.jsx b/src/lib/product/components/ProductFilter.jsx new file mode 100644 index 00000000..eca95f74 --- /dev/null +++ b/src/lib/product/components/ProductFilter.jsx @@ -0,0 +1,132 @@ +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import { useRouter } from 'next/router' +import { useState } from 'react' +import _ from 'lodash' +import { toQuery } from 'lodash-contrib' + +const orderOptions = [ + { value: 'price-asc', label: 'Harga Terendah' }, + { value: 'price-desc', label: 'Harga Tertinggi' }, + { value: 'popular', label: 'Populer' }, + { value: 'stock', label: 'Ready Stock' } +] + +const ProductFilter = ({ active, close, brands, categories, prefixUrl, defaultBrand = null }) => { + const router = useRouter() + const { query } = router + const [order, setOrder] = useState(query?.orderBy) + const [brand, setBrand] = useState(query?.brand) + const [category, setCategory] = useState(query?.category) + const [priceFrom, setPriceFrom] = useState(query?.priceFrom) + const [priceTo, setPriceTo] = useState(query?.priceTo) + + const handleSubmit = () => { + let params = { + q: router.query.q, + orderBy: order, + brand, + category, + priceFrom, + priceTo + } + params = _.pickBy(params, _.identity) + params = toQuery(params) + router.push(`${prefixUrl}?${params}`) + } + + return ( + <BottomPopup + active={active} + close={close} + title='Filter Produk' + > + <div className='flex flex-col gap-y-4'> + {!defaultBrand && ( + <div> + <label>Brand</label> + <select + name='brand' + className='form-input mt-2' + value={brand} + onChange={(e) => setBrand(e.target.value)} + > + <option value=''>Pilih Brand...</option> + {brands.map((brand, index) => ( + <option + value={brand} + key={index} + > + {brand} + </option> + ))} + </select> + </div> + )} + <div> + <label>Kategori</label> + <select + name='category' + className='form-input mt-2' + value={category} + onChange={(e) => setCategory(e.target.value)} + > + <option value=''>Pilih Kategori...</option> + {categories.map((category, index) => ( + <option + value={category} + key={index} + > + {category} + </option> + ))} + </select> + </div> + <div> + <label>Urutkan</label> + <div className='flex mt-2 gap-x-2 overflow-x-auto'> + {orderOptions.map((orderOption) => ( + <button + key={orderOption.value} + className={`btn-light px-3 font-normal flex-shrink-0 ${ + order == orderOption.value ? 'bg-yellow_r-10' : 'bg-transparent' + }`} + onClick={() => setOrder(orderOption.value)} + > + {orderOption.label} + </button> + ))} + </div> + </div> + <div> + <label>Harga</label> + <div className='flex mt-2 gap-x-4 items-center'> + <input + type='number' + className='form-input' + placeholder='Dari' + value={priceFrom} + onChange={(e) => setPriceFrom(e.target.value)} + /> + <span>—</span> + <input + type='number' + className='form-input' + placeholder='Sampai' + value={priceTo} + onChange={(e) => setPriceTo(e.target.value)} + /> + </div> + </div> + <button + type='button' + className='btn-solid-red w-full mt-2' + onClick={handleSubmit} + > + Terapkan Filter + </button> + </div> + </BottomPopup> + ) +} + +export default ProductFilter diff --git a/src/lib/product/components/ProductSearch.jsx b/src/lib/product/components/ProductSearch.jsx new file mode 100644 index 00000000..52bd5119 --- /dev/null +++ b/src/lib/product/components/ProductSearch.jsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react' +import useProductSearch from '../hooks/useProductSearch' +import ProductCard from './ProductCard' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import ProductSearchSkeleton from './Skeleton/ProductSearchSkeleton' +import ProductFilter from './ProductFilter' +import useActive from '@/core/hooks/useActive' + +const ProductSearch = ({ query, prefixUrl, defaultBrand = null }) => { + const { page = 1 } = query + if (defaultBrand) query.brand = defaultBrand.toLowerCase() + const { productSearch } = useProductSearch({ query }) + const [products, setProducts] = useState(null) + const popup = useActive() + + const pageCount = Math.ceil( + productSearch.data?.response.numFound / productSearch.data?.responseHeader.params.rows + ) + const productStart = productSearch.data?.responseHeader.params.start + const productRows = productSearch.data?.responseHeader.params.rows + const productFound = productSearch.data?.response.numFound + + const brands = productSearch.data?.facetCounts?.facetFields?.brandStr?.filter((value, index) => { + if (index % 2 === 0) { + return true + } + }) + const categories = productSearch.data?.facetCounts?.facetFields?.categoryNameStr?.filter( + (value, index) => { + if (index % 2 === 0) { + return true + } + } + ) + + useEffect(() => { + if (!products) { + setProducts(productSearch.data?.response?.products) + } + }, [query, products, productSearch]) + + if (productSearch.isLoading) { + return <ProductSearchSkeleton /> + } + + return ( + <div className='p-4'> + <h1 className='mb-2 font-semibold text-h-sm'>Produk</h1> + + <div className='mb-2 leading-6 text-gray_r-11'> + {productFound > 0 ? ( + <> + Menampilkan + {pageCount > 1 ? ( + <> + {productStart + 1}- + {productStart + productRows > productFound + ? productFound + : productStart + productRows} + dari + </> + ) : ( + '' + )} + {productFound} + produk{' '} + {query.q && ( + <> + untuk pencarian <span className='font-semibold'>{query.q}</span> + </> + )} + </> + ) : ( + 'Mungkin yang anda cari' + )} + </div> + + <button + className='btn-light mb-6 py-2 px-5' + onClick={popup.activate} + > + Filter + </button> + + <div className='grid grid-cols-2 gap-3'> + {products && + products.map((product) => ( + <ProductCard + product={product} + key={product.id} + /> + ))} + </div> + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`${prefixUrl}?${toQuery(_.omit(query, ['page']))}`} + className='mt-6 mb-2' + /> + + <ProductFilter + active={popup.active} + close={popup.deactivate} + brands={brands || []} + categories={categories || []} + prefixUrl={prefixUrl} + defaultBrand={defaultBrand} + /> + </div> + ) +} + +export default ProductSearch diff --git a/src/lib/product/components/ProductSimilar.jsx b/src/lib/product/components/ProductSimilar.jsx new file mode 100644 index 00000000..63a33089 --- /dev/null +++ b/src/lib/product/components/ProductSimilar.jsx @@ -0,0 +1,15 @@ +import PopularProductSkeleton from '@/lib/home/components/Skeleton/PopularProductSkeleton' +import useProductSimilar from '../hooks/useProductSimilar' +import ProductSlider from './ProductSlider' + +const ProductSimilar = ({ query }) => { + const { productSimilar } = useProductSimilar({ query }) + + if (productSimilar.isLoading) { + return <PopularProductSkeleton /> + } + + return <ProductSlider products={productSimilar.data} /> +} + +export default ProductSimilar diff --git a/src/lib/product/components/ProductSlider.jsx b/src/lib/product/components/ProductSlider.jsx new file mode 100644 index 00000000..060d4638 --- /dev/null +++ b/src/lib/product/components/ProductSlider.jsx @@ -0,0 +1,64 @@ +import { Swiper, SwiperSlide } from 'swiper/react' +import { FreeMode } from 'swiper' +import ProductCard from './ProductCard' +import 'swiper/css' +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { useRef } from 'react' + +const bannerClassName = + 'absolute rounded-r top-0 left-0 h-full max-w-[52%] idt-transition border border-gray_r-6' + +const ProductSlider = ({ products, simpleTitle = false, bannerMode = false }) => { + const bannerRef = useRef('') + + const changeBannerOpacity = (swiper) => { + if (!bannerMode) return + const calculateOpacity = (132 + swiper.translate) / 100 + bannerRef.current.style = `opacity: ${calculateOpacity > 0 ? calculateOpacity : 0}` + } + + return ( + <> + {bannerMode && ( + <div ref={bannerRef}> + <Image + src={products.banner.image} + alt={products.banner.name} + style={{ opacity: 1 }} + className={bannerClassName} + /> + </div> + )} + <Swiper + freeMode={{ enabled: true, sticky: false }} + slidesPerView={2.2} + spaceBetween={8} + onSliderMove={changeBannerOpacity} + onSlideChangeTransitionEnd={changeBannerOpacity} + onSlideChangeTransitionStart={changeBannerOpacity} + prefix='product' + modules={[FreeMode]} + > + {bannerMode && ( + <SwiperSlide> + <Link + href={products.banner.url} + className='w-full h-full block' + ></Link> + </SwiperSlide> + )} + {products?.products?.map((product, index) => ( + <SwiperSlide key={index}> + <ProductCard + product={product} + simpleTitle={simpleTitle} + /> + </SwiperSlide> + ))} + </Swiper> + </> + ) +} + +export default ProductSlider diff --git a/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx new file mode 100644 index 00000000..fa1e175d --- /dev/null +++ b/src/lib/product/components/Skeleton/ProductSearchSkeleton.jsx @@ -0,0 +1,14 @@ +import ProductCardSkeleton from '@/core/components/elements/Skeleton/ProductCardSkeleton' + +const ProductSearchSkeleton = () => ( + <div className='p-4 grid grid-cols-2 gap-4'> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + <ProductCardSkeleton /> + </div> +) + +export default ProductSearchSkeleton diff --git a/src/lib/product/hooks/useProductSearch.js b/src/lib/product/hooks/useProductSearch.js new file mode 100644 index 00000000..0396caec --- /dev/null +++ b/src/lib/product/hooks/useProductSearch.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import productSearchApi from '../api/productSearchApi' +import _ from 'lodash-contrib' + +const useProductSearch = ({ query }) => { + const queryString = _.toQuery(query) + const fetchProductSearch = async () => await productSearchApi({ query: queryString }) + const { data, isLoading } = useQuery(`productSearch-${queryString}`, fetchProductSearch) + + return { + productSearch: { data, isLoading } + } +} + +export default useProductSearch diff --git a/src/lib/product/hooks/useProductSimilar.js b/src/lib/product/hooks/useProductSimilar.js new file mode 100644 index 00000000..d16e4c58 --- /dev/null +++ b/src/lib/product/hooks/useProductSimilar.js @@ -0,0 +1,13 @@ +import productSimilarApi from '../api/productSimilarApi' +import { useQuery } from 'react-query' + +const useProductSimilar = ({ query }) => { + const fetchProductSimilar = async () => await productSimilarApi({ query }) + const { data, isLoading } = useQuery(`productSimilar-${query}`, fetchProductSimilar) + + return { + productSimilar: { data, isLoading } + } +} + +export default useProductSimilar diff --git a/src/lib/quotation/components/Quotation.jsx b/src/lib/quotation/components/Quotation.jsx new file mode 100644 index 00000000..b6e276a3 --- /dev/null +++ b/src/lib/quotation/components/Quotation.jsx @@ -0,0 +1,167 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Divider from '@/core/components/elements/Divider/Divider' +import Link from '@/core/components/elements/Link/Link' +import useAuth from '@/core/hooks/useAuth' +import CartApi from '@/lib/cart/api/CartApi' +import { ExclamationCircleIcon } from '@heroicons/react/24/outline' +import { useEffect, useState } from 'react' +import _ from 'lodash' +import { deleteItemCart, getCart, getItemCart } from '@/core/utils/cart' +import currencyFormat from '@/core/utils/currencyFormat' +import { toast } from 'react-hot-toast' +import checkoutApi from '@/lib/checkout/api/checkoutApi' +import { useRouter } from 'next/router' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' + +const Quotation = () => { + const router = useRouter() + const auth = useAuth() + + const [products, setProducts] = useState(null) + const [totalAmount, setTotalAmount] = useState(0) + const [totalDiscountAmount, setTotalDiscountAmount] = useState(0) + + useEffect(() => { + const loadProducts = async () => { + const cart = getCart() + const variantIds = _.filter(cart, (o) => o.selected == true) + .map((o) => o.productId) + .join(',') + const dataProducts = await CartApi({ variantIds }) + const dataProductsQuantity = _.map(dataProducts, (o) => ({ + ...o, + quantity: getItemCart({ productId: o.id }).quantity + })) + setProducts(dataProductsQuantity) + } + loadProducts() + }, []) + + useEffect(() => { + if (products) { + let calculateTotalAmount = 0 + let calculateTotalDiscountAmount = 0 + products.forEach((product) => { + calculateTotalAmount += product.price.price * product.quantity + calculateTotalDiscountAmount += + (product.price.price - product.price.priceDiscount) * product.quantity + }) + setTotalAmount(calculateTotalAmount) + setTotalDiscountAmount(calculateTotalDiscountAmount) + } + }, [products]) + + const [isLoading, setIsLoading] = useState(false) + + const checkout = async () => { + if (!products || products.length == 0) return + setIsLoading(true) + const productOrder = products.map((product) => ({ + product_id: product.id, + quantity: product.quantity + })) + let data = { + partner_shipping_id: auth.partnerId, + partner_invoice_id: auth.partnerId, + order_line: JSON.stringify(productOrder) + } + const isSuccess = await checkoutApi({ data }) + setIsLoading(false) + if (isSuccess?.id) { + for (const product of products) deleteItemCart({ productId: product.id }) + router.push(`/shop/quotation/finish?id=${isSuccess.id}`) + return + } + toast.error('Gagal melakukan transaksi, terjadi kesalahan internal') + } + + return ( + <> + <div className='p-4'> + <Alert + type='info' + className='text-caption-2 flex gap-x-3' + > + <div> + <ExclamationCircleIcon className='w-7 text-blue-700' /> + </div> + <span className='leading-5'> + Jika mengalami kesulitan dalam melakukan pembelian di website Indoteknik. Hubungi kami + disini + </span> + </Alert> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + {products && ( + <VariantGroupCard + openOnClick={false} + variants={products} + /> + )} + </div> + + <Divider /> + + <div className='p-4'> + <div className='flex justify-between items-center'> + <div className='font-medium'>Ringkasan Penawaran</div> + <div className='text-gray_r-11 text-caption-1'>{products?.length} Barang</div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex flex-col gap-y-4'> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Belanja</div> + <div>{currencyFormat(totalAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Total Diskon</div> + <div className='text-red_r-11'>- {currencyFormat(totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>Subtotal</div> + <div>{currencyFormat(totalAmount - totalDiscountAmount)}</div> + </div> + <div className='flex gap-x-2 justify-between'> + <div className='text-gray_r-11'>PPN 11% (Incl.)</div> + <div>{currencyFormat((totalAmount - totalDiscountAmount) * 0.11)}</div> + </div> + </div> + <hr className='my-4 border-gray_r-6' /> + <div className='flex gap-x-2 justify-between mb-4'> + <div>Grand Total</div> + <div className='font-semibold text-gray_r-12'> + {currencyFormat(totalAmount - totalDiscountAmount)} + </div> + </div> + <p className='text-caption-2 text-gray_r-10 mb-2'>*) Belum termasuk biaya pengiriman</p> + <p className='text-caption-2 text-gray_r-10 leading-5'> + Dengan melakukan pembelian melalui website Indoteknik, saya menyetujui{' '} + <Link + href='/' + className='inline font-normal' + > + Syarat & Ketentuan + </Link>{' '} + yang berlaku + </p> + </div> + + <Divider /> + + <div className='flex gap-x-3 p-4'> + <button + className='flex-1 btn-yellow' + onClick={checkout} + disabled={isLoading} + > + {isLoading ? 'Loading...' : 'Quotation'} + </button> + </div> + </> + ) +} + +export default Quotation diff --git a/src/lib/transaction/api/cancelTransactionApi.js b/src/lib/transaction/api/cancelTransactionApi.js new file mode 100644 index 00000000..1bba2bde --- /dev/null +++ b/src/lib/transaction/api/cancelTransactionApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const cancelTransactionApi = async ({ transaction }) => { + const auth = getAuth() + const dataCancelTransaction = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/cancel` + ) + return dataCancelTransaction +} + +export default cancelTransactionApi diff --git a/src/lib/transaction/api/checkoutPoApi.js b/src/lib/transaction/api/checkoutPoApi.js new file mode 100644 index 00000000..04421368 --- /dev/null +++ b/src/lib/transaction/api/checkoutPoApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const checkoutPoApi = async ({ id }) => { + const auth = getAuth() + const dataCheckout = await odooApi( + 'POST', + `/api/v1/partner/${auth?.partnerId}/sale_order/${id}/checkout` + ) + return dataCheckout +} + +export default checkoutPoApi diff --git a/src/lib/transaction/api/transactionApi.js b/src/lib/transaction/api/transactionApi.js new file mode 100644 index 00000000..e7c4c23f --- /dev/null +++ b/src/lib/transaction/api/transactionApi.js @@ -0,0 +1,10 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionApi = async ({ id }) => { + const auth = getAuth() + const dataTransaction = await odooApi('GET', `/api/v1/partner/${auth.partnerId}/sale_order/${id}`) + return dataTransaction +} + +export default transactionApi diff --git a/src/lib/transaction/api/transactionsApi.js b/src/lib/transaction/api/transactionsApi.js new file mode 100644 index 00000000..f4e36e6f --- /dev/null +++ b/src/lib/transaction/api/transactionsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const transactionsApi = async ({ query }) => { + const auth = getAuth() + const dataTransactions = await odooApi( + 'GET', + `/api/v1/partner/${auth.partnerId}/sale_order?${query}` + ) + return dataTransactions +} + +export default transactionsApi diff --git a/src/lib/transaction/api/uploadPoApi.js b/src/lib/transaction/api/uploadPoApi.js new file mode 100644 index 00000000..7feeff66 --- /dev/null +++ b/src/lib/transaction/api/uploadPoApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const uploadPoApi = async ({ id, data }) => { + const auth = getAuth() + const dataUploadPo = await odooApi( + 'POST', + `/api/v1/partner/${auth.partnerId}/sale_order/${id}/upload_po`, + data + ) + return dataUploadPo +} + +export default uploadPoApi diff --git a/src/lib/transaction/components/Transaction.jsx b/src/lib/transaction/components/Transaction.jsx new file mode 100644 index 00000000..7da33551 --- /dev/null +++ b/src/lib/transaction/components/Transaction.jsx @@ -0,0 +1,352 @@ +import Spinner from '@/core/components/elements/Spinner/Spinner' +import useTransaction from '../hooks/useTransaction' +import TransactionStatusBadge from './TransactionStatusBadge' +import Divider from '@/core/components/elements/Divider/Divider' +import { useRef, useState } from 'react' +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import uploadPoApi from '../api/uploadPoApi' +import { toast } from 'react-hot-toast' +import getFileBase64 from '@/core/utils/getFileBase64' +import currencyFormat from '@/core/utils/currencyFormat' +import VariantGroupCard from '@/lib/variant/components/VariantGroupCard' +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon } from '@heroicons/react/24/outline' +import Link from '@/core/components/elements/Link/Link' +import Alert from '@/core/components/elements/Alert/Alert' +import checkoutPoApi from '../api/checkoutPoApi' +import cancelTransactionApi from '../api/cancelTransactionApi' + +const Transaction = ({ id }) => { + const { transaction } = useTransaction({ id }) + + const poNumber = useRef('') + const poFile = useRef('') + const [uploadPo, setUploadPo] = useState(false) + const openUploadPo = () => setUploadPo(true) + const closeUploadPo = () => setUploadPo(false) + const submitUploadPo = async () => { + const file = poFile.current.files[0] + const name = poNumber.current.value + if (typeof file === 'undefined' || !name) { + toast.error('Nomor dan Dokumen PO harus diisi', { position: 'bottom-center' }) + return + } + if (file.size > 5000000) { + toast.error('Maksimal ukuran file adalah 5MB', { position: 'bottom-center' }) + return + } + const data = { name, file: await getFileBase64(file) } + const isUploaded = await uploadPoApi({ id, data }) + if (isUploaded) { + toast.success('Berhasil upload PO') + transaction.refetch() + closeUploadPo() + return + } + toast.error('Terjadi kesalahan internal, coba lagi nanti atau hubungi kami') + } + + const [cancelTransaction, setCancelTransaction] = useState(false) + const openCancelTransaction = () => setCancelTransaction(true) + const closeCancelTransaction = () => setCancelTransaction(false) + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ transaction: transaction.data }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transaction.refetch() + } + closeCancelTransaction() + } + + const checkout = async () => { + if (!transaction.data?.purchaseOrderFile) { + toast.error('Mohon upload dokumen PO anda sebelum melanjutkan pesanan') + return + } + await checkoutPoApi({ id }) + toast.success('Berhasil melanjutkan pesanan') + transaction.refetch() + } + + if (transaction.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + transaction.data?.name && ( + <> + <div className='flex flex-col gap-y-4 p-4'> + <DescriptionRow label='Status Transaksi'> + <div className='flex justify-end'> + <TransactionStatusBadge status={transaction.data?.status} /> + </div> + </DescriptionRow> + <DescriptionRow label='No Transaksi'>{transaction.data?.name}</DescriptionRow> + <DescriptionRow label='Ketentuan Pembayaran'> + {transaction.data?.paymentTerm} + </DescriptionRow> + <DescriptionRow label='Nama Sales'>{transaction.data?.sales}</DescriptionRow> + <DescriptionRow label='Waktu Transaksi'>{transaction.data?.dateOrder}</DescriptionRow> + </div> + + <Divider /> + + <div className='p-4 flex flex-col gap-y-4'> + <DescriptionRow label='Purchase Order'> + {transaction.data?.purchaseOrderName || '-'} + </DescriptionRow> + <div className='flex items-center'> + <p className='text-gray_r-11 leading-none'>Dokumen PO</p> + <button + type='button' + className='btn-light py-1.5 px-3 ml-auto' + onClick={ + transaction.data?.purchaseOrderFile + ? () => downloadPurchaseOrder(transaction.data) + : openUploadPo + } + > + {transaction.data?.purchaseOrderFile ? 'Download' : 'Upload'} + </button> + </div> + </div> + + <Divider /> + + <div className='font-medium p-4'>Detail Produk</div> + + <div className='p-4 pt-0 flex flex-col gap-y-3'> + <VariantGroupCard + variants={transaction.data?.products} + buyMore + /> + <div className='flex justify-between mt-3 font-medium'> + <p>Total Belanja</p> + <p>{currencyFormat(transaction.data?.amountTotal)}</p> + </div> + </div> + + <Divider /> + + <SectionAddress address={transaction.data?.address} /> + + <Divider /> + + <div className='p-4'> + <p className='font-medium'>Invoice</p> + <div className='flex flex-col gap-y-3 mt-4'> + {transaction.data?.invoices?.map((invoice, index) => ( + <Link + href={`/my/invoice/${invoice.id}`} + key={index} + > + <div className='shadow rounded-md p-4 text-gray_r-12 font-normal flex justify-between'> + <div> + <p className='mb-2'>{invoice?.name}</p> + <div className='flex items-center gap-x-1'> + {invoice.amountResidual > 0 ? ( + <div className='badge-red'>Belum Lunas</div> + ) : ( + <div className='badge-green'>Lunas</div> + )} + <p className='text-caption-2 text-gray_r-11'> + {currencyFormat(invoice.amountTotal)} + </p> + </div> + </div> + <ChevronRightIcon className='w-5 stroke-2' /> + </div> + </Link> + ))} + {transaction.data?.invoices?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Belum ada Invoice + </Alert> + )} + </div> + </div> + + <Divider /> + + <div className='p-4 pt-0'> + {transaction.data?.status == 'draft' && ( + <button + className='btn-yellow w-full mt-4' + onClick={checkout} + > + Lanjutkan Transaksi + </button> + )} + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'draft'} + onClick={downloadQuotation} + > + Download Quotation + </button> + {transaction.data?.status != 'draft' && ( + <button + className='btn-light w-full mt-4' + disabled={transaction.data?.status != 'waiting'} + onClick={openCancelTransaction} + > + Batalkan Transaksi + </button> + )} + </div> + + <BottomPopup + active={cancelTransaction} + close={closeCancelTransaction} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{transaction.data?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={closeCancelTransaction} + > + Batal + </button> + </div> + </BottomPopup> + + <BottomPopup + title='Upload PO' + close={closeUploadPo} + active={uploadPo} + > + <div> + <label>Nomor PO</label> + <input + type='text' + className='form-input mt-3' + ref={poNumber} + /> + </div> + <div className='mt-4'> + <label>Dokumen PO</label> + <input + type='file' + className='form-input mt-3 py-2' + ref={poFile} + /> + </div> + <div className='grid grid-cols-2 gap-x-3 mt-6'> + <button + type='button' + className='btn-light w-full' + onClick={closeUploadPo} + > + Batal + </button> + <button + type='button' + className='btn-solid-red w-full' + onClick={submitUploadPo} + > + Upload + </button> + </div> + </BottomPopup> + </> + ) + ) +} + +const SectionAddress = ({ address }) => { + const [section, setSection] = useState({ + customer: false, + invoice: false, + shipping: false + }) + const toggleSection = (name) => { + setSection({ ...section, [name]: !section[name] }) + } + + return ( + <> + <SectionButton + label='Detail Pelanggan' + active={section.customer} + toggle={() => toggleSection('customer')} + /> + + {section.customer && <SectionContent address={address?.customer} />} + + <Divider /> + + <SectionButton + label='Detail Pengiriman' + active={section.shipping} + toggle={() => toggleSection('shipping')} + /> + + {section.shipping && <SectionContent address={address?.shipping} />} + + <Divider /> + + <SectionButton + label='Detail Penagihan' + active={section.invoice} + toggle={() => toggleSection('invoice')} + /> + {section.invoice && <SectionContent address={address?.invoice} />} + </> + ) +} + +const SectionButton = ({ label, active, toggle }) => ( + <button + className='p-4 font-medium flex justify-between w-full' + onClick={toggle} + > + <span>{label}</span> + {active ? <ChevronUpIcon className='w-5' /> : <ChevronDownIcon className='w-5' />} + </button> +) + +const SectionContent = ({ address }) => { + let fullAddress = [] + if (address?.street) fullAddress.push(address.street) + if (address?.subDistrict?.name) fullAddress.push(address.subDistrict.name) + if (address?.district?.name) fullAddress.push(address.district.name) + if (address?.city?.name) fullAddress.push(address.city.name) + fullAddress = fullAddress.join(', ') + + return ( + <div className='flex flex-col gap-y-4 p-4 border-t border-gray_r-6'> + <DescriptionRow label='Nama'>{address.name}</DescriptionRow> + <DescriptionRow label='Email'>{address.email || '-'}</DescriptionRow> + <DescriptionRow label='No Telepon'>{address.mobile || '-'}</DescriptionRow> + <DescriptionRow label='Alamat'>{fullAddress}</DescriptionRow> + </div> + ) +} + +const DescriptionRow = ({ children, label }) => ( + <div className='grid grid-cols-2'> + <span className='text-gray_r-11'>{label}</span> + <span className='text-right'>{children}</span> + </div> +) + +export default Transaction diff --git a/src/lib/transaction/components/TransactionStatusBadge.jsx b/src/lib/transaction/components/TransactionStatusBadge.jsx new file mode 100644 index 00000000..7372e4da --- /dev/null +++ b/src/lib/transaction/components/TransactionStatusBadge.jsx @@ -0,0 +1,41 @@ +const TransactionStatusBadge = ({ status }) => { + let badgeProps = { + className: ['h-fit'], + text: '' + } + switch (status) { + case 'cancel': + badgeProps.className.push('badge-solid-red') + badgeProps.text = 'Pesanan batal' + break + case 'draft': + badgeProps.className.push('badge-red') + badgeProps.text = 'Pending quotation' + break + case 'waiting': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diterima' + break + case 'sale': + badgeProps.className.push('badge-yellow') + badgeProps.text = 'Pesanan diproses' + break + case 'shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Pesanan dikirim' + break + case 'partial_shipping': + badgeProps.className.push('badge-green') + badgeProps.text = 'Dikirim sebagian' + break + case 'done': + badgeProps.className.push('badge-solid-green') + badgeProps.text = 'Pesanan selesai' + break + } + badgeProps.className = badgeProps.className.join(' ') + + return <div className={badgeProps.className}>{badgeProps.text}</div> +} + +export default TransactionStatusBadge diff --git a/src/lib/transaction/components/Transactions.jsx b/src/lib/transaction/components/Transactions.jsx new file mode 100644 index 00000000..ccbdede2 --- /dev/null +++ b/src/lib/transaction/components/Transactions.jsx @@ -0,0 +1,216 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'react-hot-toast' +import { EllipsisVerticalIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' + +import { downloadPurchaseOrder, downloadQuotation } from '../utils/transactions' +import useTransactions from '../hooks/useTransactions' +import currencyFormat from '@/core/utils/currencyFormat' +import cancelTransactionApi from '../api/cancelTransactionApi' +import TransactionStatusBadge from './TransactionStatusBadge' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import Link from '@/core/components/elements/Link/Link' +import BottomPopup from '@/core/components/elements/Popup/BottomPopup' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import { toQuery } from 'lodash-contrib' +import _ from 'lodash' +import Alert from '@/core/components/elements/Alert/Alert' + +const Transactions = () => { + const router = useRouter() + const { q = '', page = 1 } = router.query + + const limit = 10 + + const query = { + name: q, + offset: (page - 1) * limit, + limit + } + const { transactions } = useTransactions({ query }) + + const [inputQuery, setInputQuery] = useState(q) + const [toOthers, setToOthers] = useState(null) + const [toCancel, setToCancel] = useState(null) + + const submitCancelTransaction = async () => { + const isCancelled = await cancelTransactionApi({ + transaction: toCancel + }) + if (isCancelled) { + toast.success('Berhasil batalkan transaksi') + transactions.refetch() + } + setToCancel(null) + } + + const pageCount = Math.ceil(transactions?.data?.saleOrderTotal / limit) + let pageQuery = _.omit(query, ['limit', 'offset']) + pageQuery = _.pickBy(pageQuery, _.identity) + pageQuery = toQuery(pageQuery) + + const handleSubmit = (e) => { + e.preventDefault() + router.push(`/my/transactions?q=${inputQuery}`) + } + + return ( + <div className='p-4 flex flex-col gap-y-4'> + <form + className='flex gap-x-3' + onSubmit={handleSubmit} + > + <input + type='text' + className='form-input' + placeholder='Cari Transaksi...' + value={inputQuery} + onChange={(e) => setInputQuery(e.target.value)} + /> + <button + className='btn-light bg-transparent px-3' + type='submit' + > + <MagnifyingGlassIcon className='w-6' /> + </button> + </form> + + {transactions.isLoading && ( + <div className='flex justify-center my-4'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + )} + + {!transactions.isLoading && transactions.data?.saleOrders?.length === 0 && ( + <Alert + type='info' + className='text-center' + > + Tidak ada data transaksi + </Alert> + )} + + {transactions.data?.saleOrders?.map((saleOrder, index) => ( + <div + className='p-4 shadow border border-gray_r-3 rounded-md' + key={index} + > + <div className='grid grid-cols-2'> + <Link href={`/my/transaction/${saleOrder.id}`}> + <span className='text-caption-2 text-gray_r-11'>No. Transaksi</span> + <h2 className='text-red_r-11 mt-1'>{saleOrder.name}</h2> + </Link> + <div className='flex gap-x-1 justify-end'> + <TransactionStatusBadge status={saleOrder.status} /> + <EllipsisVerticalIcon + className='w-5 h-5' + onClick={() => setToOthers(saleOrder)} + /> + </div> + </div> + <Link href={`/my/transaction/${saleOrder.id}`}> + <div className='grid grid-cols-2 mt-3'> + <div> + <span className='text-caption-2 text-gray_r-11'>No. Purchase Order</span> + <p className='mt-1 font-medium text-gray_r-12'> + {saleOrder.purchaseOrderName || '-'} + </p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Invoice</span> + <p className='mt-1 font-medium text-gray_r-12'>{saleOrder.invoiceCount} Invoice</p> + </div> + </div> + <div className='grid grid-cols-2 mt-3'> + <div> + <span className='text-caption-2 text-gray_r-11'>Sales</span> + <p className='mt-1 font-medium text-gray_r-12'>{saleOrder.sales}</p> + </div> + <div className='text-right'> + <span className='text-caption-2 text-gray_r-11'>Total Harga</span> + <p className='mt-1 font-medium text-gray_r-12'> + {currencyFormat(saleOrder.amountTotal)} + </p> + </div> + </div> + </Link> + </div> + ))} + + <Pagination + pageCount={pageCount} + currentPage={parseInt(page)} + url={`/my/transactions${pageQuery}`} + className='mt-2 mb-2' + /> + + <BottomPopup + title='Lainnya' + active={toOthers} + close={() => setToOthers(null)} + > + <div className='flex flex-col gap-y-4 mt-2'> + <button + className='text-left disabled:opacity-60' + disabled={!toOthers?.purchaseOrderFile} + onClick={() => { + downloadPurchaseOrder(toOthers) + setToOthers(null) + }} + > + Download PO + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'draft'} + onClick={() => { + downloadQuotation(toOthers) + setToOthers(null) + }} + > + Download Quotation + </button> + <button + className='text-left disabled:opacity-60' + disabled={toOthers?.status != 'waiting'} + onClick={() => { + setToCancel(toOthers) + setToOthers(null) + }} + > + Batalkan Transaksi + </button> + </div> + </BottomPopup> + + <BottomPopup + active={toCancel} + close={() => setToCancel(null)} + title='Batalkan Transaksi' + > + <div className='leading-7 text-gray_r-12/80'> + Apakah anda yakin membatalkan transaksi{' '} + <span className='underline'>{toCancel?.name}</span>? + </div> + <div className='flex mt-6 gap-x-4'> + <button + className='btn-solid-red flex-1' + type='button' + onClick={submitCancelTransaction} + > + Ya, Batalkan + </button> + <button + className='btn-light flex-1' + type='button' + onClick={() => setToCancel(null)} + > + Batal + </button> + </div> + </BottomPopup> + </div> + ) +} + +export default Transactions diff --git a/src/lib/transaction/hooks/useTransaction.js b/src/lib/transaction/hooks/useTransaction.js new file mode 100644 index 00000000..6dda0573 --- /dev/null +++ b/src/lib/transaction/hooks/useTransaction.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import transactionApi from '../api/transactionApi' + +const useTransaction = ({ id }) => { + const fetchTransaction = async () => await transactionApi({ id }) + const { data, isLoading, refetch } = useQuery(`transaction-${id}`, fetchTransaction) + + return { + transaction: { data, isLoading, refetch } + } +} + +export default useTransaction diff --git a/src/lib/transaction/hooks/useTransactions.js b/src/lib/transaction/hooks/useTransactions.js new file mode 100644 index 00000000..5b40a05a --- /dev/null +++ b/src/lib/transaction/hooks/useTransactions.js @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import transactionsApi from '../api/transactionsApi' +import _ from 'lodash-contrib' + +const useTransactions = ({ query }) => { + const queryString = _.toQuery(query) + const fetchTransactions = async () => await transactionsApi({ query: queryString }) + const { data, isLoading, refetch } = useQuery(`transactions-${queryString}`, fetchTransactions) + + return { + transactions: { data, isLoading, refetch } + } +} + +export default useTransactions diff --git a/src/lib/transaction/utils/transactions.js b/src/lib/transaction/utils/transactions.js new file mode 100644 index 00000000..4c7522be --- /dev/null +++ b/src/lib/transaction/utils/transactions.js @@ -0,0 +1,15 @@ +import { getAuth } from '@/core/utils/auth' + +const downloadPurchaseOrder = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download_po/${transaction.token}` + window.open(url, 'download') +} + +const downloadQuotation = (transaction) => { + const auth = getAuth() + const url = `${process.env.ODOO_HOST}/api/v1/partner/${auth.partnerId}/sale_order/${transaction.id}/download/${transaction.token}` + window.open(url, 'download') +} + +export { downloadPurchaseOrder, downloadQuotation } diff --git a/src/lib/variant/components/VariantCard.jsx b/src/lib/variant/components/VariantCard.jsx new file mode 100644 index 00000000..6e7ea871 --- /dev/null +++ b/src/lib/variant/components/VariantCard.jsx @@ -0,0 +1,95 @@ +import { useRouter } from 'next/router' +import { toast } from 'react-hot-toast' + +import Image from '@/core/components/elements/Image/Image' +import Link from '@/core/components/elements/Link/Link' +import { createSlug } from '@/core/utils/slug' +import currencyFormat from '@/core/utils/currencyFormat' +import { updateItemCart } from '@/core/utils/cart' + +const VariantCard = ({ product, openOnClick = true, buyMore = false }) => { + const router = useRouter() + + const addItemToCart = () => { + toast.success('Berhasil menambahkan ke keranjang', { duration: 1500 }) + updateItemCart({ + productId: product.id, + quantity: 1 + }) + return + } + + const checkoutItem = () => { + router.push(`/shop/checkout?product_id=${product.id}&qty=${product.quantity}`) + } + + const Card = () => ( + <div className='flex gap-x-3'> + <div className='w-4/12 flex items-center gap-x-2'> + <Image + src={product.parent.image} + alt={product.parent.name} + className='object-contain object-center border border-gray_r-6 h-32 w-full rounded-md' + /> + </div> + <div className='w-8/12 flex flex-col'> + <p className='product-card__title wrap-line-ellipsis-2'>{product.parent.name}</p> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {product.code || '-'} + {product.attributes.length > 0 ? ` ・ ${product.attributes.join(', ')}` : ''} + </p> + <div className='flex flex-wrap gap-x-1 items-center mt-auto'> + {product.price.discountPercentage > 0 && ( + <> + <p className='text-caption-2 text-gray_r-11 line-through'> + {currencyFormat(product.price.price)} + </p> + <span className='badge-red'>{product.price.discountPercentage}%</span> + </> + )} + <p className='text-caption-2 text-gray_r-12'> + {currencyFormat(product.price.priceDiscount)} + </p> + </div> + <p className='text-caption-2 text-gray_r-11 mt-1'> + {currencyFormat(product.price.priceDiscount)} × {product.quantity} Barang + </p> + <p className='text-caption-2 text-gray_r-12 font-bold mt-2'> + {currencyFormat(product.quantity * product.price.priceDiscount)} + </p> + </div> + </div> + ) + + if (openOnClick) { + return ( + <> + <Link href={createSlug('/shop/product/', product.parent.name, product.parent.id)}> + <Card /> + </Link> + {buyMore && ( + <div className='flex justify-end gap-x-2 mb-2'> + <button + type='button' + onClick={addItemToCart} + className='btn-yellow text-gray_r-12 py-2 px-3 text-caption-1' + > + Tambah Keranjang + </button> + <button + type='button' + onClick={checkoutItem} + className='btn-solid-red py-2 px-3 text-caption-1' + > + Beli Lagi + </button> + </div> + )} + </> + ) + } + + return <Card /> +} + +export default VariantCard diff --git a/src/lib/variant/components/VariantGroupCard.jsx b/src/lib/variant/components/VariantGroupCard.jsx new file mode 100644 index 00000000..e5f5c7fc --- /dev/null +++ b/src/lib/variant/components/VariantGroupCard.jsx @@ -0,0 +1,30 @@ +import { useState } from 'react' +import VariantCard from './VariantCard' + +const VariantGroupCard = ({ variants, ...props }) => { + const [showAll, setShowAll] = useState(false) + const variantsToShow = showAll ? variants : variants.slice(0, 2) + + return ( + <> + {variantsToShow?.map((variant, index) => ( + <VariantCard + key={index} + product={variant} + {...props} + /> + ))} + {variants.length > 2 && ( + <button + type='button' + className='btn-light py-2 w-full' + onClick={() => setShowAll(!showAll)} + > + {!showAll ? `Lihat Semua +${variants.length - variantsToShow.length}` : 'Tutup'} + </button> + )} + </> + ) +} + +export default VariantGroupCard diff --git a/src/lib/wishlist/api/createOrDeleteWishlistApi.js b/src/lib/wishlist/api/createOrDeleteWishlistApi.js new file mode 100644 index 00000000..617d139d --- /dev/null +++ b/src/lib/wishlist/api/createOrDeleteWishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const createOrDeleteWishlistApi = async ({ data }) => { + const auth = getAuth() + const dataWishlist = await odooApi( + 'POST', + `/api/v1/user/${auth.id}/wishlist/create-or-delete`, + data + ) + return dataWishlist +} + +export default createOrDeleteWishlistApi diff --git a/src/lib/wishlist/api/wishlistApi.js b/src/lib/wishlist/api/wishlistApi.js new file mode 100644 index 00000000..a8906dd4 --- /dev/null +++ b/src/lib/wishlist/api/wishlistApi.js @@ -0,0 +1,14 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistApi = async ({ productId }) => { + const auth = getAuth() + if (!auth) return { productTotal: 0, products: [] } + const dataWishlist = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?product_id=${productId}` + ) + return dataWishlist +} + +export default wishlistApi diff --git a/src/lib/wishlist/api/wishlistsApi.js b/src/lib/wishlist/api/wishlistsApi.js new file mode 100644 index 00000000..dfcce028 --- /dev/null +++ b/src/lib/wishlist/api/wishlistsApi.js @@ -0,0 +1,13 @@ +import odooApi from '@/core/api/odooApi' +import { getAuth } from '@/core/utils/auth' + +const wishlistsApi = async ({ limit, offset }) => { + const auth = getAuth() + const dataWishlists = await odooApi( + 'GET', + `/api/v1/user/${auth.id}/wishlist?limit=${limit}&offset=${offset}` + ) + return dataWishlists +} + +export default wishlistsApi diff --git a/src/lib/wishlist/components/Wishlists.jsx b/src/lib/wishlist/components/Wishlists.jsx new file mode 100644 index 00000000..e61efcc3 --- /dev/null +++ b/src/lib/wishlist/components/Wishlists.jsx @@ -0,0 +1,55 @@ +import Alert from '@/core/components/elements/Alert/Alert' +import Pagination from '@/core/components/elements/Pagination/Pagination' +import Spinner from '@/core/components/elements/Spinner/Spinner' +import ProductCard from '@/lib/product/components/ProductCard' +import { useRouter } from 'next/router' +import useWishlists from '../hooks/useWishlists' + +const Wishlists = () => { + const router = useRouter() + const { page = 1 } = router.query + const limit = 30 + const { wishlists } = useWishlists({ page, limit }) + + const pageCount = Math.ceil(wishlists.data?.productTotal / limit) + + if (wishlists.isLoading) { + return ( + <div className='flex justify-center my-6'> + <Spinner className='w-6 text-gray_r-12/50 fill-gray_r-12' /> + </div> + ) + } + + return ( + <div className='px-4 py-6'> + {wishlists.data?.products?.length == 0 && ( + <Alert + type='info' + className='text-center' + > + Wishlist anda masih kosong + </Alert> + )} + + <div className='grid grid-cols-2 gap-3'> + {wishlists.data?.products.map((product) => ( + <ProductCard + key={product.id} + product={product} + /> + ))} + </div> + + <div className='mt-6'> + <Pagination + currentPage={page} + pageCount={pageCount} + url={`/my/wishlist`} + /> + </div> + </div> + ) +} + +export default Wishlists diff --git a/src/lib/wishlist/hooks/useWishlist.js b/src/lib/wishlist/hooks/useWishlist.js new file mode 100644 index 00000000..8580a19d --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlist.js @@ -0,0 +1,13 @@ +import { useQuery } from 'react-query' +import wishlistApi from '../api/wishlistApi' + +const useWishlist = ({ productId }) => { + const fetchWishlist = async () => await wishlistApi({ productId }) + const { data, isLoading, refetch } = useQuery(`wishlist-${productId}`, fetchWishlist) + + return { + wishlist: { data, isLoading, refetch } + } +} + +export default useWishlist diff --git a/src/lib/wishlist/hooks/useWishlists.js b/src/lib/wishlist/hooks/useWishlists.js new file mode 100644 index 00000000..169fdf46 --- /dev/null +++ b/src/lib/wishlist/hooks/useWishlists.js @@ -0,0 +1,14 @@ +import { useQuery } from 'react-query' +import wishlistsApi from '../api/wishlistsApi' + +const useWishlists = ({ page, limit }) => { + const offset = (page - 1) * limit + const fetchWishlists = async () => await wishlistsApi({ limit, offset }) + const { data, isLoading } = useQuery(`wishlists-${limit}-${offset}`, fetchWishlists) + + return { + wishlists: { data, isLoading } + } +} + +export default useWishlists |
