summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src-migrate/common/libs/formatCurrency.ts5
-rw-r--r--src-migrate/common/types/cart.ts70
-rw-r--r--src-migrate/common/types/productVariant.ts13
-rw-r--r--src-migrate/common/types/promotion.ts28
-rw-r--r--src-migrate/common/types/solr.ts7
-rw-r--r--src-migrate/modules/account-activation/index.tsx1
-rw-r--r--src-migrate/modules/cart/components/CartDetail.tsx76
-rw-r--r--src-migrate/modules/cart/components/CartItemAction.tsx104
-rw-r--r--src-migrate/modules/cart/components/CartItemSelect.tsx45
-rw-r--r--src-migrate/modules/cart/stores/useCartStore.ts63
-rw-r--r--src-migrate/modules/cart/styles/CartDetail.module.css3
-rw-r--r--src-migrate/modules/cart/styles/CartItem.module.css47
-rw-r--r--src-migrate/modules/cart/styles/CartItemAction.module.css32
-rw-r--r--src-migrate/modules/cart/styles/CartSummary.module.css21
-rw-r--r--src-migrate/modules/cart/styles/ProductPromo.module.css24
-rw-r--r--src-migrate/modules/cart/ui/CartItem.tsx80
-rw-r--r--src-migrate/modules/cart/ui/CartSummary.tsx74
-rw-r--r--src-migrate/modules/cart/ui/ProductPromo.tsx33
-rw-r--r--src-migrate/modules/product/PromoCard.module.css62
-rw-r--r--src-migrate/modules/product/PromoCard.tsx117
-rw-r--r--src-migrate/modules/product/PromoProduct.module.css15
-rw-r--r--src-migrate/modules/product/PromoProduct.tsx22
-rw-r--r--src-migrate/modules/product/PromoSection.module.css11
-rw-r--r--src-migrate/modules/product/PromoSection.tsx42
-rw-r--r--src-migrate/pages/api/product-variant/[id].tsx45
-rw-r--r--src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx56
-rw-r--r--src-migrate/services/auth.ts24
-rw-r--r--src-migrate/services/banner.ts0
-rw-r--r--src-migrate/services/cart.ts29
-rw-r--r--src-migrate/services/pageContent.ts11
-rw-r--r--src/pages/api/product-variant/[id].js2
-rw-r--r--src/pages/api/product-variant/[id]/promotion/highlight.js2
32 files changed, 1134 insertions, 30 deletions
diff --git a/src-migrate/common/libs/formatCurrency.ts b/src-migrate/common/libs/formatCurrency.ts
new file mode 100644
index 00000000..41db4a6f
--- /dev/null
+++ b/src-migrate/common/libs/formatCurrency.ts
@@ -0,0 +1,5 @@
+const formatCurrency = (value: number) => {
+ return value.toLocaleString('id-ID');
+};
+
+export default formatCurrency;
diff --git a/src-migrate/common/types/cart.ts b/src-migrate/common/types/cart.ts
new file mode 100644
index 00000000..15e08093
--- /dev/null
+++ b/src-migrate/common/types/cart.ts
@@ -0,0 +1,70 @@
+type Price = {
+ price: number;
+ discount_percentage: number;
+ price_discount: number;
+};
+
+export type CartProduct = {
+ id: number;
+ image: string;
+ display_name: string;
+ name: string;
+ code: string;
+ price: Price;
+ qty: number;
+ weight: number;
+ package_weight: number;
+};
+
+export type CartItem = {
+ cart_id: number;
+ quantity: number;
+ selected: boolean;
+ can_buy: boolean;
+ cart_type: 'product' | 'promotion';
+ id: number;
+ name: string;
+ stock: number;
+ weight: number;
+ attributes: string[];
+ parent: {
+ id: number;
+ name: string;
+ image: string;
+ };
+ price: Price;
+ manufacture: {
+ id: number;
+ name: string;
+ };
+ has_flashsale: boolean;
+ subtotal: number;
+
+ code?: string;
+
+ image?: string;
+ remaining_time?: number;
+ promotion_type?: {
+ value?: string;
+ label?: string;
+ };
+ limit_qty?: {
+ all?: number;
+ user?: number;
+ transaction?: number;
+ };
+ remaining_qty?: {
+ all?: number;
+ user?: number;
+ transaction?: number;
+ };
+ used_percentage?: number;
+ products?: CartProduct[];
+ free_products?: CartProduct[];
+ package_price?: number;
+};
+
+export type CartProps = {
+ product_total: number;
+ products: CartItem[];
+};
diff --git a/src-migrate/common/types/productVariant.ts b/src-migrate/common/types/productVariant.ts
new file mode 100644
index 00000000..c4aa9534
--- /dev/null
+++ b/src-migrate/common/types/productVariant.ts
@@ -0,0 +1,13 @@
+export interface IProductVariant {
+ id: number;
+ parent_id: number;
+ display_name: string;
+ image: string;
+ name: string;
+ default_code: string;
+ price: {
+ price: number;
+ discount_percentage: number;
+ price_discount: number;
+ };
+}
diff --git a/src-migrate/common/types/promotion.ts b/src-migrate/common/types/promotion.ts
new file mode 100644
index 00000000..9e62cc3f
--- /dev/null
+++ b/src-migrate/common/types/promotion.ts
@@ -0,0 +1,28 @@
+import { IProductVariant } from './productVariant';
+
+export interface IPromotion {
+ id: number;
+ program_id: number;
+ name: string;
+ type: {
+ value: string;
+ label: string;
+ };
+ limit: number;
+ limit_user: number;
+ limit_trx: number;
+ price: number;
+ total_qty: number;
+ products: {
+ product_id: number;
+ qty: number;
+ }[];
+ free_products: {
+ product_id: number;
+ qty: number;
+ }[];
+}
+
+export interface IProductVariantPromo extends IProductVariant {
+ qty: number;
+}
diff --git a/src-migrate/common/types/solr.ts b/src-migrate/common/types/solr.ts
new file mode 100644
index 00000000..d231c305
--- /dev/null
+++ b/src-migrate/common/types/solr.ts
@@ -0,0 +1,7 @@
+export type SolrResponse<T> = {
+ response: {
+ numFound: number;
+ start: number;
+ docs: T;
+ };
+};
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/CartDetail.tsx b/src-migrate/modules/cart/components/CartDetail.tsx
new file mode 100644
index 00000000..734c61d3
--- /dev/null
+++ b/src-migrate/modules/cart/components/CartDetail.tsx
@@ -0,0 +1,76 @@
+import React, { useEffect, useMemo } from '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'
+
+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='border border-gray-300 rounded-lg p-4 md:p-6'> */}
+ <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 pl-6'>
+ <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}>
+ Checkout
+ </Button>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default CartDetail \ No newline at end of file
diff --git a/src-migrate/modules/cart/components/CartItemAction.tsx b/src-migrate/modules/cart/components/CartItemAction.tsx
new file mode 100644
index 00000000..742d1a39
--- /dev/null
+++ b/src-migrate/modules/cart/components/CartItemAction.tsx
@@ -0,0 +1,104 @@
+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'
+
+import style from '../styles/CartItemAction.module.css'
+
+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/CartItemSelect.tsx b/src-migrate/modules/cart/components/CartItemSelect.tsx
new file mode 100644
index 00000000..f44b0d7e
--- /dev/null
+++ b/src-migrate/modules/cart/components/CartItemSelect.tsx
@@ -0,0 +1,45 @@
+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/stores/useCartStore.ts b/src-migrate/modules/cart/stores/useCartStore.ts
new file mode 100644
index 00000000..1963df53
--- /dev/null
+++ b/src-migrate/modules/cart/stores/useCartStore.ts
@@ -0,0 +1,63 @@
+import { create } from 'zustand';
+import { CartProps } from '~/common/types/cart';
+import { deleteUserCart, getUserCart, upsertUserCart } 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;
+ 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 = total * 0.11;
+ let grandTotal = total + tax;
+
+ return { subtotal, discount, total, tax, grandTotal };
+};
diff --git a/src-migrate/modules/cart/styles/CartDetail.module.css b/src-migrate/modules/cart/styles/CartDetail.module.css
new file mode 100644
index 00000000..42d492bb
--- /dev/null
+++ b/src-migrate/modules/cart/styles/CartDetail.module.css
@@ -0,0 +1,3 @@
+.wrapper {
+ @apply flex flex-wrap;
+}
diff --git a/src-migrate/modules/cart/styles/CartItem.module.css b/src-migrate/modules/cart/styles/CartItem.module.css
new file mode 100644
index 00000000..8ee3d3e9
--- /dev/null
+++ b/src-migrate/modules/cart/styles/CartItem.module.css
@@ -0,0 +1,47 @@
+.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/CartItemAction.module.css b/src-migrate/modules/cart/styles/CartItemAction.module.css
new file mode 100644
index 00000000..e4db7fa5
--- /dev/null
+++ b/src-migrate/modules/cart/styles/CartItemAction.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/CartSummary.module.css b/src-migrate/modules/cart/styles/CartSummary.module.css
new file mode 100644
index 00000000..48ccec28
--- /dev/null
+++ b/src-migrate/modules/cart/styles/CartSummary.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/cart/styles/ProductPromo.module.css b/src-migrate/modules/cart/styles/ProductPromo.module.css
new file mode 100644
index 00000000..3f6e7a05
--- /dev/null
+++ b/src-migrate/modules/cart/styles/ProductPromo.module.css
@@ -0,0 +1,24 @@
+.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/ui/CartItem.tsx b/src-migrate/modules/cart/ui/CartItem.tsx
new file mode 100644
index 00000000..70d50bff
--- /dev/null
+++ b/src-migrate/modules/cart/ui/CartItem.tsx
@@ -0,0 +1,80 @@
+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/CartSummary.tsx b/src-migrate/modules/cart/ui/CartSummary.tsx
new file mode 100644
index 00000000..390c1c77
--- /dev/null
+++ b/src-migrate/modules/cart/ui/CartSummary.tsx
@@ -0,0 +1,74 @@
+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'
+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/ui/ProductPromo.tsx b/src-migrate/modules/cart/ui/ProductPromo.tsx
new file mode 100644
index 00000000..a41afc97
--- /dev/null
+++ b/src-migrate/modules/cart/ui/ProductPromo.tsx
@@ -0,0 +1,33 @@
+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/PromoCard.module.css b/src-migrate/modules/product/PromoCard.module.css
new file mode 100644
index 00000000..4d98671f
--- /dev/null
+++ b/src-migrate/modules/product/PromoCard.module.css
@@ -0,0 +1,62 @@
+.card {
+ @apply border border-gray_r-7
+ rounded-lg
+ min-w-[360px]
+ max-w-[360px]
+ 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;
+}
+
+.productSection {
+ @apply flex gap-x-3 mt-4 min-h-[180px];
+}
+
+.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;
+}
+
+.addToCartBtn {
+ @apply btn-yellow flex items-center gap-x-1 px-2 rounded-lg;
+}
diff --git a/src-migrate/modules/product/PromoCard.tsx b/src-migrate/modules/product/PromoCard.tsx
new file mode 100644
index 00000000..8bb48155
--- /dev/null
+++ b/src-migrate/modules/product/PromoCard.tsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useMemo, useState } from 'react'
+import style from "./PromoCard.module.css"
+import { ClockIcon, PlusIcon } from "lucide-react"
+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'
+
+type Props = {
+ promotion: IPromotion
+}
+
+const PromoCard = ({ promotion }: Props) => {
+ // TODO: useCountdown()
+ 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()
+ 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 response = await fetch(`/api/product-variant/${product.product_id}`)
+ const res = await response.json()
+ 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
+ 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',
+ }
+
+ 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>
+
+ <div className='px-4 mt-4 text-caption-1'>
+ <div className={style.title}>{promotion.name}</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>
+
+ <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>
+ <button className={style.addToCartBtn}>
+ {/* <PlusIcon size={16} /> */}
+ <Spinner size='xs' mr={1.5} />
+ Keranjang
+ </button>
+ </div>
+
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default PromoCard \ No newline at end of file
diff --git a/src-migrate/modules/product/PromoProduct.module.css b/src-migrate/modules/product/PromoProduct.module.css
new file mode 100644
index 00000000..c13bccb8
--- /dev/null
+++ b/src-migrate/modules/product/PromoProduct.module.css
@@ -0,0 +1,15 @@
+.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/PromoProduct.tsx b/src-migrate/modules/product/PromoProduct.tsx
new file mode 100644
index 00000000..83b05e88
--- /dev/null
+++ b/src-migrate/modules/product/PromoProduct.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import style from './PromoProduct.module.css'
+import { IProductVariantPromo } from '~/common/types/promotion'
+import Image from 'next/image'
+
+type Props = {
+ variant: IProductVariantPromo
+}
+
+const PromoProduct = ({ variant }: Props) => {
+ return (
+ <div className={style.product}>
+ <div className={style.image}>
+ <Image src={variant.image} alt={variant.display_name} width={320} height={320} />
+ </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
diff --git a/src-migrate/modules/product/PromoSection.module.css b/src-migrate/modules/product/PromoSection.module.css
new file mode 100644
index 00000000..a9c9b704
--- /dev/null
+++ b/src-migrate/modules/product/PromoSection.module.css
@@ -0,0 +1,11 @@
+.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/modules/product/PromoSection.tsx b/src-migrate/modules/product/PromoSection.tsx
new file mode 100644
index 00000000..299cbb78
--- /dev/null
+++ b/src-migrate/modules/product/PromoSection.tsx
@@ -0,0 +1,42 @@
+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 { IPromotion } from '~/common/types/promotion'
+
+type Props = {
+ productId: number
+}
+
+const PromoSection = ({ 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 handleSeeMore = () => { }
+
+ return (
+ <div className='w-full'>
+ {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}>
+ Lihat Semua
+ </button>
+ </div>
+ )}
+
+ <Skeleton isLoaded={promotionsQuery.isSuccess} className="flex gap-x-4 overflow-x-auto min-h-[340px]">
+ {promotions?.data.map((promotion) => (
+ <PromoCard key={promotion.id} promotion={promotion} />
+ ))}
+ </Skeleton>
+ </div>
+ )
+}
+
+export default PromoSection \ No newline at end of file
diff --git a/src-migrate/pages/api/product-variant/[id].tsx b/src-migrate/pages/api/product-variant/[id].tsx
new file mode 100644
index 00000000..ec95714d
--- /dev/null
+++ b/src-migrate/pages/api/product-variant/[id].tsx
@@ -0,0 +1,45 @@
+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 variantId = req.query.id as string
+ let price_tier = 'tier1'
+
+ let auth = req.cookies.auth ? JSON.parse(req.cookies.auth) : null
+ if (auth?.pricelist) price_tier = auth.pricelist
+
+ if (req.method === 'GET') {
+ const queryParams = new URLSearchParams({ q: `id:${variantId}` })
+ const response = await fetch(`${SOLR_HOST}/solr/variants/select?${queryParams.toString()}`)
+ const data: SolrResponse<any[]> = await response.json()
+
+ if (data.response.numFound === 0) {
+ res.status(404).json({ error: 'Variant not found' })
+ return
+ }
+
+ const variant = await extractVariant(data.response.docs[0], price_tier)
+
+ res.status(200).json({ data: variant })
+ }
+}
+
+const extractVariant = async (variant: any, price_tier: string) => {
+ const data: any = {}
+
+ data.id = parseInt(variant.id)
+ data.parent_id = variant.template_id_i
+ data.display_name = variant.display_name_s
+ data.image = variant.image_s
+ data.name = variant.name_s
+ data.default_code = variant.default_code_s
+ data.price = {
+ price: variant.price_v2_f,
+ discount_percentage: variant[`discount_${price_tier}_v2_f`] || 0,
+ price_discount: variant[`price_${price_tier}_v2_f`] || 0,
+ }
+
+ return data
+} \ No newline at end of file
diff --git a/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx b/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx
new file mode 100644
index 00000000..0fe8fd1b
--- /dev/null
+++ b/src-migrate/pages/api/product-variant/[id]/promotion/highlight.tsx
@@ -0,0 +1,56 @@
+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
+
+ if (req.method === 'GET') {
+ const types = ['bundling', 'discount_loading', 'merchandise']
+ const queryParams = new URLSearchParams({
+ q: `product_ids:${productId}`,
+ rows: '1'
+ })
+
+ let programs: any[] = []
+
+ for (const type of types) {
+ queryParams.set('fq', `type_value_s:${type}`)
+ const response = await fetch(`${SOLR_HOST}/solr/promotion_program_lines/select?${queryParams.toString()}`)
+ const data: SolrResponse<any[]> = await response.json()
+ programs.push(...data.response.docs)
+ }
+
+ programs = await extractPrograms(programs)
+ res.status(200).json({ data: programs })
+ }
+}
+
+const extractPrograms = async (programs: any[]) => {
+ const result = []
+
+ for (const program of programs) {
+ const data: any = {}
+
+ data.id = program.id
+ data.program_id = program.program_id_i
+ data.name = program.name_s
+ data.type = {
+ value: program.type_value_s,
+ label: program.type_label_s,
+ }
+ data.limit = program.package_limit_i
+ data.limit_user = program.package_limit_user_i
+ data.limit_trx = program.package_limit_trx_i
+ data.price = program.price_f
+ data.total_qty = program.total_qty_i
+
+ data.products = JSON.parse(program.products_s)
+ data.free_products = JSON.parse(program.free_products_s)
+
+ result.push(data)
+ }
+
+ return result
+} \ No newline at end of file
diff --git a/src-migrate/services/auth.ts b/src-migrate/services/auth.ts
index a5d02754..1cc09c10 100644
--- a/src-migrate/services/auth.ts
+++ b/src-migrate/services/auth.ts
@@ -15,39 +15,23 @@ const BASE_PATH = '/api/v1/user';
export const registerUser = async (
data: RegisterProps
): Promise<RegisterResApiProps> => {
- const response = await odooApi('POST', `${BASE_PATH}/register`, data);
-
- return response;
+ return await odooApi('POST', `${BASE_PATH}/register`, data);
};
export const activationUserToken = async (
params: ActivationTokenProps
): Promise<ActivationTokenResApiProps> => {
- const response = await odooApi(
- 'POST',
- `${BASE_PATH}/activation-token`,
- params
- );
-
- return response;
+ return await odooApi('POST', `${BASE_PATH}/activation-token`, params);
};
export const activationUserOTP = async (
params: ActivationOtpProps
): Promise<ActivationOtpResApiProps> => {
- const response = await odooApi('POST', `${BASE_PATH}/activation-otp`, params);
-
- return response;
+ return await odooApi('POST', `${BASE_PATH}/activation-otp`, params);
};
export const activationReq = async (
params: ActivationReqProps
): Promise<ActivationReqResApiProps> => {
- const response = await odooApi(
- 'POST',
- `${BASE_PATH}/activation-request`,
- params
- );
-
- return response;
+ return await odooApi('POST', `${BASE_PATH}/activation-request`, params);
};
diff --git a/src-migrate/services/banner.ts b/src-migrate/services/banner.ts
deleted file mode 100644
index e69de29b..00000000
--- a/src-migrate/services/banner.ts
+++ /dev/null
diff --git a/src-migrate/services/cart.ts b/src-migrate/services/cart.ts
new file mode 100644
index 00000000..b238be3d
--- /dev/null
+++ b/src-migrate/services/cart.ts
@@ -0,0 +1,29 @@
+import odooApi from '~/common/libs/odooApi';
+
+export const getUserCart = async (userId: number) => {
+ return await odooApi('GET', `/api/v1/user/${userId}/cart`);
+};
+
+export const upsertUserCart = async (
+ userId: number,
+ type: 'product' | 'promotion',
+ id: number,
+ qty: number,
+ selected: boolean,
+ source: 'buy' | 'add_to_cart' = 'add_to_cart'
+) => {
+ return await odooApi('POST', `/api/v1/user/${userId}/cart/create-or-update`, {
+ product_id: type === 'product' ? id : null,
+ qty,
+ selected,
+ program_line_id: type === 'promotion' ? id : null,
+ source,
+ });
+};
+
+export const deleteUserCart = async (userId: number, ids: number[]) => {
+ return await odooApi(
+ 'DELETE',
+ `/api/v1/user/${userId}/cart?ids=${ids.join(',')}`
+ );
+};
diff --git a/src-migrate/services/pageContent.ts b/src-migrate/services/pageContent.ts
index 24f2c2f0..16146059 100644
--- a/src-migrate/services/pageContent.ts
+++ b/src-migrate/services/pageContent.ts
@@ -1,14 +1,7 @@
import odooApi from '~/common/libs/odooApi';
export const getPageContent = async ({ path }: { path: string }) => {
- const params = new URLSearchParams({
- url_path: path,
- });
+ const params = new URLSearchParams({ url_path: path });
- const pageContent = await odooApi(
- 'GET',
- `/api/v1/page-content?${params.toString()}`
- );
-
- return pageContent;
+ return await odooApi('GET', `/api/v1/page-content?${params.toString()}`);
};
diff --git a/src/pages/api/product-variant/[id].js b/src/pages/api/product-variant/[id].js
new file mode 100644
index 00000000..4186a724
--- /dev/null
+++ b/src/pages/api/product-variant/[id].js
@@ -0,0 +1,2 @@
+import handler from '~/pages/api/product-variant/[id]';
+export default handler;
diff --git a/src/pages/api/product-variant/[id]/promotion/highlight.js b/src/pages/api/product-variant/[id]/promotion/highlight.js
new file mode 100644
index 00000000..93b1e781
--- /dev/null
+++ b/src/pages/api/product-variant/[id]/promotion/highlight.js
@@ -0,0 +1,2 @@
+import handler from '~/pages/api/product-variant/[id]/promotion/highlight';
+export default handler; \ No newline at end of file