diff options
| author | it-fixcomart <it@fixcomart.co.id> | 2025-07-29 09:46:05 +0700 |
|---|---|---|
| committer | it-fixcomart <it@fixcomart.co.id> | 2025-07-29 09:46:05 +0700 |
| commit | 077467cf53b46d8049df8b812577cd1a03011eba (patch) | |
| tree | 0dc641a9acb1237a3caca3f7f8a157a3e938c0b8 /src-migrate/modules | |
| parent | 0d28dc8ff5fb8c5399e356ed6ecae4fce2019ca6 (diff) | |
| parent | dc31efb2fec4c7b79917324d922ae820c4b5bb50 (diff) | |
<hafid> merging new release
Diffstat (limited to 'src-migrate/modules')
| -rw-r--r-- | src-migrate/modules/cart/components/ItemAction.tsx | 265 | ||||
| -rw-r--r-- | src-migrate/modules/cart/components/ItemSelect.tsx | 174 | ||||
| -rw-r--r-- | src-migrate/modules/cart/components/Summary.tsx | 199 | ||||
| -rw-r--r-- | src-migrate/modules/cart/stores/useCartStore.ts | 214 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/Information.tsx | 29 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/components/ProductDetail.tsx | 1 | ||||
| -rw-r--r-- | src-migrate/modules/product-detail/hook/useVariant.ts | 18 |
7 files changed, 727 insertions, 173 deletions
diff --git a/src-migrate/modules/cart/components/ItemAction.tsx b/src-migrate/modules/cart/components/ItemAction.tsx index 7220e362..b06e8e75 100644 --- a/src-migrate/modules/cart/components/ItemAction.tsx +++ b/src-migrate/modules/cart/components/ItemAction.tsx @@ -1,74 +1,227 @@ -import style from '../styles/item-action.module.css' +import style from '../styles/item-action.module.css'; -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react'; -import { Spinner, Tooltip } from '@chakra-ui/react' -import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react' +import { Spinner, Tooltip } from '@chakra-ui/react'; +import { MinusIcon, PlusIcon, Trash2Icon } from 'lucide-react'; -import { CartItem } from '~/types/cart' -import { getAuth } from '~/libs/auth' -import { deleteUserCart, upsertUserCart } from '~/services/cart' +import { CartItem } from '~/types/cart'; +import { getAuth } from '~/libs/auth'; +import { deleteUserCart, upsertUserCart } from '~/services/cart'; -import { useDebounce } from 'usehooks-ts' -import { useCartStore } from '../stores/useCartStore' -import { useProductCartContext } from '@/contexts/ProductCartContext' +import { useDebounce } from 'usehooks-ts'; +import { useCartStore } from '../stores/useCartStore'; +import { useProductCartContext } from '@/contexts/ProductCartContext'; +import { + removeSelectedItemsFromCookie, + removeCartItemsFromCookie, + quantityUpdateState, + getCartDataFromCookie, + setCartDataToCookie, +} from '~/utils/cart'; -type Props = { - item: CartItem +import { toast } from 'react-hot-toast'; + +interface Props { + item: CartItem; } -const CartItemAction = ({ item }: Props) => { - const auth = getAuth() - const { setRefreshCart } = useProductCartContext() - const [isLoadDelete, setIsLoadDelete] = useState<boolean>(false) - const [isLoadQuantity, setIsLoadQuantity] = useState<boolean>(false) +const CartItemAction: React.FC<Props> = ({ item }) => { + const auth = getAuth(); + const { setRefreshCart } = useProductCartContext(); + const [isLoadDelete, setIsLoadDelete] = useState<boolean>(false); + const [isLoadQuantity, setIsLoadQuantity] = useState<boolean>(false); + + const [quantity, setQuantity] = useState<number>(item.quantity); + + const { loadCart, cart, updateCartItem } = useCartStore(); + + const limitQty: number = item.limit_qty?.transaction || 0; + + const handleDelete = async (): Promise<void> => { + if (typeof auth !== 'object') return; + + setIsLoadDelete(true); + + try { + // Delete from server + await deleteUserCart(auth.id, [item.cart_id]); + + // Clean up cookies immediately + removeSelectedItemsFromCookie([item.id]); + removeCartItemsFromCookie([item.cart_id]); - const [quantity, setQuantity] = useState<number>(item.quantity) + // Update local cart state optimistically + if (cart) { + const updatedProducts = cart.products.filter( + (product) => product.id !== item.id + ); + const updatedCart = { + ...cart, + products: updatedProducts, + product_total: updatedProducts.length, + }; + updateCartItem(updatedCart); + } - const { loadCart } = useCartStore() + // Reload from server and refresh context + await loadCart(auth.id); + setRefreshCart(true); - const limitQty = item.limit_qty?.transaction || 0 + toast.success('Item berhasil dihapus'); + } catch (error) { + console.error('Failed to delete cart item:', error); + toast.error('Gagal menghapus item'); + + // Reload on error + await loadCart(auth.id); + } finally { + setIsLoadDelete(false); + } + }; + + const updateQuantityInCookie = ( + productId: number, + cartId: string | number, + newQuantity: number + ): boolean => { + try { + const cartData = getCartDataFromCookie() as Record<string, any>; + let itemFound = false; + const cartIdString = String(cartId); + + // Find item by cart_id key or search within objects + if (cartData[cartIdString]) { + cartData[cartIdString].quantity = newQuantity; + itemFound = true; + } else { + // Search by product id or cart_id within objects + Object.keys(cartData).forEach((key) => { + const item = cartData[key]; + if (item.id === productId || String(item.cart_id) === cartIdString) { + item.quantity = newQuantity; + itemFound = true; + } + }); + } + + if (itemFound) { + setCartDataToCookie(cartData); + return true; + } + + return false; + } catch (error) { + console.error('Error updating quantity in cookie:', error); + return false; + } + }; - const handleDelete = async () => { - if (typeof auth !== 'object') return + const decreaseQty = (): void => { + setQuantity((prevQuantity) => prevQuantity - 1); + }; - setIsLoadDelete(true) - await deleteUserCart(auth.id, [item.cart_id]) - await loadCart(auth.id) - setIsLoadDelete(false) - setRefreshCart(true) - } + const increaseQty = (): void => { + setQuantity((prevQuantity) => prevQuantity + 1); + }; + + const debounceQty = useDebounce(quantity, 1000); - 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]) + if (isNaN(debounceQty)) setQuantity(1); + if (limitQty > 0 && debounceQty > limitQty) setQuantity(limitQty); + }, [debounceQty, limitQty]); useEffect(() => { - const updateCart = async () => { - if (typeof auth !== 'object' || isNaN(debounceQty)) return - - setIsLoadQuantity(true) - await upsertUserCart({ - userId: auth.id, - type: item.cart_type, - id: item.id, - qty: debounceQty, - selected: item.selected, - }) - await loadCart(auth.id) - setIsLoadQuantity(false) - } - updateCart() + const updateCart = async (): Promise<void> => { + if (typeof auth !== 'object' || isNaN(debounceQty)) return; + if (debounceQty === item.quantity) return; + + quantityUpdateState.startUpdate(item.id); + setIsLoadQuantity(true); + + try { + // Update cookie immediately for responsive UI + updateQuantityInCookie(item.id, item.cart_id, debounceQty); + + // Update local cart state optimistically + if (cart) { + const updatedProducts = cart.products.map((product) => + product.id === item.id + ? { ...product, quantity: debounceQty } + : product + ); + const updatedCart = { + ...cart, + products: updatedProducts, + }; + updateCartItem(updatedCart); + } + + // Send update to server + await upsertUserCart({ + userId: auth.id, + type: item.cart_type, + id: item.id, + qty: debounceQty, + selected: item.selected, + }); + + // Reload from server to ensure consistency + await loadCart(auth.id); + + // Re-update cookie if server reload overwrote it + const currentCookieData = getCartDataFromCookie() as Record< + string, + any + >; + let needsReUpdate = false; + + Object.keys(currentCookieData).forEach((key) => { + const cookieItem = currentCookieData[key]; + if ( + (cookieItem.id === item.id || + String(cookieItem.cart_id) === String(item.cart_id)) && + cookieItem.quantity !== debounceQty + ) { + needsReUpdate = true; + } + }); + + if (needsReUpdate) { + updateQuantityInCookie(item.id, item.cart_id, debounceQty); + } + } catch (error) { + console.error('Error updating quantity:', error); + toast.error('Gagal mengupdate quantity'); + + // Revert changes on error + updateQuantityInCookie(item.id, item.cart_id, item.quantity); + loadCart(auth.id); + } finally { + setIsLoadQuantity(false); + quantityUpdateState.endUpdate(item.id); + } + }; + + updateCart(); //eslint-disable-next-line react-hooks/exhaustive-deps - }, [debounceQty]) + }, [debounceQty]); + + const handleQuantityInputChange = ( + e: React.ChangeEvent<HTMLInputElement> + ): void => { + const value = parseInt(e.target.value); + setQuantity(isNaN(value) ? 1 : value); + }; return ( <div className={style.actionSection}> - <button className={style.deleteButton} onClick={handleDelete} disabled={isLoadDelete}> + <button + className={style.deleteButton} + onClick={handleDelete} + disabled={isLoadDelete} + > {isLoadDelete && <Spinner size='xs' />} {!isLoadDelete && <Trash2Icon size={16} />} </button> @@ -90,8 +243,8 @@ const CartItemAction = ({ item }: Props) => { <input type='number' - className={style.quantity.toString()} - onChange={(e) => setQuantity(parseInt(e.target.value))} + className={style.quantity} + onChange={handleQuantityInputChange} value={quantity} /> @@ -106,7 +259,7 @@ const CartItemAction = ({ item }: Props) => { </Tooltip> </div> </div> - ) -} + ); +}; -export default CartItemAction
\ No newline at end of file +export default CartItemAction; diff --git a/src-migrate/modules/cart/components/ItemSelect.tsx b/src-migrate/modules/cart/components/ItemSelect.tsx index d4a1b537..f95ee36c 100644 --- a/src-migrate/modules/cart/components/ItemSelect.tsx +++ b/src-migrate/modules/cart/components/ItemSelect.tsx @@ -1,56 +1,142 @@ -import { Checkbox, Spinner } from '@chakra-ui/react' -import React, { useState } from 'react' +import { Checkbox } from '@chakra-ui/react'; +import React, { useState, useCallback, useEffect } from 'react'; +import { getAuth } from '~/libs/auth'; +import { CartItem } from '~/types/cart'; +import { upsertUserCart } from '~/services/cart'; +import { useCartStore } from '../stores/useCartStore'; +import { toast } from 'react-hot-toast'; +import { + getSelectedItemsFromCookie, + updateSelectedItemInCookie, + checkboxUpdateState, +} from '~/utils/cart'; -import { getAuth } from '~/libs/auth' -import { CartItem } from '~/types/cart' -import { upsertUserCart } from '~/services/cart' +interface Props { + item: CartItem; +} -import { useCartStore } from '../stores/useCartStore' +const CartItemSelect: React.FC<Props> = ({ item }) => { + const auth = getAuth(); + const { updateCartItem, cart, loadCart } = useCartStore(); + const [isUpdating, setIsUpdating] = useState<boolean>(false); + const [localSelected, setLocalSelected] = useState<boolean>(item.selected); -type Props = { - item: CartItem -} + // Subscribe to global checkbox update state + useEffect(() => { + const handleUpdateStateChange = (isUpdating: boolean): void => { + // This component doesn't need to react to global state changes + // Individual checkboxes are managed independently + }; + + checkboxUpdateState.addListener(handleUpdateStateChange); + return () => checkboxUpdateState.removeListener(handleUpdateStateChange); + }, []); + + // Sync local state with cookie and server data + useEffect(() => { + if (isUpdating) return; + + const selectedItems = getSelectedItemsFromCookie() as Record< + number, + boolean + >; + const storedState = selectedItems[item.id]; + + if (storedState !== undefined) { + // Update local state if cookie differs + if (localSelected !== storedState) { + setLocalSelected(storedState); + } -const CartItemSelect = ({ item }: Props) => { - const auth = getAuth() - const { updateCartItem, cart } = useCartStore() + // Sync cart state with cookie if needed + if (storedState !== item.selected && cart) { + const updatedCartItems = cart.products.map((cartItem) => + cartItem.id === item.id + ? { ...cartItem, selected: storedState } + : cartItem + ); + updateCartItem({ ...cart, products: updatedCartItems }); + } + } else { + // Initialize cookie with server state + setLocalSelected(item.selected); + updateSelectedItemInCookie(item.id, item.selected, false); + } + }, [item.id, item.selected, localSelected, cart, updateCartItem, isUpdating]); - const [isLoad, setIsLoad] = useState<boolean>(false) + const handleChange = useCallback( + async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => { + if (typeof auth !== 'object' || !cart || isUpdating) return; - const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { - if (typeof auth !== 'object' || !cart) return - - setIsLoad(true); - const updatedCartItems = cart.products.map(cartItem => - cartItem.id === item.id - ? { ...cartItem, selected: e.target.checked } - : cartItem - ); + const newSelectedState = e.target.checked; - // Update the entire cart - const updatedCart = { ...cart, products: updatedCartItems }; - updateCartItem(updatedCart); + // Update local state immediately + setLocalSelected(newSelectedState); + setIsUpdating(true); + checkboxUpdateState.startUpdate(); - setIsLoad(false); - } + try { + // Update cookie immediately for responsive UI + updateSelectedItemInCookie(item.id, newSelectedState, false); + + // Update cart state optimistically + const updatedCartItems = cart.products.map((cartItem) => + cartItem.id === item.id + ? { ...cartItem, selected: newSelectedState } + : cartItem + ); + updateCartItem({ ...cart, products: updatedCartItems }); + + // Send update to server + await upsertUserCart({ + userId: auth.id, + type: item.cart_type, + id: item.id, + qty: item.quantity, + selected: newSelectedState, + // purchase_tax_id: item.purchase_tax_id, + // vendor_id: item.vendor_id + }); + + // Reload cart for consistency + await loadCart(auth.id); + } catch (error) { + console.error('Failed to update item selection:', error); + toast.error('Gagal memperbarui pilihan barang'); + + // Revert changes on error + setLocalSelected(!newSelectedState); + updateSelectedItemInCookie(item.id, !newSelectedState, false); + loadCart(auth.id); + } finally { + setIsUpdating(false); + checkboxUpdateState.endUpdate(); + } + }, + [auth, cart, item, isUpdating, updateCartItem, loadCart] + ); + + const isDisabled: boolean = isUpdating; return ( - <div className='w-6 my-auto'> - {isLoad && ( - <Spinner className='my-auto' size='sm' /> - )} - - {!isLoad && ( - <Checkbox - borderColor='gray.600' - colorScheme='red' - size='lg' - isChecked={item.selected} - onChange={handleChange} - /> - )} + <div className='w-6 my-auto relative'> + <Checkbox + borderColor='gray.600' + colorScheme='red' + size='lg' + isChecked={localSelected} + onChange={handleChange} + isDisabled={isDisabled} + opacity={isDisabled ? 0.5 : 1} + cursor={isDisabled ? 'not-allowed' : 'pointer'} + _disabled={{ + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: 'gray.100', + }} + /> </div> - ) -} + ); +}; -export default CartItemSelect
\ No newline at end of file +export default CartItemSelect; diff --git a/src-migrate/modules/cart/components/Summary.tsx b/src-migrate/modules/cart/components/Summary.tsx index 0af5ab18..312160fb 100644 --- a/src-migrate/modules/cart/components/Summary.tsx +++ b/src-migrate/modules/cart/components/Summary.tsx @@ -1,20 +1,19 @@ -import style from '../styles/summary.module.css' - -import React from 'react' -import formatCurrency from '~/libs/formatCurrency' -import clsxm from '~/libs/clsxm' -import { Skeleton } from '@chakra-ui/react' -import _ from 'lodash' +import style from '../styles/summary.module.css'; +import React, { useEffect, useState, useMemo } from 'react'; +import formatCurrency from '~/libs/formatCurrency'; +import clsxm from '~/libs/clsxm'; +import { Skeleton, Box, useColorModeValue, Text } from '@chakra-ui/react'; type Props = { - total?: number - discount?: number - subtotal?: number - tax?: number - shipping?: number - grandTotal?: number - isLoaded: boolean -} + total?: number; + discount?: number; + subtotal?: number; + tax?: number; + shipping?: number; + grandTotal?: number; + isLoaded: boolean; + products?: any[]; // Added to detect changes in selected products +}; const CartSummary = ({ total, @@ -24,40 +23,176 @@ const CartSummary = ({ shipping, grandTotal, isLoaded = false, + products = [], }: Props) => { - const PPN : number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; - return ( - <> - <div className='text-h-sm font-medium'>Ringkasan Pesanan</div> + const PPN: number = process.env.NEXT_PUBLIC_PPN + ? parseFloat(process.env.NEXT_PUBLIC_PPN) + : 0; + const [isMounted, setIsMounted] = useState(false); + + // Local state to store calculated values + const [summaryValues, setSummaryValues] = useState({ + subtotal: 0, + discount: 0, + total: 0, + tax: 0, + shipping: 0, + grandTotal: 0, + }); + + // This fixes hydration issues by ensuring the component only renders fully after mounting + useEffect(() => { + setIsMounted(true); + }, []); + + // Calculate summary based on products whenever products change + useMemo(() => { + if (!products || products.length === 0) return; + + // Only count selected products + const selectedProducts = products.filter((product) => product.selected); + + // Calculate values based on selected products + let calculatedSubtotal = 0; + let calculatedDiscount = 0; + + selectedProducts.forEach((product) => { + // Get raw price and discount from product + const productBasePrice = product.price?.price || 0; + const productQty = product.quantity || 1; + const productDiscountedPrice = + product.price?.price_discount || productBasePrice; + const productDiscount = productBasePrice - productDiscountedPrice; + + calculatedSubtotal += productBasePrice * productQty; + calculatedDiscount += productDiscount * productQty; + }); + + const calculatedTotal = calculatedSubtotal - calculatedDiscount; + const calculatedTax = calculatedTotal * (PPN - 1); + const calculatedShipping = shipping || 0; + const calculatedGrandTotal = + calculatedTotal + calculatedTax + calculatedShipping; + + // If calculated values are different from props, use calculated ones + const shouldUpdateValues = + Math.abs((subtotal || 0) - calculatedSubtotal) > 0.01 || + Math.abs((discount || 0) - calculatedDiscount) > 0.01 || + Math.abs((total || 0) - calculatedTotal) > 0.01 || + Math.abs((tax || 0) - calculatedTax) > 0.01 || + Math.abs((grandTotal || 0) - calculatedGrandTotal) > 0.01; - <div className="h-6" /> + if (shouldUpdateValues && isLoaded) { + setSummaryValues({ + subtotal: calculatedSubtotal, + discount: calculatedDiscount, + total: calculatedTotal, + tax: calculatedTax, + shipping: calculatedShipping, + grandTotal: calculatedGrandTotal, + }); + } else if (isLoaded) { + // Use values from props when available + setSummaryValues({ + subtotal: subtotal || 0, + discount: discount || 0, + total: total || 0, + tax: tax || 0, + shipping: shipping || 0, + grandTotal: grandTotal || 0, + }); + } + }, [ + products, + isLoaded, + subtotal, + discount, + total, + tax, + shipping, + grandTotal, + PPN, + ]); + + // Update local values whenever props change + useEffect(() => { + if (isLoaded) { + setSummaryValues({ + subtotal: subtotal || 0, + discount: discount || 0, + total: total || 0, + tax: tax || 0, + shipping: shipping || 0, + grandTotal: grandTotal || 0, + }); + } + }, [isLoaded, subtotal, discount, total, tax, shipping, grandTotal]); + + if (!isMounted) { + return ( + <Box p={4} borderWidth='1px' borderRadius='lg' boxShadow='sm'> + <Text fontSize='lg' fontWeight='medium' mb={4}> + Ringkasan Pesanan + </Text> + {Array(6) + .fill(0) + .map((_, index) => ( + <Skeleton key={index} height='24px' my={2} /> + ))} + </Box> + ); + } + + // Use local state for rendering to ensure responsiveness + const { + subtotal: displaySubtotal, + discount: displayDiscount, + total: displayTotal, + tax: displayTax, + shipping: displayShipping, + grandTotal: displayGrandTotal, + } = summaryValues; + + return ( + <div className={style.summaryContainer}> + <Text fontSize='lg' fontWeight='medium' mb={4}> + Ringkasan Pesanan + </Text> <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> + <span className={style.value}> + Rp {formatCurrency(displaySubtotal)} + </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> + <span className={clsxm(style.value, style.discount)}> + - Rp {formatCurrency(displayDiscount)} + </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> + <span className={style.value}>Rp {formatCurrency(displayTotal)}</span> </Skeleton> <Skeleton isLoaded={isLoaded} className={style.line}> - <span className={style.label}>Tax {((PPN - 1) * 100).toFixed(0)}%</span> - <span className={style.value}>Rp {formatCurrency(tax || 0)}</span> + <span className={style.label}> + Tax {((PPN - 1) * 100).toFixed(0)}% + </span> + <span className={style.value}>Rp {formatCurrency(displayTax)}</span> </Skeleton> <Skeleton isLoaded={isLoaded} className={style.line}> <span className={style.label}>Biaya Kirim</span> - <span className={style.value}>Rp {formatCurrency(shipping || 0)}</span> + <span className={style.value}> + Rp {formatCurrency(displayShipping)} + </span> </Skeleton> <div className={style.divider} /> @@ -66,11 +201,13 @@ const CartSummary = ({ <span className={clsxm(style.label, style.grandTotal)}> Grand Total </span> - <span className={style.value}>Rp {formatCurrency(grandTotal || 0)}</span> + <span className={style.value}> + Rp {formatCurrency(grandTotal || 0)} + </span> </Skeleton> </div> - </> - ) -} + </div> + ); +}; -export default CartSummary
\ No newline at end of file +export default CartSummary; diff --git a/src-migrate/modules/cart/stores/useCartStore.ts b/src-migrate/modules/cart/stores/useCartStore.ts index e7d2cdd3..be48b1ed 100644 --- a/src-migrate/modules/cart/stores/useCartStore.ts +++ b/src-migrate/modules/cart/stores/useCartStore.ts @@ -1,23 +1,39 @@ import { create } from 'zustand'; import { CartItem, CartProps } from '~/types/cart'; import { getUserCart } from '~/services/cart'; +import { + syncCartWithCookie, + getCartDataFromCookie, + getSelectedItemsFromCookie, + forceResetAllSelectedItems, +} from '~/utils/cart'; -type State = { +interface Summary { + subtotal: number; + discount: number; + total: number; + tax: number; + grandTotal: number; +} + +interface SyncResult { + cartData?: Record<string, any>; + selectedItems?: Record<number, boolean>; + needsUpdate: boolean; +} + +interface State { cart: CartProps | null; isLoadCart: boolean; - summary: { - subtotal: number; - discount: number; - total: number; - tax: number; - grandTotal: number; - }; -}; + summary: Summary; +} -type Action = { +interface Action { loadCart: (userId: number) => Promise<void>; updateCartItem: (updateCart: CartProps) => void; -}; + forceResetSelection: () => void; + clearCart: () => void; +} export const useCartStore = create<State & Action>((set, get) => ({ cart: null, @@ -29,50 +45,180 @@ export const useCartStore = create<State & Action>((set, get) => ({ tax: 0, grandTotal: 0, }, - loadCart: async (userId) => { - if (get().isLoadCart === true) return; + + loadCart: async (userId: number): Promise<void> => { + if (get().isLoadCart) return; set({ isLoadCart: true }); - const cart: CartProps = (await getUserCart(userId)) as CartProps; - set({ cart }); - set({ isLoadCart: false }); - const summary = computeSummary(cart); + try { + const cart: CartProps = (await getUserCart(userId)) as CartProps; + + // Sync with cookie data + const syncResult = syncCartWithCookie(cart) as SyncResult; + + if (syncResult?.needsUpdate && cart.products) { + const selectedItems = getSelectedItemsFromCookie() as Record< + number, + boolean + >; + + const updatedCart: CartProps = { + ...cart, + products: cart.products.map((item) => ({ + ...item, + selected: + selectedItems[item.id] !== undefined + ? selectedItems[item.id] + : item.selected, + })), + }; + + set({ cart: updatedCart }); + } else { + set({ cart }); + } + + // Update summary + const summary = computeSummary(get().cart!); + set({ summary }); + } catch (error) { + console.error('Failed to load cart:', error); + + // Fallback to cookie data + await handleFallbackFromCookie(); + } finally { + set({ isLoadCart: false }); + } + }, + + updateCartItem: (updatedCart: CartProps): void => { + set({ cart: updatedCart }); + syncCartWithCookie(updatedCart); + + const summary = computeSummary(updatedCart); set({ summary }); }, - updateCartItem: (updatedCart) => { - const cart = get().cart; + + forceResetSelection: (): void => { + const { cart } = get(); if (!cart) return; + forceResetAllSelectedItems(); + + const updatedCart: CartProps = { + ...cart, + products: cart.products.map((item) => ({ ...item, selected: false })), + }; + set({ cart: updatedCart }); + const summary = computeSummary(updatedCart); set({ summary }); }, + clearCart: (): void => { + set({ + cart: null, + summary: { + subtotal: 0, + discount: 0, + total: 0, + tax: 0, + grandTotal: 0, + }, + }); + }, })); -const computeSummary = (cart: CartProps) => { +// Helper function for cookie fallback +const handleFallbackFromCookie = async (): Promise<void> => { + try { + const cartData = getCartDataFromCookie() as Record<string, any>; + + if (Object.keys(cartData).length === 0) return; + + const products: CartItem[] = Object.values(cartData).map( + transformCookieItemToProduct + ); + + const fallbackCart: CartProps = { + product_total: products.length, + products, + }; + + useCartStore.setState({ cart: fallbackCart }); + + const summary = computeSummary(fallbackCart); + useCartStore.setState({ summary }); + } catch (error) { + console.error('Cookie fallback failed:', error); + } +}; + +// Helper function to transform cookie item to product format +const transformCookieItemToProduct = (item: any): CartItem => ({ + image_program: item.image_program || '', + cart_id: item.cart_id, + quantity: item.quantity, + selected: item.selected, + can_buy: true, + cart_type: item.cart_type, + id: item.id, + name: item.product?.name || item.program_line?.name || '', + stock: 0, + is_in_bu: false, + on_hand_qty: 0, + available_quantity: 0, + weight: 0, + attributes: [], + parent: { + id: 0, + name: '', + image: '', + }, + price: item.price || { + price: 0, + discount_percentage: 0, + price_discount: 0, + }, + manufacture: { + id: 0, + name: '', + }, + has_flashsale: false, + subtotal: 0, + code: item.code, + image: item.image, + package_price: item.package_price, +}); + +const computeSummary = (cart: CartProps): Summary => { + if (!cart?.products) { + return { subtotal: 0, discount: 0, total: 0, grandTotal: 0, tax: 0 }; + } + + const PPN = parseFloat(process.env.NEXT_PUBLIC_PPN || '1.11'); let subtotal = 0; let discount = 0; - const PPN: number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0; - - for (const item of cart?.products) { + for (const item of cart.products) { if (!item.selected) continue; - let price = 0; - if (item.cart_type === 'promotion') - price = (item?.package_price || 0) * item.quantity; - else if (item.cart_type === 'product') - price = item.price.price * item.quantity; + const price = + item.cart_type === 'promotion' + ? (item?.package_price || 0) * item.quantity + : item.price.price * item.quantity; subtotal += price; discount += price - item.price.price_discount * item.quantity; } - let total = subtotal - discount; - let grandTotal = total * PPN; - let tax = grandTotal - total; - // let grandTotal = total + tax; - return { subtotal, discount, total, grandTotal, tax }; -};
\ No newline at end of file + const total = subtotal - discount; + + // PERBAIKAN: + const tax = total * (PPN - 1); + const grandTotal = total + tax; + + return { subtotal, discount, total, grandTotal, tax }; +}; diff --git a/src-migrate/modules/product-detail/components/Information.tsx b/src-migrate/modules/product-detail/components/Information.tsx index 5e1ea186..a7a58cbc 100644 --- a/src-migrate/modules/product-detail/components/Information.tsx +++ b/src-migrate/modules/product-detail/components/Information.tsx @@ -11,7 +11,7 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import currencyFormat from '@/core/utils/currencyFormat'; -import { InputGroup, InputRightElement } from '@chakra-ui/react'; +import { InputGroup, InputRightElement, Spinner } from '@chakra-ui/react'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; import Image from 'next/image'; import { formatToShortText } from '~/libs/formatNumber'; @@ -19,6 +19,7 @@ import { createSlug } from '~/libs/slug'; import { getVariantSLA } from '~/services/productVariant'; import { IProductDetail } from '~/types/product'; import { useProductDetail } from '../stores/useProductDetail'; +import useVariant from '../hook/useVariant'; const Skeleton = dynamic(() => import('@chakra-ui/react').then((mod) => mod.Skeleton) @@ -41,6 +42,9 @@ const Information = ({ product }: Props) => { const [variantOptions, setVariantOptions] = useState<any[]>( product?.variants ); + const variantId = selectedVariant?.id; + const { slaVariant, isLoading } = useVariant({ variantId }); + // let variantOptions = product?.variants; // const querySLA = useQuery<IProductVariantSLA>({ @@ -50,14 +54,8 @@ const Information = ({ product }: Props) => { // }); // const sla = querySLA?.data; - const getsla = async () => { - const querySLA = await getVariantSLA(selectedVariant?.id); - setSla(querySLA); - }; - useEffect(() => { if (selectedVariant) { - getsla(); setInputValue( selectedVariant?.code + (selectedVariant?.attributes[0] @@ -67,6 +65,16 @@ const Information = ({ product }: Props) => { } }, [selectedVariant]); + useEffect(() => { + if (isLoading){ + setSla(null); + } + if (slaVariant) { + setSla(slaVariant); + } + }, [slaVariant, isLoading]); + + const handleOnChange = (vals: any) => { setDisableFilter(true); let code = vals.replace(/\s-\s.*$/, '').trim(); @@ -223,7 +231,12 @@ const Information = ({ product }: Props) => { </div> <div className={style['row']}> <div className={style['label']}>Persiapan Barang</div> - <div className={style['value']}>{sla?.sla_date}</div> + {isLoading && ( + <div className={style['value']}> + <Skeleton height={5} width={100} /> + </div> + )} + {!isLoading && <div className={style['value']}>{sla?.sla_date}</div>} </div> </div> ); diff --git a/src-migrate/modules/product-detail/components/ProductDetail.tsx b/src-migrate/modules/product-detail/components/ProductDetail.tsx index 0660b9c0..192e1dc3 100644 --- a/src-migrate/modules/product-detail/components/ProductDetail.tsx +++ b/src-migrate/modules/product-detail/components/ProductDetail.tsx @@ -42,6 +42,7 @@ const ProductDetail = ({ product }: Props) => { setIsApproval, isApproval, setSelectedVariant, + setSla, } = useProductDetail(); useEffect(() => { diff --git a/src-migrate/modules/product-detail/hook/useVariant.ts b/src-migrate/modules/product-detail/hook/useVariant.ts new file mode 100644 index 00000000..18451f7e --- /dev/null +++ b/src-migrate/modules/product-detail/hook/useVariant.ts @@ -0,0 +1,18 @@ +import { useQuery } from "react-query" +import { number } from "zod" +import { getVariantById, getVariantSLA } from "~/services/productVariant" + +interface Props { + variantId : number +} +const useVariant = ({variantId}:Props) => { + const fetchVariant = async () => await getVariantSLA(variantId ) + const {data, isLoading, refetch} = useQuery(variantId ? `variant-${variantId}` : '', fetchVariant, + { + enabled: !!variantId, + }) + + return {slaVariant: data, isLoading, refetch} +} + +export default useVariant
\ No newline at end of file |
