diff options
| author | IT Fixcomart <it@fixcomart.co.id> | 2025-05-31 02:33:14 +0000 |
|---|---|---|
| committer | IT Fixcomart <it@fixcomart.co.id> | 2025-05-31 02:33:14 +0000 |
| commit | 2a1dea70b8f0062fe8eebeb7139a7b77a24e220b (patch) | |
| tree | e7a5db13b2655cbdfb1c81859e240652fdc87bbb | |
| parent | 0b2e31247d4fe7eb1432079979478a0cfc38d049 (diff) | |
| parent | 2732c04b36f98a25895826b28003b1e2c56ad952 (diff) | |
Merged in fix_responsive_cart (pull request #413)
<miqdad> Fix unresponsive cart
| -rw-r--r-- | src-migrate/modules/cart/components/ItemAction.tsx | 246 | ||||
| -rw-r--r-- | src-migrate/modules/cart/components/ItemSelect.tsx | 172 | ||||
| -rw-r--r-- | src-migrate/modules/cart/components/Summary.tsx | 201 | ||||
| -rw-r--r-- | src-migrate/modules/cart/stores/useCartStore.ts | 151 | ||||
| -rw-r--r-- | src-migrate/pages/shop/cart/index.tsx | 399 | ||||
| -rw-r--r-- | src-migrate/utils/cart.js | 455 | ||||
| -rw-r--r-- | src-migrate/utils/checkBoxState.js | 90 |
7 files changed, 1431 insertions, 283 deletions
diff --git a/src-migrate/modules/cart/components/ItemAction.tsx b/src-migrate/modules/cart/components/ItemAction.tsx index 7220e362..4dcebd9e 100644 --- a/src-migrate/modules/cart/components/ItemAction.tsx +++ b/src-migrate/modules/cart/components/ItemAction.tsx @@ -1,74 +1,214 @@ -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'; + +import { toast } from 'react-hot-toast'; type Props = { - item: CartItem -} + 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 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 [quantity, setQuantity] = useState<number>(item.quantity); - const { loadCart } = useCartStore() + const { loadCart, cart, updateCartItem } = useCartStore(); - const limitQty = item.limit_qty?.transaction || 0 + 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) - setRefreshCart(true) - } - - const decreaseQty = () => { setQuantity((quantity) => quantity -= 1) } - const increaseQty = () => { setQuantity((quantity) => quantity += 1) } - const debounceQty = useDebounce(quantity, 1000) + 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]); + + // 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); + } + + // Reload from server and refresh context + await loadCart(auth.id); + setRefreshCart(true); + + 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, cartId, newQuantity) => { + try { + const cartData = getCartDataFromCookie(); + let itemFound = false; + + // Find item by cart_id key or search within objects + if (cartData[cartId]) { + cartData[cartId].quantity = newQuantity; + itemFound = true; + } else { + // Search by product id or cart_id within objects + for (const key in cartData) { + const item = cartData[key]; + if (item.id === productId || item.cart_id === cartId) { + item.quantity = newQuantity; + itemFound = true; + break; + } + } + } + + if (itemFound) { + setCartDataToCookie(cartData); + return true; + } + + return false; + } catch (error) { + console.error('Error updating quantity in cookie:', error); + return 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]) + 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() + 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(); + let needsReUpdate = false; + + for (const key in currentCookieData) { + const cookieItem = currentCookieData[key]; + if ( + (cookieItem.id === item.id || + cookieItem.cart_id === item.cart_id) && + cookieItem.quantity !== debounceQty + ) { + needsReUpdate = true; + break; + } + } + + 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]); 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> @@ -106,7 +246,7 @@ const CartItemAction = ({ item }: Props) => { </Tooltip> </div> </div> - ) -} + ); +}; -export default CartItemAction
\ No newline at end of file +export default CartItemAction;
\ No newline at end of file diff --git a/src-migrate/modules/cart/components/ItemSelect.tsx b/src-migrate/modules/cart/components/ItemSelect.tsx index d4a1b537..72ab49aa 100644 --- a/src-migrate/modules/cart/components/ItemSelect.tsx +++ b/src-migrate/modules/cart/components/ItemSelect.tsx @@ -1,56 +1,140 @@ -import { Checkbox, Spinner } from '@chakra-ui/react' -import React, { useState } from 'react' - -import { getAuth } from '~/libs/auth' -import { CartItem } from '~/types/cart' -import { upsertUserCart } from '~/services/cart' - -import { useCartStore } from '../stores/useCartStore' +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'; type Props = { - item: CartItem -} + item: CartItem; +}; const CartItemSelect = ({ item }: Props) => { - const auth = getAuth() - const { updateCartItem, cart } = useCartStore() + const auth = getAuth(); + const { updateCartItem, cart, loadCart } = useCartStore(); + const [isUpdating, setIsUpdating] = useState<boolean>(false); + const [localSelected, setLocalSelected] = useState<boolean>(item.selected); + + // Subscribe to global checkbox update state + useEffect(() => { + const handleUpdateStateChange = (isUpdating) => { + // 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(); + const storedState = selectedItems[item.id]; + + if (storedState !== undefined) { + // Update local state if cookie differs + if (localSelected !== storedState) { + setLocalSelected(storedState); + } + + // 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 handleChange = useCallback( + async (e: React.ChangeEvent<HTMLInputElement>) => { + if (typeof auth !== 'object' || !cart || isUpdating) return; + + const newSelectedState = e.target.checked; + + // Update local state immediately + setLocalSelected(newSelectedState); + setIsUpdating(true); + checkboxUpdateState.startUpdate(item.id); + + 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 }); - const [isLoad, setIsLoad] = useState<boolean>(false) + // 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 + }); - 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 - ); + // Reload cart for consistency + await loadCart(auth.id); + } catch (error) { + console.error('Failed to update item selection:', error); + toast.error('Gagal memperbarui pilihan barang'); - // Update the entire cart - const updatedCart = { ...cart, products: updatedCartItems }; - updateCartItem(updatedCart); + // Revert changes on error + setLocalSelected(!newSelectedState); + updateSelectedItemInCookie(item.id, !newSelectedState, false); + loadCart(auth.id); + } finally { + setIsUpdating(false); + checkboxUpdateState.endUpdate(item.id); + } + }, + [auth, cart, item, isUpdating, updateCartItem, loadCart] + ); - setIsLoad(false); - } + const isDisabled = + isUpdating || checkboxUpdateState.isCheckboxUpdating(item.id); 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..68db6323 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,53 +23,191 @@ 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} /> - <Skeleton isLoaded={isLoaded} className={style.line}> + <Skeleton isLoaded={isLoaded}> <span className={clsxm(style.label, style.grandTotal)}> Grand Total </span> - <span className={style.value}>Rp {formatCurrency(grandTotal || 0)}</span> + <span className={clsxm(style.value, style.grandTotalValue)}> + Rp {formatCurrency(displayGrandTotal)} + </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..dc47b011 100644 --- a/src-migrate/modules/cart/stores/useCartStore.ts +++ b/src-migrate/modules/cart/stores/useCartStore.ts @@ -1,6 +1,12 @@ 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 = { cart: CartProps | null; @@ -17,6 +23,8 @@ type State = { type Action = { loadCart: (userId: number) => Promise<void>; updateCartItem: (updateCart: CartProps) => void; + forceResetSelection: () => void; + clearCart: () => void; }; export const useCartStore = create<State & Action>((set, get) => ({ @@ -29,50 +37,153 @@ export const useCartStore = create<State & Action>((set, get) => ({ tax: 0, grandTotal: 0, }, + loadCart: async (userId) => { - if (get().isLoadCart === true) return; + if (get().isLoadCart) return; set({ isLoadCart: true }); - const cart: CartProps = (await getUserCart(userId)) as CartProps; - set({ cart }); - set({ isLoadCart: false }); - const summary = computeSummary(cart); - set({ summary }); + try { + const cart: CartProps = (await getUserCart(userId)) as CartProps; + + // Sync with cookie data + const syncResult = syncCartWithCookie(cart); + + if (syncResult?.needsUpdate && cart.products) { + const selectedItems = getSelectedItemsFromCookie(); + + const updatedCart = { + ...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) => { - const cart = get().cart; + set({ cart: updatedCart }); + syncCartWithCookie(updatedCart); + + const summary = computeSummary(updatedCart); + set({ summary }); + }, + + forceResetSelection: () => { + const { cart } = get(); if (!cart) return; + forceResetAllSelectedItems(); + + const updatedCart = { + ...cart, + products: cart.products.map((item) => ({ ...item, selected: false })), + }; + set({ cart: updatedCart }); + const summary = computeSummary(updatedCart); set({ summary }); }, + clearCart: () => { + set({ + cart: null, + summary: { + subtotal: 0, + discount: 0, + total: 0, + tax: 0, + grandTotal: 0, + }, + }); + }, })); +// Helper function for cookie fallback +const handleFallbackFromCookie = async () => { + try { + const cartData = getCartDataFromCookie(); + + if (Object.keys(cartData).length === 0) return; + + const products = 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 => ({ + cart_id: item.cart_id, + id: item.id, + cart_type: item.cart_type, + product_id: item.product?.id, + product_name: item.product?.name, + program_line_id: item.program_line?.id, + program_line_name: item.program_line?.name, + quantity: item.quantity, + selected: item.selected, + price: item.price, + package_price: item.package_price, + source: item.source, +}); + +// Helper function to compute cart summary const computeSummary = (cart: CartProps) => { + if (!cart?.products) { + return { subtotal: 0, discount: 0, total: 0, grandTotal: 0, tax: 0 }; + } + + const PPN = parseFloat(process.env.NEXT_PUBLIC_PPN || '0'); 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 }; + const total = subtotal - discount; + const grandTotal = total * (1 + PPN); + const tax = grandTotal - total; + + return { subtotal, discount, total, grandTotal, tax }; };
\ No newline at end of file diff --git a/src-migrate/pages/shop/cart/index.tsx b/src-migrate/pages/shop/cart/index.tsx index 24baa933..03854d79 100644 --- a/src-migrate/pages/shop/cart/index.tsx +++ b/src-migrate/pages/shop/cart/index.tsx @@ -1,6 +1,6 @@ import style from './cart.module.css'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { Button, Checkbox, Spinner, Tooltip } from '@chakra-ui/react'; import { toast } from 'react-hot-toast'; @@ -14,127 +14,137 @@ import clsxm from '~/libs/clsxm'; import useDevice from '@/core/hooks/useDevice'; import CartSummaryMobile from '~/modules/cart/components/CartSummaryMobile'; import Image from '~/components/ui/image'; -import { CartItem } from '~/types/cart'; import { deleteUserCart, upsertUserCart } from '~/services/cart'; import { Trash2Icon } from 'lucide-react'; import { useProductCartContext } from '@/contexts/ProductCartContext'; +import { + getSelectedItemsFromCookie, + syncSelectedItemsWithCookie, + setAllSelectedInCookie, + removeSelectedItemsFromCookie, + removeCartItemsFromCookie, + checkboxUpdateState, + quantityUpdateState, +} from '~/utils/cart'; + +const SELECT_ALL_ID = 'select_all_checkbox'; const CartPage = () => { const router = useRouter(); const auth = getAuth(); const [isStepApproval, setIsStepApproval] = useState(false); - const [isSelectedAll, setIsSelectedAll] = useState(false); - const [isButtonChek, setIsButtonChek] = useState(false); - const [buttonSelectNow, setButtonSelectNow] = useState(true); - const [isLoad, setIsLoad] = useState<boolean>(false); const [isLoadDelete, setIsLoadDelete] = useState<boolean>(false); const { loadCart, cart, summary, updateCartItem } = useCartStore(); - const useDivvice = useDevice(); + const device = useDevice(); const { setRefreshCart } = useProductCartContext(); const [isTop, setIsTop] = useState(true); - const [hasChanged, setHasChanged] = useState(false); - const prevCartRef = useRef<CartItem[] | null>(null); + const [isUpdating, setIsUpdating] = useState(false); + const [isAnyCheckboxUpdating, setIsAnyCheckboxUpdating] = useState(false); + const [isAnyQuantityUpdating, setIsAnyQuantityUpdating] = useState(false); + // Subscribe to update state changes useEffect(() => { - const handleScroll = () => { - setIsTop(window.scrollY < 200); - }; + const handleCheckboxUpdate = (isUpdating) => + setIsAnyCheckboxUpdating(isUpdating); + const handleQuantityUpdate = (isUpdating) => + setIsAnyQuantityUpdating(isUpdating); + + checkboxUpdateState.addListener(handleCheckboxUpdate); + quantityUpdateState.addListener(handleQuantityUpdate); - window.addEventListener('scroll', handleScroll); return () => { - window.removeEventListener('scroll', handleScroll); + checkboxUpdateState.removeListener(handleCheckboxUpdate); + quantityUpdateState.removeListener(handleQuantityUpdate); }; }, []); + // Handle scroll for sticky header styling useEffect(() => { - if (typeof auth === 'object' && !cart) { - loadCart(auth.id); - setIsStepApproval(auth?.feature?.soApproval); - } - }, [auth, loadCart, cart, isButtonChek]); + const handleScroll = () => setIsTop(window.scrollY < 200); - useEffect(() => { - if (typeof auth === 'object' && !cart) { - loadCart(auth.id); - setIsStepApproval(auth?.feature?.soApproval); - } - }, [auth, loadCart, cart, isButtonChek]); + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + // Initialize cart and sync with cookies useEffect(() => { - const hasSelectedChanged = () => { - if (prevCartRef.current && cart) { - const prevCart = prevCartRef.current; - return cart.products.some( - (item, index) => - prevCart[index] && prevCart[index].selected !== item.selected - ); + const initializeCart = async () => { + if (typeof auth === 'object' && !cart) { + await loadCart(auth.id); + setIsStepApproval(auth?.feature?.soApproval); + + if (cart?.products) { + const { items, needsUpdate } = syncSelectedItemsWithCookie( + cart.products + ); + + if (needsUpdate) { + const updatedCart = { + ...cart, + products: cart.products.map((item) => ({ + ...item, + selected: + items[item.id] !== undefined ? items[item.id] : item.selected, + })), + }; + updateCartItem(updatedCart); + } + } } - return false; }; - if (hasSelectedChanged()) { - setHasChanged(true); - // Perform necessary actions here if selection has changed - } else { - setHasChanged(false); - } - - prevCartRef.current = cart ? [...cart.products] : null; - }, [cart]); + initializeCart(); + }, [auth, cart, loadCart, updateCartItem]); + // Computed values const hasSelectedPromo = useMemo(() => { - if (!cart) return false; - return cart?.products?.some( - (item) => item.cart_type === 'promotion' && item.selected + return ( + cart?.products?.some( + (item) => item.cart_type === 'promotion' && item.selected + ) || false ); }, [cart]); const hasSelected = useMemo(() => { - if (!cart) return false; - return cart?.products?.some((item) => item.selected); + return cart?.products?.some((item) => item.selected) || false; }, [cart]); const hasSelectNoPrice = useMemo(() => { - if (!cart) return false; - return cart?.products?.some( - (item) => item.selected && item.price.price_discount === 0 + return ( + cart?.products?.some( + (item) => item.selected && item.price.price_discount === 0 + ) || false ); }, [cart]); const hasSelectedAll = useMemo(() => { - if (!cart || !Array.isArray(cart.products)) return false; + if (!cart?.products?.length) return false; return cart.products.every((item) => item.selected); }, [cart]); - useEffect(() => { - const updateCartItems = async () => { - if (typeof auth === 'object' && cart) { - const upsertPromises = cart.products.map((item) => - upsertUserCart({ - userId: auth.id, - type: item.cart_type, - id: item.id, - qty: item.quantity, - selected: item.selected, - }) - ); - try { - await Promise.all(upsertPromises); - await loadCart(auth.id); - } catch (error) { - console.error('Failed to update cart items:', error); - } - } - }; - - updateCartItems(); - }, [hasChanged]); + // Button states + const areButtonsDisabled = + isUpdating || + isLoadDelete || + isAnyCheckboxUpdating || + isAnyQuantityUpdating; + const isSelectAllDisabled = + isUpdating || checkboxUpdateState.isCheckboxUpdating(SELECT_ALL_ID); + // Handlers const handleCheckout = () => { + if (areButtonsDisabled) { + toast.error('Harap tunggu pembaruan selesai'); + return; + } router.push('/shop/checkout'); }; const handleQuotation = () => { + if (areButtonsDisabled) { + toast.error('Harap tunggu pembaruan selesai'); + return; + } if (hasSelectedPromo || !hasSelected) { toast.error('Maaf, Barang promo tidak dapat dibuat quotation'); } else { @@ -142,22 +152,63 @@ const CartPage = () => { } }; - const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { - if (cart) { + const handleSelectAll = async (e: React.ChangeEvent<HTMLInputElement>) => { + if (!cart || isUpdating || typeof auth !== 'object') return; + + const newSelectedState = !hasSelectedAll; + setIsUpdating(true); + checkboxUpdateState.startUpdate(SELECT_ALL_ID); + + try { + // Update UI immediately const updatedCart = { ...cart, products: cart.products.map((item) => ({ ...item, - selected: !hasSelectedAll, + selected: newSelectedState, })), }; - updateCartItem(updatedCart); - if (hasSelectedAll) { - setIsSelectedAll(false); - } else { - setIsSelectedAll(true); - } + + // Update cookies + const productIds = cart.products.map((item) => item.id); + setAllSelectedInCookie(productIds, newSelectedState, false); + + // Update server + const updatePromises = cart.products.map((item) => + upsertUserCart({ + userId: auth.id, + type: item.cart_type, + id: item.id, + qty: item.quantity, + selected: newSelectedState, + purchase_tax_id: item.purchase_tax_id || null, + }) + ); + + await Promise.all(updatePromises); + await loadCart(auth.id); + } catch (error) { + console.error('Error updating select all:', error); + toast.error('Gagal memperbarui pilihan'); + + // Revert on error + const revertedCart = { + ...cart, + products: cart.products.map((item) => ({ + ...item, + selected: !newSelectedState, + })), + }; + updateCartItem(revertedCart); + setAllSelectedInCookie( + cart.products.map((item) => item.id), + !newSelectedState, + false + ); + } finally { + setIsUpdating(false); + checkboxUpdateState.endUpdate(SELECT_ALL_ID); } }; @@ -165,53 +216,124 @@ const CartPage = () => { if (typeof auth !== 'object' || !cart) return; setIsLoadDelete(true); - for (const item of cart.products) { - if (item.selected === true) { + checkboxUpdateState.startUpdate('delete_operation'); + + try { + const itemsToDelete = cart.products.filter((item) => item.selected); + const itemIdsToDelete = itemsToDelete.map((item) => item.id); + const cartIdsToDelete = itemsToDelete.map((item) => item.cart_id); + + // Delete from server + for (const item of itemsToDelete) { await deleteUserCart(auth.id, [item.cart_id]); - await loadCart(auth.id); } + + // Update local state optimistically + const updatedProducts = cart.products.filter((item) => !item.selected); + const updatedCart = { + ...cart, + products: updatedProducts, + product_total: updatedProducts.length, + }; + updateCartItem(updatedCart); + + // Clean up cookies + removeSelectedItemsFromCookie(itemIdsToDelete); + removeCartItemsFromCookie(cartIdsToDelete); + + // Reload from server + loadCart(auth.id).catch((error) => + console.error('Error reloading cart:', error) + ); + + setRefreshCart(true); + toast.success('Item berhasil dihapus'); + } catch (error) { + console.error('Failed to delete cart items:', error); + toast.error('Gagal menghapus item'); + loadCart(auth.id); + } finally { + setIsLoadDelete(false); + checkboxUpdateState.endUpdate('delete_operation'); } - setIsLoadDelete(false); - setRefreshCart(true); + }; + + // Tooltip messages + const getTooltipMessage = () => { + if (isAnyQuantityUpdating) return 'Harap tunggu update quantity selesai'; + if (isAnyCheckboxUpdating) return 'Harap tunggu pembaruan checkbox selesai'; + if (isLoadDelete) return 'Harap tunggu penghapusan selesai'; + if (isUpdating) return 'Harap tunggu pembaruan selesai'; + return ''; + }; + + const getQuotationTooltip = () => { + const baseMessage = getTooltipMessage(); + if (baseMessage) return baseMessage; + if (hasSelectedPromo) return 'Barang promo tidak dapat dibuat quotation'; + if (!hasSelected) return 'Tidak ada item yang dipilih'; + return ''; + }; + + const getCheckoutTooltip = () => { + const baseMessage = getTooltipMessage(); + if (baseMessage) return baseMessage; + if (!hasSelected) return 'Tidak ada item yang dipilih'; + if (hasSelectNoPrice) return 'Terdapat item yang tidak ada harga'; + return ''; + }; + + const getDeleteTooltip = () => { + const baseMessage = getTooltipMessage(); + if (baseMessage) return baseMessage; + if (!hasSelected) return 'Tidak ada item yang dipilih'; + return ''; }; return ( <> + {/* Sticky Header */} <div className={`${ isTop ? 'border-b-[0px]' : 'border-b-[1px]' - } sticky md:top-[157px] flex-col bg-white py-4 border-gray-300 z-50 sm:w-full md:w-3/4`} + } sticky md:top-[157px] flex-col bg-white py-4 border-gray-300 z-50 sm:w-full md:w-3/4`} > - <h1 className={`${style['title']}`}>Keranjang Belanja</h1> + <div className='flex items-center justify-between mb-2'> + <h1 className={style.title}>Keranjang Belanja</h1> + </div> + <div className='h-2' /> - <div className={`flex items-center object-center justify-between `}> + <div className='flex items-center object-center justify-between flex-wrap gap-2'> <div className='flex items-center object-center'> - {isLoad && <Spinner className='my-auto' size='sm' />} - {!isLoad && ( - <Checkbox - borderColor='gray.600' - colorScheme='red' - size='lg' - isChecked={hasSelectedAll} - onChange={handleChange} - /> - )} + <Checkbox + borderColor='gray.600' + colorScheme='red' + size='lg' + isChecked={hasSelectedAll} + onChange={handleSelectAll} + isDisabled={isSelectAllDisabled} + opacity={isSelectAllDisabled ? 0.5 : 1} + cursor={isSelectAllDisabled ? 'not-allowed' : 'pointer'} + _disabled={{ + opacity: 0.5, + cursor: 'not-allowed', + backgroundColor: 'gray.100', + }} + /> <p className='p-2 text-caption-2'> {hasSelectedAll ? 'Uncheck all' : 'Select all'} </p> </div> - <div className='delate all flex items-center object-center'> - <Tooltip - label={clsxm({ - 'Tidak ada item yang dipilih': !hasSelected, - })} - > + + <div className='flex items-center object-center'> + <Tooltip label={getDeleteTooltip()}> <Button bg='#fadede' variant='outline' colorScheme='red' - w='full' - isDisabled={!hasSelected} + w='auto' + size={device.isMobile ? 'sm' : 'md'} + isDisabled={!hasSelected || areButtonsDisabled} onClick={handleDelete} > {isLoadDelete && <Spinner size='xs' />} @@ -223,19 +345,20 @@ const CartPage = () => { </div> </div> - <div className={style['content']}> + {/* Main Content */} + <div className={style.content}> <div className={style['item-wrapper']}> <div className={style['item-skeleton']}> {!cart && <CartItemModule.Skeleton count={5} height='120px' />} </div> - <div className={style['items']}> + <div className={style.items}> {cart?.products?.map((item) => ( <CartItemModule key={item.id} item={item} /> ))} {cart?.products?.length === 0 && ( - <div className='flex flex-col items-center'> + <div className='flex flex-col items-center p-4'> <Image src='/images/empty_cart.svg' alt='Empty Cart' @@ -261,17 +384,28 @@ const CartPage = () => { )} </div> </div> + + {/* Cart Summary */} <div className={`${style['summary-wrapper']} ${ - useDivvice.isMobile && cart?.product_total === 0 ? 'hidden' : '' + device.isMobile && (!cart || cart?.product_total === 0) + ? 'hidden' + : '' }`} > - <div className={style['summary']}> - {useDivvice.isMobile && ( - <CartSummaryMobile {...summary} isLoaded={!!cart} /> - )} - {!useDivvice.isMobile && ( - <CartSummary {...summary} isLoaded={!!cart} /> + <div className={style.summary}> + {device.isMobile ? ( + <CartSummaryMobile + {...summary} + isLoaded={!!cart} + products={cart?.products} + /> + ) : ( + <CartSummary + {...summary} + isLoaded={!!cart} + products={cart?.products} + /> )} <div @@ -281,34 +415,31 @@ const CartPage = () => { : style['summary-buttons'] } > - <Tooltip - label={ - hasSelectedPromo && - 'Barang promo tidak dapat dibuat quotation' - } - > + <Tooltip label={getQuotationTooltip()}> <Button colorScheme='yellow' w='full' - isDisabled={hasSelectedPromo || !hasSelected} + isDisabled={ + hasSelectedPromo || !hasSelected || areButtonsDisabled + } onClick={handleQuotation} > + {areButtonsDisabled && <Spinner size='sm' mr={2} />} Quotation </Button> </Tooltip> + {!isStepApproval && ( - <Tooltip - label={clsxm({ - 'Tidak ada item yang dipilih': !hasSelected, - 'Terdapat item yang tidak ada harga': hasSelectNoPrice, - })} - > + <Tooltip label={getCheckoutTooltip()}> <Button colorScheme='red' w='full' - isDisabled={!hasSelected || hasSelectNoPrice} + isDisabled={ + !hasSelected || hasSelectNoPrice || areButtonsDisabled + } onClick={handleCheckout} > + {areButtonsDisabled && <Spinner size='sm' mr={2} />} Checkout </Button> </Tooltip> @@ -321,4 +452,4 @@ const CartPage = () => { ); }; -export default CartPage; +export default CartPage;
\ No newline at end of file diff --git a/src-migrate/utils/cart.js b/src-migrate/utils/cart.js new file mode 100644 index 00000000..4bdee49a --- /dev/null +++ b/src-migrate/utils/cart.js @@ -0,0 +1,455 @@ +// cart-cookie-utils.js +import Cookies from 'js-cookie'; +import checkboxUpdateState from './checkBoxState'; + +// Constants +const CART_ITEMS_COOKIE = 'cart_data'; +const SELECTED_ITEMS_COOKIE = 'cart_selected_items'; +const COOKIE_EXPIRY_DAYS = 7; // Cookie akan berlaku selama 7 hari + +/** + * Mengambil data cart lengkap dari cookie + * @returns {Object} Object dengan key cart_id dan value cart item data lengkap + */ +export const getCartDataFromCookie = () => { + try { + const storedData = Cookies.get(CART_ITEMS_COOKIE); + return storedData ? JSON.parse(storedData) : {}; + } catch (error) { + console.error('Error reading cart data from cookie:', error); + return {}; + } +}; + +/** + * Menyimpan data cart lengkap ke cookie + * @param {Object} cartData Object dengan key cart_id dan value cart item data lengkap + */ +export const setCartDataToCookie = (cartData) => { + try { + Cookies.set(CART_ITEMS_COOKIE, JSON.stringify(cartData), { + expires: COOKIE_EXPIRY_DAYS, + path: '/', + sameSite: 'strict', + }); + } catch (error) { + console.error('Error saving cart data to cookie:', error); + } +}; + +/** + * Mengambil state selected items dari cookie + * @returns {Object} Object dengan key product id dan value boolean selected status + */ +export const getSelectedItemsFromCookie = () => { + try { + const storedItems = Cookies.get(SELECTED_ITEMS_COOKIE); + return storedItems ? JSON.parse(storedItems) : {}; + } catch (error) { + console.error('Error reading selected items from cookie:', error); + return {}; + } +}; + +/** + * Menyimpan state selected items ke cookie + * @param {Object} items Object dengan key product id dan value boolean selected status + */ +export const setSelectedItemsToCookie = (items) => { + try { + Cookies.set(SELECTED_ITEMS_COOKIE, JSON.stringify(items), { + expires: COOKIE_EXPIRY_DAYS, + path: '/', + sameSite: 'strict', + }); + } catch (error) { + console.error('Error saving selected items to cookie:', error); + } +}; + +/** + * Transform cart items dari format API ke format yang lebih simpel untuk disimpan di cookie + * @param {Array} cartItems Array cart items dari API + * @returns {Object} Object dengan key cart_id dan value cart item data + */ +export const transformCartItemsForCookie = (cartItems) => { + if (!cartItems || !Array.isArray(cartItems)) return {}; + + const cartData = {}; + + cartItems.forEach((item) => { + // Skip items yang tidak memiliki cart_id + if (!item.cart_id) return; + + cartData[item.cart_id] = { + id: item.id, + cart_id: item.cart_id, + cart_type: item.cart_type, + product: item.product_id + ? { + id: item.product_id, + name: item.product_name || '', + } + : null, + program_line: item.program_line_id + ? { + id: item.program_line_id, + name: item.program_line_name || '', + } + : null, + quantity: item.quantity, + selected: item.selected, + price: item.price, + package_price: item.package_price, + source: item.source || 'add_to_cart', + }; + }); + + return cartData; +}; + +/** + * Sinkronisasi cart data dan selected items dari server dengan cookie + * @param {Object} cart Cart object dari API + * @returns {Object} Object yang berisi updated cartData dan selectedItems + */ +export const syncCartWithCookie = (cart) => { + try { + if (!cart || !cart.products) return { needsUpdate: false }; + + // Transform data API ke cookie + const serverCartData = transformCartItemsForCookie(cart.products); + + // Ambil data lama dari cookie + const existingCartData = getCartDataFromCookie(); + + // Ambil selected status dari cookie + const selectedItems = getSelectedItemsFromCookie(); + + // Gabungkan data cart, (prioritize data server) + const mergedCartData = { ...existingCartData, ...serverCartData }; + + // Periksa apakah ada perbedaan status selected + let needsUpdate = false; + + // Update selected status berdasarkan cookie jika ada + for (const cartId in mergedCartData) { + const item = mergedCartData[cartId]; + if (item.id && selectedItems[item.id] !== undefined) { + // Jika status di cookie berbeda dengan di cart + if (item.selected !== selectedItems[item.id]) { + needsUpdate = true; + item.selected = selectedItems[item.id]; + } + } else if (item.id) { + selectedItems[item.id] = item.selected; + } + } + + // Simpan ke cookie + setCartDataToCookie(mergedCartData); + setSelectedItemsToCookie(selectedItems); + + return { + cartData: mergedCartData, + selectedItems, + needsUpdate, + }; + } catch (error) { + console.error('Error syncing cart with cookie:', error); + return { needsUpdate: false }; + } +}; + +/** + * Update selected status item di cookie + * @param {number} productId ID produk + * @param {boolean} isSelected Status selected baru + * @param {boolean} notifyUpdate Whether to notify checkbox update state (default: true) + */ +export const updateSelectedItemInCookie = ( + productId, + isSelected, + notifyUpdate = true +) => { + try { + // Notify checkbox update state if requested + if (notifyUpdate) { + checkboxUpdateState.startUpdate(); + } + + const selectedItems = getSelectedItemsFromCookie(); + selectedItems[productId] = isSelected; + setSelectedItemsToCookie(selectedItems); + + // Update juga di cart data + const cartData = getCartDataFromCookie(); + + for (const cartId in cartData) { + const item = cartData[cartId]; + if (item.id === productId) { + item.selected = isSelected; + } + } + + setCartDataToCookie(cartData); + + return { selectedItems, cartData }; + } catch (error) { + console.error('Error updating selected item in cookie:', error); + return {}; + } finally { + // End update notification if requested + if (notifyUpdate) { + checkboxUpdateState.endUpdate(); + } + } +}; + +/** + * Set semua item menjadi selected atau unselected di cookie + * @param {Array} productIds Array product IDs + * @param {boolean} isSelected Status selected baru + * @param {boolean} notifyUpdate Whether to notify checkbox update state (default: true) + */ +export const setAllSelectedInCookie = ( + productIds, + isSelected, + notifyUpdate = true +) => { + try { + // Notify checkbox update state if requested + if (notifyUpdate) { + checkboxUpdateState.startUpdate(); + } + + const selectedItems = getSelectedItemsFromCookie(); + + productIds.forEach((id) => { + if (id) selectedItems[id] = isSelected; + }); + + setSelectedItemsToCookie(selectedItems); + + // Update juga di cart data + const cartData = getCartDataFromCookie(); + + for (const cartId in cartData) { + if (productIds.includes(cartData[cartId].id)) { + cartData[cartId].selected = isSelected; + } + } + + setCartDataToCookie(cartData); + + return { selectedItems, cartData }; + } catch (error) { + console.error('Error setting all selected in cookie:', error); + return {}; + } finally { + // End update notification if requested + if (notifyUpdate) { + checkboxUpdateState.endUpdate(); + } + } +}; + +/** + * Hapus item dari cookie + * @param {Array} cartIds Array cart IDs untuk dihapus + */ +export const removeCartItemsFromCookie = (cartIds) => { + try { + const cartData = getCartDataFromCookie(); + const selectedItems = getSelectedItemsFromCookie(); + const productIdsToRemove = []; + + // Hapus item dari cartData dan catat product IDs + cartIds.forEach((cartId) => { + if (cartData[cartId]) { + if (cartData[cartId].id) { + productIdsToRemove.push(cartData[cartId].id); + } + delete cartData[cartId]; + } + }); + + // Hapus dari selectedItems + productIdsToRemove.forEach((productId) => { + if (selectedItems[productId] !== undefined) { + delete selectedItems[productId]; + } + }); + + // Simpan kembali ke cookie + setCartDataToCookie(cartData); + setSelectedItemsToCookie(selectedItems); + + return { cartData, selectedItems }; + } catch (error) { + console.error('Error removing cart items from cookie:', error); + return {}; + } +}; + +/** + * Hapus item selected dari cookie berdasarkan product IDs + * @param {Array} productIds Array product IDs untuk dihapus + */ +/** + * Hapus item selected dari cookie berdasarkan product IDs dan juga hapus dari cart data + * @param {Array} productIds Array product IDs untuk dihapus + */ + +/** + * Force reset semua selected items ke unselected state + */ +export const forceResetAllSelectedItems = () => { + try { + checkboxUpdateState.startUpdate(); + + const cartData = getCartDataFromCookie(); + const selectedItems = {}; + + // Reset semua selected status di cartData + for (const cartId in cartData) { + cartData[cartId].selected = false; + if (cartData[cartId].id) { + selectedItems[cartData[cartId].id] = false; + } + } + + // Simpan kembali ke cookie + setCartDataToCookie(cartData); + setSelectedItemsToCookie(selectedItems); + + return { cartData, selectedItems }; + } catch (error) { + console.error('Error resetting all selected items:', error); + return {}; + } finally { + checkboxUpdateState.endUpdate(); + } +}; + +/** + * Sync selected items between cookie and cart data + * @param {Array} cartProducts Products array from cart + */ +export const syncSelectedItemsWithCookie = (cartProducts) => { + try { + if (!cartProducts || !Array.isArray(cartProducts)) { + return { items: {}, needsUpdate: false }; + } + + const selectedItems = getSelectedItemsFromCookie(); + let needsUpdate = false; + + // Check if we need to update any items based on cookie values + cartProducts.forEach((product) => { + if (product.id && selectedItems[product.id] !== undefined) { + if (product.selected !== selectedItems[product.id]) { + needsUpdate = true; + } + } else if (product.id) { + // If not in cookie, add with current value + selectedItems[product.id] = product.selected; + } + }); + + // Update the cookie with the latest values + setSelectedItemsToCookie(selectedItems); + + return { items: selectedItems, needsUpdate }; + } catch (error) { + console.error('Error syncing selected items with cookie:', error); + return { items: {}, needsUpdate: false }; + } +}; + +// Export the checkbox update state for use in components +export { checkboxUpdateState }; + +/** + * Hapus item selected dari cookie berdasarkan product IDs dan juga hapus dari cart data + * @param {Array} productIds Array product IDs untuk dihapus + */ +/** + * Hapus item selected dari cookie berdasarkan product IDs dan juga hapus dari cart data + * @param {Array} productIds Array product IDs untuk dihapus + */ +export const removeSelectedItemsFromCookie = (productIds) => { + try { + const selectedItems = getSelectedItemsFromCookie(); + const cartData = getCartDataFromCookie(); + const cartIdsToRemove = []; + + // Find cart IDs that match the product IDs + for (const cartId in cartData) { + if (productIds.includes(cartData[cartId].id)) { + cartIdsToRemove.push(cartId); + } + } + + // Remove from selectedItems + productIds.forEach((productId) => { + if (selectedItems[productId] !== undefined) { + delete selectedItems[productId]; + } + }); + + // Remove from cartData + cartIdsToRemove.forEach((cartId) => { + delete cartData[cartId]; + }); + + // Save both cookies + setSelectedItemsToCookie(selectedItems); + setCartDataToCookie(cartData); + + return { selectedItems, cartData }; + } catch (error) { + console.error('Error removing selected items from cookie:', error); + return {}; + } +}; + +class QuantityUpdateState { + constructor() { + this.updateItems = new Set(); + this.listeners = new Set(); + } + + startUpdate(itemId) { + this.updateItems.add(itemId); + this.notifyListeners(); + } + + endUpdate(itemId) { + this.updateItems.delete(itemId); + this.notifyListeners(); + } + + isAnyQuantityUpdating() { + return this.updateItems.size > 0; + } + + isItemUpdating(itemId) { + return this.updateItems.has(itemId); + } + + addListener(callback) { + this.listeners.add(callback); + } + + removeListener(callback) { + this.listeners.delete(callback); + } + + notifyListeners() { + const isUpdating = this.isAnyQuantityUpdating(); + this.listeners.forEach(callback => callback(isUpdating)); + } +} + +export const quantityUpdateState = new QuantityUpdateState();
\ No newline at end of file diff --git a/src-migrate/utils/checkBoxState.js b/src-migrate/utils/checkBoxState.js new file mode 100644 index 00000000..9568c321 --- /dev/null +++ b/src-migrate/utils/checkBoxState.js @@ -0,0 +1,90 @@ +/** + * State manager for checkbox updates + * Tracks global and individual checkbox update states + */ +class CheckboxUpdateState { + constructor() { + this.updateCount = 0; + this.listeners = new Set(); + this.updatingCheckboxIds = new Set(); + } + + // Global update state (for buttons quotation and checkout) + isUpdating() { + return this.updateCount > 0; + } + + // Individual checkbox state + isCheckboxUpdating(itemId) { + return this.updatingCheckboxIds.has(String(itemId)); + } + + // Start update + startUpdate(itemId = null) { + this.updateCount++; + + if (itemId !== null) { + this.updatingCheckboxIds.add(String(itemId)); + } + + this.notifyListeners(); + return this.updateCount; + } + + // End update + endUpdate(itemId = null) { + this.updateCount = Math.max(0, this.updateCount - 1); + + if (itemId !== null) { + this.updatingCheckboxIds.delete(String(itemId)); + } + + this.notifyListeners(); + return this.updateCount; + } + + // Reset all states + reset() { + this.updateCount = 0; + this.updatingCheckboxIds.clear(); + this.notifyListeners(); + } + + // Listener management + addListener(callback) { + if (typeof callback === 'function') { + this.listeners.add(callback); + // Immediate callback with current state + callback(this.isUpdating()); + } + } + + removeListener(callback) { + this.listeners.delete(callback); + } + + // Debug helpers + getUpdateCount() { + return this.updateCount; + } + + getUpdatingCheckboxIds() { + return [...this.updatingCheckboxIds]; + } + + // Private method to notify listeners + notifyListeners() { + const isUpdating = this.isUpdating(); + + this.listeners.forEach((listener) => { + try { + listener(isUpdating); + } catch (error) { + console.error('Checkbox update state listener error:', error); + } + }); + } +} + +const checkboxUpdateState = new CheckboxUpdateState(); +export default checkboxUpdateState; |
