diff options
Diffstat (limited to 'src-migrate')
152 files changed, 4008 insertions, 129 deletions
diff --git a/src-migrate/common/components/skeleton/PageContentSkeleton.tsx b/src-migrate/common/components/skeleton/PageContentSkeleton.tsx deleted file mode 100644 index bf85cff1..00000000 --- a/src-migrate/common/components/skeleton/PageContentSkeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -const PageContentSkeleton = () => { - return ( - <div className="animate-pulse grid gap-y-4"> - <div className="w-full h-10 bg-gray-300 rounded" /> - <div className="h-2" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-8/12 h-4 bg-gray-300 rounded" /> - <div className="h-2" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-full h-4 bg-gray-300 rounded" /> - <div className="w-1/2 h-4 bg-gray-300 rounded" /> - </div> - ) -} - -export default PageContentSkeleton
\ No newline at end of file diff --git a/src-migrate/common/components/elements/Seo.tsx b/src-migrate/components/seo.tsx index 2245663a..1e78ed4d 100644 --- a/src-migrate/common/components/elements/Seo.tsx +++ b/src-migrate/components/seo.tsx @@ -3,7 +3,7 @@ import React from 'react' import { NextSeo } from "next-seo" import { MetaTag, NextSeoProps } from 'next-seo/lib/types'; -const Seo = (props: NextSeoProps) => { +export const Seo = (props: NextSeoProps) => { const router = useRouter() const additionalMetaTags: MetaTag[] = [ @@ -29,6 +29,4 @@ const Seo = (props: NextSeoProps) => { additionalMetaTags={additionalMetaTags} /> ) -} - -export default Seo
\ No newline at end of file +}
\ No newline at end of file diff --git a/src-migrate/components/ui/image.tsx b/src-migrate/components/ui/image.tsx new file mode 100644 index 00000000..de0ad1da --- /dev/null +++ b/src-migrate/components/ui/image.tsx @@ -0,0 +1,34 @@ +import NextImage, { ImageProps as NextImageProps } from 'next/image'; +import { useState } from 'react'; + +import clsxm from '~/libs/clsxm'; + +type ImageProps = { + rounded?: string; +} & NextImageProps; + +const Image = (props: ImageProps) => { + const { alt, src, className, rounded, ...rest } = props; + const [isLoading, setLoading] = useState(true); + + return ( + <NextImage + className={clsxm( + 'duration-500 ease-in-out', + isLoading + ? 'scale-[1.02] blur-xl grayscale' + : 'scale-100 blur-0 grayscale-0', + rounded, + className + )} + src={src} + alt={alt} + loading='lazy' + quality={100} + onLoadingComplete={() => setLoading(false)} + unoptimized + {...rest} + /> + ); +}; +export default Image;
\ No newline at end of file diff --git a/src-migrate/common/components/elements/Modal.tsx b/src-migrate/components/ui/modal.tsx index 9c5c73ce..34e1d1c3 100644 --- a/src-migrate/common/components/elements/Modal.tsx +++ b/src-migrate/components/ui/modal.tsx @@ -1,13 +1,12 @@ -import { XMarkIcon } from "@heroicons/react/24/outline"; -import { AnimatePresence, motion } from "framer-motion" -import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import ReactDOM from "react-dom"; +import { useRouter } from "next/router"; +import { AnimatePresence, motion } from "framer-motion" import { useWindowSize } from "usehooks-ts"; -import clsxm from "~/common/libs/clsxm"; - +import { XMarkIcon } from "@heroicons/react/24/outline"; +import clsxm from "~/libs/clsxm"; -type Props = { +export interface ModalProps { children: React.ReactNode active: boolean title?: string @@ -16,14 +15,14 @@ type Props = { mode?: "mobile" | "desktop" } -const Modal = ({ +export const Modal = ({ children, active = false, title, close, className, mode -}: Props) => { +}: ModalProps) => { const router = useRouter() const { width } = useWindowSize() const [rendered, setRendered] = useState<boolean>(false) @@ -37,7 +36,7 @@ const Modal = ({ const modalClassNames = clsxm( "fixed bg-white max-h-[80vh] overflow-auto p-4 pt-0 z-[60] border-gray_r-6", { - "left-1/2 -translate-x-1/2 translate-y-1/2 bottom-1/2 w-11/12 md:w-1/4 lg:w-1/3 border rounded-xl": mode === 'desktop', + "left-1/2 -translate-x-1/2 translate-y-1/2 bottom-1/2 w-11/12 md:w-[500px] border rounded-xl": mode === 'desktop', "left-0 w-full border-t bottom-0 rounded-t-xl": mode === 'mobile' }, className @@ -72,8 +71,8 @@ const Modal = ({ {title} </div> {close && ( - <button className="rounded-full h-10 w-10 flex justify-center bg-white" type='button' onClick={close}> - <XMarkIcon className='w-5 stroke-2' /> + <button className="rounded-full h-10 w-10 flex justify-center items-center bg-white" type='button' onClick={close}> + <XMarkIcon className='w-5 h-5 ' /> </button> )} </div> @@ -85,6 +84,4 @@ const Modal = ({ </AnimatePresence>, document.querySelector('body')! ) -} - -export default Modal
\ No newline at end of file +}
\ No newline at end of file diff --git a/src-migrate/common/components/elements/ReCaptcha.tsx b/src-migrate/components/ui/re-captcha.tsx index 1bc31d90..e31aa1e3 100644 --- a/src-migrate/common/components/elements/ReCaptcha.tsx +++ b/src-migrate/components/ui/re-captcha.tsx @@ -2,16 +2,14 @@ import ReCAPTCHA, { ReCAPTCHAProps } from "react-google-recaptcha" const GOOGLE_RECAPTCHA_KEY = process.env.NEXT_PUBLIC_RECAPTCHA_GOOGLE || '' -type Props = Omit<ReCAPTCHAProps, 'sitekey'> & { +export interface ReCaptchaProps extends Omit<ReCAPTCHAProps, 'sitekey'> { sitekey?: string; } -const ReCaptcha = (props: Props) => { +export const ReCaptcha = (props: ReCaptchaProps) => { const { sitekey, ...rest } = props return ( <ReCAPTCHA sitekey={sitekey || GOOGLE_RECAPTCHA_KEY} {...rest} /> ) } - -export default ReCaptcha
\ No newline at end of file diff --git a/src-migrate/components/ui/smooth-render.tsx b/src-migrate/components/ui/smooth-render.tsx new file mode 100644 index 00000000..5de3b28d --- /dev/null +++ b/src-migrate/components/ui/smooth-render.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import clsxm from '~/libs/clsxm' + +type Props = { + children: React.ReactNode, + isLoaded: boolean, + height: string, + duration?: string + delay?: string +} & React.HTMLProps<HTMLDivElement> + +const SmoothRender = (props: Props) => { + const { + children, + isLoaded, + height, + duration = 0, + delay = 0, + style, + className, + ...rest + } = props + + return ( + <div + className={clsxm('overflow-y-hidden transition-all', className)} + style={{ + opacity: isLoaded ? 1 : 0, + height: isLoaded ? height : 0, + transitionDuration: duration || '', + transitionDelay: delay || '', + ...style + }} + {...rest} + > + {isLoaded && children} + </div> + ) +} + +export default SmoothRender
\ No newline at end of file diff --git a/src-migrate/common/constants/menu.ts b/src-migrate/constants/menu.ts index 853da507..d1adebca 100644 --- a/src-migrate/common/constants/menu.ts +++ b/src-migrate/constants/menu.ts @@ -1,20 +1,20 @@ -import { SecondaryNavItemProps } from '../types/nav' +import { SecondaryNavItemProps } from '~/types/nav'; export const SECONDARY_MENU_ITEMS: SecondaryNavItemProps[] = [ { label: 'Semua Brand', - href: '/shop/brands' + href: '/shop/brands', }, { label: 'Ready Stock', - href: '/shop/search?orderBy=stock' + href: '/shop/search?orderBy=stock', }, { label: 'Blog Indoteknik', - href: 'https://blog.indoteknik.com/' + href: 'https://blog.indoteknik.com/', }, { label: 'Indoteknik TV', - href: '/video' - } -] + href: '/video', + }, +]; diff --git a/src-migrate/constants/promotion.ts b/src-migrate/constants/promotion.ts new file mode 100644 index 00000000..e6dfcc9b --- /dev/null +++ b/src-migrate/constants/promotion.ts @@ -0,0 +1,17 @@ +export const PROMO_CATEGORY = { + bundling: { + name: 'Bundling', + alias: 'Silat', + description: 'Kombinasi Kilat (SiLat)', + }, + discount_loading: { + name: 'Discount Loading', + alias: 'Barong', + description: 'Barang Borong (BaRong)', + }, + merchandise: { + name: 'Merchandise', + alias: 'Angklung', + description: 'Menang Langsung (Angklung)', + }, +}; diff --git a/src-migrate/constants/utm-source.ts b/src-migrate/constants/utm-source.ts new file mode 100644 index 00000000..95c03ed2 --- /dev/null +++ b/src-migrate/constants/utm-source.ts @@ -0,0 +1,8 @@ +export const UTM_SOURCE = { + '/': 'web.home', + '/shop/product/[slug]': 'web.other-product', + '/shop/search': 'web.search', + '/shop/brands/[slug]': 'web.brand', + '/shop/category/[slug]': 'web.category', + '/shop/cart': 'web.cart', +}; diff --git a/src-migrate/hooks/useUtmSource.ts b/src-migrate/hooks/useUtmSource.ts new file mode 100644 index 00000000..a72fae36 --- /dev/null +++ b/src-migrate/hooks/useUtmSource.ts @@ -0,0 +1,20 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { UTM_SOURCE } from '~/constants/utm-source'; + +const useUtmSource = () => { + const router = useRouter(); + const [source, setSource] = useState<string>(); + + useEffect(() => { + console.log(router.pathname); + + if (router.pathname) { + setSource(UTM_SOURCE[router.pathname as keyof typeof UTM_SOURCE]); + } + }, [router.pathname]); + + return source; +}; + +export default useUtmSource; diff --git a/src-migrate/common/libs/auth.ts b/src-migrate/libs/auth.ts index fb4e836a..86ce26e1 100644 --- a/src-migrate/common/libs/auth.ts +++ b/src-migrate/libs/auth.ts @@ -1,5 +1,5 @@ import { deleteCookie, getCookie, setCookie } from 'cookies-next'; -import { AuthProps } from '../types/auth'; +import { AuthProps } from '~/types/auth'; const COOKIE_KEY = 'auth'; diff --git a/src-migrate/common/libs/clsxm.ts b/src-migrate/libs/clsxm.ts index 0fc10317..0fc10317 100644 --- a/src-migrate/common/libs/clsxm.ts +++ b/src-migrate/libs/clsxm.ts diff --git a/src-migrate/libs/formatCurrency.ts b/src-migrate/libs/formatCurrency.ts new file mode 100644 index 00000000..d683acf3 --- /dev/null +++ b/src-migrate/libs/formatCurrency.ts @@ -0,0 +1,5 @@ +const formatCurrency = (value: number) => { + return Math.round(value).toLocaleString('id-ID'); +}; + +export default formatCurrency; diff --git a/src-migrate/libs/formatNumber.ts b/src-migrate/libs/formatNumber.ts new file mode 100644 index 00000000..da243418 --- /dev/null +++ b/src-migrate/libs/formatNumber.ts @@ -0,0 +1,8 @@ +export const formatToShortText = (number: number) => { + if (number > 1000) { + return `${Math.floor(number / 1000)}rb+`; + } else if (number > 100) { + return `${Math.floor(number / 100) * 100}+`; + } + return number.toString(); +}; diff --git a/src-migrate/common/libs/odooApi.ts b/src-migrate/libs/odooApi.ts index 2dbc18d3..9482542b 100644 --- a/src-migrate/common/libs/odooApi.ts +++ b/src-migrate/libs/odooApi.ts @@ -1,7 +1,7 @@ import axios, { AxiosRequestConfig, Method } from 'axios'; import { getCookie, setCookie } from 'cookies-next'; import { getAuth } from './auth'; -import { AuthApiProps, AuthProps } from '../types/auth'; +import { AuthApiProps } from '~/types/auth'; const ODOO_HOST = process.env.NEXT_PUBLIC_ODOO_API_HOST as string; diff --git a/src-migrate/libs/slug.ts b/src-migrate/libs/slug.ts new file mode 100644 index 00000000..5ab3b3dd --- /dev/null +++ b/src-migrate/libs/slug.ts @@ -0,0 +1,34 @@ +import { toTitleCase } from './toTitleCase'; + +export const createSlug = ( + prefix: string, + name: string, + id: string, + withHost = false +) => { + const cleanName = name + .trim() + .replace(new RegExp(/[^A-Za-z0-9]/, 'g'), '-') + .toLowerCase(); + + let slug = `${cleanName}-${id}`; + const splitSlug = slug.split('-'); + const filterSlug = splitSlug.filter((x) => x !== ''); + + slug = `${prefix}${filterSlug.join('-')}`; + + if (withHost) slug = process.env.NEXT_PUBLIC_SELF_HOST + slug; + + return slug; +}; + +export const getIdFromSlug = (slug: string) => { + let id = slug.split('-'); + return id[id.length - 1]; +}; + +export const getNameFromSlug = (slug: string) => { + let name = slug.split('-'); + name.pop(); + return toTitleCase(name.join(' ')); +}; diff --git a/src-migrate/libs/toTitleCase.ts b/src-migrate/libs/toTitleCase.ts new file mode 100644 index 00000000..dad66813 --- /dev/null +++ b/src-migrate/libs/toTitleCase.ts @@ -0,0 +1,5 @@ +export const toTitleCase = (val: string) => { + return val.replace(/\w\S*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); + }); +}; diff --git a/src-migrate/libs/whatsappUrl.ts b/src-migrate/libs/whatsappUrl.ts new file mode 100644 index 00000000..a3fcf8ad --- /dev/null +++ b/src-migrate/libs/whatsappUrl.ts @@ -0,0 +1,48 @@ +import { getAuth } from './auth'; + +const TEMPLATES = { + default: 'Bisa tolong bantu kebutuhan saya?', + product: + 'Saya mencari barang berikut:\n\n{{url}}\n\n```Brand: {{manufacture}}\nName: {{productName}}```', +}; + +interface WhatsappUrlProps { + template: keyof typeof TEMPLATES; + payload: any; + greeting?: boolean; + needLogin?: boolean; + fallbackUrl?: string; +} + +export const whatsappUrl = ({ + template, + payload, + greeting = true, + needLogin = true, + fallbackUrl, +}: WhatsappUrlProps) => { + const auth = getAuth(); + + let greetingText = ''; + + if (needLogin && !auth) { + return fallbackUrl + ? `/login?next=${encodeURIComponent(fallbackUrl)}` + : '/login'; + } + + let result = TEMPLATES[template].replace( + /{{(.*?)}}/g, + (match, key) => payload[key] || '' + ); + + if (greeting && typeof auth === 'object') { + greetingText = `Halo Indoteknik.com, Saya ${auth.name} `; + if (auth.parentName) greetingText += `dari ${auth.parentName}`; + greetingText += '.\n\n'; + + result = greetingText + result; + } + + return `https://wa.me/6281717181922?text=${encodeURIComponent(result)}`; +}; diff --git a/src-migrate/modules/account-activation/components/FormEmail.tsx b/src-migrate/modules/account-activation/components/FormEmail.tsx index ec300ba4..f7925481 100644 --- a/src-migrate/modules/account-activation/components/FormEmail.tsx +++ b/src-migrate/modules/account-activation/components/FormEmail.tsx @@ -3,9 +3,9 @@ import Link from "next/link" import { useRouter } from "next/router" import { ChangeEvent, useEffect, useState } from "react" import { useMutation } from "react-query" -import Modal from "~/common/components/elements/Modal" -import { useRegisterStore } from "~/common/stores/useRegisterStore" -import { ActivationReqProps } from "~/common/types/auth" +import { Modal } from "~/components/ui/modal" +import { useRegisterStore } from "~/modules/register/stores/useRegisterStore" +import { ActivationReqProps } from "~/types/auth" import { activationReq } from "~/services/auth" const FormEmail = () => { diff --git a/src-migrate/modules/account-activation/components/FormOTP.tsx b/src-migrate/modules/account-activation/components/FormOTP.tsx index 6815a088..cf4da2db 100644 --- a/src-migrate/modules/account-activation/components/FormOTP.tsx +++ b/src-migrate/modules/account-activation/components/FormOTP.tsx @@ -3,9 +3,9 @@ import { useRouter } from "next/router" import { useEffect, useState } from "react" import { useMutation } from "react-query" import { useCountdown } from "usehooks-ts" -import Modal from '~/common/components/elements/Modal' -import { setAuth } from "~/common/libs/auth" -import { ActivationOtpProps, ActivationReqProps } from "~/common/types/auth" +import { Modal } from "~/components/ui/modal" +import { setAuth } from "~/libs/auth" +import { ActivationOtpProps, ActivationReqProps } from "~/types/auth" import { activationReq, activationUserOTP } from "~/services/auth" const FormOTP = () => { diff --git a/src-migrate/modules/account-activation/components/FormToken.tsx b/src-migrate/modules/account-activation/components/FormToken.tsx index b68b244f..2835ec0e 100644 --- a/src-migrate/modules/account-activation/components/FormToken.tsx +++ b/src-migrate/modules/account-activation/components/FormToken.tsx @@ -4,10 +4,10 @@ import { useEffect, useState } from "react" import Link from "next/link" import { useMutation } from "react-query" -import Modal from "~/common/components/elements/Modal" -import { ActivationTokenProps } from "~/common/types/auth" +import { Modal } from "~/components/ui/modal" +import { ActivationTokenProps } from "~/types/auth" import { activationUserToken } from "~/services/auth" -import { setAuth } from "~/common/libs/auth" +import { setAuth } from "~/libs/auth" const FormToken = () => { const router = useRouter() diff --git a/src-migrate/modules/account-activation/index.tsx b/src-migrate/modules/account-activation/index.tsx index 97c96953..c6e2c683 100644 --- a/src-migrate/modules/account-activation/index.tsx +++ b/src-migrate/modules/account-activation/index.tsx @@ -1,4 +1,3 @@ -import { useRouter } from "next/router" import FormToken from "./components/FormToken" import FormEmail from "./components/FormEmail" import FormOTP from "./components/FormOTP" diff --git a/src-migrate/modules/cart/components/CartSummaryMobile.tsx b/src-migrate/modules/cart/components/CartSummaryMobile.tsx new file mode 100644 index 00000000..d9f72e0e --- /dev/null +++ b/src-migrate/modules/cart/components/CartSummaryMobile.tsx @@ -0,0 +1,111 @@ +import style from '../styles/summary.module.css'; + +import React, { useState } from 'react'; +import formatCurrency from '~/libs/formatCurrency'; +import clsxm from '~/libs/clsxm'; +import { Button, Skeleton } from '@chakra-ui/react'; +import _ from 'lodash'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import BottomPopup from '@/core/components/elements/Popup/BottomPopup'; +import useDevice from '@/core/hooks/useDevice'; + +type Props = { + total?: number; + discount?: number; + subtotal?: number; + tax?: number; + shipping?: number; + grandTotal?: number; + isLoaded: boolean; +}; + +const CartSummaryMobile = ({ + total, + discount, + subtotal, + tax, + shipping, + grandTotal, + isLoaded = false, +}: Props) => { + const [showPopup, setShowPopup] = useState(false); + return ( + <> + <BottomPopup + className=' !h-[35%]' + title='Ringkasan Pensanan' + active={showPopup} + close={() => setShowPopup(false)} + > + <div className='mt-4'> + <div className='flex flex-col gap-y-3'> + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Total Belanja</span> + <span className={style.value}> + Rp {formatCurrency(subtotal || 0)} + </span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Total Diskon</span> + <span className={clsxm(style.value, style.discount)}> + - Rp {formatCurrency(discount || 0)} + </span> + </Skeleton> + + <div className={style.divider} /> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Subtotal</span> + <span className={style.value}> + Rp {formatCurrency(total || 0)} + </span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Tax 11%</span> + <span className={style.value}>Rp {formatCurrency(tax || 0)}</span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Biaya Kirim</span> + <span className={style.value}> + Rp {formatCurrency(shipping || 0)} + </span> + </Skeleton> + + <div className={style.divider} /> + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={clsxm(style.label, style.grandTotal)}> + Grand Total + </span> + <span className={style.value}> + Rp {formatCurrency(grandTotal || 0)} + </span> + </Skeleton> + </div> + </div> + </BottomPopup> + <div className='flex flex-col gap-y-3'> + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={clsxm(style.label, style.grandTotal)}> + Grand Total + </span> + <button + onClick={() => setShowPopup(true)} + className='bg-gray-300 w-6 h-6 items-center justify-center cursor-pointer hover:bg-red-400 md:hidden ' + > + <ChevronDownIcon className='h-6 w-6 text-white' /> + </button> + </Skeleton> + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.value}> + Rp {formatCurrency(grandTotal || 0)} + </span> + </Skeleton> + </div> + </> + ); +}; + +export default CartSummaryMobile; diff --git a/src-migrate/modules/cart/components/Item.tsx b/src-migrate/modules/cart/components/Item.tsx new file mode 100644 index 00000000..6ded6373 --- /dev/null +++ b/src-migrate/modules/cart/components/Item.tsx @@ -0,0 +1,156 @@ +import style from '../styles/item.module.css' + +import { Skeleton, SkeletonProps, Tooltip } from '@chakra-ui/react' +import { InfoIcon } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' + +import { PROMO_CATEGORY } from '~/constants/promotion' +import formatCurrency from '~/libs/formatCurrency' +import { createSlug } from '~/libs/slug' +import { CartItem as CartItemProps } from '~/types/cart' + +import CartItemAction from './ItemAction' +import CartItemPromo from './ItemPromo' +import CartItemSelect from './ItemSelect' + +type Props = { + item: CartItemProps + editable?: boolean +} + +const CartItem = ({ item, editable = true }: Props) => { + return ( + <div className={style.wrapper}> + {item.cart_type === 'promotion' && ( + <div className={style.header}> + {item.promotion_type?.value && ( + <Tooltip label={PROMO_CATEGORY[item.promotion_type?.value].description} placement="top" bgColor='red.600' p={2} rounded={6}> + <div className={style.badgeType}> + Paket {PROMO_CATEGORY[item.promotion_type?.value].alias} + <InfoIcon size={14} /> + </div> + </Tooltip> + )} + <div className='w-2' /> + <div> + Selamat! Pembelian anda lebih hemat {' '} + <span className={style.savingAmt}> + Rp{formatCurrency((item.package_price || 0) * item.quantity - item.subtotal)} + </span> + </div> + </div> + )} + + <div className={style.mainProdWrapper}> + {editable && <CartItemSelect item={item} />} + <div className='w-4' /> + + <CartItem.Image item={item} /> + + <div className={style.details}> + <CartItem.Name item={item} /> + + <div className='mt-2 flex justify-between w-full'> + <div className='flex flex-col gap-y-1'> + {item.cart_type === 'promotion' && ( + <div className={style.discPriceSection}> + <span className={style.priceBefore}> + Rp {formatCurrency((item.package_price || 0))} + </span> + <span className={style.price}> + Rp {formatCurrency(item.price.price)} + </span> + </div> + )} + + {item.cart_type === 'product' && ( + <div className={style.discPriceSection}> + {item.price.discount_percentage > 0 && ( + <span className={style.priceBefore}> + Rp {formatCurrency((item.price.price || 0))} + </span> + )} + + <div className={style.price}> + {item.price.price_discount > 0 && `Rp ${formatCurrency(item.price.price_discount)}`} + {item.price.price_discount === 0 && '-'} + </div> + </div> + )} + + <div>{item.cart_type === 'product' && item.code}</div> + <div>{Math.round(item.weight * 10) / 10} Kg</div> + </div> + + {editable && <CartItemAction item={item} />} + {!editable && <div className={style.quantity}>{item.quantity}</div>} + </div> + </div> + + </div> + + <div className="flex flex-col"> + {item.products?.map((product) => <CartItemPromo key={product.id} product={product} />)} + {item.free_products?.map((product) => <CartItemPromo key={product.id} product={product} />)} + </div> + </div> + ) +} + +CartItem.Image = function CartItemImage({ item }: { item: CartItemProps }) { + const image = item?.image || item?.parent?.image + + return ( + <> + {item.cart_type === 'promotion' && ( + <div className={style.image}> + {image && <Image src={image} alt={item.name} width={128} height={128} />} + {!image && <div className={style.noImage}>No Image</div>} + </div> + )} + + {item.cart_type === 'product' && ( + <Link + href={createSlug('/shop/product/', item.parent.name, item.parent.id.toString())} + className={style.image} + > + {image && <Image src={image} alt={item.name} width={128} height={128} />} + {!image && <div className={style.noImage}>No Image</div>} + </Link> + )} + </> + ) +} + +CartItem.Name = function CartItemName({ item }: { item: CartItemProps }) { + return ( + <> + {item.cart_type === 'promotion' && ( + <div className={style.name}>{item.name}</div> + )} + + {item.cart_type === 'product' && ( + <Link + href={createSlug('/shop/product/', item.parent.name, item.parent.id.toString())} + className={style.name} + > + {item.name} + </Link> + )} + </> + ) +} + +CartItem.Skeleton = function CartItemSkeleton(props: SkeletonProps & { count: number }) { + return Array.from({ length: props.count }).map((_, index) => ( + <Skeleton key={index} + height='100px' + width='100%' + rounded='md' + {...props} + /> + )) +} + +export default CartItem
\ No newline at end of file diff --git a/src-migrate/modules/cart/components/ItemAction.tsx b/src-migrate/modules/cart/components/ItemAction.tsx new file mode 100644 index 00000000..e73d507b --- /dev/null +++ b/src-migrate/modules/cart/components/ItemAction.tsx @@ -0,0 +1,111 @@ +import style from '../styles/item-action.module.css' + +import React, { useEffect, useState } from 'react' + +import { Spinner, Tooltip } from '@chakra-ui/react' +import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react' + +import { CartItem } from '~/types/cart' +import { getAuth } from '~/libs/auth' +import { deleteUserCart, upsertUserCart } from '~/services/cart' + +import { useDebounce } from 'usehooks-ts' +import { useCartStore } from '../stores/useCartStore' + + +type Props = { + item: CartItem +} + +const CartItemAction = ({ item }: Props) => { + const auth = getAuth() + + const [isLoadDelete, setIsLoadDelete] = useState<boolean>(false) + const [isLoadQuantity, setIsLoadQuantity] = useState<boolean>(false) + + const [quantity, setQuantity] = useState<number>(item.quantity) + + const { loadCart } = useCartStore() + + const limitQty = item.limit_qty?.transaction || 0 + + const handleDelete = async () => { + if (typeof auth !== 'object') return + + setIsLoadDelete(true) + await deleteUserCart(auth.id, [item.cart_id]) + await loadCart(auth.id) + setIsLoadDelete(false) + } + + const decreaseQty = () => { setQuantity((quantity) => quantity -= 1) } + const increaseQty = () => { setQuantity((quantity) => quantity += 1) } + const debounceQty = useDebounce(quantity, 1000) + useEffect(() => { + if (isNaN(debounceQty)) setQuantity(1) + if (limitQty > 0 && debounceQty > limitQty) setQuantity(limitQty) + }, [debounceQty, limitQty]) + + useEffect(() => { + const updateCart = async () => { + if (typeof auth !== 'object' || isNaN(debounceQty)) return + + setIsLoadQuantity(true) + await upsertUserCart({ + userId: auth.id, + type: item.cart_type, + id: item.id, + qty: debounceQty, + selected: item.selected, + }) + await loadCart(auth.id) + setIsLoadQuantity(false) + } + updateCart() + //eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceQty]) + + return ( + <div className={style.actionSection}> + <button className={style.deleteButton} onClick={handleDelete} disabled={isLoadDelete}> + {isLoadDelete && <Spinner size='xs' />} + {!isLoadDelete && <Trash2Icon size={16} />} + </button> + + <div className={style.quantitySection}> + {isLoadQuantity && ( + <div className={style.quantityLoading}> + <Spinner size='sm' /> + </div> + )} + + <button + className={style.quantityControl} + onClick={decreaseQty} + disabled={quantity <= 1} + > + <MinusIcon size={16} /> + </button> + + <input + type='number' + className={style.quantity.toString()} + onChange={(e) => setQuantity(parseInt(e.target.value))} + value={quantity} + /> + + <Tooltip label={limitQty > 0 ? `Max. ${limitQty}` : ''}> + <button + className={style.quantityControl} + onClick={increaseQty} + disabled={limitQty > 0 && quantity >= limitQty} + > + <PlusIcon size={16} /> + </button> + </Tooltip> + </div> + </div> + ) +} + +export default CartItemAction
\ No newline at end of file diff --git a/src-migrate/modules/cart/components/ItemPromo.tsx b/src-migrate/modules/cart/components/ItemPromo.tsx new file mode 100644 index 00000000..878e17ac --- /dev/null +++ b/src-migrate/modules/cart/components/ItemPromo.tsx @@ -0,0 +1,44 @@ +import style from '../styles/item-promo.module.css' + +import Image from 'next/image' +import Link from 'next/link' +import { createSlug } from '~/libs/slug' + +import { CartProduct } from '~/types/cart' + +type Props = { + product: CartProduct +} + +const CartItemPromo = ({ product }: Props) => { + return ( + <div key={product.id} className={style.wrapper}> + <Link href={createSlug('/shop/product/', product.parent.name, product.parent.id.toString())} className={style.imageWrapper}> + {product?.image && <Image src={product.image} alt={product.name} width={128} height={128} className={style.image} />} + </Link> + + <div className={style.details}> + <Link href={createSlug('/shop/product/', product.parent.name, product.parent.id.toString())} className={style.name}> + {product.display_name} + </Link> + + <div className='flex w-full'> + <div className="flex flex-col"> + <div className={style.code}>{product.code}</div> + <div> + <span className={style.weightLabel}>Berat Barang: </span> + <span>{product.package_weight} Kg</span> + </div> + </div> + + <div className={style.quantity}> + {product.qty} + </div> + </div> + </div> + + </div> + ) +} + +export default CartItemPromo
\ No newline at end of file diff --git a/src-migrate/modules/cart/components/ItemSelect.tsx b/src-migrate/modules/cart/components/ItemSelect.tsx new file mode 100644 index 00000000..b904a1de --- /dev/null +++ b/src-migrate/modules/cart/components/ItemSelect.tsx @@ -0,0 +1,54 @@ +import { Checkbox, Spinner } from '@chakra-ui/react' +import React, { useState } from 'react' + +import { getAuth } from '~/libs/auth' +import { CartItem } from '~/types/cart' +import { upsertUserCart } from '~/services/cart' + +import { useCartStore } from '../stores/useCartStore' + +type Props = { + item: CartItem +} + +const CartItemSelect = ({ item }: Props) => { + const auth = getAuth() + const { loadCart } = useCartStore() + + const [isLoad, setIsLoad] = useState<boolean>(false) + + const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { + if (typeof auth !== 'object') return + + setIsLoad(true) + await upsertUserCart({ + userId: auth.id, + type: item.cart_type, + id: item.id, + qty: item.quantity, + selected: e.target.checked + }) + await loadCart(auth.id) + setIsLoad(false) + } + + return ( + <div className='w-6 my-auto'> + {isLoad && ( + <Spinner className='my-auto' size='sm' /> + )} + + {!isLoad && ( + <Checkbox + borderColor='gray.600' + colorScheme='red' + size='lg' + isChecked={item.selected} + onChange={handleChange} + /> + )} + </div> + ) +} + +export default CartItemSelect
\ No newline at end of file diff --git a/src-migrate/modules/cart/components/Summary.tsx b/src-migrate/modules/cart/components/Summary.tsx new file mode 100644 index 00000000..2e55c8df --- /dev/null +++ b/src-migrate/modules/cart/components/Summary.tsx @@ -0,0 +1,75 @@ +import style from '../styles/summary.module.css' + +import React from 'react' +import formatCurrency from '~/libs/formatCurrency' +import clsxm from '~/libs/clsxm' +import { Skeleton } from '@chakra-ui/react' +import _ from 'lodash' + +type Props = { + total?: number + discount?: number + subtotal?: number + tax?: number + shipping?: number + grandTotal?: number + isLoaded: boolean +} + +const CartSummary = ({ + total, + discount, + subtotal, + tax, + shipping, + grandTotal, + isLoaded = false, +}: Props) => { + return ( + <> + <div className='text-h-sm font-medium'>Ringkasan Pesanan</div> + + <div className="h-6" /> + + <div className='flex flex-col gap-y-3'> + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Total Belanja</span> + <span className={style.value}>Rp {formatCurrency(subtotal || 0)}</span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Total Diskon</span> + <span className={clsxm(style.value, style.discount)}>- Rp {formatCurrency(discount || 0)}</span> + </Skeleton> + + <div className={style.divider} /> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Subtotal</span> + <span className={style.value}>Rp {formatCurrency(total || 0)}</span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Tax 11%</span> + <span className={style.value}>Rp {formatCurrency(tax || 0)}</span> + </Skeleton> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={style.label}>Biaya Kirim</span> + <span className={style.value}>Rp {formatCurrency(shipping || 0)}</span> + </Skeleton> + + <div className={style.divider} /> + + <Skeleton isLoaded={isLoaded} className={style.line}> + <span className={clsxm(style.label, style.grandTotal)}> + Grand Total + </span> + <span className={style.value}>Rp {formatCurrency(grandTotal || 0)}</span> + </Skeleton> + </div> + </> + ) +} + +export default CartSummary
\ No newline at end of file diff --git a/src-migrate/modules/cart/stores/useCartStore.ts b/src-migrate/modules/cart/stores/useCartStore.ts new file mode 100644 index 00000000..3d9a0aed --- /dev/null +++ b/src-migrate/modules/cart/stores/useCartStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { CartProps } from '~/types/cart'; +import { getUserCart } from '~/services/cart'; + +type State = { + cart: CartProps | null; + isLoadCart: boolean; + summary: { + subtotal: number; + discount: number; + total: number; + tax: number; + grandTotal: number; + }; +}; + +type Action = { + loadCart: (userId: number) => Promise<void>; +}; + +export const useCartStore = create<State & Action>((set, get) => ({ + cart: null, + isLoadCart: false, + summary: { + subtotal: 0, + discount: 0, + total: 0, + tax: 0, + grandTotal: 0, + }, + loadCart: async (userId) => { + if (get().isLoadCart === true) return; + + set({ isLoadCart: true }); + const cart: CartProps = (await getUserCart(userId)) as CartProps; + set({ cart }); + set({ isLoadCart: false }); + + const summary = computeSummary(cart); + set({ summary }); + }, +})); + +const computeSummary = (cart: CartProps) => { + let subtotal = 0; + let discount = 0; + for (const item of cart.products) { + if (!item.selected) continue; + + let price = 0; + if (item.cart_type === 'promotion') + price = (item?.package_price || 0) * item.quantity; + else if (item.cart_type === 'product') + price = item.price.price * item.quantity; + + subtotal += price; + discount += price - item.price.price_discount * item.quantity; + } + let total = subtotal - discount; + let tax = Math.round(total * 0.11); + let grandTotal = total + tax; + + return { subtotal, discount, total, tax, grandTotal }; +}; diff --git a/src-migrate/modules/cart/styles/item-action.module.css b/src-migrate/modules/cart/styles/item-action.module.css new file mode 100644 index 00000000..e4db7fa5 --- /dev/null +++ b/src-migrate/modules/cart/styles/item-action.module.css @@ -0,0 +1,32 @@ +.actionSection { + @apply flex ml-auto h-fit my-auto; +} + +.deleteButton { + @apply bg-red-100 disabled:bg-gray-100 + text-red-700 disabled:text-gray-500 + hover:bg-red-200 + disabled:cursor-not-allowed + transition-all + p-2.5 rounded; +} + +.quantitySection { + @apply relative flex border border-gray-300 rounded ml-4 items-center text-red-700; +} + +.quantityLoading { + @apply absolute flex items-center justify-center text-white rounded w-full h-full bg-gray-900/50 z-10; +} + +.quantityControl { + @apply h-full w-8 flex items-center justify-center hover:bg-gray-100 + disabled:text-gray-500 + disabled:bg-transparent + disabled:cursor-not-allowed + transition; +} + +.quantity { + @apply text-gray-900 font-medium max-w-[28px] outline-none text-center; +} diff --git a/src-migrate/modules/cart/styles/item-promo.module.css b/src-migrate/modules/cart/styles/item-promo.module.css new file mode 100644 index 00000000..15bf8146 --- /dev/null +++ b/src-migrate/modules/cart/styles/item-promo.module.css @@ -0,0 +1,31 @@ +.wrapper { + @apply md:ml-16 ml-8 mt-4 flex; +} + +.imageWrapper { + @apply md:h-24 md:w-24 md:min-w-[96px] + h-20 w-20 min-w-[80px] + border border-gray-300 rounded + p-2.5; +} + +.image { + @apply w-full h-full object-contain; +} + +.details { + @apply ml-4 w-full flex flex-col gap-y-1; +} + +.name { + @apply font-medium; +} + +.code, +.weightLabel { + @apply text-gray-600; +} + +.quantity { + @apply w-12 min-w-[42px] py-2.5 bg-gray-100 border border-gray-300 h-fit my-auto rounded-md ml-auto font-medium text-center; +} diff --git a/src-migrate/modules/cart/styles/item.module.css b/src-migrate/modules/cart/styles/item.module.css new file mode 100644 index 00000000..dfbbf5e8 --- /dev/null +++ b/src-migrate/modules/cart/styles/item.module.css @@ -0,0 +1,60 @@ +.wrapper { + @apply border-b border-gray-300 pb-8; +} + +.header { + @apply mb-4 flex items-center text-caption-1 leading-6; +} + +.badgeType { + @apply min-w-fit p-2 flex items-center gap-x-1.5 rounded-md border border-danger-500 text-danger-500; +} + +.mainProdWrapper { + @apply flex; +} + +.image { + @apply md:h-32 md:w-32 md:min-w-[128px] + w-24 h-24 min-w-[96px] rounded flex p-2 border border-gray-300; +} + +.noImage { + @apply m-auto font-semibold text-gray-400; +} + +.details { + @apply ml-4 flex flex-col gap-y-1 w-full; +} + +.name { + @apply font-medium; +} + +.spacing2 { + @apply h-2; +} + +.discPriceSection { + @apply flex flex-col md:flex-row gap-x-2.5; +} + +.priceBefore { + @apply line-through text-gray-500; +} + +.price { + @apply text-red-600 font-medium; +} + +.savingAmt { + @apply text-success-600; +} + +.weightLabel { + @apply text-gray-500; +} + +.quantity { + @apply py-2.5 bg-red-100 border border-red-300 text-red-800 h-fit my-auto rounded-md ml-auto font-medium w-12 text-center; +} diff --git a/src-migrate/modules/cart/styles/summary.module.css b/src-migrate/modules/cart/styles/summary.module.css new file mode 100644 index 00000000..48ccec28 --- /dev/null +++ b/src-migrate/modules/cart/styles/summary.module.css @@ -0,0 +1,21 @@ +.line { + @apply flex justify-between; +} + +.label, +.value { + @apply text-gray-700; +} + +.value, +.grandTotal { + @apply font-medium; +} + +.discount { + @apply text-red-700; +} + +.divider { + @apply my-0.5 h-0.5 bg-gray-200; +} diff --git a/src-migrate/modules/footer-banner/index.tsx b/src-migrate/modules/footer-banner/index.tsx new file mode 100644 index 00000000..b214493d --- /dev/null +++ b/src-migrate/modules/footer-banner/index.tsx @@ -0,0 +1,32 @@ +import Link from "next/link" +import React, { useMemo } from "react"; +import { useQuery } from "react-query" +import Image from "~/components/ui/image" +import { getBanner } from "~/services/banner" +import { getRandomInt } from '@/utils/getRandomInt' + +const FooterBanner = () => { + const fetchFooterBanner = useQuery({ + queryKey: 'footerBanner', + queryFn: () => getBanner({ type: 'bottom-search-promotion' }) + }) + // ubah dari static menjadid dynamic dengan menggunakan random index + const length = useMemo(() => fetchFooterBanner.data?.length, [fetchFooterBanner.data]); + const randomIndex = useMemo(() => getRandomInt(length), [length]); + const banner = fetchFooterBanner?.data?.[randomIndex] || false; + + return banner && ( + <> + {banner.url && ( + <Link href={banner.url}> + <Image src={banner.image} alt={banner.name} width={924} height={150} className='object-cover object-center rounded-lg' /> + </Link> + )} + + {!banner.url && ( + <Image src={banner.image} alt={banner.name} width={924} height={150} className='object-cover object-center rounded-lg' /> + )} + </> + ) +} +export default FooterBanner
\ No newline at end of file diff --git a/src-migrate/modules/header/components/HeaderDesktop.tsx b/src-migrate/modules/header/components/HeaderDesktop.tsx index 3860bded..131fa7da 100644 --- a/src-migrate/modules/header/components/HeaderDesktop.tsx +++ b/src-migrate/modules/header/components/HeaderDesktop.tsx @@ -8,7 +8,7 @@ import Link from 'next/link' import SearchBar from "./SearchBar"; // Constants -import { SECONDARY_MENU_ITEMS } from "~/common/constants/menu"; +import { SECONDARY_MENU_ITEMS } from "~/constants/menu"; const LOGO_WIDTH = 210; const LOGO_HEIGHT = LOGO_WIDTH / 3; @@ -54,7 +54,7 @@ const HeaderDesktop = () => { <Image src='/images/socials/Whatsapp-2.png' alt='Whatsapp' width={48} height={48} /> <div> <div className='font-semibold'>Whatsapp</div> - 0812 8080 622 (Chat) + 0817 1718 1922 (Chat) </div> </a> </div> diff --git a/src-migrate/modules/page-content/index.tsx b/src-migrate/modules/page-content/index.tsx index 608079f8..547b1957 100644 --- a/src-migrate/modules/page-content/index.tsx +++ b/src-migrate/modules/page-content/index.tsx @@ -1,7 +1,6 @@ import { useMemo } from "react" import { useQuery } from "react-query" -import PageContentSkeleton from "~/common/components/skeleton/PageContentSkeleton" -import { PageContentProps } from "~/common/types/pageContent" +import { PageContentProps } from "~/types/pageContent" import { getPageContent } from "~/services/pageContent" type Props = { @@ -26,4 +25,20 @@ const PageContent = ({ path }: Props) => { ) } +const PageContentSkeleton = () => ( + <div className="animate-pulse grid gap-y-4"> + <div className="w-full h-10 bg-gray-300 rounded" /> + <div className="h-2" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-8/12 h-4 bg-gray-300 rounded" /> + <div className="h-2" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-full h-4 bg-gray-300 rounded" /> + <div className="w-1/2 h-4 bg-gray-300 rounded" /> + </div> +) + export default PageContent
\ No newline at end of file diff --git a/src-migrate/modules/popup-information/index.tsx b/src-migrate/modules/popup-information/index.tsx index 78e9dcf2..0d36f8e9 100644 --- a/src-migrate/modules/popup-information/index.tsx +++ b/src-migrate/modules/popup-information/index.tsx @@ -1,9 +1,9 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; -import Modal from '~/common/components/elements/Modal'; -import { getAuth } from '~/common/libs/auth'; + +import { Modal } from "~/components/ui/modal"; +import { getAuth } from '~/libs/auth'; import PageContent from '../page-content'; -import Link from 'next/link'; const PagePopupInformation = () => { const router = useRouter(); @@ -12,25 +12,18 @@ const PagePopupInformation = () => { const [active, setActive] = useState<boolean>(false); useEffect(() => { - if (isHomePage && !auth) { - setActive(true); - } + if (isHomePage && !auth) setActive(true); }, [isHomePage, auth]); return ( <div className='group'> <Modal active={active} - className='!w-fit !bg-transparent !border-none' + className='!w-fit !bg-transparent !border-none overflow-hidden' close={() => setActive(false)} mode='desktop' > - <div> - <Link href={'/register'}> - <PageContent path='/onbording-popup' /> - </Link> - <Link href={'/register'} className='btn-yellow w-full mt-2'> - Daftar Sekarang - </Link> + <div className='w-[350px] md:w-[530px]' onClick={() => setActive(false)}> + <PageContent path='/onbording-popup' /> </div> </Modal> </div> diff --git a/src-migrate/modules/product-card/components/ProductCard.tsx b/src-migrate/modules/product-card/components/ProductCard.tsx new file mode 100644 index 00000000..0febfadb --- /dev/null +++ b/src-migrate/modules/product-card/components/ProductCard.tsx @@ -0,0 +1,144 @@ +import style from '../styles/product-card.module.css' +import ImageNext from 'next/image'; +import clsx from 'clsx' +import Link from 'next/link' +import React, { useEffect, useMemo, useState } from 'react' +import Image from '~/components/ui/image' +import useUtmSource from '~/hooks/useUtmSource' +import clsxm from '~/libs/clsxm' +import formatCurrency from '~/libs/formatCurrency' +import { formatToShortText } from '~/libs/formatNumber' +import { createSlug } from '~/libs/slug' +import { IProduct } from '~/types/product' + +type Props = { + product: IProduct + layout?: 'vertical' | 'horizontal' +} + +const ProductCard = ({ product, layout = 'vertical' }: Props) => { + const utmSource = useUtmSource() + + + const URL = { + product: createSlug('/shop/product/', product.name, product.id.toString()) + `?utm_source=${utmSource}`, + manufacture: createSlug('/shop/brands/', product.manufacture.name, product.manufacture.id.toString()), + } + + const image = useMemo(() => { + if (product.image) return product.image + '?ratio=square' + return '/images/noimage.jpeg' + }, [product.image]) + + return ( + <div className={clsxm(style['wrapper'], { + [style['wrapper-v']]: layout === 'vertical', + [style['wrapper-h']]: layout === 'horizontal', + })} + > + <div className={clsxm('relative', { + [style['image-v']]: layout === 'vertical', + [style['image-h']]: layout === 'horizontal', + })}> + <Link href={URL.product}> + + <div className="relative"> + <Image + src={image} + alt={product.name} + width={128} + height={128} + className='object-contain object-center h-full w-full' + /> + <div className="absolute top-0 right-0 flex mt-2"> + <div className="gambarB "> + {product.isSni && ( + <ImageNext + src="/images/sni-logo.png" + alt="SNI Logo" + className="w-3 h-4 object-contain object-top sm:h-4" + width={50} + height={50} + /> + )} + </div> + <div className="gambarC "> + {product.isTkdn && ( + <ImageNext + src="/images/TKDN.png" + alt="TKDN" + className="w-5 h-4 object-contain object-top ml-1 mr-1 sm:h-6" + width={50} + height={50} + /> + )} + </div> + </div> + </div> + + {product.variant_total > 1 && ( + <div className={style['variant-badge']}>{product.variant_total} Varian</div> + )} + </Link> + </div> + + <div className={clsxm({ + [style['content-v']]: layout === 'vertical', + [style['content-h']]: layout === 'horizontal', + })}> + <Link + href={URL.manufacture} + className={style['brand']} + > + {product.manufacture.name} + </Link> + + <div className='h-0.5' /> + + <Link + href={URL.product} + className={clsxm(style['name'], { + [style['name-v']]: layout === 'vertical', + [style['name-h']]: layout === 'horizontal', + })} + > + {product.name} + </Link> + <div className='h-1.5' /> + + <div className={style['price']}> + Rp {formatCurrency(product.lowest_price.price)} + </div> + + <div className='h-1.5' /> + + <div className={style['price-inc']}> + Inc PPN: + Rp {formatCurrency(Math.round(product.lowest_price.price * 1.11))} + </div> + + <div className='h-1' /> + + <div className='flex items-center gap-x-2.5'> + {product.stock_total > 0 && ( + <div className={style['ready-stock']}> + Ready Stock + </div> + )} + {product.qty_sold > 0 && ( + <div className={style['sold']}> + {formatToShortText(product.qty_sold)} Terjual + </div> + )} + </div> + + </div> + </div> + ) +} + +const classPrefix = ({ layout }: Props) => { + +} + +export default ProductCard
\ No newline at end of file diff --git a/src-migrate/modules/product-card/index.tsx b/src-migrate/modules/product-card/index.tsx new file mode 100644 index 00000000..c87167bc --- /dev/null +++ b/src-migrate/modules/product-card/index.tsx @@ -0,0 +1,3 @@ +import ProductCard from "./components/ProductCard"; + +export default ProductCard
\ No newline at end of file diff --git a/src-migrate/modules/product-card/styles/product-card.module.css b/src-migrate/modules/product-card/styles/product-card.module.css new file mode 100644 index 00000000..653bf2ca --- /dev/null +++ b/src-migrate/modules/product-card/styles/product-card.module.css @@ -0,0 +1,54 @@ +.wrapper { + @apply w-full flex; +} +.wrapper-v { + @apply flex-col border border-gray-300 rounded-md h-[350px]; +} +.wrapper-h { + @apply flex-row gap-x-2 pt-4; +} + +.image-v { + @apply w-full h-48 px-4 border-b border-gray-300; +} +.image-h { + @apply w-4/12 h-24 px-1; +} + +.content-v { + @apply w-full p-2; +} +.content-h { + @apply w-8/12; +} + +.brand { + @apply text-danger-500 font-medium block; +} + +.name { + @apply text-gray-700 font-medium line-clamp-3; +} +.name-v { + @apply min-h-[64px]; +} +.name-h { + @apply min-h-[32px]; +} + +.price { + @apply text-danger-500 font-medium; +} + +.ready-stock { + @apply bg-danger-500 text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap; +} + +.price-inc, +.sold { + @apply text-gray-600 text-[11px]; +} + +.variant-badge { + @apply bg-gray-500/20 backdrop-blur-md absolute rounded-md bottom-2 left-2 px-2 py-1 text-caption-2; +} diff --git a/src-migrate/modules/product-detail/components/AddToCart.tsx b/src-migrate/modules/product-detail/components/AddToCart.tsx new file mode 100644 index 00000000..097db98a --- /dev/null +++ b/src-migrate/modules/product-detail/components/AddToCart.tsx @@ -0,0 +1,78 @@ +import { Button, useToast } from '@chakra-ui/react' +import { useRouter } from 'next/router' + +import { getAuth } from '~/libs/auth' +import { upsertUserCart } from '~/services/cart' + +type Props = { + variantId: number | null, + quantity?: number; + source?: 'buy' | 'add_to_cart'; +} + +const AddToCart = ({ + variantId, + quantity = 1, + source = 'add_to_cart' +}: Props) => { + const auth = getAuth() + const router = useRouter() + const toast = useToast({ + position: 'top', + isClosable: true + }) + + const handleClick = async () => { + if (typeof auth !== 'object') { + const currentUrl = encodeURIComponent(router.asPath) + router.push(`/login?next=${currentUrl}`) + return; + } + + if ( + !variantId || + isNaN(quantity) || + typeof auth !== 'object' + ) return; + + toast.promise( + upsertUserCart({ + userId: auth.id, + type: 'product', + id: variantId, + qty: quantity, + selected: true, + source: source, + qtyAppend: true + }), + { + loading: { title: 'Menambahkan ke keranjang', description: 'Mohon tunggu...' }, + success: { title: 'Menambahkan ke keranjang', description: 'Berhasil menambahkan ke keranjang belanja' }, + error: { title: 'Menambahkan ke keranjang', description: 'Gagal menambahkan ke keranjang belanja' }, + } + ) + + if (source === 'buy') { + router.push('/shop/checkout?source=buy') + } + } + + const btnConfig = { + 'add_to_cart': { + colorScheme: 'yellow', + text: 'Keranjang' + }, + 'buy': { + colorScheme: 'red', + text: 'Beli' + } + } + + return ( + <Button onClick={handleClick} colorScheme={btnConfig[source].colorScheme} className='w-full'> + {btnConfig[source].text} + </Button> + ) +} + +export default AddToCart
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/AddToWishlist.tsx b/src-migrate/modules/product-detail/components/AddToWishlist.tsx new file mode 100644 index 00000000..697b2d5c --- /dev/null +++ b/src-migrate/modules/product-detail/components/AddToWishlist.tsx @@ -0,0 +1,61 @@ +import { Button, useToast } from '@chakra-ui/react' +import { HeartIcon } from 'lucide-react' +import React from 'react' +import { useQuery } from 'react-query' +import { getAuth } from '~/libs/auth' +import clsxm from '~/libs/clsxm' +import { getUserWishlist, upsertUserWishlist } from '~/services/wishlist' + +type Props = { + productId: number +} + +const AddToWishlist = ({ productId }: Props) => { + const auth = getAuth() + const toast = useToast({ + position: 'top', + isClosable: true + }) + + const searchParams = { product_id: productId.toString() } + const query = useQuery({ + queryKey: ['wishlist', searchParams, auth], + queryFn: () => { + if (typeof auth !== 'object') return null; + return getUserWishlist(auth.id, searchParams) + }, + refetchOnWindowFocus: false + }) + + const isAdded = query.data?.product_total ? query.data.product_total > 0 : false; + + const toggleWishlist = async () => { + if (typeof auth !== 'object') return; + await upsertUserWishlist(auth.id, productId) + await query.refetch() + } + + const handleClick = async () => { + toast.promise(toggleWishlist(), { + loading: { title: 'Update Wishlist', description: 'Mohon tunggu...' }, + success: { title: 'Update Wishlist', description: 'Berhasil update wishlist' }, + error: { title: 'Update Wishlist', description: 'Gagal update wishlist' }, + }) + } + + return ( + <Button + variant='link' + colorScheme='gray' + onClick={handleClick} + leftIcon={<HeartIcon size={18} className={clsxm('transition-colors', { + 'text-danger-500 fill-danger-500': isAdded, + 'fill-transparent': !isAdded + })} />} + > + Wishlist + </Button> + ) +} + +export default AddToWishlist
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/Breadcrumb.tsx b/src-migrate/modules/product-detail/components/Breadcrumb.tsx new file mode 100644 index 00000000..f41859a9 --- /dev/null +++ b/src-migrate/modules/product-detail/components/Breadcrumb.tsx @@ -0,0 +1,41 @@ +import React, { Fragment } from 'react' +import { useQuery } from 'react-query' +import { getProductCategoryBreadcrumb } from '~/services/product' +import Link from 'next/link' +import { createSlug } from '~/libs/slug' + +type Props = { + id: number, + name: string +} + +const Breadcrumb = ({ id, name }: Props) => { + const query = useQuery({ + queryKey: ['product-category-breadcrumb'], + queryFn: () => getProductCategoryBreadcrumb(id), + refetchOnWindowFocus: false + }) + + const breadcrumbs = query.data || [] + + return ( + <div className='line-clamp-2 md:line-clamp-1 leading-7 text-caption-1'> + <Link href='/' className='text-danger-500'>Home</Link> + <span className='mx-2'>/</span> + {breadcrumbs.map((category, index) => ( + <Fragment key={index}> + <Link + href={createSlug('/shop/category/', category.name, category.id.toString())} + className='text-danger-500' + > + {category.name} + </Link> + <span className='mx-2'>/</span> + </Fragment> + ))} + <span>{name}</span> + </div> + ) +} + +export default Breadcrumb
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/Image.tsx b/src-migrate/modules/product-detail/components/Image.tsx new file mode 100644 index 00000000..30ca0d34 --- /dev/null +++ b/src-migrate/modules/product-detail/components/Image.tsx @@ -0,0 +1,133 @@ +import style from '../styles/image.module.css'; +import ImageNext from 'next/image'; +import React, { useEffect, useMemo, useState } from 'react' +import { InfoIcon } from 'lucide-react' +import { Tooltip } from '@chakra-ui/react' + +import { IProductDetail } from '~/types/product' +import ImageUI from '~/components/ui/image' +import moment from 'moment'; + +type Props = { + product: IProductDetail +} + +const Image = ({ product }: Props) => { + const flashSale = product.flash_sale + const [count, setCount] = useState(flashSale?.remaining_time || 0); + + + + useEffect(() => { + let interval: NodeJS.Timeout; + + if (flashSale?.remaining_time && flashSale.remaining_time > 0) { + setCount(flashSale.remaining_time); + + interval = setInterval(() => { + setCount((prevCount) => prevCount - 1); + }, 1000); + } + + return () => { + clearInterval(interval); + }; + }, [flashSale?.remaining_time]); + + const duration = moment.duration(count, 'seconds') + + const image = useMemo(() => { + if (product.image) return product.image + '?ratio=square' + return '/images/noimage.jpeg' + }, [product.image]) + + return ( + <div className={style['wrapper']}> + {/* <div className="relative"> */} + <ImageUI + src={image} + alt={product.name} + width={256} + height={256} + className={style['image']} + loading='eager' + priority + /> + <div className="absolute top-4 right-10 flex "> + <div className="gambarB "> + {product.isSni && ( + <ImageNext + src="/images/sni-logo.png" + alt="SNI Logo" + className="w-12 h-8 object-contain object-top sm:h-6" + width={50} + height={50} + /> + )} + </div> + <div className="gambarC "> + {product.isTkdn && ( + <ImageNext + src="/images/TKDN.png" + alt="TKDN" + className="w-16 h-8 object-contain object-top ml-1 mr-1 sm:h-6" + width={50} + height={50} + /> + )} + </div> + </div> + {/* </div> */} + + + + <div className={style['absolute-info']}> + <Tooltip + placement='bottom-end' + label='Gambar atau foto berperan sebagai ilustrasi produk. Kadang tidak sesuai dengan kondisi terbaru dengan berbagai perubahan dan perbaikan. Hubungi admin kami untuk informasi yang lebih baik perihal gambar.' + > + <div className="text-gray-600"> + <InfoIcon size={20} /> + </div> + </Tooltip> + </div> + + {flashSale.remaining_time > 0 && ( + <div className='absolute bottom-0 w-full h-14'> + <div className="relative w-full h-full"> + <ImageUI + src='/images/BG-FLASH-SALE.jpg' + alt='Flash Sale Indoteknik' + width={200} + height={100} + className={style['flashsale-bg']} + /> + + <div className={style['flashsale']}> + <div className='flex items-center gap-x-3'> + <div className={style['disc-badge']}>{Math.floor(product.lowest_price.discount_percentage)}%</div> + <div className={style['flashsale-text']}> + <ImageUI + src='/images/ICON_FLASH_SALE_WEBSITE_INDOTEKNIK.svg' + alt='Icon Flash Sale' + width={20} + height={20} + /> + {product.flash_sale.tag} + </div> + </div> + <div className={style['countdown']}> + <span>{duration.hours().toString().padStart(2, '0')}</span> + <span>{duration.minutes().toString().padStart(2, '0')}</span> + <span>{duration.seconds().toString().padStart(2, '0')}</span> + </div> + </div> + + </div> + </div> + )} + </div> + ) +} + +export default Image
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/Information.tsx b/src-migrate/modules/product-detail/components/Information.tsx new file mode 100644 index 00000000..52eb6b88 --- /dev/null +++ b/src-migrate/modules/product-detail/components/Information.tsx @@ -0,0 +1,56 @@ +import style from '../styles/information.module.css' + +import React from 'react' +import dynamic from 'next/dynamic' +import Link from 'next/link' +import { useQuery } from 'react-query' + +import { IProductDetail } from '~/types/product' +import { IProductVariantSLA } from '~/types/productVariant' +import { createSlug } from '~/libs/slug' +import { getVariantSLA } from '~/services/productVariant' +import { formatToShortText } from '~/libs/formatNumber' + +const Skeleton = dynamic(() => import('@chakra-ui/react').then((mod) => mod.Skeleton)) + +type Props = { + product: IProductDetail +} + +const Information = ({ product }: Props) => { + const querySLA = useQuery<IProductVariantSLA>({ + queryKey: ['variant-sla', product.variants[0].id], + queryFn: () => getVariantSLA(product.variants[0].id), + enabled: product.variant_total === 1 + }) + + const sla = querySLA?.data + + return ( + <div className={style['wrapper']}> + <div className={style['row']}> + <div className={style['label']}>SKU Number</div> + <div className={style['value']}>SKU-{product.id}</div> + </div> + <div className={style['row']}> + <div className={style['label']}>Manufacture</div> + <div className={style['value']}> + {!!product.manufacture.name ? ( + <Link + href={createSlug('/shop/brands/', product.manufacture.name, product.manufacture.id.toString())} + className='text-danger-500 hover:underline' + > + {product.manufacture.name} + </Link> + ) : '-'} + </div> + </div> + <div className={style['row']}> + <div className={style['label']}>Terjual</div> + <div className={style['value']}>{product.qty_sold > 0 ? formatToShortText(product.qty_sold) : '-'}</div> + </div> + </div> + ) +} + +export default Information
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/PriceAction.tsx b/src-migrate/modules/product-detail/components/PriceAction.tsx new file mode 100644 index 00000000..81271f6e --- /dev/null +++ b/src-migrate/modules/product-detail/components/PriceAction.tsx @@ -0,0 +1,115 @@ +import style from '../styles/price-action.module.css'; + +import React, { useEffect } from 'react'; +import formatCurrency from '~/libs/formatCurrency'; +import { IProductDetail } from '~/types/product'; +import { useProductDetail } from '../stores/useProductDetail'; +import AddToCart from './AddToCart'; +import Link from 'next/link'; +import { getAuth } from '~/libs/auth'; + +type Props = { + product: IProductDetail; +}; + +const PriceAction = ({ product }: Props) => { + const { + activePrice, + setActive, + activeVariantId, + quantityInput, + setQuantityInput, + askAdminUrl, + isApproval, + setIsApproval, + } = useProductDetail(); + + useEffect(() => { + setActive(product.variants[0]) + if(product.variants.length > 2 && product.variants[0].price.price === 0){ + const variants = product.variants + for (let i = 0; i < variants.length; i++) { + if(variants[i].price.price > 0){ + setActive(variants[i]) + break; + } + } + } + + }, [product, setActive]); + + + + return ( + <div + className='block md:sticky top-[150px] bg-white py-0 md:py-6 z-10' + id='price-section' + > + {!!activePrice && activePrice.price > 0 && ( + <> + <div className='flex items-end gap-x-2'> + {activePrice.discount_percentage > 0 && ( + <> + <div className={style['disc-badge']}> + {Math.floor(activePrice.discount_percentage)}% + </div> + <div className={style['disc-price']}> + Rp {formatCurrency(activePrice.price || 0)} + </div> + </> + )} + <div className={style['main-price']}> + Rp {formatCurrency(activePrice.price_discount || 0)} + </div> + </div> + <div className='h-1' /> + <div className={style['secondary-text']}> + Termasuk PPN: Rp{' '} + {formatCurrency(Math.round(activePrice.price_discount * 1.11))} + </div> + </> + )} + + {!!activePrice && activePrice.price === 0 && ( + <span> + Hubungi kami untuk dapatkan harga terbaik,{' '} + <Link + href={askAdminUrl} + target='_blank' + className={style['contact-us']} + > + klik disini + </Link> + </span> + )} + + <div className='h-4' /> + + <div className={style['action-wrapper']}> + <label htmlFor='quantity' className='hidden'> + Quantity + </label> + <input + type='number' + id='quantity' + value={quantityInput} + onChange={(e) => setQuantityInput(e.target.value)} + className={style['quantity-input']} + /> + <AddToCart + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + {!isApproval && ( + <AddToCart + source='buy' + variantId={activeVariantId} + quantity={Number(quantityInput)} + /> + )} + </div> + </div> + ); +}; + +export default PriceAction; diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx new file mode 100644 index 00000000..fad35a7d --- /dev/null +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -0,0 +1,191 @@ +import style from '../styles/product-detail.module.css' + +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import { Button } from '@chakra-ui/react' +import { MessageCircleIcon, Share2Icon } from 'lucide-react' +import { LazyLoadComponent } from 'react-lazy-load-image-component' +import { RWebShare } from 'react-web-share' + +import useDevice from '@/core/hooks/useDevice' +import { whatsappUrl } from '~/libs/whatsappUrl' +import ProductPromoSection from '~/modules/product-promo/components/Section' +import { IProductDetail } from '~/types/product' +import { useProductDetail } from '../stores/useProductDetail' +import AddToWishlist from './AddToWishlist' +import Breadcrumb from './Breadcrumb' +import ProductImage from './Image' +import Information from './Information' +import PriceAction from './PriceAction' +import SimilarBottom from './SimilarBottom' +import SimilarSide from './SimilarSide' +import VariantList from './VariantList' +import { getAuth } from '~/libs/auth' + +import { gtagProductDetail } from '@/core/utils/googleTag' + +type Props = { + product: IProductDetail +} + +const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST + +const ProductDetail = ({ product }: Props) => { + const { isDesktop, isMobile } = useDevice() + const router = useRouter() + const auth = getAuth() + const { setAskAdminUrl, askAdminUrl, activeVariantId, setIsApproval, isApproval } = useProductDetail() + + useEffect(() => { + gtagProductDetail(product); + },[product]) + + useEffect(() => { + const createdAskUrl = whatsappUrl({ + template: 'product', + payload: { + manufacture: product.manufacture.name, + productName: product.name, + url: process.env.NEXT_PUBLIC_SELF_HOST + router.asPath + }, + fallbackUrl: router.asPath + }) + + setAskAdminUrl(createdAskUrl) + }, [router.asPath, product.manufacture.name, product.name, setAskAdminUrl]) + + useEffect(() => { + if (typeof auth === 'object') { + setIsApproval(auth?.feature?.soApproval); + } + }, []); + + return ( + <> + <div className='md:flex md:flex-wrap'> + <div className="w-full mb-4 md:mb-0 px-4 md:px-0"> + <Breadcrumb id={product.id} name={product.name} /> + </div> + <div className='md:w-9/12 md:flex md:flex-col md:pr-4 md:pt-6'> + <div className='md:flex md:flex-wrap'> + <div className="md:w-4/12"> + <ProductImage product={product} /> + </div> + + <div className='md:w-8/12 px-4 md:pl-6'> + <div className='h-6 md:h-0' /> + + <h1 className={style['title']}> + {product.name} + </h1> + + <div className='h-6 md:h-8' /> + + <Information product={product} /> + + <div className='h-6' /> + + <div className="flex gap-x-5"> + <Button + as={Link} + href={askAdminUrl} + variant='link' + target='_blank' + colorScheme='gray' + leftIcon={<MessageCircleIcon size={18} />} + > + Ask Admin + </Button> + + <AddToWishlist productId={product.id} /> + + <RWebShare + data={{ + text: 'Check out this product', + title: `${product.name} - Indoteknik.com`, + url: SELF_HOST + router.asPath + }} + > + <Button + variant='link' + colorScheme='gray' + leftIcon={<Share2Icon size={18} />} + > + Share + </Button> + </RWebShare> + </div> + + </div> + </div> + + <div className='h-full'> + {isMobile && ( + <div className='px-4 pt-6'> + <PriceAction product={product} /> + </div> + )} + + <div className='h-4 md:h-10' /> + {!!activeVariantId && !isApproval && <ProductPromoSection productId={activeVariantId} />} + + <div className={style['section-card']}> + <h2 className={style['heading']}> + Variant ({product.variant_total}) + </h2> + <div className='h-4' /> + <VariantList variants={product.variants} /> + </div> + + <div className='h-0 md:h-6' /> + + <div className={style['section-card']}> + <h2 className={style['heading']}> + Informasi Produk + </h2> + <div className='h-4' /> + <div + className={style['description']} + dangerouslySetInnerHTML={{ __html: !product.description || product.description == '<p><br></p>' ? 'Belum ada deskripsi' : product.description }} + /> + </div> + </div> + </div> + + {isDesktop && ( + <div className="md:w-3/12"> + <PriceAction product={product} /> + + <div className='h-6' /> + + <div className={style['heading']}> + Produk Serupa + </div> + + <div className='h-4' /> + + <SimilarSide product={product} /> + </div> + )} + + <div className='md:w-full pt-4 md:py-10 px-4 md:px-0'> + <div className={style['heading']}> + Kamu Mungkin Juga Suka + </div> + + <div className='h-6' /> + + <LazyLoadComponent> + <SimilarBottom product={product} /> + </LazyLoadComponent> + </div> + + <div className='h-6 md:h-0' /> + </div> + </> + ) +} + +export default ProductDetail
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/SimilarBottom.tsx b/src-migrate/modules/product-detail/components/SimilarBottom.tsx new file mode 100644 index 00000000..40d4dd82 --- /dev/null +++ b/src-migrate/modules/product-detail/components/SimilarBottom.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from '@chakra-ui/react' +import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar' +import ProductSlider from '~/modules/product-slider' +import { IProductDetail } from '~/types/product' + +type Props = { + product: IProductDetail +} + +const SimilarBottom = ({ product }: Props) => { + const productSimilar = useProductSimilar({ + name: product.name, + except: { productId: product.id } + }) + + const products = productSimilar.data?.products || [] + + return ( + <Skeleton + isLoaded={!productSimilar.isLoading} + rounded='lg' + className='h-[350px]' + > + <ProductSlider products={products} productLayout='vertical' /> + </Skeleton> + ); +} + +export default SimilarBottom
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/SimilarSide.tsx b/src-migrate/modules/product-detail/components/SimilarSide.tsx new file mode 100644 index 00000000..d70a314d --- /dev/null +++ b/src-migrate/modules/product-detail/components/SimilarSide.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from '@chakra-ui/react' + +import ProductCard from '~/modules/product-card' +import useProductSimilar from '~/modules/product-similar/hooks/useProductSimilar' +import { IProductDetail } from '~/types/product' + +type Props = { + product: IProductDetail +} + +const SimilarSide = ({ product }: Props) => { + const productSimilar = useProductSimilar({ + name: product.name, + except: { productId: product.id, manufactureId: product.manufacture.id }, + }) + + const products = productSimilar.data?.products || [] + + return ( + <Skeleton + isLoaded={!productSimilar.isLoading} + className="h-[500px] overflow-auto grid grid-cols-1 gap-y-4 divide-y divide-gray-300 border border-gray-300 rounded-lg" + rounded='lg' + > + {products.map((product) => ( + <ProductCard + key={product.id} + product={product} + layout='horizontal' + /> + ))} + </Skeleton> + ) +} + +export default SimilarSide
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/components/VariantList.tsx b/src-migrate/modules/product-detail/components/VariantList.tsx new file mode 100644 index 00000000..3d5b9b74 --- /dev/null +++ b/src-migrate/modules/product-detail/components/VariantList.tsx @@ -0,0 +1,117 @@ +import style from '../styles/variant-list.module.css' + +import React from 'react' +import { Button, Skeleton } from '@chakra-ui/react' + +import formatCurrency from '~/libs/formatCurrency' +import clsxm from '~/libs/clsxm' +import { IProductVariantDetail, IProductVariantSLA } from '~/types/productVariant' +import { useProductDetail } from '../stores/useProductDetail' +import { LazyLoadComponent } from 'react-lazy-load-image-component'; +import { getVariantSLA } from '~/services/productVariant' +import { useQuery } from 'react-query' +import useDevice from '@/core/hooks/useDevice' + +type Props = { + variants: IProductVariantDetail[] +} + +const VariantList = ({ variants }: Props) => { + return ( + <div className='overflow-auto'> + <div className={style['wrapper']}> + <div className={style['header']}> + <div className="w-2/12">Part Number</div> + <div className="w-2/12">Variant</div> + <div className="w-1/12">Stock</div> + <div className="w-2/12">Masa Persiapan</div> + <div className="w-1/12">Berat</div> + <div className="w-3/12">Harga</div> + <div className='w-1/12 sticky right-0 bg-gray-200'></div> + </div> + {variants.map((variant) => ( + <LazyLoadComponent key={variant.id}> + <Row variant={variant} /> + </LazyLoadComponent> + ))} + </div> + </div> + ) +} + +const Row = ({ variant }: { variant: IProductVariantDetail }) => { + const { isMobile } = useDevice() + + const { activeVariantId, setActive } = useProductDetail() + const querySLA = useQuery<IProductVariantSLA>({ + queryKey: ['variant-sla', variant.id], + queryFn: () => getVariantSLA(variant.id), + refetchOnWindowFocus: false, + }) + + const sla = querySLA?.data + + const handleSelect = (variant: IProductVariantDetail) => { + const priceSectionEl = document.getElementById('price-section') + if (isMobile && priceSectionEl) { + window.scrollTo({ + top: priceSectionEl.offsetTop - 120, + behavior: 'smooth' + }) + } + setActive(variant) + } + + return ( + <div className={style['row']}> + <div className='w-2/12'>{variant.code}</div> + <div className='w-2/12'>{variant.attributes.join(', ') || '-'}</div> + <div className='w-1/12'> + <Skeleton isLoaded={querySLA.isSuccess} h='21px' w={16}> + {sla?.qty !== undefined && ( + <div className={clsxm('text-center rounded-md', { + [style['stock-empty']]: sla.qty == 0, + [style['stock-ready']]: sla.qty > 0, + })} + > + {sla.qty > 0 && sla.qty} + {sla.qty == 0 && '-'} + </div> + )} + </Skeleton> + </div> + <div className='w-2/12'> + <Skeleton isLoaded={querySLA.isSuccess} h='21px' w={16}> + {sla?.sla_date} + </Skeleton> + </div> + <div className='w-1/12'> + {variant.weight > 0 ? `${variant.weight} Kg` : '-'} + </div> + <div className='w-3/12'> + {variant.price.discount_percentage > 0 && ( + <div className='flex items-center gap-x-1'> + <div className={style['disc-badge']}>{Math.floor(variant.price.discount_percentage)}%</div> + <div className={style['disc-price']}>Rp {formatCurrency(variant.price.price)}</div> + </div> + )} + {variant.price.price_discount > 0 && `Rp ${formatCurrency(variant.price.price_discount)}`} + {variant.price.price_discount === 0 && '-'} + </div> + <div className='w-1/12 sticky right-0 bg-white md:bg-transparent'> + <Button + onClick={() => handleSelect(variant)} + size='sm' + w='100%' + className={clsxm(style['select-btn'], { + [style['select-btn--active']]: variant.id === activeVariantId + })} + > + Pilih + </Button> + </div> + </div> + ) +} + +export default VariantList
\ No newline at end of file diff --git a/src-migrate/modules/product-detail/index.ts b/src-migrate/modules/product-detail/index.ts new file mode 100644 index 00000000..246bc06a --- /dev/null +++ b/src-migrate/modules/product-detail/index.ts @@ -0,0 +1,3 @@ +import ProductDetail from './components/ProductDetail'; + +export default ProductDetail; diff --git a/src-migrate/modules/product-detail/stores/useProductDetail.ts b/src-migrate/modules/product-detail/stores/useProductDetail.ts new file mode 100644 index 00000000..2da8835d --- /dev/null +++ b/src-migrate/modules/product-detail/stores/useProductDetail.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; +import { IProductVariantDetail } from '~/types/productVariant'; + +type State = { + activeVariantId: number | null; + activePrice: IProductVariantDetail['price'] | null; + quantityInput: string; + askAdminUrl: string; + isApproval : boolean; +}; + +type Action = { + setActive: (variant: IProductVariantDetail) => void; + setQuantityInput: (value: string) => void; + setAskAdminUrl: (url: string) => void; + setIsApproval : (value : boolean) => void; +}; + +export const useProductDetail = create<State & Action>((set, get) => ({ + activeVariantId: null, + activePrice: null, + quantityInput: '1', + askAdminUrl: '', + isApproval : false, + setActive: (variant) => { + set({ activeVariantId: variant.id, activePrice: variant.price }); + }, + setQuantityInput: (value: string) => { + set({ quantityInput: value }); + }, + setAskAdminUrl: (url: string) => { + set({ askAdminUrl: url }); + }, + setIsApproval : (value : boolean) => { + set({ isApproval : value }) + } +})); diff --git a/src-migrate/modules/product-detail/styles/image.module.css b/src-migrate/modules/product-detail/styles/image.module.css new file mode 100644 index 00000000..e472fe8d --- /dev/null +++ b/src-migrate/modules/product-detail/styles/image.module.css @@ -0,0 +1,35 @@ +.wrapper { + @apply h-[250px] md:h-[340px] flex items-center justify-center border border-gray-200 rounded-lg p-4 relative; +} + +.image { + @apply object-contain object-center h-full w-full; +} + +.absolute-info { + @apply absolute hidden md:block top-4 right-4; +} + +.disc-badge { + @apply bg-warning-500 py-1 px-3 w-fit font-semibold rounded-full; +} + +.countdown { + @apply flex gap-x-1; +} + +.countdown span { + @apply py-0.5 w-8 bg-warning-500 rounded-md text-center; +} + +.flashsale-text { + @apply flex items-center gap-x-2 text-white font-medium text-caption-1; +} + +.flashsale-bg { + @apply absolute top-0 w-full h-full object-cover object-center z-10; +} + +.flashsale { + @apply absolute top-0 w-full h-full z-20 flex items-center justify-between px-3; +} diff --git a/src-migrate/modules/product-detail/styles/information.module.css b/src-migrate/modules/product-detail/styles/information.module.css new file mode 100644 index 00000000..c9b29020 --- /dev/null +++ b/src-migrate/modules/product-detail/styles/information.module.css @@ -0,0 +1,19 @@ +.wrapper { + @apply grid grid-cols-1; +} + +.row { + @apply flex p-3 rounded; +} + +.row:nth-child(odd) { + @apply bg-gray-100; +} + +.label { + @apply w-1/2 md:w-1/3 font-medium text-gray-500; +} + +.value { + @apply w-1/2 md:w-3/4 text-gray-950; +} diff --git a/src-migrate/modules/product-detail/styles/price-action.module.css b/src-migrate/modules/product-detail/styles/price-action.module.css new file mode 100644 index 00000000..651de958 --- /dev/null +++ b/src-migrate/modules/product-detail/styles/price-action.module.css @@ -0,0 +1,24 @@ +.secondary-text { + @apply font-medium text-gray-500; +} +.main-price { + @apply font-medium text-danger-500 text-title-md; +} +.action-wrapper { + @apply flex gap-x-2.5; +} +.quantity-input { + @apply px-2 rounded text-center border border-gray-300 w-14 h-10 focus:outline-none; +} + +.contact-us { + @apply text-danger-500 font-medium underline; +} + +.disc-badge { + @apply bg-danger-500 px-2 py-1.5 rounded text-white text-caption-2; +} + +.disc-price { + @apply line-through text-gray-600 text-caption-2; +} diff --git a/src-migrate/modules/product-detail/styles/product-detail.module.css b/src-migrate/modules/product-detail/styles/product-detail.module.css new file mode 100644 index 00000000..c668167c --- /dev/null +++ b/src-migrate/modules/product-detail/styles/product-detail.module.css @@ -0,0 +1,15 @@ +.title { + @apply font-medium text-h-lg leading-8 md:text-title-md md:leading-10; +} + +.section-card { + @apply p-4 md:p-6 md:bg-gray-50 rounded-xl; +} + +.heading { + @apply text-h-md md:text-h-lg font-medium; +} + +.description { + @apply leading-relaxed text-gray-700; +} diff --git a/src-migrate/modules/product-detail/styles/variant-list.module.css b/src-migrate/modules/product-detail/styles/variant-list.module.css new file mode 100644 index 00000000..6d46df84 --- /dev/null +++ b/src-migrate/modules/product-detail/styles/variant-list.module.css @@ -0,0 +1,35 @@ +.wrapper { + @apply grid grid-cols-1 w-[200%] md:w-full; +} + +.header { + @apply flex py-2.5 pl-4 font-medium bg-gray-200 rounded-md; +} + +.row { + @apply flex items-center py-2.5 pl-4 text-gray-800; +} + +.select-btn { + @apply !bg-gray-200 hover:!bg-danger-500 hover:!text-white; +} + +.select-btn--active { + @apply !text-white !bg-danger-500 hover:!text-white; +} + +.stock-empty { + @apply bg-red-50 border border-red-500 text-red-800; +} + +.stock-ready { + @apply bg-green-50 border border-green-500 text-green-800; +} + +.disc-badge { + @apply bg-danger-500 p-1 rounded text-white text-caption-2; +} + +.disc-price { + @apply text-caption-2 line-through text-gray-600; +} diff --git a/src-migrate/modules/product-promo/components/AddToCart.tsx b/src-migrate/modules/product-promo/components/AddToCart.tsx new file mode 100644 index 00000000..87017c14 --- /dev/null +++ b/src-migrate/modules/product-promo/components/AddToCart.tsx @@ -0,0 +1,100 @@ +import { Button, Spinner, useToast } from '@chakra-ui/react' +import { CheckIcon, PlusIcon } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +import { getAuth } from '~/libs/auth' +import { upsertUserCart } from '~/services/cart' +import { IPromotion } from '~/types/promotion' + +import DesktopView from '../../../../src/core/components/views/DesktopView'; +import MobileView from '../../../../src/core/components/views/MobileView'; + +type Props = { + promotion: IPromotion +} + +type Status = 'idle' | 'loading' | 'success' + +const ProductPromoAddToCart = ({ promotion }: Props) => { + const auth = getAuth() + const toast = useToast() + const router = useRouter() + + const [status, setStatus] = useState<Status>('idle') + + const handleButton = async () => { + if (typeof auth !== 'object') { + const currentUrl = encodeURIComponent(router.asPath) + router.push(`/login?next=${currentUrl}`) + return + } + if (status === 'success') return + + setStatus('loading') + await upsertUserCart({ + userId: auth.id, + type: 'promotion', + id: promotion.id, + qty: 1, + selected: true, + source: 'add_to_cart', + qtyAppend: true + }) + setStatus('idle') + + toast({ + title: 'Tambah ke keranjang', + description: 'Berhasil menambahkan barang ke keranjang belanja', + status: 'success', + duration: 3000, + isClosable: true, + position: 'top', + }) + } + + useEffect(() => { + if (status === 'success') setTimeout(() => { setStatus('idle') }, 3000) + }, [status]) + + return ( + <div> + <MobileView> + <Button + colorScheme='yellow' + px={2} + w='36px' + gap={1} + isDisabled={status === 'loading'} + onClick={handleButton} + > + {status === 'success' && <CheckIcon size={16} />} + {status === 'loading' && <Spinner size='xs' mr={1.5} />} + {status === 'idle' && <PlusIcon size={16} />} + + {status === 'success' && <span>Berhasil</span>} + {/* {status !== 'success' && <span>Keranjang</span>} */} + </Button> + </MobileView> + <DesktopView> + <Button + colorScheme='yellow' + px={2} + w='110px' + gap={1} + isDisabled={status === 'loading'} + onClick={handleButton} + > + {status === 'success' && <CheckIcon size={16} />} + {status === 'loading' && <Spinner size='xs' mr={1.5} />} + {status === 'idle' && <PlusIcon size={16} />} + + {status === 'success' && <span>Berhasil</span>} + {status !== 'success' && <span>Keranjang</span>} + </Button> + </DesktopView> + </div> + ) +} + +export default ProductPromoAddToCart
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/Card.tsx b/src-migrate/modules/product-promo/components/Card.tsx new file mode 100644 index 00000000..56e29e38 --- /dev/null +++ b/src-migrate/modules/product-promo/components/Card.tsx @@ -0,0 +1,206 @@ +import style from "../styles/card.module.css" + +import React, { useEffect, useMemo, useState } from 'react' +import { InfoIcon, PlusIcon } from "lucide-react" +import { Skeleton, Tooltip } from '@chakra-ui/react' +import { motion } from "framer-motion" + +import { PROMO_CATEGORY } from "~/constants/promotion" +import { getVariantById } from "~/services/productVariant" + +import { IProductVariantPromo, IPromotion } from '~/types/promotion' +import formatCurrency from '~/libs/formatCurrency' +import clsxm from '~/libs/clsxm' + +import ProductPromoItem from './Item' +import ProductPromoAddToCart from "./AddToCart" +import ProductPromoCardCountdown from "./CardCountdown" + +import MobileView from '../../../../src/core/components/views/MobileView'; +import DesktopView from '../../../../src/core/components/views/DesktopView'; + +type Props = { + promotion: IPromotion + +} + +const ProductPromoCard = ({ promotion}: Props) => { + const [products, setProducts] = useState<IProductVariantPromo[]>([]) + const [freeProducts, setFreeProducts] = useState<IProductVariantPromo[]>([]) + const [error, setError] = useState<string | null>(null) + + useEffect(() => { + const getProducts = async () => { + try { + const datas = [] + for (const product of promotion.products) { + const res = await getVariantById(product.product_id) + res.data.qty = product.qty + datas.push(res.data) + } + setProducts(datas) + } catch (err) { + setError('Failed to fetch product variants.') + console.error(err) + } + } + + getProducts() + }, [promotion.products]) + + useEffect(() => { + const getFreeProducts = async () => { + try { + const datas = [] + for (const product of promotion.free_products) { + const res = await getVariantById(product.product_id) + res.data.qty = product.qty + datas.push(res.data) + } + setFreeProducts(datas) + } catch (err) { + setError('Failed to fetch free product variants.') + console.error(err) + } + } + + getFreeProducts() + }, [promotion.free_products]) + + const priceTotal = useMemo(() => { + let total = 0; + [...products, ...freeProducts].forEach((product) => { + total += product.price.price_discount * product.qty + }) + return total + }, [products, freeProducts]) + + const allProducts = [...products, ...freeProducts] + + + + return ( + <div> + <MobileView> + <div className={style.card}> + <ProductPromoCardCountdown promotion={promotion} /> + + <div className='px-4 mt-4 text-caption-1'> + <div className="flex justify-between items-center"> + <div className={style.title}>{promotion.name}</div> + + <Tooltip label={PROMO_CATEGORY[promotion.type.value].description} placement="top" bgColor='red.600' p={1} rounded={6}> + {/* <div className={style.badgeType} > */} + {/* Paket {PROMO_CATEGORY[promotion.type.value].alias} */} + <InfoIcon className={style.badgeType} size={25} /> + {/* </div> */} + </Tooltip> + </div> + + <Skeleton className={clsxm(style.productSection, { 'justify-center': allProducts.length === 2 })} isLoaded={allProducts.length > 0}> + {allProducts.map((product, index) => ( + <React.Fragment key={product.id}> + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6 }}> + <ProductPromoItem + variant={product} + isFree={index + 1 > products.length && promotion.type.value === 'merchandise'} + // isFree={index + 1 > products.length } + /> + </motion.div> + <motion.div initial={{ y: 30, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.5, delay: 0.1 }}> + {index + 1 < allProducts.length && ( + <div className="h-fit p-1 rounded-full border border-danger-500 text-danger-500 mt-[38px]"> + <PlusIcon size={14} strokeWidth='2px' /> + </div> + )} + </motion.div> + </React.Fragment> + ))} + </Skeleton> + + <div className={style.priceSection}> + <div className={style.priceCol}> + <Skeleton className={style.priceRow} isLoaded={priceTotal > 0}> + <span className={style.basePrice}>Rp{formatCurrency(priceTotal)}</span> + <span className="text-[11px]">Hemat <span className={style.savingAmt}>Rp {formatCurrency(priceTotal - promotion.price)}</span></span> + </Skeleton> + + <div className={style.priceRow}> + <span className={style.price}>Rp{formatCurrency(promotion.price)}</span> + <span className={style.totalItems}>(Total {promotion.total_qty} barang)</span> + </div> + + </div> + <div> + <ProductPromoAddToCart promotion={promotion} /> + </div> + + </div> + </div> + </div> + </MobileView> + <DesktopView> + <div className={style.card}> + <ProductPromoCardCountdown promotion={promotion} /> + + <div className='px-4 mt-4 text-caption-1'> + <div className="flex justify-between items-center"> + <div className={style.title}>{promotion.name}</div> + + <Tooltip label={PROMO_CATEGORY[promotion.type.value].description} placement="top" bgColor='red.600' p={2} rounded={6}> + <div className={style.badgeType}> + Paket {PROMO_CATEGORY[promotion.type.value].alias} + <InfoIcon size={16} /> + </div> + </Tooltip> + </div> + + <Skeleton className={clsxm(style.productSection, { 'justify-center': allProducts.length === 2 })} isLoaded={allProducts.length > 0}> + {allProducts.map((product, index) => ( + <React.Fragment key={product.id}> + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6 }}> + <ProductPromoItem + variant={product} + isFree={index + 1 > products.length && promotion.type.value === 'merchandise'} + // isFree={index + 1 > products.length } + /> + </motion.div> + <motion.div initial={{ y: 30, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.5, delay: 0.1 }}> + {index + 1 < allProducts.length && ( + <div className="h-fit p-1 rounded-full border border-danger-500 text-danger-500 mt-[38px]"> + <PlusIcon size={14} strokeWidth='2px' /> + </div> + )} + </motion.div> + </React.Fragment> + ))} + </Skeleton> + + <div className={style.priceSection}> + <div className={style.priceCol}> + <Skeleton className={style.priceRow} isLoaded={priceTotal > 0}> + <span className={style.basePrice}>Rp{formatCurrency(priceTotal)}</span> + <span>Hemat <span className={style.savingAmt}>Rp {formatCurrency(priceTotal - promotion.price)}</span></span> + </Skeleton> + + <div className={style.priceRow}> + <span className={style.price}>Rp{formatCurrency(promotion.price)}</span> + <span className={style.totalItems}>(Total {promotion.total_qty} barang)</span> + </div> + </div> + <div> + <ProductPromoAddToCart promotion={promotion} /> + </div> + + </div> + </div> + </div> + </DesktopView> + </div> + // shouldRender && ( + + // ) + ) +} + +export default ProductPromoCard diff --git a/src-migrate/modules/product-promo/components/CardCountdown.tsx b/src-migrate/modules/product-promo/components/CardCountdown.tsx new file mode 100644 index 00000000..b61ad115 --- /dev/null +++ b/src-migrate/modules/product-promo/components/CardCountdown.tsx @@ -0,0 +1,67 @@ +import style from '../styles/card-countdown.module.css' + +import React, { useEffect, useState } from 'react' +import { useQuery } from 'react-query' +import { ClockIcon } from 'lucide-react' +import { Skeleton } from '@chakra-ui/react' +import moment from 'moment' + +import clsxm from '~/libs/clsxm' +import { IPromotion } from '~/types/promotion' +import { getPromotionProgram } from '~/services/promotionProgram' + +type Props = { + promotion: IPromotion +} + +const ProductPromoCardCountdown = ({ promotion }: Props) => { + const query = useQuery(['promotion-program', promotion.program_id], async () => { + return await getPromotionProgram(promotion.program_id) + }) + + const program = query.data?.data || null + + const [count, setCount] = useState(program?.time_left || 0); + + useEffect(() => { + let interval: NodeJS.Timeout; + + if (program?.time_left && program?.time_left > 0) { + setCount(program?.time_left); + + interval = setInterval(() => { + setCount((prevCount) => prevCount - 1); + }, 1000); + } + + return () => { + clearInterval(interval); + }; + }, [program?.time_left]); + + const duration = moment.duration(count, 'seconds') + + const countdownClass = { + 'text-white': true, + 'bg-[#312782]': promotion.type.value === 'bundling', + 'bg-[#329E44]': promotion.type.value === 'discount_loading', + 'bg-[#FAD147]': promotion.type.value === 'merchandise', + 'text-gray-700': promotion.type.value === 'merchandise', + } + + return ( + <Skeleton isLoaded={query.isFetched} className={clsxm(style.countdownSection, countdownClass)}> + <span> + <ClockIcon size={20} /> + </span> + <span>Berakhir dalam</span> + <div className={style.countdown}> + <span>{duration.hours().toString().padStart(2, '0')}</span> + <span>{duration.minutes().toString().padStart(2, '0')}</span> + <span>{duration.seconds().toString().padStart(2, '0')}</span> + </div> + </Skeleton> + ) +} + +export default ProductPromoCardCountdown
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/CategoryTab.tsx b/src-migrate/modules/product-promo/components/CategoryTab.tsx new file mode 100644 index 00000000..c8e698c2 --- /dev/null +++ b/src-migrate/modules/product-promo/components/CategoryTab.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import style from '../styles/category-tab.module.css' +import { useModalStore } from '../stores/useModalStore' +import clsxm from '~/libs/clsxm' +import { ICategoryPromo } from '~/types/promotion' + +const TABS: ICategoryPromo[] = [ + { value: 'bundling', label: 'Bundling' }, + { value: 'discount_loading', label: 'Discount Loading' }, + { value: 'merchandise', label: 'Free Merchant' }, +] + +const ProductPromoCategoryTab = () => { + const { activeTab, changeTab } = useModalStore() + return ( + <div className={style.tabs}> + {TABS.map((tab) => ( + <button + key={tab.value} + type='button' + className={clsxm({ + [style.tab]: true, + [style.tabActive]: activeTab === tab.value + })} + onClick={() => changeTab(tab.value)} + > + {tab.label} + </button> + ))} + </div> + ) +} + +export default ProductPromoCategoryTab
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/Item.tsx b/src-migrate/modules/product-promo/components/Item.tsx new file mode 100644 index 00000000..b396160f --- /dev/null +++ b/src-migrate/modules/product-promo/components/Item.tsx @@ -0,0 +1,34 @@ +import style from '../styles/item.module.css' + +import { Tooltip } from '@chakra-ui/react' + +import Image from '~/components/ui/image' +import { IProductVariantPromo } from '~/types/promotion' + +type Props = { + variant: IProductVariantPromo, + isFree?: boolean +} + +const ProductPromoItem = ({ + variant, + isFree = false +}: Props) => { + return ( + <div className={style.item}> + <div className={style.image}> + <Image src={variant.image || '/images/noimage.jpeg'} alt={variant.display_name} width={120} height={120} quality={100} /> + <div className={style.quantity}> + {variant.qty} pcs {isFree ? '(free)' : ''} + </div> + </div> + <Tooltip label={variant.display_name} placement='top' fontSize='sm'> + <div className={style.name}> + {variant.name} + </div> + </Tooltip> + </div> + ) +} + +export default ProductPromoItem
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/Modal.tsx b/src-migrate/modules/product-promo/components/Modal.tsx new file mode 100644 index 00000000..0de672c2 --- /dev/null +++ b/src-migrate/modules/product-promo/components/Modal.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Modal } from "~/components/ui/modal" +import { useModalStore } from '../stores/useModalStore' +import ProductPromoCategoryTab from './CategoryTab' +import ProductPromoModalContent from './ModalContent' + +const ProductPromoModal = () => { + const { active, closeModal } = useModalStore() + + return ( + <Modal + active={active} + close={closeModal} + title='Promo Tersedia' + > + <ProductPromoCategoryTab /> + + <div className='h-4' /> + + <ProductPromoModalContent /> + </Modal> + ) +} + +export default ProductPromoModal
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/ModalContent.tsx b/src-migrate/modules/product-promo/components/ModalContent.tsx new file mode 100644 index 00000000..ab5129f8 --- /dev/null +++ b/src-migrate/modules/product-promo/components/ModalContent.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "react-query" +import { Skeleton } from "@chakra-ui/react" + +import { getVariantPromoByCategory } from "~/services/productVariant" + +import { useModalStore } from "../stores/useModalStore" +import ProductPromoCard from "./Card" + +const ProductPromoModalContent = () => { + const { activeTab, variantId } = useModalStore() + + const promotionsQuery = useQuery( + `variant-promo:${variantId}:${activeTab}`, + async () => { + if (!variantId) return + + return getVariantPromoByCategory(variantId, activeTab) + }, + ) + + const promotions = promotionsQuery.data + + return ( + <Skeleton isLoaded={!promotionsQuery.isLoading} className='min-h-[70vh] max-h-[70vh]'> + <div className="grid grid-cols-1 gap-y-6 pb-6"> + {promotions?.data.map((promo) => ( + <ProductPromoCard key={promo.id} promotion={promo} /> + ))} + {promotions?.data.length === 0 && ( + <div className="py-10 rounded-lg h-fit text-center text-body-1 font-semibold text-gray-800 bg-gray-200">Belum ada promo pada kategori ini</div> + )} + </div> + </Skeleton> + ) +} + +export default ProductPromoModalContent
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/components/Section.tsx b/src-migrate/modules/product-promo/components/Section.tsx new file mode 100644 index 00000000..4e8a7dd5 --- /dev/null +++ b/src-migrate/modules/product-promo/components/Section.tsx @@ -0,0 +1,61 @@ +import style from "../styles/section.module.css" + +import { Button, Skeleton } from '@chakra-ui/react' +import { useQuery } from 'react-query' + +import SmoothRender from "~/components/ui/smooth-render" +import clsxm from "~/libs/clsxm" +import { IPromotion } from '~/types/promotion' +import { useModalStore } from "../stores/useModalStore" +import ProductPromoCard from './Card' +import ProductPromoModal from "./Modal" + +type Props = { + productId: number; +} + +const ProductPromoSection = ({ productId }: Props) => { + const promotionsQuery = useQuery({ + queryKey: [`promotions.highlight`, productId], + queryFn: async () => await fetch(`/api/product-variant/${productId}/promotion/highlight`).then((res) => res.json()) as { data: IPromotion[] } + }) + + const promotions = promotionsQuery.data + + const { openModal } = useModalStore() + + return ( + <SmoothRender + isLoaded={(promotions?.data && promotions?.data.length > 0) || false} + height='450px' + duration='700ms' + > + <ProductPromoModal /> + + {promotions?.data && promotions?.data.length > 0 && ( + <div className={style.titleWrapper}> + <span className={style.title}>Promo Tersedia</span> + <Button colorScheme="yellow" type='button' onClick={() => openModal(productId)}> + Lihat Semua + </Button> + </div> + )} + + <Skeleton + isLoaded={promotionsQuery.isSuccess} + className={clsxm( + "flex gap-x-4 overflow-x-auto px-4 md:px-0", { + "min-h-[340px]": promotions?.data && promotions?.data.length > 0 + })} + > + {promotions?.data.map((promotion) => ( + <div key={promotion.id} className="min-w-[400px] max-w-[400px]"> + <ProductPromoCard promotion={promotion} /> + </div> + ))} + </Skeleton> + </SmoothRender> + ) +} + +export default ProductPromoSection
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/stores/useModalStore.ts b/src-migrate/modules/product-promo/stores/useModalStore.ts new file mode 100644 index 00000000..464bb598 --- /dev/null +++ b/src-migrate/modules/product-promo/stores/useModalStore.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand'; +import { CategoryPromo } from '~/types/promotion'; + +type State = { + active: boolean; + variantId?: number; + activeTab: CategoryPromo; +}; + +type Action = { + openModal: (variantId: number) => void; + closeModal: () => void; + changeTab: (tab: State['activeTab']) => void; +}; + +const defaultState: Omit<State, 'activeTab'> = { + active: false, + variantId: undefined, +}; + +export const useModalStore = create<State & Action>((set) => ({ + ...defaultState, + activeTab: 'bundling', + openModal: (variantId: number) => set({ active: true, variantId }), + closeModal: () => set(defaultState), + // TABS + changeTab: (tab) => set({ activeTab: tab }), +})); diff --git a/src-migrate/modules/product-promo/styles/card-countdown.module.css b/src-migrate/modules/product-promo/styles/card-countdown.module.css new file mode 100644 index 00000000..dae8945f --- /dev/null +++ b/src-migrate/modules/product-promo/styles/card-countdown.module.css @@ -0,0 +1,14 @@ +.countdownSection { + @apply w-fit p-2.5 pr-6 + rounded-r-full + font-medium + flex items-center gap-x-2.5; +} + +.countdown { + @apply flex gap-x-1; +} + +.countdown span { + @apply py-0.5 w-8 bg-red-600 text-gray_r-4 rounded-md text-center; +}
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/styles/card.module.css b/src-migrate/modules/product-promo/styles/card.module.css new file mode 100644 index 00000000..faa3b370 --- /dev/null +++ b/src-migrate/modules/product-promo/styles/card.module.css @@ -0,0 +1,58 @@ +.card { + @apply border border-gray_r-7 + rounded-lg + h-fit + py-3; +} + +.title { + @apply font-semibold text-h-md; +} + +.badgeType { + @apply p-2 flex gap-x-1.5 rounded-md border border-danger-500 text-danger-500; +} + +.productSection { + @apply flex gap-x-2 overflow-x-auto overflow-y-hidden mt-4 min-h-[160px]; +} + +.priceSection { + @apply flex items-center justify-between mt-4; +} + +.priceCol { + @apply flex flex-col gap-y-1; +} + +.priceRow { + @apply flex gap-x-2 items-center; +} + +.basePrice { + @apply line-through; +} + +.savingAmt { + @apply text-success-600 font-medium; +} + +.price { + @apply text-body-1 text-danger-600 font-medium; +} + +.totalItems { + @apply text-gray_r-9; +} + +@media only screen and (max-width: 384px) { + .basePrice { + @apply text-[13px]; + } + .price{ + @apply text-[15px]; + } + .totalItems{ + @apply text-[11px]; + } + }
\ No newline at end of file diff --git a/src-migrate/modules/product-promo/styles/category-tab.module.css b/src-migrate/modules/product-promo/styles/category-tab.module.css new file mode 100644 index 00000000..cab2cb1b --- /dev/null +++ b/src-migrate/modules/product-promo/styles/category-tab.module.css @@ -0,0 +1,12 @@ +.tabs { + @apply flex gap-x-4; +} + +.tab { + @apply py-1.5 duration-300; + transition-property: background-color; +} + +.tabActive { + @apply cursor-default border-b-2 border-danger-500 font-medium; +} diff --git a/src-migrate/modules/product-promo/styles/item.module.css b/src-migrate/modules/product-promo/styles/item.module.css new file mode 100644 index 00000000..b6a8b2ef --- /dev/null +++ b/src-migrate/modules/product-promo/styles/item.module.css @@ -0,0 +1,19 @@ +.item { + @apply w-[100px] h-[100px]; +} + +.image { + @apply w-full h-[100px] relative border border-gray_r-6 p-2.5 rounded-lg mb-3; +} + +.fillDesc { + @apply mt-2 text-danger-600; +} + +.quantity { + @apply backdrop-blur-lg border border-danger-300 text-danger-600 font-semibold px-2 py-1 text-caption-2 flex items-center justify-center rounded absolute bottom-2.5; +} + +.name { + @apply mt-1 line-clamp-2 leading-5 font-medium; +} diff --git a/src-migrate/modules/product-promo/styles/section.module.css b/src-migrate/modules/product-promo/styles/section.module.css new file mode 100644 index 00000000..d830f5d4 --- /dev/null +++ b/src-migrate/modules/product-promo/styles/section.module.css @@ -0,0 +1,7 @@ +.titleWrapper { + @apply w-full mb-4 h-20 bg-[#C70817] rounded-none md:rounded-lg flex items-center justify-between px-4 py-1; +} + +.title { + @apply font-semibold text-xl text-white; +} diff --git a/src-migrate/modules/product-similar/hooks/useProductSimilar.tsx b/src-migrate/modules/product-similar/hooks/useProductSimilar.tsx new file mode 100644 index 00000000..f2c49472 --- /dev/null +++ b/src-migrate/modules/product-similar/hooks/useProductSimilar.tsx @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query' +import { GetProductSimilarProps, getProductSimilar } from '~/services/product' + +type Props = GetProductSimilarProps + +const useProductSimilar = (props: Props) => { + const similarQuery = useQuery({ + queryKey: ['product-similar', props], + queryFn: () => getProductSimilar(props), + }) + + return similarQuery +} + +export default useProductSimilar
\ No newline at end of file diff --git a/src-migrate/modules/product-slider/components/ProductSlider.tsx b/src-migrate/modules/product-slider/components/ProductSlider.tsx new file mode 100644 index 00000000..05f8c322 --- /dev/null +++ b/src-migrate/modules/product-slider/components/ProductSlider.tsx @@ -0,0 +1,42 @@ +import 'swiper/css' + +import React from 'react' +import { Swiper, SwiperSlide } from 'swiper/react' +import { FreeMode } from 'swiper' + +import ProductCard from '~/modules/product-card' +import { IProduct } from '~/types/product' +import useDevice from '@/core/hooks/useDevice' + +type Props = { + products: IProduct[], + productLayout?: 'vertical' | 'horizontal', +} + +const ProductSlider = ({ products, productLayout }: Props) => { + const { isDesktop } = useDevice() + + return ( + <div> + <Swiper + slidesPerView={isDesktop ? 6.7 : 2.2} + spaceBetween={isDesktop ? 16 : 12} + prefix='product-slider' + modules={[FreeMode]} + freeMode={{ enabled: true, sticky: false }} + className='!pb-0.5' + > + {products.map((product) => ( + <SwiperSlide key={product.id}> + <ProductCard + product={product} + layout={productLayout} + /> + </SwiperSlide> + ))} + </Swiper> + </div > + ) +} + +export default ProductSlider
\ No newline at end of file diff --git a/src-migrate/modules/product-slider/index.ts b/src-migrate/modules/product-slider/index.ts new file mode 100644 index 00000000..1593a543 --- /dev/null +++ b/src-migrate/modules/product-slider/index.ts @@ -0,0 +1,3 @@ +import ProductSlider from './components/ProductSlider'; + +export default ProductSlider; diff --git a/src-migrate/modules/register/components/Form.tsx b/src-migrate/modules/register/components/Form.tsx index e9dc4906..4baaf380 100644 --- a/src-migrate/modules/register/components/Form.tsx +++ b/src-migrate/modules/register/components/Form.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useMemo } from "react"; import { useMutation } from "react-query"; -import { useRegisterStore } from "~/common/stores/useRegisterStore"; -import { RegisterProps } from "~/common/types/auth"; +import { useRegisterStore } from "../stores/useRegisterStore"; +import { RegisterProps } from "~/types/auth"; import { registerUser } from "~/services/auth"; import TermCondition from "./TermCondition"; import FormCaptcha from "./FormCaptcha"; diff --git a/src-migrate/modules/register/components/FormCaptcha.tsx b/src-migrate/modules/register/components/FormCaptcha.tsx index 967be017..fbea2b10 100644 --- a/src-migrate/modules/register/components/FormCaptcha.tsx +++ b/src-migrate/modules/register/components/FormCaptcha.tsx @@ -1,5 +1,5 @@ -import ReCaptcha from '~/common/components/elements/ReCaptcha' -import { useRegisterStore } from '~/common/stores/useRegisterStore' +import { ReCaptcha } from '~/components/ui/re-captcha' +import { useRegisterStore } from "../stores/useRegisterStore"; const FormCaptcha = () => { const { updateValidCaptcha } = useRegisterStore() diff --git a/src-migrate/modules/register/components/TermCondition.tsx b/src-migrate/modules/register/components/TermCondition.tsx index 6b95ba19..b7729deb 100644 --- a/src-migrate/modules/register/components/TermCondition.tsx +++ b/src-migrate/modules/register/components/TermCondition.tsx @@ -1,7 +1,7 @@ import { Checkbox } from '@chakra-ui/react' import React from 'react' -import Modal from '~/common/components/elements/Modal' -import { useRegisterStore } from '~/common/stores/useRegisterStore' +import { Modal } from '~/components/ui/modal' +import { useRegisterStore } from "../stores/useRegisterStore"; import PageContent from '~/modules/page-content' const TermCondition = () => { diff --git a/src-migrate/common/stores/useRegisterStore.ts b/src-migrate/modules/register/stores/useRegisterStore.ts index 90ce8a2b..d8abf52b 100644 --- a/src-migrate/common/stores/useRegisterStore.ts +++ b/src-migrate/modules/register/stores/useRegisterStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { RegisterProps } from '../types/auth'; -import { registerSchema } from '../validations/auth'; +import { RegisterProps } from '~/types/auth'; +import { registerSchema } from '~/validations/auth'; import { ZodError } from 'zod'; type State = { diff --git a/src-migrate/modules/side-banner/index.tsx b/src-migrate/modules/side-banner/index.tsx new file mode 100644 index 00000000..6214edfb --- /dev/null +++ b/src-migrate/modules/side-banner/index.tsx @@ -0,0 +1,30 @@ +import React, { useMemo } from "react"; +import Link from "next/link"; +import { useQuery } from "react-query"; +import Image from "~/components/ui/image"; +import { getBanner } from "~/services/banner"; +import { getRandomInt } from '@/utils/getRandomInt'; + +const SideBanner = () => { + const fetchSideBanner = useQuery({ + queryKey: 'sideBanner', + queryFn: () => getBanner({ type: 'side-banner-search' }) + }); + // ubah dari static menjadid dynamic dengan menggunakan random index + const length = useMemo(() => fetchSideBanner.data?.length, [fetchSideBanner.data]); + const randomIndex = useMemo(() => getRandomInt(length), [length]); + const banner = fetchSideBanner?.data?.[randomIndex] || false; + + return banner && ( + <> + {banner.url ? ( + <Link href={banner.url}> + <Image src={banner.image} alt={banner.name} width={315} height={450} className='object-cover object-center rounded-lg' /> + </Link> + ) : ( + <Image src={banner.image} alt={banner.name} width={315} height={450} className='object-cover object-center rounded-lg' /> + )} + </> + ); +} +export default SideBanner; diff --git a/src-migrate/pages/_app.tsx b/src-migrate/pages/_app.tsx index 2dc82559..36640c04 100644 --- a/src-migrate/pages/_app.tsx +++ b/src-migrate/pages/_app.tsx @@ -1,5 +1,5 @@ -import '~/common/styles/fonts/Inter/inter.css' -import '~/common/styles/globals.css' +import '~/styles/fonts/Inter/inter.css' +import '~/styles/globals.css' import type { AppProps } from "next/app" export default function MyApp({ Component, pageProps }: AppProps) { diff --git a/src-migrate/pages/api/product-variant/[id].tsx b/src-migrate/pages/api/product-variant/[id].tsx new file mode 100644 index 00000000..955fde6a --- /dev/null +++ b/src-migrate/pages/api/product-variant/[id].tsx @@ -0,0 +1,53 @@ +import moment from "moment"; +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/types/solr"; + +const SOLR_HOST = process.env.SOLR_HOST as string + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const variantId = req.query.id as string + let price_tier = 'tier1' + + let auth = req.cookies.auth ? JSON.parse(req.cookies.auth) : null + if (auth?.pricelist) price_tier = auth.pricelist + + if (req.method === 'GET') { + const queryParams = new URLSearchParams({ q: `id:${variantId}` }) + const response = await fetch(`${SOLR_HOST}/solr/variants/select?${queryParams.toString()}`) + const data: SolrResponse<any[]> = await response.json() + + if (data.response.numFound === 0) { + res.status(404).json({ error: 'Variant not found' }) + return + } + + const variant = await map(data.response.docs[0], price_tier) + + res.status(200).json({ data: variant }) + } +} + +const map = async (variant: any, price_tier: string) => { + const data: any = {} + const price = variant[`price_${price_tier}_v2_f`] || 0 + + data.id = parseInt(variant.id) + data.parent_id = variant.template_id_i + data.display_name = variant.display_name_s + data.image = variant.image_s + data.name = variant.name_s + data.default_code = variant.default_code_s + data.price = { discount_percentage: 0, price, price_discount: price } + + return data +} + +const checkIsFlashsale = (variant: any) => { + const endDateStr = variant.flashsale_end_date_s || null + if (!endDateStr) return false + + const now = moment() + const endDate = moment(endDateStr, 'YYYY-MM-DD HH:mm:ss') + + return variant.flashsale_id_i > 0 && now.isSameOrBefore(endDate) +}
\ No newline at end of file diff --git a/src-migrate/pages/api/product-variant/[id]/promotion/[category].tsx b/src-migrate/pages/api/product-variant/[id]/promotion/[category].tsx new file mode 100644 index 00000000..8da0d9a3 --- /dev/null +++ b/src-migrate/pages/api/product-variant/[id]/promotion/[category].tsx @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/types/solr"; + +const SOLR_HOST = process.env.SOLR_HOST as string + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const productId = req.query.id as string + const category = req.query.category as string + + if (req.method === 'GET') { + const queryParams = new URLSearchParams({ q: `product_ids:${productId}` }) + queryParams.append('fq', `type_value_s:${category}`) + queryParams.append('fq', `active_b:true`) + + const response = await fetch(`${SOLR_HOST}/solr/promotion_program_lines/select?${queryParams.toString()}`) + const data: SolrResponse<any[]> = await response.json() + + const promotions = await map(data.response.docs) + res.status(200).json({ data: promotions }) + } +} + +const map = async (promotions: any[]) => { + const result = [] + + for (const promotion of promotions) { + const data: any = {} + + data.id = promotion.id + data.program_id = promotion.program_id_i + data.name = promotion.name_s + data.type = { + value: promotion.type_value_s, + label: promotion.type_label_s, + } + data.limit = promotion.package_limit_i + data.limit_user = promotion.package_limit_user_i + data.limit_trx = promotion.package_limit_trx_i + data.price = promotion.price_f + data.total_qty = promotion.total_qty_i + + data.products = JSON.parse(promotion.products_s) + data.free_products = JSON.parse(promotion.free_products_s) + + result.push(data) + } + + return result +}
\ No newline at end of file diff --git a/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx b/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx new file mode 100644 index 00000000..c4acacf1 --- /dev/null +++ b/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/types/solr"; + +const SOLR_HOST = process.env.SOLR_HOST as string + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const productId = req.query.id as string + + if (req.method === 'GET') { + const types = ['bundling', 'discount_loading', 'merchandise'] + const queryParams = new URLSearchParams({ + q: `product_ids:${productId}`, + rows: '1' + }) + + let programs: any[] = [] + + for (const type of types) { + queryParams.set('fq', `type_value_s:${type}`) + queryParams.append('fq', `active_b:true`) + + const response = await fetch(`${SOLR_HOST}/solr/promotion_program_lines/select?${queryParams.toString()}`) + const data: SolrResponse<any[]> = await response.json() + programs.push(...data.response.docs) + } + + programs = await extractPrograms(programs) + res.status(200).json({ data: programs }) + } +} + +const extractPrograms = async (programs: any[]) => { + const result = [] + + for (const program of programs) { + const data: any = {} + + data.id = program.id + data.program_id = program.program_id_i + data.name = program.name_s + data.type = { + value: program.type_value_s, + label: program.type_label_s, + } + data.limit = program.package_limit_i + data.limit_user = program.package_limit_user_i + data.limit_trx = program.package_limit_trx_i + data.price = program.price_f + data.total_qty = program.total_qty_i + + data.products = JSON.parse(program.products_s) + data.free_products = JSON.parse(program.free_products_s) + + result.push(data) + } + + return result +}
\ No newline at end of file diff --git a/src-migrate/pages/api/promotion-program/[id].tsx b/src-migrate/pages/api/promotion-program/[id].tsx new file mode 100644 index 00000000..c509b802 --- /dev/null +++ b/src-migrate/pages/api/promotion-program/[id].tsx @@ -0,0 +1,42 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/types/solr"; + +const SOLR_HOST = process.env.SOLR_HOST as string + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const id = req.query.id as string + + if (req.method === 'GET') { + const queryParams = new URLSearchParams({ q: `id:${id}` }) + const response = await fetch(`${SOLR_HOST}/solr/promotion_programs/select?${queryParams.toString()}`) + const data: SolrResponse<any[]> = await response.json() + + if (data.response.numFound === 0) { + res.status(404).json({ error: 'Program not found' }) + return + } + + const program = await map(data.response.docs[0]) + + res.status(200).json({ data: program }) + } +} + +const map = async (program: any) => { + const data: any = {} + + data.id = program.id + data.name = program.name_s + data.start_time = program.start_time_s + data.end_time = program.end_time_s + data.applies_to = program.applies_to_s + data.time_left = (new Date(data.end_time).getTime() - new Date().getTime()) / 1000 + + // const duration = moment.duration(data.time_left, 'seconds') + // const days = duration.days() + // const hours = duration.hours() + // const minutes = duration.minutes() + // const seconds = duration.seconds() + + return data +}
\ No newline at end of file diff --git a/src-migrate/pages/register.tsx b/src-migrate/pages/register.tsx index 1246c6f5..136eaa3b 100644 --- a/src-migrate/pages/register.tsx +++ b/src-migrate/pages/register.tsx @@ -1,7 +1,7 @@ import BasicLayout from "@/core/components/layouts/BasicLayout" import { useWindowSize } from "usehooks-ts" -import Seo from "~/common/components/elements/Seo" +import { Seo } from "~/components/seo" import Register from "~/modules/register" const RegisterPage = () => { diff --git a/src-migrate/pages/shop/cart/cart.module.css b/src-migrate/pages/shop/cart/cart.module.css new file mode 100644 index 00000000..806104be --- /dev/null +++ b/src-migrate/pages/shop/cart/cart.module.css @@ -0,0 +1,35 @@ +.title { + @apply text-h-lg font-semibold; +} + +.content { + @apply flex flex-wrap ; +} + +.item-wrapper { + @apply w-full md:w-3/4 min-h-screen; +} + +.item-skeleton { + @apply grid grid-cols-1 gap-y-4; +} + +.items { + @apply flex flex-col gap-y-6 border-t border-gray-300 pt-6; +} + +.summary-wrapper { + @apply w-full md:w-1/4 md:pl-6 mt-6 md:mt-0 bottom-0 md:sticky sticky bg-white; +} + +.summary { + @apply border border-gray-300 p-4 rounded-md sticky top-[180px]; +} + +.summary-buttons { + @apply grid grid-cols-2 gap-x-3 mt-6; +} + +.summary-buttons-step-approval { + @apply grid grid-cols-1 gap-y-3 mt-6; +} diff --git a/src-migrate/pages/shop/cart/index.tsx b/src-migrate/pages/shop/cart/index.tsx new file mode 100644 index 00000000..d89707d2 --- /dev/null +++ b/src-migrate/pages/shop/cart/index.tsx @@ -0,0 +1,156 @@ +import style from './cart.module.css'; + +import React, { useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import { Button, Tooltip } from '@chakra-ui/react'; + +import { getAuth } from '~/libs/auth'; +import { useCartStore } from '~/modules/cart/stores/useCartStore'; + +import CartItem from '~/modules/cart/components/Item'; +import CartSummary from '~/modules/cart/components/Summary'; +import clsxm from '~/libs/clsxm'; +import useDevice from '@/core/hooks/useDevice'; +import CartSummaryMobile from '~/modules/cart/components/CartSummaryMobile'; +import Image from '~/components/ui/image'; + +const CartPage = () => { + const auth = getAuth(); + const [isStepApproval, setIsStepApproval] = React.useState(false); + + const { loadCart, cart, summary } = useCartStore(); + + const useDivvice = useDevice(); + + useEffect(() => { + if (typeof auth === 'object' && !cart) { + loadCart(auth.id); + setIsStepApproval(auth?.feature?.soApproval); + } + }, [auth, loadCart, cart]); + + const hasSelectedPromo = useMemo(() => { + if (!cart) return false; + for (const item of cart.products) { + if (item.cart_type === 'promotion' && item.selected) return true; + } + return false; + }, [cart]); + + const hasSelected = useMemo(() => { + if (!cart) return false; + for (const item of cart.products) { + if (item.selected) return true; + } + return false; + }, [cart]); + + const hasSelectNoPrice = useMemo(() => { + if (!cart) return false; + for (const item of cart.products) { + if (item.selected && item.price.price_discount == 0) return true; + } + return false; + }, [cart]); + + return ( + <> + <div className={style['title']}>Keranjang Belanja</div> + + <div className='h-6' /> + + <div className={style['content']}> + <div className={style['item-wrapper']}> + <div className={style['item-skeleton']}> + {!cart && <CartItem.Skeleton count={5} height='120px' />} + </div> + + <div className={style['items']}> + {cart?.products.map((item) => ( + <CartItem key={item.id} item={item} /> + ))} + + {cart?.products?.length === 0 && ( + <div className='flex flex-col items-center'> + <Image + src='/images/empty_cart.svg' + alt='Empty Cart' + width={450} + height={450} + /> + <div className='text-title-sm md:text-title-lg text-center font-semibold'> + Keranjangnya masih kosong nih + </div> + <div className='text-body-2 md:text-body-1 text-center mt-3'> + Yuk, tambahin barang-barang yang kamu mau ke keranjang + sekarang! + <br /> + Ada banyak potongan belanjanya pakai kode voucher + </div> + <Link + href='/' + className='btn-solid-red rounded-full text-body-1 mt-6' + > + Mulai Belanja + </Link> + </div> + )} + </div> + </div> + <div + className={`${style['summary-wrapper']} ${ + useDivvice.isMobile && cart?.product_total === 0 ? 'hidden' : '' + }`} + > + <div className={style['summary']}> + {useDivvice.isMobile && ( + <CartSummaryMobile {...summary} isLoaded={!!cart} /> + )} + {!useDivvice.isMobile && ( + <CartSummary {...summary} isLoaded={!!cart} /> + )} + + <div className={isStepApproval ? style['summary-buttons-step-approval'] : style['summary-buttons'] }> + <Tooltip + label={ + hasSelectedPromo && + 'Barang promo tidak dapat dibuat quotation' + } + > + <Button + colorScheme='yellow' + w='full' + isDisabled={hasSelectedPromo || !hasSelected} + as={Link} + href='/shop/quotation' + > + Quotation + </Button> + </Tooltip> + {!isStepApproval && ( + <Tooltip + label={clsxm({ + 'Tidak ada item yang dipilih': !hasSelected, + 'Terdapat item yang tidak ada harga': hasSelectNoPrice, + })} + > + <Button + colorScheme='red' + w='full' + isDisabled={!hasSelected || hasSelectNoPrice} + as={Link} + href='/shop/checkout' + > + Checkout + </Button> + </Tooltip> + )} + </div> + </div> + </div> + </div> + </> + ); +}; + +export default CartPage; diff --git a/src-migrate/pages/shop/product/[slug].tsx b/src-migrate/pages/shop/product/[slug].tsx new file mode 100644 index 00000000..fc72a6b0 --- /dev/null +++ b/src-migrate/pages/shop/product/[slug].tsx @@ -0,0 +1,83 @@ +import { GetServerSideProps, NextPage } from 'next' +import React, { useEffect } from 'react' +import dynamic from 'next/dynamic' +import cookie from 'cookie' + +import { getProductById } from '~/services/product' +import { getIdFromSlug } from '~/libs/slug' +import { IProductDetail } from '~/types/product' + +import { Seo } from '~/components/seo' +import { useRouter } from 'next/router' +import { useProductContext } from '@/contexts/ProductContext' + +const BasicLayout = dynamic(() => import('@/core/components/layouts/BasicLayout'), { ssr: false }) +const ProductDetail = dynamic(() => import('~/modules/product-detail'), { ssr: false }) + +type PageProps = { + product: IProductDetail +} + +export const getServerSideProps: GetServerSideProps<PageProps> = (async (context) => { + const { slug } = context.query + const cookieString = context.req.headers.cookie; + const cookies = cookieString ? cookie.parse(cookieString) : {}; + const auth = cookies?.auth ? JSON.parse(cookies.auth) : {}; + const tier = auth?.pricelist || '' + + const productId = getIdFromSlug(slug as string) + + const product = await getProductById(productId, tier) + + if (!product) return { notFound: true } + + return { + props: { product } + } +}) + +const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST + +const ProductDetailPage: NextPage<PageProps> = ({ product }) => { + const router = useRouter(); + + const { setProduct } = useProductContext(); + + useEffect(() => { + if (product) setProduct(product); + }, [product, setProduct]); + + return ( + <BasicLayout> + <Seo + title={`${product.name} - Indoteknik.com`} + description='Temukan pilihan produk B2B Industri & Alat Teknik untuk Perusahaan, UMKM & Pemerintah dengan lengkap, mudah dan transparan.' + openGraph={{ + url: SELF_HOST + router.asPath, + images: [ + { + url: product?.image, + width: 800, + height: 800, + alt: product?.name, + }, + ], + type: 'product', + }} + additionalMetaTags={[ + { + name: 'keywords', + content: `${product?.name}, Harga ${product?.name}, Beli ${product?.name}, Spesifikasi ${product?.name}`, + } + ]} + canonical={SELF_HOST + router.asPath} + /> + + <div className='md:container pt-4 md:pt-6'> + <ProductDetail product={product} /> + </div> + </BasicLayout> + ) +} + +export default ProductDetailPage
\ No newline at end of file diff --git a/src-migrate/services/auth.ts b/src-migrate/services/auth.ts index a5d02754..35ba290a 100644 --- a/src-migrate/services/auth.ts +++ b/src-migrate/services/auth.ts @@ -1,4 +1,4 @@ -import odooApi from '~/common/libs/odooApi'; +import odooApi from '~/libs/odooApi'; import { RegisterResApiProps, RegisterProps, @@ -8,46 +8,30 @@ import { ActivationOtpResApiProps, ActivationReqProps, ActivationReqResApiProps, -} from '~/common/types/auth'; +} from '~/types/auth'; const BASE_PATH = '/api/v1/user'; export const registerUser = async ( data: RegisterProps ): Promise<RegisterResApiProps> => { - const response = await odooApi('POST', `${BASE_PATH}/register`, data); - - return response; + return await odooApi('POST', `${BASE_PATH}/register`, data); }; export const activationUserToken = async ( params: ActivationTokenProps ): Promise<ActivationTokenResApiProps> => { - const response = await odooApi( - 'POST', - `${BASE_PATH}/activation-token`, - params - ); - - return response; + return await odooApi('POST', `${BASE_PATH}/activation-token`, params); }; export const activationUserOTP = async ( params: ActivationOtpProps ): Promise<ActivationOtpResApiProps> => { - const response = await odooApi('POST', `${BASE_PATH}/activation-otp`, params); - - return response; + return await odooApi('POST', `${BASE_PATH}/activation-otp`, params); }; export const activationReq = async ( params: ActivationReqProps ): Promise<ActivationReqResApiProps> => { - const response = await odooApi( - 'POST', - `${BASE_PATH}/activation-request`, - params - ); - - return response; + return await odooApi('POST', `${BASE_PATH}/activation-request`, params); }; diff --git a/src-migrate/services/banner.ts b/src-migrate/services/banner.ts index e69de29b..1b46ba06 100644 --- a/src-migrate/services/banner.ts +++ b/src-migrate/services/banner.ts @@ -0,0 +1,11 @@ +import odooApi from '~/libs/odooApi'; +import { IBanner } from '~/types/banner'; + +export const getBanner = async ({ + type, +}: { + type: string; +}): Promise<IBanner[]> => { + const searchParams = new URLSearchParams({ type }); + return await odooApi('GET', `/api/v1/banner?${searchParams.toString()}`); +}; diff --git a/src-migrate/services/cart.ts b/src-migrate/services/cart.ts new file mode 100644 index 00000000..11f87125 --- /dev/null +++ b/src-migrate/services/cart.ts @@ -0,0 +1,41 @@ +import odooApi from '~/libs/odooApi'; + +export const getUserCart = async (userId: number) => { + return await odooApi('GET', `/api/v1/user/${userId}/cart`); +}; + +interface UpsertUserCartProps { + userId: number; + type: 'product' | 'promotion'; + id: number; + qty: number; + selected: boolean; + source?: 'buy' | 'add_to_cart'; + qtyAppend?: boolean; +} + +export const upsertUserCart = async ({ + userId, + type, + id, + qty, + selected, + source = 'add_to_cart', + qtyAppend = false, +}: UpsertUserCartProps) => { + return await odooApi('POST', `/api/v1/user/${userId}/cart/create-or-update`, { + product_id: type === 'product' ? id : null, + qty, + selected, + program_line_id: type === 'promotion' ? id : null, + source, + qty_append: qtyAppend, + }); +}; + +export const deleteUserCart = async (userId: number, ids: number[]) => { + return await odooApi( + 'DELETE', + `/api/v1/user/${userId}/cart?ids=${ids.join(',')}` + ); +}; diff --git a/src-migrate/services/checkout.ts b/src-migrate/services/checkout.ts new file mode 100644 index 00000000..e6642ccb --- /dev/null +++ b/src-migrate/services/checkout.ts @@ -0,0 +1,5 @@ +import odooApi from '~/libs/odooApi'; + +export const getUserCheckout = async (userId: number) => { + return await odooApi('GET', `/api/v1/user/${userId}/sale_order/checkout`); +};
\ No newline at end of file diff --git a/src-migrate/services/pageContent.ts b/src-migrate/services/pageContent.ts index 24f2c2f0..516b4bed 100644 --- a/src-migrate/services/pageContent.ts +++ b/src-migrate/services/pageContent.ts @@ -1,14 +1,7 @@ -import odooApi from '~/common/libs/odooApi'; +import odooApi from '~/libs/odooApi'; export const getPageContent = async ({ path }: { path: string }) => { - const params = new URLSearchParams({ - url_path: path, - }); + const params = new URLSearchParams({ url_path: path }); - const pageContent = await odooApi( - 'GET', - `/api/v1/page-content?${params.toString()}` - ); - - return pageContent; + return await odooApi('GET', `/api/v1/page-content?${params.toString()}`); }; diff --git a/src-migrate/services/product.ts b/src-migrate/services/product.ts new file mode 100644 index 00000000..fe415d11 --- /dev/null +++ b/src-migrate/services/product.ts @@ -0,0 +1,66 @@ +import { IProduct, IProductDetail } from '~/types/product'; +import snakeCase from 'snakecase-keys'; +import odooApi from '~/libs/odooApi'; +import { ICategoryBreadcrumb } from '~/types/category'; + +const SELF_HOST = process.env.NEXT_PUBLIC_SELF_HOST; + +export const getProductById = async ( + id: string, + tier: string +): Promise<IProductDetail | null> => { + const url = `${SELF_HOST}/api/shop/product-detail`; + const params = new URLSearchParams({ id, auth: tier }); + return await fetch(`${url}?${params.toString()}`) + .then((res) => res.json()) + .then((res) => { + if (res.length > 0) return snakeCase(res[0]) as IProductDetail; + return null; + }); +}; + +export interface GetProductSimilarProps { + name: string; + except?: { + productId?: number; + manufactureId?: number; + }; + limit?: number; +} + +export interface GetProductSimilarRes { + products: IProduct[]; + num_found: number; + num_found_exact: boolean; + start: number; +} + +export const getProductSimilar = async ({ + name, + except, + limit = 30, +}: GetProductSimilarProps): Promise<GetProductSimilarRes> => { + const query = [ + `q=${name}`, + 'page=1', + 'orderBy=popular-weekly', + 'operation=OR', + 'priceFrom=1', + ]; + + if (except?.productId) query.push(`fq=-product_id_i:${except.productId}`); + if (except?.manufactureId) + query.push(`fq=-manufacture_id_i:${except.manufactureId}`); + + const url = `${SELF_HOST}/api/shop/search?${query.join('&')}`; + + return await fetch(url) + .then((res) => res.json()) + .then((res) => snakeCase(res.response)); +}; + +export const getProductCategoryBreadcrumb = async ( + id: number +): Promise<ICategoryBreadcrumb[]> => { + return await odooApi('GET', `/api/v1/product/${id}/category-breadcrumb`); +}; diff --git a/src-migrate/services/productVariant.ts b/src-migrate/services/productVariant.ts new file mode 100644 index 00000000..9fec4d1f --- /dev/null +++ b/src-migrate/services/productVariant.ts @@ -0,0 +1,23 @@ +import odooApi from '~/libs/odooApi'; +import { IProductVariantSLA } from '~/types/productVariant'; +import { CategoryPromo, IPromotion } from '~/types/promotion'; + +export const getVariantById = async (variantId: number) => { + const url = `/api/product-variant/${variantId}`; + return await fetch(url).then((res) => res.json()); +}; + +export const getVariantPromoByCategory = async ( + variantId: number, + type: CategoryPromo +): Promise<{ data: IPromotion[] }> => { + const url = `/api/product-variant/${variantId}/promotion/${type}`; + return await fetch(url).then((res) => res.json()); +}; + +export const getVariantSLA = async ( + variantId: number +): Promise<IProductVariantSLA> => { + const url = `/api/v1/product_variant/${variantId}/stock`; + return await odooApi('GET', url); +}; diff --git a/src-migrate/services/promotionProgram.ts b/src-migrate/services/promotionProgram.ts new file mode 100644 index 00000000..c8c46c65 --- /dev/null +++ b/src-migrate/services/promotionProgram.ts @@ -0,0 +1,8 @@ +import { IPromotionProgram } from '~/types/promotionProgram'; + +export const getPromotionProgram = async ( + programId: number +): Promise<{ data: IPromotionProgram }> => { + const url = `/api/promotion-program/${programId}`; + return await fetch(url).then((res) => res.json()); +}; diff --git a/src-migrate/services/wishlist.ts b/src-migrate/services/wishlist.ts new file mode 100644 index 00000000..6fb8cb2e --- /dev/null +++ b/src-migrate/services/wishlist.ts @@ -0,0 +1,23 @@ +import odooApi from '~/libs/odooApi'; + +export const getUserWishlist = async ( + userId: number, + searchParams: { + product_id?: string; + } = {} +): Promise<{ product_total: number }> => { + const url = `/api/v1/user/${userId}/wishlist`; + const searchParamsObj = new URLSearchParams(searchParams); + + return await odooApi('GET', url + '?' + searchParamsObj.toString()); +}; + +export const upsertUserWishlist = async ( + userId: number, + productId: number +): Promise<{ id: number }> => { + const url = `/api/v1/user/${userId}/wishlist/create-or-delete`; + const data = { product_id: productId }; + + return await odooApi('POST', url, data); +}; diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff b/src-migrate/styles/fonts/Inter/Inter-Black.woff Binary files differindex a18593a0..a18593a0 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Black.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff2 b/src-migrate/styles/fonts/Inter/Inter-Black.woff2 Binary files differindex 68f64c9e..68f64c9e 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Black.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff Binary files differindex b6b01943..b6b01943 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff2 Binary files differindex 1c9c7ca8..1c9c7ca8 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff b/src-migrate/styles/fonts/Inter/Inter-Bold.woff Binary files differindex eaf3d4bf..eaf3d4bf 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Bold.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff2 b/src-migrate/styles/fonts/Inter/Inter-Bold.woff2 Binary files differindex 2846f29c..2846f29c 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Bold.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff Binary files differindex 32750761..32750761 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff2 Binary files differindex 0b1fe8e1..0b1fe8e1 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff Binary files differindex c2c17ede..c2c17ede 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff2 Binary files differindex c24c2bdc..c24c2bdc 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff Binary files differindex c42f7052..c42f7052 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 Binary files differindex 4a81dc79..4a81dc79 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff Binary files differindex d0de5f39..d0de5f39 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff2 Binary files differindex f2ea706f..f2ea706f 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff Binary files differindex 81f1a28e..81f1a28e 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 Binary files differindex 9af717ba..9af717ba 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff b/src-migrate/styles/fonts/Inter/Inter-Italic.woff Binary files differindex a806b382..a806b382 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Italic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff2 b/src-migrate/styles/fonts/Inter/Inter-Italic.woff2 Binary files differindex a619fc54..a619fc54 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Italic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff b/src-migrate/styles/fonts/Inter/Inter-Light.woff Binary files differindex c496464d..c496464d 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Light.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff2 b/src-migrate/styles/fonts/Inter/Inter-Light.woff2 Binary files differindex bc4be665..bc4be665 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Light.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff Binary files differindex f84a9de3..f84a9de3 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff2 Binary files differindex 842b2dfc..842b2dfc 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff b/src-migrate/styles/fonts/Inter/Inter-Medium.woff Binary files differindex d546843f..d546843f 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Medium.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff2 b/src-migrate/styles/fonts/Inter/Inter-Medium.woff2 Binary files differindex f92498a2..f92498a2 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Medium.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff Binary files differindex 459a6568..459a6568 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff2 Binary files differindex 0e3019f4..0e3019f4 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff b/src-migrate/styles/fonts/Inter/Inter-Regular.woff Binary files differindex 62d3a618..62d3a618 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Regular.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff2 b/src-migrate/styles/fonts/Inter/Inter-Regular.woff2 Binary files differindex 6c2b6893..6c2b6893 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Regular.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff Binary files differindex a815f43a..a815f43a 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff +++ b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff2 b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff2 Binary files differindex 611e90c9..611e90c9 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff Binary files differindex 909e43a9..909e43a9 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 Binary files differindex 545685bd..545685bd 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff b/src-migrate/styles/fonts/Inter/Inter-Thin.woff Binary files differindex 62bc58cd..62bc58cd 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff +++ b/src-migrate/styles/fonts/Inter/Inter-Thin.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff2 b/src-migrate/styles/fonts/Inter/Inter-Thin.woff2 Binary files differindex abbc3a5c..abbc3a5c 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-Thin.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff Binary files differindex 700a7f06..700a7f06 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff +++ b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff2 Binary files differindex ab0b2002..ab0b2002 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-italic.var.woff2 b/src-migrate/styles/fonts/Inter/Inter-italic.var.woff2 Binary files differindex b826d5af..b826d5af 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-italic.var.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-italic.var.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter-roman.var.woff2 b/src-migrate/styles/fonts/Inter/Inter-roman.var.woff2 Binary files differindex 6a256a06..6a256a06 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter-roman.var.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter-roman.var.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/Inter.var.woff2 b/src-migrate/styles/fonts/Inter/Inter.var.woff2 Binary files differindex 365eedc5..365eedc5 100644 --- a/src-migrate/common/styles/fonts/Inter/Inter.var.woff2 +++ b/src-migrate/styles/fonts/Inter/Inter.var.woff2 diff --git a/src-migrate/common/styles/fonts/Inter/inter.css b/src-migrate/styles/fonts/Inter/inter.css index de6ce273..de6ce273 100644 --- a/src-migrate/common/styles/fonts/Inter/inter.css +++ b/src-migrate/styles/fonts/Inter/inter.css diff --git a/src-migrate/common/styles/globals.css b/src-migrate/styles/globals.css index ea20b247..ea20b247 100644 --- a/src-migrate/common/styles/globals.css +++ b/src-migrate/styles/globals.css diff --git a/src-migrate/common/types/auth.ts b/src-migrate/types/auth.ts index 65fd06c7..e93a475a 100644 --- a/src-migrate/common/types/auth.ts +++ b/src-migrate/types/auth.ts @@ -1,12 +1,12 @@ -import { registerSchema } from '../validations/auth'; -import { OdooApiProps } from './odoo'; +import { registerSchema } from '~/validations/auth'; +import { OdooApiRes } from './odoo'; import { z } from 'zod'; export type AuthProps = { id: number; - parent_id: number; - parent_name: string; - partner_id: number; + parentId: number; + parentName: string; + partnerId: number; name: string; email: string; phone: string; @@ -15,9 +15,13 @@ export type AuthProps = { company: boolean; pricelist: string | null; token: string; + feature : { + onlyReadyStock : boolean, + soApproval : boolean + } }; -export type AuthApiProps = OdooApiProps & { result: AuthProps }; +export type AuthApiProps = OdooApiRes<AuthProps>; export type RegisterProps = z.infer<typeof registerSchema>; diff --git a/src-migrate/types/banner.ts b/src-migrate/types/banner.ts new file mode 100644 index 00000000..dbccc378 --- /dev/null +++ b/src-migrate/types/banner.ts @@ -0,0 +1,8 @@ +export interface IBanner { + background_color: string | false; + group_by_week: number | false; + image: string; + name: string; + sequence: number; + url: string; +} diff --git a/src-migrate/types/cart.ts b/src-migrate/types/cart.ts new file mode 100644 index 00000000..5a2cf4a9 --- /dev/null +++ b/src-migrate/types/cart.ts @@ -0,0 +1,76 @@ +import { CategoryPromo } from "./promotion"; + +type Price = { + price: number; + discount_percentage: number; + price_discount: number; +}; + +export type CartProduct = { + id: number; + image: string; + parent: { + id: number; + name: string; + }; + display_name: string; + name: string; + code: string; + price: Price; + qty: number; + weight: number; + package_weight: number; +}; + +export type CartItem = { + cart_id: number; + quantity: number; + selected: boolean; + can_buy: boolean; + cart_type: 'product' | 'promotion'; + id: number; + name: string; + stock: number; + weight: number; + attributes: string[]; + parent: { + id: number; + name: string; + image: string; + }; + price: Price; + manufacture: { + id: number; + name: string; + }; + has_flashsale: boolean; + subtotal: number; + + code?: string; + + image?: string; + remaining_time?: number; + promotion_type?: { + value?: CategoryPromo; + label?: string; + }; + limit_qty?: { + all?: number; + user?: number; + transaction?: number; + }; + remaining_qty?: { + all?: number; + user?: number; + transaction?: number; + }; + used_percentage?: number; + products?: CartProduct[]; + free_products?: CartProduct[]; + package_price?: number; +}; + +export type CartProps = { + product_total: number; + products: CartItem[]; +}; diff --git a/src-migrate/types/category.ts b/src-migrate/types/category.ts new file mode 100644 index 00000000..1037b5f9 --- /dev/null +++ b/src-migrate/types/category.ts @@ -0,0 +1,4 @@ +export interface ICategoryBreadcrumb { + id: number; + name: string; +} diff --git a/src-migrate/types/checkout.ts b/src-migrate/types/checkout.ts new file mode 100644 index 00000000..dc1365d8 --- /dev/null +++ b/src-migrate/types/checkout.ts @@ -0,0 +1,16 @@ +import { CartItem } from './cart'; + +export interface ICheckout { + total_purchase: number; + total_discount: number; + discount_voucher: number; + subtotal: number; + tax: number; + grand_total: number; + total_weight: { + kg: number; + g: number; + }; + has_product_without_weight: boolean; + products: CartItem[]; +} diff --git a/src-migrate/common/types/nav.ts b/src-migrate/types/nav.ts index ba97b1bf..ba97b1bf 100644 --- a/src-migrate/common/types/nav.ts +++ b/src-migrate/types/nav.ts diff --git a/src-migrate/common/types/odoo.ts b/src-migrate/types/odoo.ts index b34bc667..73a029e9 100644 --- a/src-migrate/common/types/odoo.ts +++ b/src-migrate/types/odoo.ts @@ -1,6 +1,7 @@ -export type OdooApiProps = { +export interface OdooApiRes<T> { status: { code: number; description: string; }; -}; + result: T; +} diff --git a/src-migrate/common/types/pageContent.ts b/src-migrate/types/pageContent.ts index 4361deb7..4361deb7 100644 --- a/src-migrate/common/types/pageContent.ts +++ b/src-migrate/types/pageContent.ts diff --git a/src-migrate/types/product.ts b/src-migrate/types/product.ts new file mode 100644 index 00000000..681cdc8e --- /dev/null +++ b/src-migrate/types/product.ts @@ -0,0 +1,38 @@ +import { IProductVariantDetail } from './productVariant'; + +export interface IProduct { + id: number; + image: string; + code: string; + display_name: string; + name: string; + weight: number; + qty_sold: number; + stock_total: number; + variant_total: number; + description: string; + isSni: boolean; + isTkdn: boolean; + categories: { + id: string; + name: string; + }[]; + flash_sale: { + id: string; + remaining_time: number; + tag: string; + }; + lowest_price: { + price: number; + price_discount: number; + discount_percentage: number; + }; + manufacture: { + id: number; + name: string; + }; +} + +export interface IProductDetail extends IProduct { + variants: IProductVariantDetail[]; +} diff --git a/src-migrate/types/productVariant.ts b/src-migrate/types/productVariant.ts new file mode 100644 index 00000000..861b216a --- /dev/null +++ b/src-migrate/types/productVariant.ts @@ -0,0 +1,33 @@ +export interface IProductVariantDetail { + id: number; + image: string; + code: string; + name: string; + weight: number; + is_flashsale: { + remaining_time: number; + is_flashsale: boolean; + }; + price: { + price: number; + price_discount: number; + discount_percentage: number; + }; + manufacture: + | { + id: string; + name: string; + } + | {}; + parent: { + id: string; + name: string; + image: string; + }; + attributes: string[]; +} + +export interface IProductVariantSLA { + qty: number; + sla_date: string; +} diff --git a/src-migrate/types/promotion.ts b/src-migrate/types/promotion.ts new file mode 100644 index 00000000..85190aad --- /dev/null +++ b/src-migrate/types/promotion.ts @@ -0,0 +1,44 @@ +export interface IPromotion { + id: number; + program_id: number; + name: string; + type: { + value: CategoryPromo; + label: string; + }; + limit: number; + limit_user: number; + limit_trx: number; + price: number; + total_qty: number; + products: { + product_id: number; + qty: number; + }[]; + free_products: { + product_id: number; + qty: number; + }[]; +} + +export interface IProductVariantPromo { + id: number; + parent_id: number; + display_name: string; + image: string; + name: string; + default_code: string; + price: { + price: number; + discount_percentage: number; + price_discount: number; + }; + qty: number; +} + +export type CategoryPromo = 'bundling' | 'discount_loading' | 'merchandise'; + +export interface ICategoryPromo { + value: CategoryPromo; + label: string; +} diff --git a/src-migrate/types/promotionProgram.ts b/src-migrate/types/promotionProgram.ts new file mode 100644 index 00000000..205884b6 --- /dev/null +++ b/src-migrate/types/promotionProgram.ts @@ -0,0 +1,8 @@ +export type IPromotionProgram = { + id: number; + name: string; + start_time: string; + end_time: string; + applies_to: string; + time_left: number; +}; diff --git a/src-migrate/types/solr.ts b/src-migrate/types/solr.ts new file mode 100644 index 00000000..d231c305 --- /dev/null +++ b/src-migrate/types/solr.ts @@ -0,0 +1,7 @@ +export type SolrResponse<T> = { + response: { + numFound: number; + start: number; + docs: T; + }; +}; diff --git a/src-migrate/common/validations/auth.ts b/src-migrate/validations/auth.ts index 78fc5e71..78fc5e71 100644 --- a/src-migrate/common/validations/auth.ts +++ b/src-migrate/validations/auth.ts |
