summaryrefslogtreecommitdiff
path: root/src-migrate/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src-migrate/modules')
-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
3 files changed, 436 insertions, 88 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 };
+};