diff options
46 files changed, 821 insertions, 304 deletions
diff --git a/src-migrate/common/libs/parse b/src-migrate/common/libs/parse new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src-migrate/common/libs/parse diff --git a/src-migrate/common/types/cart.ts b/src-migrate/common/types/cart.ts index 15e08093..3aceeac4 100644 --- a/src-migrate/common/types/cart.ts +++ b/src-migrate/common/types/cart.ts @@ -1,3 +1,5 @@ +import { CategoryPromo } from "./promotion"; + type Price = { price: number; discount_percentage: number; @@ -45,7 +47,7 @@ export type CartItem = { image?: string; remaining_time?: number; promotion_type?: { - value?: string; + value?: CategoryPromo; label?: string; }; limit_qty?: { diff --git a/src-migrate/common/types/checkout.ts b/src-migrate/common/types/checkout.ts new file mode 100644 index 00000000..dc1365d8 --- /dev/null +++ b/src-migrate/common/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/promotion.ts b/src-migrate/common/types/promotion.ts index 9e62cc3f..1f8316cf 100644 --- a/src-migrate/common/types/promotion.ts +++ b/src-migrate/common/types/promotion.ts @@ -5,7 +5,7 @@ export interface IPromotion { program_id: number; name: string; type: { - value: string; + value: CategoryPromo; label: string; }; limit: number; @@ -26,3 +26,10 @@ export interface IPromotion { export interface IProductVariantPromo extends IProductVariant { qty: number; } + +export type CategoryPromo = 'bundling' | 'discount_loading' | 'merchandise'; + +export interface ICategoryPromo { + value: CategoryPromo; + label: string; +} diff --git a/src-migrate/common/types/promotionProgram.ts b/src-migrate/common/types/promotionProgram.ts new file mode 100644 index 00000000..205884b6 --- /dev/null +++ b/src-migrate/common/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/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/modules/cart/components/CartDetail.tsx b/src-migrate/modules/cart/components/Detail.tsx index 734c61d3..c9de086b 100644 --- a/src-migrate/modules/cart/components/CartDetail.tsx +++ b/src-migrate/modules/cart/components/Detail.tsx @@ -1,10 +1,14 @@ +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 '../ui/CartItem' -import style from '../styles/CartDetail.module.css' -import CartSummary from '../ui/CartSummary' -import { Button, Tooltip } from '@chakra-ui/react' + +import CartItem from './Item' +import CartSummary from './Summary' const CartDetail = () => { const auth = getAuth() @@ -46,7 +50,7 @@ const CartDetail = () => { </div> </div> - <div className='w-full md:w-1/4 pl-6'> + <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'> @@ -54,7 +58,8 @@ const CartDetail = () => { <Button colorScheme='yellow' w='full' - isDisabled={hasSelectedPromo || !hasSelected}> + isDisabled={hasSelectedPromo || !hasSelected} + > Quotation </Button> </Tooltip> @@ -62,7 +67,10 @@ const CartDetail = () => { <Button colorScheme='red' w='full' - isDisabled={!hasSelected}> + isDisabled={!hasSelected} + as={Link} + href='/shop/checkout' + > Checkout </Button> </Tooltip> diff --git a/src-migrate/modules/cart/components/Item.tsx b/src-migrate/modules/cart/components/Item.tsx new file mode 100644 index 00000000..92beda86 --- /dev/null +++ b/src-migrate/modules/cart/components/Item.tsx @@ -0,0 +1,109 @@ +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 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' +import { PROMO_CATEGORY } from '~/constants/promotion' +import { InfoIcon } from 'lucide-react' + +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.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.subtotal)} + </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/CartItemAction.tsx b/src-migrate/modules/cart/components/ItemAction.tsx index 742d1a39..3e264aef 100644 --- a/src-migrate/modules/cart/components/CartItemAction.tsx +++ b/src-migrate/modules/cart/components/ItemAction.tsx @@ -1,3 +1,5 @@ +import style from '../styles/item-action.module.css' + import React, { useEffect, useState } from 'react' import { Spinner, Tooltip } from '@chakra-ui/react' @@ -10,7 +12,6 @@ import { deleteUserCart, upsertUserCart } from '~/services/cart' import { useDebounce } from 'usehooks-ts' import { useCartStore } from '../stores/useCartStore' -import style from '../styles/CartItemAction.module.css' type Props = { item: CartItem diff --git a/src-migrate/modules/cart/components/ItemPromo.tsx b/src-migrate/modules/cart/components/ItemPromo.tsx new file mode 100644 index 00000000..951d4d6a --- /dev/null +++ b/src-migrate/modules/cart/components/ItemPromo.tsx @@ -0,0 +1,41 @@ +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'> + <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/CartItemSelect.tsx b/src-migrate/modules/cart/components/ItemSelect.tsx index f44b0d7e..96e7c713 100644 --- a/src-migrate/modules/cart/components/CartItemSelect.tsx +++ b/src-migrate/modules/cart/components/ItemSelect.tsx @@ -1,8 +1,10 @@ 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 = { diff --git a/src-migrate/modules/cart/ui/CartSummary.tsx b/src-migrate/modules/cart/components/Summary.tsx index 390c1c77..a835bca9 100644 --- a/src-migrate/modules/cart/ui/CartSummary.tsx +++ b/src-migrate/modules/cart/components/Summary.tsx @@ -1,5 +1,6 @@ +import style from '../styles/summary.module.css' + import React from 'react' -import style from '../styles/CartSummary.module.css' import formatCurrency from '~/common/libs/formatCurrency' import clsxm from '~/common/libs/clsxm' import { Skeleton } from '@chakra-ui/react' diff --git a/src-migrate/modules/cart/stores/useCartStore.ts b/src-migrate/modules/cart/stores/useCartStore.ts index 1963df53..d3eaadb7 100644 --- a/src-migrate/modules/cart/stores/useCartStore.ts +++ b/src-migrate/modules/cart/stores/useCartStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { CartProps } from '~/common/types/cart'; -import { deleteUserCart, getUserCart, upsertUserCart } from '~/services/cart'; +import { getUserCart } from '~/services/cart'; type State = { cart: CartProps | null; @@ -56,7 +56,7 @@ const computeSummary = (cart: CartProps) => { discount += price - item.price.price_discount * item.quantity; } let total = subtotal - discount; - let tax = total * 0.11; + 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/CartItem.module.css b/src-migrate/modules/cart/styles/CartItem.module.css deleted file mode 100644 index 8ee3d3e9..00000000 --- a/src-migrate/modules/cart/styles/CartItem.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.wrapper { - @apply border-b border-gray-300 pb-8; -} - -.mainProdWrapper { - @apply flex; -} - -.image { - @apply h-32 w-32 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; -} - -.name { - @apply font-medium; -} - -.spacing2 { - @apply h-2; -} - -.discPriceSection { - @apply flex 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; -} diff --git a/src-migrate/modules/cart/styles/ProductPromo.module.css b/src-migrate/modules/cart/styles/ProductPromo.module.css deleted file mode 100644 index 3f6e7a05..00000000 --- a/src-migrate/modules/cart/styles/ProductPromo.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.wrapper { - @apply ml-16 mt-4 flex; -} - -.imageWrapper { - @apply h-24 w-24 border border-gray-300 rounded p-2.5; -} - -.details { - @apply ml-4 flex flex-col gap-y-1; -} - -.name { - @apply font-medium; -} - -.code, -.weightLabel { - @apply text-gray-600; -} - -.quantity { - @apply py-2.5 bg-gray-100 border border-gray-300 h-fit my-auto rounded-md ml-auto font-medium w-12 text-center; -} diff --git a/src-migrate/modules/cart/styles/CartDetail.module.css b/src-migrate/modules/cart/styles/detail.module.css index 42d492bb..42d492bb 100644 --- a/src-migrate/modules/cart/styles/CartDetail.module.css +++ b/src-migrate/modules/cart/styles/detail.module.css diff --git a/src-migrate/modules/cart/styles/CartItemAction.module.css b/src-migrate/modules/cart/styles/item-action.module.css index e4db7fa5..e4db7fa5 100644 --- a/src-migrate/modules/cart/styles/CartItemAction.module.css +++ b/src-migrate/modules/cart/styles/item-action.module.css 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..17dbf1c7 --- /dev/null +++ b/src-migrate/modules/cart/styles/item-promo.module.css @@ -0,0 +1,31 @@ +.wrapper { + @apply md:ml-16 ml-12 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 flex flex-col gap-y-1; +} + +.name { + @apply font-medium; +} + +.code, +.weightLabel { + @apply text-gray-600; +} + +.quantity { + @apply w-12 min-w-[48px] 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..6380cdad --- /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 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/CartSummary.module.css b/src-migrate/modules/cart/styles/summary.module.css index 48ccec28..48ccec28 100644 --- a/src-migrate/modules/cart/styles/CartSummary.module.css +++ b/src-migrate/modules/cart/styles/summary.module.css diff --git a/src-migrate/modules/cart/ui/CartItem.tsx b/src-migrate/modules/cart/ui/CartItem.tsx deleted file mode 100644 index 70d50bff..00000000 --- a/src-migrate/modules/cart/ui/CartItem.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import Image from 'next/image' -import React from 'react' -import formatCurrency from '~/common/libs/formatCurrency' -import { CartItem as CartItemProps } from '~/common/types/cart' -import ProductPromo from './ProductPromo' -import { Skeleton, SkeletonProps } from '@chakra-ui/react' -import style from '../styles/CartItem.module.css' -import CartItemAction from '../components/CartItemAction' -import CartItemSelect from '../components/CartItemSelect' - -type Props = { - item: CartItemProps -} - -const CartItem = ({ item }: Props) => { - const image = item?.image || item?.parent?.image - - return ( - <div className={style.wrapper}> - <div className={style.mainProdWrapper}> - <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={style.spacing2} /> - {item.cart_type === 'promotion' && ( - <div className={style.discPriceSection}> - <span className={style.priceBefore}> - Rp {formatCurrency((item.package_price || 0))} - </span> - <span className={style.savingAmt}> - Hemat Rp {formatCurrency((item.package_price || 0) - item.subtotal)} - </span> - <span className={style.price}> - Rp {formatCurrency(item.subtotal)} - </span> - </div> - )} - {item.cart_type === 'product' && ( - <> - <div className={style.price}> - Rp {formatCurrency(item.price.price)} - </div> - <div>{item.code}</div> - </> - )} - <div> - <span className={style.weightLabel}>Berat barang: </span> - {item.weight} Kg - </div> - </div> - - <CartItemAction item={item} /> - </div> - - <div className="flex flex-col"> - {item.products?.map((product) => <ProductPromo key={product.id} product={product} />)} - {item.free_products?.map((product) => <ProductPromo 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/ui/ProductPromo.tsx b/src-migrate/modules/cart/ui/ProductPromo.tsx deleted file mode 100644 index a41afc97..00000000 --- a/src-migrate/modules/cart/ui/ProductPromo.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Image from 'next/image' -import React from 'react' -import { CartProduct } from '~/common/types/cart' -import style from '../styles/ProductPromo.module.css' - -type Props = { - product: CartProduct -} - -const ProductPromo = ({ 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} />} - </div> - - <div className={style.details}> - <div className={style.name}>{product.display_name}</div> - <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> - ) -} - -export default ProductPromo
\ No newline at end of file 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/PromoCard.tsx b/src-migrate/modules/product-promo/components/Card.tsx index 8bb48155..2874c2cc 100644 --- a/src-migrate/modules/product/PromoCard.tsx +++ b/src-migrate/modules/product-promo/components/Card.tsx @@ -1,27 +1,32 @@ +import style from "../styles/card.module.css" + import React, { useEffect, useMemo, useState } from 'react' -import style from "./PromoCard.module.css" -import { ClockIcon, PlusIcon } from "lucide-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 PromoProduct from './PromoProduct' -import { Skeleton, Spinner } from '@chakra-ui/react' import clsxm from '~/common/libs/clsxm' -import { useCountdown } from 'usehooks-ts' +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 PromoCard = ({ promotion }: Props) => { - // TODO: useCountdown() +const ProductPromoCard = ({ promotion }: Props) => { const [products, setProducts] = useState<IProductVariantPromo[]>([]) useEffect(() => { const getProducts = async () => { const datas = [] for (const product of promotion.products) { - const response = await fetch(`/api/product-variant/${product.product_id}`) - const res = await response.json() + const res = await getVariantById(product.product_id) res.data.qty = product.qty datas.push(res.data) } @@ -37,8 +42,7 @@ const PromoCard = ({ promotion }: Props) => { const getFreeProducts = async () => { const datas = [] for (const product of promotion.free_products) { - const response = await fetch(`/api/product-variant/${product.product_id}`) - const res = await response.json() + const res = await getVariantById(product.product_id) res.data.qty = product.qty datas.push(res.data) } @@ -52,40 +56,43 @@ const PromoCard = ({ promotion }: Props) => { let total = 0; [...products, ...freeProducts].forEach((product) => { total += product.price.price_discount * product.qty - console.log({ product }); - }) return total }, [products, freeProducts]) - 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', - } + const allProducts = [...products, ...freeProducts] return ( <div className={style.card}> - <div className={clsxm(style.countdownSection, countdownClass)}> - <span> - <ClockIcon size={20} /> - </span> - <span>Berakhir dalam</span> - <div className={style.countdown}> - <span>00</span> - <span>01</span> - <span>35</span> - </div> - </div> + <ProductPromoCardCountdown promotion={promotion} /> <div className='px-4 mt-4 text-caption-1'> - <div className={style.title}>{promotion.name}</div> + <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={style.productSection} isLoaded={[...products, ...freeProducts].length > 0}> - {products.map((product) => <PromoProduct key={product.id} variant={product} />)} - {freeProducts.map((product) => <PromoProduct key={product.id} variant={product} />)} + <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}> @@ -101,11 +108,7 @@ const PromoCard = ({ promotion }: Props) => { </div> </div> <div> - <button className={style.addToCartBtn}> - {/* <PlusIcon size={16} /> */} - <Spinner size='xs' mr={1.5} /> - Keranjang - </button> + <ProductPromoAddToCart promotion={promotion} /> </div> </div> @@ -114,4 +117,4 @@ const PromoCard = ({ promotion }: Props) => { ) } -export default PromoCard
\ No newline at end of file +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/PromoProduct.tsx b/src-migrate/modules/product-promo/components/Item.tsx index 83b05e88..058b2f6c 100644 --- a/src-migrate/modules/product/PromoProduct.tsx +++ b/src-migrate/modules/product-promo/components/Item.tsx @@ -1,22 +1,24 @@ +import style from '../styles/item.module.css' + import React from 'react' -import style from './PromoProduct.module.css' -import { IProductVariantPromo } from '~/common/types/promotion' import Image from 'next/image' +import { IProductVariantPromo } from '~/common/types/promotion' + type Props = { variant: IProductVariantPromo } -const PromoProduct = ({ variant }: Props) => { +const ProductPromoItem = ({ variant }: Props) => { return ( - <div className={style.product}> + <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.fillDesc}>Isi {variant.qty} barang</div> <div className={style.name}>{variant.name}</div> </div> ) } -export default PromoProduct
\ No newline at end of file +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/PromoSection.tsx b/src-migrate/modules/product-promo/components/Section.tsx index 299cbb78..47e1de29 100644 --- a/src-migrate/modules/product/PromoSection.tsx +++ b/src-migrate/modules/product-promo/components/Section.tsx @@ -1,15 +1,19 @@ +import style from "../styles/section.module.css" + import React from 'react' -import style from "./PromoSection.module.css" -import PromoCard from './PromoCard' import { useQuery } from 'react-query' -import { Skeleton } from '@chakra-ui/react' +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 PromoSection = ({ productId }: Props) => { +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[] }, @@ -17,26 +21,30 @@ const PromoSection = ({ productId }: Props) => { const promotions = promotionsQuery.data - const handleSeeMore = () => { } + 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 type='button' onClick={handleSeeMore} className={style.seeMore}> + <Button colorScheme="yellow" type='button' onClick={() => openModal(productId)}> Lihat Semua - </button> + </Button> </div> )} - <Skeleton isLoaded={promotionsQuery.isSuccess} className="flex gap-x-4 overflow-x-auto min-h-[340px]"> + <Skeleton isLoaded={promotionsQuery.isSuccess} className="flex gap-x-4 overflow-x-auto min-h-[340px] px-4 md:px-0"> {promotions?.data.map((promotion) => ( - <PromoCard key={promotion.id} promotion={promotion} /> + <div key={promotion.id} className="min-w-[400px] max-w-[400px]"> + <ProductPromoCard promotion={promotion} /> + </div> ))} </Skeleton> </div> ) } -export default PromoSection
\ No newline at end of file +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/PromoCard.module.css b/src-migrate/modules/product-promo/styles/card.module.css index 4d98671f..a2ad9af6 100644 --- a/src-migrate/modules/product/PromoCard.module.css +++ b/src-migrate/modules/product-promo/styles/card.module.css @@ -1,32 +1,20 @@ .card { @apply border border-gray_r-7 rounded-lg - min-w-[360px] - max-w-[360px] + h-fit py-3; } -.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; -} - .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-3 mt-4 min-h-[180px]; + @apply flex gap-x-2 overflow-x-auto overflow-y-hidden mt-4 min-h-[160px]; } .priceSection { @@ -56,7 +44,3 @@ .totalItems { @apply text-gray_r-9; } - -.addToCartBtn { - @apply btn-yellow flex items-center gap-x-1 px-2 rounded-lg; -} 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; +} diff --git a/src-migrate/modules/product/PromoProduct.module.css b/src-migrate/modules/product/PromoProduct.module.css deleted file mode 100644 index c13bccb8..00000000 --- a/src-migrate/modules/product/PromoProduct.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.product { - @apply w-4/12; -} - -.image { - @apply border border-gray_r-6 p-2.5 rounded-lg; -} - -.fillDesc { - @apply mt-2 text-danger-600; -} - -.name { - @apply mt-1 line-clamp-3 font-medium; -} diff --git a/src-migrate/modules/product/PromoSection.module.css b/src-migrate/modules/product/PromoSection.module.css deleted file mode 100644 index a9c9b704..00000000 --- a/src-migrate/modules/product/PromoSection.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.titleWrapper { - @apply w-full mb-4 h-20 bg-[#C70817] rounded-lg flex items-center justify-between px-4 py-1; -} - -.seeMore { - @apply py-2 px-3 btn-yellow rounded-lg text-body-2; -} - -.title { - @apply font-semibold text-xl text-white; -} diff --git a/src-migrate/pages/api/product-variant/[id].tsx b/src-migrate/pages/api/product-variant/[id].tsx index ec95714d..b3bd4096 100644 --- a/src-migrate/pages/api/product-variant/[id].tsx +++ b/src-migrate/pages/api/product-variant/[id].tsx @@ -20,13 +20,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return } - const variant = await extractVariant(data.response.docs[0], price_tier) + const variant = await map(data.response.docs[0], price_tier) res.status(200).json({ data: variant }) } } -const extractVariant = async (variant: any, price_tier: string) => { +const map = async (variant: any, price_tier: string) => { const data: any = {} data.id = parseInt(variant.id) 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..b1207c5e --- /dev/null +++ b/src-migrate/pages/api/product-variant/[id]/promotion/[category].tsx @@ -0,0 +1,51 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/common/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}`, + fq: `type_value_s:${category}`, + rows: '1' + }) + + 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/promotion-program/[id].tsx b/src-migrate/pages/api/promotion-program/[id].tsx new file mode 100644 index 00000000..ba716e85 --- /dev/null +++ b/src-migrate/pages/api/promotion-program/[id].tsx @@ -0,0 +1,43 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { SolrResponse } from "~/common/types/solr"; +import moment from 'moment' + +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/services/checkout.ts b/src-migrate/services/checkout.ts new file mode 100644 index 00000000..3dd1c8e8 --- /dev/null +++ b/src-migrate/services/checkout.ts @@ -0,0 +1,7 @@ +import odooApi from '~/common/libs/odooApi'; + +export const getUserCheckout = async (userId: number) => { + return await odooApi('GET', `/api/v1/user/${userId}/sale_order/checkout`); +}; + +// /api/v1/user/${id}/sale_order/checkout?voucher=${voucher}
\ No newline at end of file diff --git a/src-migrate/services/promotionProgram.ts b/src-migrate/services/promotionProgram.ts new file mode 100644 index 00000000..a5026c71 --- /dev/null +++ b/src-migrate/services/promotionProgram.ts @@ -0,0 +1,8 @@ +import { IPromotionProgram } from '~/common/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/variant.ts b/src-migrate/services/variant.ts new file mode 100644 index 00000000..213187d2 --- /dev/null +++ b/src-migrate/services/variant.ts @@ -0,0 +1,14 @@ +import { CategoryPromo, IPromotion } from '~/common/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()); +}; diff --git a/src/pages/api/product-variant/[id]/promotion/[category].js b/src/pages/api/product-variant/[id]/promotion/[category].js new file mode 100644 index 00000000..aef03c22 --- /dev/null +++ b/src/pages/api/product-variant/[id]/promotion/[category].js @@ -0,0 +1,2 @@ +import handler from '~/pages/api/product-variant/[id]/promotion/[category]'; +export default handler; diff --git a/src/pages/api/promotion-program/[id].js b/src/pages/api/promotion-program/[id].js new file mode 100644 index 00000000..f2bb550e --- /dev/null +++ b/src/pages/api/promotion-program/[id].js @@ -0,0 +1,2 @@ +import handler from '~/pages/api/promotion-program/[id]'; +export default handler; |
