339 lines
11 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
}
|
|
)
|
|
);
|