summaryrefslogtreecommitdiff
path: root/src-migrate/modules/product-promo
diff options
context:
space:
mode:
authorRafi Zadanly <zadanlyr@gmail.com>2023-12-22 17:33:46 +0700
committerRafi Zadanly <zadanlyr@gmail.com>2023-12-22 17:33:46 +0700
commit89f32128f37d99b490de7590e2116a9cfd853f89 (patch)
treefeb74cc6bd0030b291fbf3dbba9b89a7afd6ea31 /src-migrate/modules/product-promo
parentc9366090153e8aba3a673b2b77cbc8acc24e59a5 (diff)
Update promotion program feature
Diffstat (limited to 'src-migrate/modules/product-promo')
-rw-r--r--src-migrate/modules/product-promo/components/AddToCart.tsx61
-rw-r--r--src-migrate/modules/product-promo/components/Card.tsx120
-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.tsx24
-rw-r--r--src-migrate/modules/product-promo/components/Modal.tsx25
-rw-r--r--src-migrate/modules/product-promo/components/ModalContent.tsx33
-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
14 files changed, 540 insertions, 0 deletions
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..9d856ccf
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/AddToCart.tsx
@@ -0,0 +1,61 @@
+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'
+
+type Props = {
+ promotion: IPromotion
+}
+
+type Status = 'idle' | 'loading' | 'success'
+
+const ProductPromoAddToCart = ({ promotion }: Props) => {
+ const auth = getAuth()
+ const toast = useToast()
+
+ const [status, setStatus] = useState<Status>('idle')
+
+ const handleButton = async () => {
+ if (typeof auth !== 'object') 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..2874c2cc
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Card.tsx
@@ -0,0 +1,120 @@
+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 { IProductVariantPromo, IPromotion } from '~/common/types/promotion'
+import formatCurrency from '~/common/libs/formatCurrency'
+import clsxm from '~/common/libs/clsxm'
+import { PROMO_CATEGORY } from "~/constants/promotion"
+import { getVariantById } from "~/services/variant"
+
+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} />
+ </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..058b2f6c
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/Item.tsx
@@ -0,0 +1,24 @@
+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
+}
+
+const ProductPromoItem = ({ variant }: Props) => {
+ return (
+ <div className={style.item}>
+ <div className={style.image}>
+ <Image src={variant.image} alt={variant.display_name} width={320} height={320} />
+ <div className={style.quantity}>{variant.qty} pcs</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..45995af6
--- /dev/null
+++ b/src-migrate/modules/product-promo/components/ModalContent.tsx
@@ -0,0 +1,33 @@
+import { useQuery } from "react-query"
+import { Skeleton } from "@chakra-ui/react"
+import { motion } from "framer-motion"
+
+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-[60vh] grid grid-cols-1'>
+ {promotions?.data.map((promo) => (
+ <ProductPromoCard key={promo.id} promotion={promo} />
+ ))}
+ </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..86127836
--- /dev/null
+++ b/src-migrate/modules/product-promo/styles/item.module.css
@@ -0,0 +1,19 @@
+.item {
+ @apply min-w-[110px] max-w-[110px];
+}
+
+.image {
+ @apply 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;
+}