summaryrefslogtreecommitdiff
path: root/src-migrate
diff options
context:
space:
mode:
Diffstat (limited to 'src-migrate')
-rw-r--r--src-migrate/common/components/skeleton/PageContentSkeleton.tsx19
-rw-r--r--src-migrate/components/seo.tsx (renamed from src-migrate/common/components/elements/Seo.tsx)6
-rw-r--r--src-migrate/components/ui/image.tsx34
-rw-r--r--src-migrate/components/ui/modal.tsx (renamed from src-migrate/common/components/elements/Modal.tsx)25
-rw-r--r--src-migrate/components/ui/re-captcha.tsx (renamed from src-migrate/common/components/elements/ReCaptcha.tsx)6
-rw-r--r--src-migrate/components/ui/smooth-render.tsx41
-rw-r--r--src-migrate/constants/menu.ts (renamed from src-migrate/common/constants/menu.ts)14
-rw-r--r--src-migrate/constants/promotion.ts17
-rw-r--r--src-migrate/constants/utm-source.ts8
-rw-r--r--src-migrate/hooks/useUtmSource.ts20
-rw-r--r--src-migrate/libs/auth.ts (renamed from src-migrate/common/libs/auth.ts)2
-rw-r--r--src-migrate/libs/clsxm.ts (renamed from src-migrate/common/libs/clsxm.ts)0
-rw-r--r--src-migrate/libs/formatCurrency.ts5
-rw-r--r--src-migrate/libs/formatNumber.ts8
-rw-r--r--src-migrate/libs/odooApi.ts (renamed from src-migrate/common/libs/odooApi.ts)2
-rw-r--r--src-migrate/libs/slug.ts34
-rw-r--r--src-migrate/libs/toTitleCase.ts5
-rw-r--r--src-migrate/libs/whatsappUrl.ts48
-rw-r--r--src-migrate/modules/account-activation/components/FormEmail.tsx6
-rw-r--r--src-migrate/modules/account-activation/components/FormOTP.tsx6
-rw-r--r--src-migrate/modules/account-activation/components/FormToken.tsx6
-rw-r--r--src-migrate/modules/account-activation/index.tsx1
-rw-r--r--src-migrate/modules/cart/components/CartSummaryMobile.tsx111
-rw-r--r--src-migrate/modules/cart/components/Item.tsx156
-rw-r--r--src-migrate/modules/cart/components/ItemAction.tsx111
-rw-r--r--src-migrate/modules/cart/components/ItemPromo.tsx44
-rw-r--r--src-migrate/modules/cart/components/ItemSelect.tsx54
-rw-r--r--src-migrate/modules/cart/components/Summary.tsx75
-rw-r--r--src-migrate/modules/cart/stores/useCartStore.ts64
-rw-r--r--src-migrate/modules/cart/styles/item-action.module.css32
-rw-r--r--src-migrate/modules/cart/styles/item-promo.module.css31
-rw-r--r--src-migrate/modules/cart/styles/item.module.css60
-rw-r--r--src-migrate/modules/cart/styles/summary.module.css21
-rw-r--r--src-migrate/modules/footer-banner/index.tsx32
-rw-r--r--src-migrate/modules/header/components/HeaderDesktop.tsx4
-rw-r--r--src-migrate/modules/page-content/index.tsx19
-rw-r--r--src-migrate/modules/popup-information/index.tsx21
-rw-r--r--src-migrate/modules/product-card/components/ProductCard.tsx144
-rw-r--r--src-migrate/modules/product-card/index.tsx3
-rw-r--r--src-migrate/modules/product-card/styles/product-card.module.css54
-rw-r--r--src-migrate/modules/product-detail/components/AddToCart.tsx78
-rw-r--r--src-migrate/modules/product-detail/components/AddToWishlist.tsx61
-rw-r--r--src-migrate/modules/product-detail/components/Breadcrumb.tsx41
-rw-r--r--src-migrate/modules/product-detail/components/Image.tsx133
-rw-r--r--src-migrate/modules/product-detail/components/Information.tsx56
-rw-r--r--src-migrate/modules/product-detail/components/PriceAction.tsx115
-rw-r--r--src-migrate/modules/product-detail/components/ProductDetail.tsx191
-rw-r--r--src-migrate/modules/product-detail/components/SimilarBottom.tsx29
-rw-r--r--src-migrate/modules/product-detail/components/SimilarSide.tsx36
-rw-r--r--src-migrate/modules/product-detail/components/VariantList.tsx117
-rw-r--r--src-migrate/modules/product-detail/index.ts3
-rw-r--r--src-migrate/modules/product-detail/stores/useProductDetail.ts37
-rw-r--r--src-migrate/modules/product-detail/styles/image.module.css35
-rw-r--r--src-migrate/modules/product-detail/styles/information.module.css19
-rw-r--r--src-migrate/modules/product-detail/styles/price-action.module.css24
-rw-r--r--src-migrate/modules/product-detail/styles/product-detail.module.css15
-rw-r--r--src-migrate/modules/product-detail/styles/variant-list.module.css35
-rw-r--r--src-migrate/modules/product-promo/components/AddToCart.tsx100
-rw-r--r--src-migrate/modules/product-promo/components/Card.tsx206
-rw-r--r--src-migrate/modules/product-promo/components/CardCountdown.tsx67
-rw-r--r--src-migrate/modules/product-promo/components/CategoryTab.tsx34
-rw-r--r--src-migrate/modules/product-promo/components/Item.tsx34
-rw-r--r--src-migrate/modules/product-promo/components/Modal.tsx25
-rw-r--r--src-migrate/modules/product-promo/components/ModalContent.tsx37
-rw-r--r--src-migrate/modules/product-promo/components/Section.tsx61
-rw-r--r--src-migrate/modules/product-promo/stores/useModalStore.ts28
-rw-r--r--src-migrate/modules/product-promo/styles/card-countdown.module.css14
-rw-r--r--src-migrate/modules/product-promo/styles/card.module.css58
-rw-r--r--src-migrate/modules/product-promo/styles/category-tab.module.css12
-rw-r--r--src-migrate/modules/product-promo/styles/item.module.css19
-rw-r--r--src-migrate/modules/product-promo/styles/section.module.css7
-rw-r--r--src-migrate/modules/product-similar/hooks/useProductSimilar.tsx15
-rw-r--r--src-migrate/modules/product-slider/components/ProductSlider.tsx42
-rw-r--r--src-migrate/modules/product-slider/index.ts3
-rw-r--r--src-migrate/modules/register/components/Form.tsx4
-rw-r--r--src-migrate/modules/register/components/FormCaptcha.tsx4
-rw-r--r--src-migrate/modules/register/components/TermCondition.tsx4
-rw-r--r--src-migrate/modules/register/stores/useRegisterStore.ts (renamed from src-migrate/common/stores/useRegisterStore.ts)4
-rw-r--r--src-migrate/modules/side-banner/index.tsx30
-rw-r--r--src-migrate/pages/_app.tsx4
-rw-r--r--src-migrate/pages/api/product-variant/[id].tsx53
-rw-r--r--src-migrate/pages/api/product-variant/[id]/promotion/[category].tsx49
-rw-r--r--src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx58
-rw-r--r--src-migrate/pages/api/promotion-program/[id].tsx42
-rw-r--r--src-migrate/pages/register.tsx2
-rw-r--r--src-migrate/pages/shop/cart/cart.module.css35
-rw-r--r--src-migrate/pages/shop/cart/index.tsx156
-rw-r--r--src-migrate/pages/shop/product/[slug].tsx83
-rw-r--r--src-migrate/services/auth.ts28
-rw-r--r--src-migrate/services/banner.ts11
-rw-r--r--src-migrate/services/cart.ts41
-rw-r--r--src-migrate/services/checkout.ts5
-rw-r--r--src-migrate/services/pageContent.ts13
-rw-r--r--src-migrate/services/product.ts66
-rw-r--r--src-migrate/services/productVariant.ts23
-rw-r--r--src-migrate/services/promotionProgram.ts8
-rw-r--r--src-migrate/services/wishlist.ts23
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Black.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Black.woff)bin138764 -> 138764 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Black.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Black.woff2)bin102868 -> 102868 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff)bin146824 -> 146824 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff2)bin108752 -> 108752 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Bold.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Bold.woff)bin143208 -> 143208 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Bold.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Bold.woff2)bin106140 -> 106140 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff)bin151052 -> 151052 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff2)bin111808 -> 111808 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff)bin142920 -> 142920 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff2)bin106108 -> 106108 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff)bin150628 -> 150628 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2)bin111708 -> 111708 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff)bin140724 -> 140724 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff2)bin104232 -> 104232 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff)bin149996 -> 149996 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff2)bin111392 -> 111392 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Italic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Italic.woff)bin144372 -> 144372 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Italic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Italic.woff2)bin106876 -> 106876 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Light.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Light.woff)bin140632 -> 140632 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Light.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Light.woff2)bin104332 -> 104332 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-LightItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff)bin150092 -> 150092 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-LightItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff2)bin111332 -> 111332 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Medium.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Medium.woff)bin142552 -> 142552 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Medium.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Medium.woff2)bin105924 -> 105924 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff)bin150988 -> 150988 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff2)bin112184 -> 112184 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Regular.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Regular.woff)bin133844 -> 133844 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Regular.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Regular.woff2)bin98868 -> 98868 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-SemiBold.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff)bin142932 -> 142932 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-SemiBold.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff2)bin105804 -> 105804 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff)bin151180 -> 151180 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff2)bin112048 -> 112048 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Thin.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-Thin.woff)bin135920 -> 135920 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-Thin.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-Thin.woff2)bin99632 -> 99632 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff (renamed from src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff)bin145480 -> 145480 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff2)bin106496 -> 106496 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-italic.var.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-italic.var.woff2)bin245036 -> 245036 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter-roman.var.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter-roman.var.woff2)bin227180 -> 227180 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/Inter.var.woff2 (renamed from src-migrate/common/styles/fonts/Inter/Inter.var.woff2)bin324864 -> 324864 bytes
-rw-r--r--src-migrate/styles/fonts/Inter/inter.css (renamed from src-migrate/common/styles/fonts/Inter/inter.css)0
-rw-r--r--src-migrate/styles/globals.css (renamed from src-migrate/common/styles/globals.css)0
-rw-r--r--src-migrate/types/auth.ts (renamed from src-migrate/common/types/auth.ts)16
-rw-r--r--src-migrate/types/banner.ts8
-rw-r--r--src-migrate/types/cart.ts76
-rw-r--r--src-migrate/types/category.ts4
-rw-r--r--src-migrate/types/checkout.ts16
-rw-r--r--src-migrate/types/nav.ts (renamed from src-migrate/common/types/nav.ts)0
-rw-r--r--src-migrate/types/odoo.ts (renamed from src-migrate/common/types/odoo.ts)5
-rw-r--r--src-migrate/types/pageContent.ts (renamed from src-migrate/common/types/pageContent.ts)0
-rw-r--r--src-migrate/types/product.ts38
-rw-r--r--src-migrate/types/productVariant.ts33
-rw-r--r--src-migrate/types/promotion.ts44
-rw-r--r--src-migrate/types/promotionProgram.ts8
-rw-r--r--src-migrate/types/solr.ts7
-rw-r--r--src-migrate/validations/auth.ts (renamed from src-migrate/common/validations/auth.ts)0
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 &amp; Alat Teknik untuk Perusahaan, UMKM &amp; 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
index a18593a0..a18593a0 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Black.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff2 b/src-migrate/styles/fonts/Inter/Inter-Black.woff2
index 68f64c9e..68f64c9e 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Black.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Black.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff
index b6b01943..b6b01943 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff2
index 1c9c7ca8..1c9c7ca8 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-BlackItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-BlackItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff b/src-migrate/styles/fonts/Inter/Inter-Bold.woff
index eaf3d4bf..eaf3d4bf 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Bold.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff2 b/src-migrate/styles/fonts/Inter/Inter-Bold.woff2
index 2846f29c..2846f29c 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Bold.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Bold.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff
index 32750761..32750761 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff2
index 0b1fe8e1..0b1fe8e1 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-BoldItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-BoldItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff
index c2c17ede..c2c17ede 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff2
index c24c2bdc..c24c2bdc 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBold.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBold.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff
index c42f7052..c42f7052 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2
index 4a81dc79..4a81dc79 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraBoldItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff
index d0de5f39..d0de5f39 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff2
index f2ea706f..f2ea706f 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLight.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLight.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff
index 81f1a28e..81f1a28e 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff2
index 9af717ba..9af717ba 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ExtraLightItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-ExtraLightItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff b/src-migrate/styles/fonts/Inter/Inter-Italic.woff
index a806b382..a806b382 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Italic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff2 b/src-migrate/styles/fonts/Inter/Inter-Italic.woff2
index a619fc54..a619fc54 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Italic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Italic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff b/src-migrate/styles/fonts/Inter/Inter-Light.woff
index c496464d..c496464d 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Light.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff2 b/src-migrate/styles/fonts/Inter/Inter-Light.woff2
index bc4be665..bc4be665 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Light.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Light.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff
index f84a9de3..f84a9de3 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff2
index 842b2dfc..842b2dfc 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-LightItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-LightItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff b/src-migrate/styles/fonts/Inter/Inter-Medium.woff
index d546843f..d546843f 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Medium.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff2 b/src-migrate/styles/fonts/Inter/Inter-Medium.woff2
index f92498a2..f92498a2 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Medium.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Medium.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff
index 459a6568..459a6568 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff2
index 0e3019f4..0e3019f4 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-MediumItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-MediumItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff b/src-migrate/styles/fonts/Inter/Inter-Regular.woff
index 62d3a618..62d3a618 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Regular.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff2 b/src-migrate/styles/fonts/Inter/Inter-Regular.woff2
index 6c2b6893..6c2b6893 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Regular.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Regular.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff
index a815f43a..a815f43a 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff2 b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff2
index 611e90c9..611e90c9 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBold.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-SemiBold.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff
index 909e43a9..909e43a9 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff2
index 545685bd..545685bd 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-SemiBoldItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-SemiBoldItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff b/src-migrate/styles/fonts/Inter/Inter-Thin.woff
index 62bc58cd..62bc58cd 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-Thin.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff2 b/src-migrate/styles/fonts/Inter/Inter-Thin.woff2
index abbc3a5c..abbc3a5c 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-Thin.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-Thin.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff
index 700a7f06..700a7f06 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff
+++ b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff2 b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff2
index ab0b2002..ab0b2002 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-ThinItalic.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-ThinItalic.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-italic.var.woff2 b/src-migrate/styles/fonts/Inter/Inter-italic.var.woff2
index b826d5af..b826d5af 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-italic.var.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-italic.var.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter-roman.var.woff2 b/src-migrate/styles/fonts/Inter/Inter-roman.var.woff2
index 6a256a06..6a256a06 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter-roman.var.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter-roman.var.woff2
Binary files differ
diff --git a/src-migrate/common/styles/fonts/Inter/Inter.var.woff2 b/src-migrate/styles/fonts/Inter/Inter.var.woff2
index 365eedc5..365eedc5 100644
--- a/src-migrate/common/styles/fonts/Inter/Inter.var.woff2
+++ b/src-migrate/styles/fonts/Inter/Inter.var.woff2
Binary files differ
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