summaryrefslogtreecommitdiff
path: root/src-migrate
diff options
context:
space:
mode:
authorMiqdad <ahmadmiqdad27@gmail.com>2025-05-19 11:02:19 +0700
committerMiqdad <ahmadmiqdad27@gmail.com>2025-05-19 11:02:19 +0700
commit7d4445bb9bad3d6c945503086a07bd882536e5f6 (patch)
treea24e5b110887fd96ee7803c7857a254c3aeb9590 /src-migrate
parent746a11b810ae9e8a974a76d0548297cd0faff9b5 (diff)
<miqdad> fix unresponsive cart select
Diffstat (limited to 'src-migrate')
-rw-r--r--src-migrate/modules/cart/components/ItemSelect.tsx170
-rw-r--r--src-migrate/modules/cart/components/Summary.tsx219
-rw-r--r--src-migrate/modules/cart/stores/useCartStore.ts135
-rw-r--r--src-migrate/pages/shop/cart/index.tsx395
-rw-r--r--src-migrate/utils/cart.js290
5 files changed, 1015 insertions, 194 deletions
diff --git a/src-migrate/modules/cart/components/ItemSelect.tsx b/src-migrate/modules/cart/components/ItemSelect.tsx
index d4a1b537..733ee64d 100644
--- a/src-migrate/modules/cart/components/ItemSelect.tsx
+++ b/src-migrate/modules/cart/components/ItemSelect.tsx
@@ -1,56 +1,138 @@
-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,
+} 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);
+
+ // Initialize local state from cookie or server
+ useEffect(() => {
+ if (isUpdating) return; // Skip if we're currently updating
+
+ // Check cookie first
+ const selectedItems = getSelectedItemsFromCookie();
+ const storedState = selectedItems[item.id];
+
+ if (storedState !== undefined) {
+ // Only update local state if it differs from current state
+ if (localSelected !== storedState) {
+ setLocalSelected(storedState);
+ }
+
+ // If cookie state differs from server state and we're not in the middle of an update,
+ // synchronize the item state with cookie
+ if (storedState !== item.selected) {
+ // Update cart item silently to match cookie
+ if (cart) {
+ const updatedCartItems = cart.products.map((cartItem) =>
+ cartItem.id === item.id
+ ? { ...cartItem, selected: storedState }
+ : cartItem
+ );
+
+ const updatedCart = { ...cart, products: updatedCartItems };
+ updateCartItem(updatedCart);
+ }
+ }
+ } else {
+ // Fall back to server state if no cookie exists
+ setLocalSelected(item.selected);
+
+ // Save this state to cookie for future use
+ updateSelectedItemInCookie(item.id, item.selected);
+ }
+ }, [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 for responsiveness
+ setLocalSelected(newSelectedState);
+ setIsUpdating(true);
+
+ try {
+ // Update cookie immediately
+ updateSelectedItemInCookie(item.id, newSelectedState);
+
+ // Update cart state immediately for UI responsiveness
+ const updatedCartItems = cart.products.map((cartItem) =>
+ cartItem.id === item.id
+ ? { ...cartItem, selected: newSelectedState }
+ : cartItem
+ );
+
+ const updatedCart = { ...cart, products: updatedCartItems };
+ updateCartItem(updatedCart);
+
+ // Save to server
+ await upsertUserCart({
+ userId: auth.id,
+ type: item.cart_type,
+ id: item.id,
+ qty: item.quantity,
+ selected: newSelectedState,
+ });
- const [isLoad, setIsLoad] = useState<boolean>(false)
+ // Reload cart to ensure consistency
+ await loadCart(auth.id);
+ } catch (error) {
+ console.error('Failed to update item selection:', error);
+ toast.error('Gagal memperbarui pilihan barang');
- 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
- );
+ // Revert local state on error
+ setLocalSelected(!newSelectedState);
- // Update the entire cart
- const updatedCart = { ...cart, products: updatedCartItems };
- updateCartItem(updatedCart);
+ // Update cookie back
+ updateSelectedItemInCookie(item.id, !newSelectedState);
- setIsLoad(false);
- }
+ // Reload cart to get server state
+ loadCart(auth.id);
+ } finally {
+ setIsUpdating(false);
+ }
+ },
+ [auth, cart, item, isUpdating, updateCartItem, loadCart]
+ );
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={isUpdating}
+ opacity={isUpdating ? 0.5 : 1}
+ cursor={isUpdating ? '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..b4fbab6b 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,203 @@ 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,
+ });
+
+ const bgHighlight = useColorModeValue('red.50', 'red.900');
+
+ // 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 (
+ <Box
+ className={style.summaryContainer}
+ p={4}
+ borderWidth='1px'
+ borderRadius='lg'
+ boxShadow='sm'
+ position='sticky'
+ top='170px'
+ >
+ <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}>
- <span className={clsxm(style.label, style.grandTotal)}>
- Grand Total
- </span>
- <span className={style.value}>Rp {formatCurrency(grandTotal || 0)}</span>
+ <Skeleton isLoaded={isLoaded}>
+ <Box className={style.line} p={2} borderRadius='md' bg={bgHighlight}>
+ <span className={clsxm(style.label, style.grandTotal)}>
+ Grand Total
+ </span>
+ <span className={clsxm(style.value, style.grandTotalValue)}>
+ Rp {formatCurrency(displayGrandTotal)}
+ </span>
+ </Box>
</Skeleton>
</div>
- </>
- )
-}
+ </Box>
+ );
+};
-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..d211304a 100644
--- a/src-migrate/modules/cart/stores/useCartStore.ts
+++ b/src-migrate/modules/cart/stores/useCartStore.ts
@@ -1,6 +1,15 @@
import { create } from 'zustand';
import { CartItem, CartProps } from '~/types/cart';
import { getUserCart } from '~/services/cart';
+import {
+ syncCartWithCookie,
+ getCartDataFromCookie,
+ getSelectedItemsFromCookie,
+ updateSelectedItemInCookie,
+ setAllSelectedInCookie,
+ removeCartItemsFromCookie,
+ forceResetAllSelectedItems,
+} from '~/utils/cart';
type State = {
cart: CartProps | null;
@@ -17,6 +26,8 @@ type State = {
type Action = {
loadCart: (userId: number) => Promise<void>;
updateCartItem: (updateCart: CartProps) => void;
+ syncCartWithCookieAndUpdate: (cart: CartProps) => { needsUpdate: boolean };
+ forceResetSelection: () => void;
};
export const useCartStore = create<State & Action>((set, get) => ({
@@ -29,34 +40,140 @@ export const useCartStore = create<State & Action>((set, get) => ({
tax: 0,
grandTotal: 0,
},
+
loadCart: async (userId) => {
if (get().isLoadCart === true) return;
set({ isLoadCart: true });
- const cart: CartProps = (await getUserCart(userId)) as CartProps;
- set({ cart });
- set({ isLoadCart: false });
+ try {
+ // Fetch cart from API
+ const cart: CartProps = (await getUserCart(userId)) as CartProps;
- const summary = computeSummary(cart);
- set({ summary });
+ // Sync with cookie and get updated data if needed
+ const { needsUpdate } = get().syncCartWithCookieAndUpdate(cart);
+
+ // If no update needed from cookie, just set the cart directly
+ if (!needsUpdate) {
+ set({ cart });
+ }
+
+ // Update summary with current cart
+ const summary = computeSummary(get().cart!);
+ set({ summary });
+ } catch (error) {
+ console.error('Failed to load cart from API:', error);
+
+ // Fallback to cookie if API fails
+ try {
+ const cartData = getCartDataFromCookie();
+ if (Object.keys(cartData).length > 0) {
+ // Transform cart data from cookie to expected format
+ const products = Object.values(cartData).map((item) => ({
+ 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,
+ }));
+
+ const fallbackCart: CartProps = {
+ product_total: products.length,
+ products,
+ };
+
+ set({ cart: fallbackCart });
+ const summary = computeSummary(fallbackCart);
+ set({ summary });
+ }
+ } catch (cookieError) {
+ console.error('Failed to fallback to cookie:', cookieError);
+ }
+ } finally {
+ set({ isLoadCart: false });
+ }
},
+
updateCartItem: (updatedCart) => {
const cart = get().cart;
if (!cart) return;
set({ cart: updatedCart });
+
+ // Sync updated cart with cookie
+ syncCartWithCookie(updatedCart);
+
const summary = computeSummary(updatedCart);
set({ summary });
},
+ syncCartWithCookieAndUpdate: (cart) => {
+ if (!cart) return { needsUpdate: false };
+
+ // Sync cart with cookie
+ const result = syncCartWithCookie(cart);
+
+ // If we need to update the cart based on cookie data
+ if (result.needsUpdate && cart.products) {
+ // Create updated cart with selections from cookie
+ const selectedItems = getSelectedItemsFromCookie();
+
+ const updatedCart = {
+ ...cart,
+ products: cart.products.map((item) => ({
+ ...item,
+ selected:
+ selectedItems[item.id] !== undefined
+ ? selectedItems[item.id]
+ : item.selected,
+ })),
+ };
+
+ // Update the store
+ set({ cart: updatedCart });
+ }
+
+ return result;
+ },
+
+ forceResetSelection: () => {
+ const { cart } = get();
+ if (!cart) return;
+
+ // Reset all selections in cookie
+ forceResetAllSelectedItems();
+
+ // Update the cart in state
+ const updatedCart = {
+ ...cart,
+ products: cart.products.map((item) => ({
+ ...item,
+ selected: false,
+ })),
+ };
+
+ set({ cart: updatedCart });
+
+ // Update summary
+ const summary = computeSummary(updatedCart);
+ set({ summary });
+ },
}));
const computeSummary = (cart: CartProps) => {
let subtotal = 0;
let discount = 0;
- const PPN: number = process.env.NEXT_PUBLIC_PPN ? parseFloat(process.env.NEXT_PUBLIC_PPN) : 0;
-
+ const PPN: number = process.env.NEXT_PUBLIC_PPN
+ ? parseFloat(process.env.NEXT_PUBLIC_PPN)
+ : 0;
+
for (const item of cart?.products) {
if (!item.selected) continue;
@@ -74,5 +191,5 @@ const computeSummary = (cart: CartProps) => {
let tax = grandTotal - total;
// let grandTotal = total + tax;
- return { subtotal, discount, total, grandTotal, tax };
-}; \ No newline at end of file
+ return { subtotal, discount, total, grandTotal, tax };
+};
diff --git a/src-migrate/pages/shop/cart/index.tsx b/src-migrate/pages/shop/cart/index.tsx
index 24baa933..475a4259 100644
--- a/src-migrate/pages/shop/cart/index.tsx
+++ b/src-migrate/pages/shop/cart/index.tsx
@@ -1,8 +1,16 @@
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 {
+ Button,
+ Checkbox,
+ Spinner,
+ Tooltip,
+ Text,
+ Box,
+ Flex,
+} from '@chakra-ui/react';
import { toast } from 'react-hot-toast';
import { useRouter } from 'next/router';
import { getAuth } from '~/libs/auth';
@@ -14,26 +22,125 @@ 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,
+ setSelectedItemsToCookie,
+ syncSelectedItemsWithCookie,
+ setAllSelectedInCookie,
+ removeSelectedItemsFromCookie,
+ forceResetAllSelectedItems,
+} from '~/utils/cart';
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 [isStateMismatch, setIsStateMismatch] = useState(false);
+
+ // Function to check if cart state is inconsistent
+ const checkCartStateMismatch = () => {
+ if (!cart || !cart.products || isUpdating) return false;
+
+ try {
+ // Ambil status selected dari cookie
+ const selectedItems = getSelectedItemsFromCookie();
+
+ // Periksa ketidaksesuaian antara UI dan cookie
+ // 1. Periksa item yang selected di UI tapi tidak di cookie
+ for (const product of cart.products) {
+ const cookieState = selectedItems[product.id];
+
+ // Jika ada di cookie tapi tidak sama dengan UI
+ if (cookieState !== undefined && cookieState !== product.selected) {
+ return true;
+ }
+
+ // Jika tidak ada di cookie tapi selected di UI
+ if (cookieState === undefined && product.selected) {
+ return true;
+ }
+ }
+
+ // 2. Periksa item yang selected di cookie tapi tidak ada di cart
+ for (const productId in selectedItems) {
+ const isSelected = selectedItems[productId];
+ if (isSelected) {
+ // Cek apakah product id ini ada di cart
+ const productExists = cart.products.some(
+ (p) => p.id.toString() === productId.toString()
+ );
+ if (!productExists) {
+ // Ada item selected di cookie yang tidak ada di cart
+ return true;
+ }
+ }
+ }
+
+ return false;
+ } catch (error) {
+ console.error('Error checking cart state mismatch:', error);
+ return false;
+ }
+ }; // Function to reset all selected items when state is inconsistent
+ const handleResetSelections = () => {
+ if (!cart) return;
+
+ setIsUpdating(true);
+ try {
+ // Use the forceResetSelection function from the store
+ useCartStore.getState().forceResetSelection();
+
+ // Set state back to normal
+ setIsStateMismatch(false);
+
+ // Give visual feedback
+ toast.success('Semua pilihan telah direset');
+
+ // Optional: Sync with server if needed
+ if (typeof auth === 'object') {
+ const updatePromises = cart.products.map((item) =>
+ upsertUserCart({
+ userId: auth.id,
+ type: item.cart_type,
+ id: item.id,
+ qty: item.quantity,
+ selected: false,
+ })
+ );
+
+ Promise.all(updatePromises)
+ .then(() => loadCart(auth.id))
+ .catch((error) => {
+ console.error('Error updating selections to server:', error);
+ })
+ .finally(() => setIsUpdating(false));
+ } else {
+ setIsUpdating(false);
+ }
+ } catch (error) {
+ console.error('Error resetting selections:', error);
+ setIsUpdating(false);
+ toast.error('Gagal mereset pilihan');
+ }
+ };
+
+ // Check for state inconsistency
+ useEffect(() => {
+ if (!cart || !cart.products || isUpdating) return;
+
+ const hasMismatch = checkCartStateMismatch();
+ setIsStateMismatch(hasMismatch);
+ }, [cart, isUpdating]);
useEffect(() => {
const handleScroll = () => {
@@ -47,40 +154,35 @@ const CartPage = () => {
}, []);
useEffect(() => {
- if (typeof auth === 'object' && !cart) {
- loadCart(auth.id);
- setIsStepApproval(auth?.feature?.soApproval);
- }
- }, [auth, loadCart, cart, isButtonChek]);
+ const loadCartWithStorage = async () => {
+ if (typeof auth === 'object' && !cart) {
+ await loadCart(auth.id);
+ setIsStepApproval(auth?.feature?.soApproval);
- useEffect(() => {
- if (typeof auth === 'object' && !cart) {
- loadCart(auth.id);
- setIsStepApproval(auth?.feature?.soApproval);
- }
- }, [auth, loadCart, cart, isButtonChek]);
+ // Sync selected items with server data using cookies
+ if (cart?.products) {
+ const { items, needsUpdate } = syncSelectedItemsWithCookie(
+ cart.products
+ );
- useEffect(() => {
- const hasSelectedChanged = () => {
- if (prevCartRef.current && cart) {
- const prevCart = prevCartRef.current;
- return cart.products.some(
- (item, index) =>
- prevCart[index] && prevCart[index].selected !== item.selected
- );
+ // If there's a mismatch between cookie and server data, update the UI
+ 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]);
+ loadCartWithStorage();
+ }, [auth, cart]);
const hasSelectedPromo = useMemo(() => {
if (!cart) return false;
@@ -103,38 +205,24 @@ const CartPage = () => {
const hasSelectedAll = useMemo(() => {
if (!cart || !Array.isArray(cart.products)) return false;
- return cart.products.every((item) => item.selected);
+ return (
+ cart.products.length > 0 && 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]);
-
const handleCheckout = () => {
+ if (isUpdating || isLoadDelete) {
+ toast.error('Harap tunggu pembaruan selesai');
+ return;
+ }
router.push('/shop/checkout');
};
const handleQuotation = () => {
+ if (isUpdating || isLoadDelete) {
+ toast.error('Harap tunggu pembaruan selesai');
+ return;
+ }
if (hasSelectedPromo || !hasSelected) {
toast.error('Maaf, Barang promo tidak dapat dibuat quotation');
} else {
@@ -143,20 +231,62 @@ const CartPage = () => {
};
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
- if (cart) {
+ if (cart && !isUpdating && typeof auth === 'object') {
+ const newSelectedState = !hasSelectedAll;
+
+ // 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);
+
+ // Get all product IDs in cart
+ const productIds = cart.products.map((item) => item.id);
+
+ // Update cookies immediately for responsive UI
+ setAllSelectedInCookie(productIds, newSelectedState);
+
+ setIsUpdating(true);
+
+ try {
+ // Update all items on server in background
+ const updatePromises = cart.products.map((item) =>
+ upsertUserCart({
+ userId: auth.id,
+ type: item.cart_type,
+ id: item.id,
+ qty: item.quantity,
+ selected: newSelectedState,
+ })
+ );
+
+ await Promise.all(updatePromises);
+ await loadCart(auth.id);
+ } catch (error) {
+ console.error('Error updating select all:', error);
+
+ // Revert changes on error
+ const revertedCart = {
+ ...cart,
+ products: cart.products.map((item) => ({
+ ...item,
+ selected: !newSelectedState,
+ })),
+ };
+
+ updateCartItem(revertedCart);
+
+ // Revert cookies
+ setAllSelectedInCookie(productIds, !newSelectedState);
+
+ toast.error('Gagal memperbarui pilihan');
+ } finally {
+ setIsUpdating(false);
}
}
};
@@ -165,14 +295,25 @@ const CartPage = () => {
if (typeof auth !== 'object' || !cart) return;
setIsLoadDelete(true);
- for (const item of cart.products) {
- if (item.selected === true) {
+ try {
+ const itemsToDelete = cart.products.filter((item) => item.selected);
+ const itemIdsToDelete = itemsToDelete.map((item) => item.id);
+
+ for (const item of itemsToDelete) {
await deleteUserCart(auth.id, [item.cart_id]);
- await loadCart(auth.id);
}
+
+ // Remove deleted items from cookie
+ removeSelectedItemsFromCookie(itemIdsToDelete);
+
+ await loadCart(auth.id);
+ setRefreshCart(true);
+ } catch (error) {
+ console.error('Failed to delete cart items:', error);
+ toast.error('Gagal menghapus item');
+ } finally {
+ setIsLoadDelete(false);
}
- setIsLoadDelete(false);
- setRefreshCart(true);
};
return (
@@ -180,27 +321,45 @@ const CartPage = () => {
<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>
+ {isStateMismatch && (
+ <Button
+ colorScheme='red'
+ size='sm'
+ isLoading={isUpdating}
+ onClick={handleResetSelections}
+ >
+ Reset Pilihan
+ </Button>
+ )}
+ </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={handleChange}
+ isDisabled={isUpdating || isLoadDelete}
+ opacity={isUpdating ? 0.5 : 1}
+ cursor={isUpdating ? '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'>
+ <div className='flex items-center object-center'>
<Tooltip
label={clsxm({
'Tidak ada item yang dipilih': !hasSelected,
@@ -210,8 +369,9 @@ const CartPage = () => {
bg='#fadede'
variant='outline'
colorScheme='red'
- w='full'
- isDisabled={!hasSelected}
+ w='auto'
+ size={device.isMobile ? 'sm' : 'md'}
+ isDisabled={!hasSelected || isUpdating}
onClick={handleDelete}
>
{isLoadDelete && <Spinner size='xs' />}
@@ -223,19 +383,19 @@ const CartPage = () => {
</div>
</div>
- <div className={style['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'
@@ -263,15 +423,24 @@ const CartPage = () => {
</div>
<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
@@ -282,17 +451,24 @@ const CartPage = () => {
}
>
<Tooltip
- label={
- hasSelectedPromo &&
- 'Barang promo tidak dapat dibuat quotation'
- }
+ label={clsxm({
+ 'Barang promo tidak dapat dibuat quotation': hasSelectedPromo,
+ 'Harap tunggu pembaruan selesai': isUpdating,
+ 'Tidak ada item yang dipilih': !hasSelected,
+ })}
>
<Button
colorScheme='yellow'
w='full'
- isDisabled={hasSelectedPromo || !hasSelected}
+ isDisabled={
+ hasSelectedPromo ||
+ !hasSelected ||
+ isUpdating ||
+ isLoadDelete
+ }
onClick={handleQuotation}
>
+ {isUpdating ? <Spinner size='sm' mr={2} /> : null}
Quotation
</Button>
</Tooltip>
@@ -301,14 +477,21 @@ const CartPage = () => {
label={clsxm({
'Tidak ada item yang dipilih': !hasSelected,
'Terdapat item yang tidak ada harga': hasSelectNoPrice,
+ 'Harap tunggu pembaruan selesai': isUpdating,
})}
>
<Button
colorScheme='red'
w='full'
- isDisabled={!hasSelected || hasSelectNoPrice}
+ isDisabled={
+ !hasSelected ||
+ hasSelectNoPrice ||
+ isUpdating ||
+ isLoadDelete
+ }
onClick={handleCheckout}
>
+ {isUpdating ? <Spinner size='sm' mr={2} /> : null}
Checkout
</Button>
</Tooltip>
diff --git a/src-migrate/utils/cart.js b/src-migrate/utils/cart.js
new file mode 100644
index 00000000..431ff530
--- /dev/null
+++ b/src-migrate/utils/cart.js
@@ -0,0 +1,290 @@
+// cart-cookie-utils.js
+import Cookies from 'js-cookie';
+
+// 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 dari API ke format 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, prioritaskan 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) {
+ // Jika tidak ada di cookie, tambahkan dari cart
+ selectedItems[item.id] = item.selected;
+ }
+ }
+
+ // Simpan kembali 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
+ */
+export const updateSelectedItemInCookie = (productId, isSelected) => {
+ try {
+ 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 {};
+ }
+};
+
+/**
+ * Set semua item menjadi selected atau unselected di cookie
+ * @param {Array} productIds Array product IDs
+ * @param {boolean} isSelected Status selected baru
+ */
+export const setAllSelectedInCookie = (productIds, isSelected) => {
+ try {
+ 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 {};
+ }
+};
+
+/**
+ * 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 {};
+ }
+};
+
+/**
+ * Force reset semua selected items ke unselected state
+ */
+export const forceResetAllSelectedItems = () => {
+ try {
+ 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 {};
+ }
+};