ifixkart-storefront/store/cartStore.ts

339 lines
11 KiB
TypeScript

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { ProductResponse, ProductVariantResponse } from '@/services/api/catalogService';
import { getAccessToken, apiFetch } from '@/services/api/client';
import { getCatalogPriceInfo, parseMoney } from '@/lib/utils';
import {
compareKindLabel,
detectProductKind,
isSameCompareKind,
sameTypeCompareList,
} from '@/lib/compareSpecs';
export interface CartItem {
id: string;
product: ProductResponse;
selectedVariant: ProductVariantResponse;
quantity: number;
}
function normalizeVariantPrices(
variant: ProductVariantResponse,
product?: ProductResponse
): ProductVariantResponse {
let price = parseMoney(variant.price);
if (!price && product) {
price = getCatalogPriceInfo(product).price;
}
return {
...variant,
price,
compare_price:
variant.compare_price != null && variant.compare_price !== ''
? parseMoney(variant.compare_price)
: null,
cost_price: parseMoney(variant.cost_price),
};
}
function normalizeCartItems(cart: CartItem[]): CartItem[] {
return (cart || []).map((item) => ({
...item,
quantity: Number(item.quantity) || 1,
selectedVariant: normalizeVariantPrices(
item.selectedVariant,
item.product
),
}));
}
export const COMPARE_LIMIT = 4;
export type AddToCompareResult =
| { ok: true; already: boolean }
| { ok: false; reason: 'full' | 'type_mismatch'; expected?: string; actual?: string };
export function isSameCompareProduct(
a: ProductResponse,
b: ProductResponse | string
): boolean {
if (typeof b === 'string') {
return a.product_id === b || a.slug === b;
}
if (a.product_id && b.product_id && a.product_id === b.product_id) return true;
if (a.slug && b.slug && a.slug === b.slug) return true;
return false;
}
interface CartState {
cart: CartItem[];
wishlist: ProductResponse[];
compareList: ProductResponse[];
cartDrawerOpen: boolean;
quickViewProduct: ProductResponse | null;
_hasHydrated: boolean;
addToCart: (
product: ProductResponse,
variant?: ProductVariantResponse,
quantity?: number
) => Promise<{ ok: boolean; reason?: 'no_variant' | 'out_of_stock' | 'stock_limit' }>;
removeFromCart: (itemId: string) => Promise<void>;
updateQuantity: (itemId: string, quantity: number) => Promise<void>;
clearCart: () => Promise<void>;
setCartDrawerOpen: (open: boolean) => void;
setHasHydrated: (value: boolean) => void;
toggleWishlist: (product: ProductResponse) => void;
isInWishlist: (productId: string) => boolean;
toggleCompare: (product: ProductResponse) => AddToCompareResult;
addToCompare: (product: ProductResponse) => AddToCompareResult;
removeFromCompare: (productId: string) => void;
replaceCompareProduct: (oldId: string, product: ProductResponse) => AddToCompareResult;
clearCompare: () => void;
isInCompare: (productId: string) => boolean;
setQuickViewProduct: (product: ProductResponse | null) => void;
getCartTotal: () => number;
getItemCount: () => number;
}
async function syncAddToBackend(variantId: string, qty: number) {
const token = getAccessToken();
if (!token) return;
try {
await apiFetch('/api/v1/cart/items', {
method: 'POST',
body: JSON.stringify({ variant_id: variantId, qty }),
});
} catch (err) {
console.error('Failed to sync cart add', err);
}
}
async function syncCartSnapshot() {
const token = getAccessToken();
if (!token) return;
try {
const items = useCartStore.getState().cart.map((item) => ({
variant_id: item.selectedVariant.variant_id,
qty: item.quantity,
}));
await apiFetch('/api/v1/cart/items', {
method: 'PUT',
body: JSON.stringify({ items }),
});
} catch (err) {
console.error('Failed to sync cart snapshot', err);
}
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
cart: [],
wishlist: [],
compareList: [],
cartDrawerOpen: false,
quickViewProduct: null,
_hasHydrated: false,
setHasHydrated: (value) => set({ _hasHydrated: value }),
addToCart: async (product, variant, quantity = 1) => {
const rawVariant = variant || product.variants?.[0];
if (!rawVariant?.variant_id) {
console.error('Cannot add to cart: product has no variant', product.product_id);
return { ok: false, reason: 'no_variant' };
}
const selectedVariant = normalizeVariantPrices(rawVariant, product);
const available = selectedVariant.available_stock;
const itemId = `${product.product_id}-${selectedVariant.variant_id}`;
const existing = get().cart.find((item) => item.id === itemId);
const nextQty = (existing?.quantity || 0) + quantity;
if (typeof available === 'number') {
if (available <= 0) {
console.warn('Out of stock', selectedVariant.sku);
return { ok: false, reason: 'out_of_stock' };
}
if (nextQty > available) {
console.warn(`Only ${available} units available for`, selectedVariant.sku);
return { ok: false, reason: 'stock_limit' };
}
}
set((state) => {
const existingIndex = state.cart.findIndex((item) => item.id === itemId);
let updatedCart: CartItem[];
if (existingIndex > -1) {
updatedCart = state.cart.map((item, idx) =>
idx === existingIndex
? { ...item, quantity: item.quantity + quantity }
: item
);
} else {
updatedCart = [
...state.cart,
{ id: itemId, product, selectedVariant, quantity },
];
}
return { cart: updatedCart, cartDrawerOpen: true };
});
await syncAddToBackend(selectedVariant.variant_id, quantity);
return { ok: true };
},
removeFromCart: async (itemId) => {
set((state) => ({ cart: state.cart.filter((row) => row.id !== itemId) }));
await syncCartSnapshot();
},
updateQuantity: async (itemId, quantity) => {
set((state) => {
const row = state.cart.find((item) => item.id === itemId);
const available = row?.selectedVariant?.available_stock;
let nextQty = quantity;
if (typeof available === 'number' && nextQty > available) {
nextQty = available;
}
return {
cart: nextQty <= 0
? state.cart.filter((item) => item.id !== itemId)
: state.cart.map((item) => (item.id === itemId ? { ...item, quantity: nextQty } : item)),
};
});
await syncCartSnapshot();
},
clearCart: async () => {
set({ cart: [] });
const token = getAccessToken();
if (!token) return;
try {
await apiFetch('/api/v1/cart', { method: 'DELETE' });
} catch (err) {
console.error('Failed to clear cart', err);
}
},
setCartDrawerOpen: (open) => set({ cartDrawerOpen: open }),
toggleWishlist: (product) =>
set((state) => {
const exists = state.wishlist.some((item) => item.product_id === product.product_id);
const updatedWishlist = exists
? state.wishlist.filter((item) => item.product_id !== product.product_id)
: [...state.wishlist, product];
try {
localStorage.setItem('wishlist', JSON.stringify(updatedWishlist.map((p) => p.product_id)));
} catch { /* ignore */ }
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('wishlistUpdated'));
}
return { wishlist: updatedWishlist };
}),
isInWishlist: (productId) => get().wishlist.some((item) => item.product_id === productId),
toggleCompare: (product) => {
const exists = get().compareList.some((item) => isSameCompareProduct(item, product));
if (exists) {
get().removeFromCompare(product.product_id || product.slug);
return { ok: true, already: true };
}
return get().addToCompare(product);
},
addToCompare: (product) => {
const list = sameTypeCompareList(get().compareList);
if (list.length !== get().compareList.length) {
set({ compareList: list });
}
if (list.some((item) => isSameCompareProduct(item, product))) {
return { ok: true, already: true };
}
if (list.length > 0 && !isSameCompareKind(list, product)) {
return {
ok: false,
reason: 'type_mismatch',
expected: compareKindLabel(detectProductKind(list[0])),
actual: compareKindLabel(detectProductKind(product)),
};
}
if (list.length >= COMPARE_LIMIT) {
return { ok: false, reason: 'full' };
}
set({ compareList: [...list, product] });
return { ok: true, already: false };
},
removeFromCompare: (productId) =>
set((state) => ({
compareList: state.compareList.filter((item) => !isSameCompareProduct(item, productId)),
})),
replaceCompareProduct: (oldId, product) => {
const remaining = sameTypeCompareList(
get().compareList.filter((item) => !isSameCompareProduct(item, oldId))
);
if (remaining.some((item) => isSameCompareProduct(item, product))) {
set({ compareList: remaining });
return { ok: true, already: true };
}
if (remaining.length > 0 && !isSameCompareKind(remaining, product)) {
return {
ok: false,
reason: 'type_mismatch',
expected: compareKindLabel(detectProductKind(remaining[0])),
actual: compareKindLabel(detectProductKind(product)),
};
}
if (remaining.length >= COMPARE_LIMIT) {
return { ok: false, reason: 'full' };
}
set({ compareList: [...remaining, product] });
return { ok: true, already: false };
},
clearCompare: () => set({ compareList: [] }),
isInCompare: (productId) =>
get().compareList.some((item) => isSameCompareProduct(item, productId)),
setQuickViewProduct: (product) => set({ quickViewProduct: product }),
getCartTotal: () =>
get().cart.reduce((total, item) => {
const unit = parseMoney(item.selectedVariant?.price);
const qty = Number(item.quantity) || 0;
return total + unit * qty;
}, 0),
getItemCount: () =>
get().cart.reduce((count, item) => count + (Number(item.quantity) || 0), 0),
}),
{
name: 'ifixkart-ecommerce-storage',
skipHydration: true,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
cart: state.cart,
wishlist: state.wishlist,
compareList: state.compareList,
}),
onRehydrateStorage: () => (state) => {
if (state) {
state.cart = normalizeCartItems(state.cart || []);
state.compareList = sameTypeCompareList(state.compareList || []);
state.setHasHydrated(true);
}
},
}
)
);