summaryrefslogtreecommitdiff
path: root/src-migrate/modules/cart/components
diff options
context:
space:
mode:
authorIT Fixcomart <it@fixcomart.co.id>2025-05-31 02:33:14 +0000
committerIT Fixcomart <it@fixcomart.co.id>2025-05-31 02:33:14 +0000
commit2a1dea70b8f0062fe8eebeb7139a7b77a24e220b (patch)
treee7a5db13b2655cbdfb1c81859e240652fdc87bbb /src-migrate/modules/cart/components
parent0b2e31247d4fe7eb1432079979478a0cfc38d049 (diff)
parent2732c04b36f98a25895826b28003b1e2c56ad952 (diff)
Merged in fix_responsive_cart (pull request #413)
<miqdad> Fix unresponsive cart
Diffstat (limited to 'src-migrate/modules/cart/components')
-rw-r--r--src-migrate/modules/cart/components/ItemAction.tsx246
-rw-r--r--src-migrate/modules/cart/components/ItemSelect.tsx172
-rw-r--r--src-migrate/modules/cart/components/Summary.tsx201
3 files changed, 490 insertions, 129 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;