summaryrefslogtreecommitdiff
path: root/src-migrate/modules
diff options
context:
space:
mode:
authorIT Fixcomart <it@fixcomart.co.id>2024-01-04 03:07:56 +0000
committerIT Fixcomart <it@fixcomart.co.id>2024-01-04 03:07:56 +0000
commit0d33de3744f612262c12d648cd7147a2ef238a36 (patch)
tree285af0ed69169621228e252affdac958f016dab2 /src-migrate/modules
parentbb8ee26d89842b4f9b99b48f2a7cc464c6ecc4ee (diff)
parent67398e6f10d6f7729d8f1ace7005ef13d32c5ddd (diff)
Merged in Feature/promotion-program (pull request #124)
Feature/promotion program
Diffstat (limited to 'src-migrate/modules')
-rw-r--r--src-migrate/modules/account-activation/index.tsx1
-rw-r--r--src-migrate/modules/cart/components/Detail.tsx83
-rw-r--r--src-migrate/modules/cart/components/Item.tsx110
-rw-r--r--src-migrate/modules/cart/components/ItemAction.tsx105
-rw-r--r--src-migrate/modules/cart/components/ItemPromo.tsx40
-rw-r--r--src-migrate/modules/cart/components/ItemSelect.tsx47
-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/detail.module.css3
-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/product-promo/components/AddToCart.tsx78
-rw-r--r--src-migrate/modules/product-promo/components/Card.tsx124
-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.tsx30
-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.tsx50
-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.css46
-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
27 files changed, 1242 insertions, 1 deletions
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/Detail.tsx b/src-migrate/modules/cart/components/Detail.tsx
new file mode 100644
index 00000000..ccb0bb4d
--- /dev/null
+++ b/src-migrate/modules/cart/components/Detail.tsx
@@ -0,0 +1,83 @@
+import style from '../styles/detail.module.css'
+
+import React, { useEffect, useMemo } from 'react'
+import Link from 'next/link'
+import { Button, Tooltip } from '@chakra-ui/react'
+
+import { getAuth } from '~/common/libs/auth'
+import { useCartStore } from '../stores/useCartStore'
+
+import CartItem from './Item'
+import CartSummary from './Summary'
+
+const CartDetail = () => {
+ const auth = getAuth()
+
+ const { loadCart, cart, summary } = useCartStore()
+
+ useEffect(() => {
+ if (typeof auth === 'object' && !cart) loadCart(auth.id)
+ }, [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])
+
+ return (
+ <div className={style.wrapper}>
+ <div className='w-full md:w-3/4'>
+ <div className=''>
+ <div className='text-h-lg font-semibold mb-6'>Keranjang Belanja</div>
+ <div className='grid grid-cols-1 gap-y-4'>
+ {!cart && <CartItem.Skeleton count={5} height='120px' />}
+ </div>
+ <div className='flex flex-col gap-y-8 border-t border-gray-300 pt-8'>
+ {cart?.products.map((item) => <CartItem key={item.id} item={item} />)}
+ </div>
+ </div>
+ </div>
+
+ <div className='w-full md:w-1/4 md:pl-6 mt-6 md:mt-0'>
+ <div className='border border-gray-300 p-4 rounded-md sticky top-[180px]'>
+ <CartSummary {...summary} isLoaded={!!cart} />
+ <div className='grid grid-cols-2 gap-x-3 mt-6'>
+ <Tooltip label={hasSelectedPromo ? 'Barang promo tidak dapat dibuat quotation' : ''}>
+ <Button
+ colorScheme='yellow'
+ w='full'
+ isDisabled={hasSelectedPromo || !hasSelected}
+ >
+ Quotation
+ </Button>
+ </Tooltip>
+ <Tooltip label={hasSelected ? '' : 'Tidak ada item yang dipilih'}>
+ <Button
+ colorScheme='red'
+ w='full'
+ isDisabled={!hasSelected}
+ as={Link}
+ href='/shop/checkout'
+ >
+ Checkout
+ </Button>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default CartDetail \ No newline at end of file
diff --git a/src-migrate/modules/cart/components/Item.tsx b/src-migrate/modules/cart/components/Item.tsx
new file mode 100644
index 00000000..baf48bb6
--- /dev/null
+++ b/src-migrate/modules/cart/components/Item.tsx
@@ -0,0 +1,110 @@
+import style from '../styles/item.module.css'
+
+import Image from 'next/image'
+import React from 'react'
+import { Skeleton, SkeletonProps, Tooltip } from '@chakra-ui/react'
+import { InfoIcon } from 'lucide-react'
+
+import { PROMO_CATEGORY } from '~/constants/promotion'
+
+import formatCurrency from '~/common/libs/formatCurrency'
+import { CartItem as CartItemProps } from '~/common/types/cart'
+
+import CartItemPromo from './ItemPromo'
+import CartItemAction from './ItemAction'
+import CartItemSelect from './ItemSelect'
+
+type Props = {
+ item: CartItemProps
+ editable?: boolean
+}
+
+const CartItem = ({ item, editable = true }: Props) => {
+ const image = item?.image || item?.parent?.image
+
+ 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' />
+ <div className={style.image}>
+ {image && <Image src={image} alt={item.name} width={128} height={128} />}
+ {!image && <div className={style.noImage}>No Image</div>}
+ </div>
+
+ <div className={style.details}>
+ <div className={style.name}>{item.name}</div>
+ <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.price}>
+ Rp {formatCurrency(item.price.price)}
+ </div>
+ <div>{item.code}</div>
+ </>
+ )}
+
+ <div>
+ {item.weight} 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.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..3e264aef
--- /dev/null
+++ b/src-migrate/modules/cart/components/ItemAction.tsx
@@ -0,0 +1,105 @@
+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 '~/common/types/cart'
+import { getAuth } from '~/common/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(auth.id, item.cart_type, item.id, debounceQty, 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..bb286e8b
--- /dev/null
+++ b/src-migrate/modules/cart/components/ItemPromo.tsx
@@ -0,0 +1,40 @@
+import style from '../styles/item-promo.module.css'
+
+import Image from 'next/image'
+import React from 'react'
+
+import { CartProduct } from '~/common/types/cart'
+
+type Props = {
+ product: CartProduct
+}
+
+const CartItemPromo = ({ product }: Props) => {
+ return (
+ <div key={product.id} className={style.wrapper}>
+ <div className={style.imageWrapper}>
+ {product?.image && <Image src={product.image} alt={product.name} width={128} height={128} className={style.image} />}
+ </div>
+
+ <div className={style.details}>
+ <div className={style.name}>{product.display_name}</div>
+ <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..96e7c713
--- /dev/null
+++ b/src-migrate/modules/cart/components/ItemSelect.tsx
@@ -0,0 +1,47 @@
+import { Checkbox, Spinner } from '@chakra-ui/react'
+import React, { useState } from 'react'
+
+import { getAuth } from '~/common/libs/auth'
+import { CartItem } from '~/common/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(auth.id, item.cart_type, item.id, item.quantity, e.target.checked)
+ await loadCart(auth.id)
+ setIsLoad(false)
+ }
+
+ return (
+ <div className='w-5 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..a835bca9
--- /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 '~/common/libs/formatCurrency'
+import clsxm from '~/common/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..0643b8e6
--- /dev/null
+++ b/src-migrate/modules/cart/stores/useCartStore.ts
@@ -0,0 +1,64 @@
+import { create } from 'zustand';
+import { CartProps } from '~/common/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/detail.module.css b/src-migrate/modules/cart/styles/detail.module.css
new file mode 100644
index 00000000..42d492bb
--- /dev/null
+++ b/src-migrate/modules/cart/styles/detail.module.css
@@ -0,0 +1,3 @@
+.wrapper {
+ @apply flex flex-wrap;
+}
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..5bc192c0
--- /dev/null
+++ b/src-migrate/modules/cart/styles/item-promo.module.css
@@ -0,0 +1,31 @@
+.wrapper {
+ @apply md:ml-12 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/product-promo/components/AddToCart.tsx b/src-migrate/modules/product-promo/components/AddToCart.tsx
new file mode 100644
index 00000000..58bb2ad7
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/AddToCart.tsx
@@ -0,0 +1,78 @@
+import React, { useEffect, useState } from 'react'
+import { CheckIcon, PlusIcon } from 'lucide-react'
+import { IPromotion } from '~/common/types/promotion'
+import { upsertUserCart } from '~/services/cart'
+import { getAuth } from '~/common/libs/auth'
+import { Button, Spinner, useToast } from '@chakra-ui/react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+
+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)
+ toast({
+ title: 'Masuk Akun',
+ description: <>
+ Masuk akun untuk dapat menambahkan promo ke keranjang belanja. {' '}
+ <Link className='underline' href={`/login?next=${currentUrl}`}>Klik disini</Link>
+ </>,
+ status: 'error',
+ duration: 4000,
+ isClosable: true,
+ position: 'top',
+ })
+ return
+ }
+ if (status === 'success') return
+
+ setStatus('loading')
+ await upsertUserCart(auth.id, 'promotion', promotion.id, 1, 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 (
+ <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>
+ )
+}
+
+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..e894c143
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Card.tsx
@@ -0,0 +1,124 @@
+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/variant"
+
+import { IProductVariantPromo, IPromotion } from '~/common/types/promotion'
+import formatCurrency from '~/common/libs/formatCurrency'
+import clsxm from '~/common/libs/clsxm'
+
+import ProductPromoItem from './Item'
+import ProductPromoAddToCart from "./AddToCart"
+import ProductPromoCardCountdown from "./CardCountdown"
+
+type Props = {
+ promotion: IPromotion
+}
+
+const ProductPromoCard = ({ promotion }: Props) => {
+ const [products, setProducts] = useState<IProductVariantPromo[]>([])
+
+ useEffect(() => {
+ const getProducts = async () => {
+ 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)
+ }
+
+ getProducts()
+ }, [promotion.products])
+
+ const [freeProducts, setFreeProducts] = useState<IProductVariantPromo[]>([])
+
+ useEffect(() => {
+ const getFreeProducts = async () => {
+ 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)
+ }
+
+ 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 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) => (
+ <>
+ <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6 }}>
+ <ProductPromoItem
+ variant={product}
+ isFree={index + 1 > products.length && promotion.type.value === 'merchandise'}
+ />
+ </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>
+ </>
+ ))}
+ </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>
+ )
+}
+
+export default ProductPromoCard \ No newline at end of file
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..e398a390
--- /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 '~/common/libs/clsxm'
+import { IPromotion } from '~/common/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..edc4aa92
--- /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 '~/common/libs/clsxm'
+import { ICategoryPromo } from '~/common/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..15ca4878
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Item.tsx
@@ -0,0 +1,30 @@
+import style from '../styles/item.module.css'
+
+import React from 'react'
+import Image from 'next/image'
+
+import { IProductVariantPromo } from '~/common/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} alt={variant.display_name} width={120} height={120} quality={100} />
+ <div className={style.quantity}>
+ {variant.qty} pcs {isFree ? '(free)' : ''}
+ </div>
+ </div>
+ <div className={style.name}>{variant.name}</div>
+ </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..598b7bbe
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Modal.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import Modal from '~/common/components/elements/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..90cf79e7
--- /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/variant"
+
+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..47e1de29
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Section.tsx
@@ -0,0 +1,50 @@
+import style from "../styles/section.module.css"
+
+import React from 'react'
+import { useQuery } from 'react-query'
+import { Button, Skeleton } from '@chakra-ui/react'
+
+import ProductPromoCard from './Card'
+import { IPromotion } from '~/common/types/promotion'
+import ProductPromoModal from "./Modal"
+import { useModalStore } from "../stores/useModalStore"
+
+type Props = {
+ productId: number
+}
+
+const ProductPromoSection = ({ productId }: Props) => {
+ const promotionsQuery = useQuery(
+ `promotions-highlight:${productId}`,
+ async () => await fetch(`/api/product-variant/${productId}/promotion/highlight`).then((res) => res.json()) as { data: IPromotion[] },
+ )
+
+ const promotions = promotionsQuery.data
+
+ const { openModal } = useModalStore()
+
+ return (
+ <div className='w-full'>
+ <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="flex gap-x-4 overflow-x-auto min-h-[340px] px-4 md:px-0">
+ {promotions?.data.map((promotion) => (
+ <div key={promotion.id} className="min-w-[400px] max-w-[400px]">
+ <ProductPromoCard promotion={promotion} />
+ </div>
+ ))}
+ </Skeleton>
+ </div>
+ )
+}
+
+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..bbb2b1fb
--- /dev/null
+++ b/src-migrate/modules/product-promo/stores/useModalStore.ts
@@ -0,0 +1,28 @@
+import { create } from 'zustand';
+import { CategoryPromo } from '~/common/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..a2ad9af6
--- /dev/null
+++ b/src-migrate/modules/product-promo/styles/card.module.css
@@ -0,0 +1,46 @@
+.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;
+}
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;
+}