188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import type { GuestCartLine } from "./customer";
|
|
|
|
export type CartItem = GuestCartLine;
|
|
|
|
const KEY = "meshkee-guest-cart";
|
|
const LEGACY_KEY = "salar-cart";
|
|
const CART_EVENT = "cart-updated";
|
|
|
|
function cookieDomain(): string | undefined {
|
|
if (typeof window === "undefined") return undefined;
|
|
const host = window.location.hostname.toLowerCase();
|
|
if (host === "localhost" || host === "127.0.0.1") return undefined;
|
|
const parts = host.split(".").filter(Boolean);
|
|
if (parts.length < 2) return undefined;
|
|
return `.${parts.slice(-2).join(".")}`;
|
|
}
|
|
|
|
function parseItems(raw: string | null): CartItem[] {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed
|
|
.map((entry) => normalizeLine(entry))
|
|
.filter((item): item is CartItem => Boolean(item));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function normalizeLine(entry: unknown): CartItem | null {
|
|
if (!entry || typeof entry !== "object") return null;
|
|
const item = entry as Record<string, unknown>;
|
|
const id = String(item.id ?? item.variantId ?? item.storeItemVariantId ?? "").trim();
|
|
if (!id) return null;
|
|
const quantity = Math.max(1, Math.floor(Number(item.quantity ?? item.qty) || 1));
|
|
const price = Number(item.price) || 0;
|
|
const name = String(item.name ?? "محصول");
|
|
const slug = item.slug ? String(item.slug) : undefined;
|
|
const image = item.image != null && item.image !== "" ? String(item.image) : undefined;
|
|
return { id, name, ...(slug ? { slug } : {}), price, quantity, ...(image ? { image } : {}) };
|
|
}
|
|
|
|
function writeSharedCookie(items: CartItem[]) {
|
|
if (typeof document === "undefined") return;
|
|
const domain = cookieDomain();
|
|
const maxAge = 60 * 60 * 24 * 30;
|
|
const encoded = encodeURIComponent(
|
|
JSON.stringify(
|
|
items.map(({ id, name, slug, price, quantity }) => ({
|
|
id,
|
|
name,
|
|
slug,
|
|
price,
|
|
quantity,
|
|
image: null as string | null,
|
|
})),
|
|
),
|
|
);
|
|
if (encoded.length > 3500) return;
|
|
let cookie = `${KEY}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
|
if (domain) cookie += `; domain=${domain}`;
|
|
document.cookie = cookie;
|
|
}
|
|
|
|
function notify() {
|
|
window.dispatchEvent(new Event(CART_EVENT));
|
|
}
|
|
|
|
export function getCart(): CartItem[] {
|
|
if (typeof window === "undefined") return [];
|
|
const primary = parseItems(window.localStorage.getItem(KEY));
|
|
if (primary.length > 0) return primary;
|
|
const legacy = parseItems(window.localStorage.getItem(LEGACY_KEY));
|
|
if (legacy.length > 0) {
|
|
writeCart(legacy);
|
|
window.localStorage.removeItem(LEGACY_KEY);
|
|
return legacy;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function writeCart(items: CartItem[]) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(KEY, JSON.stringify(items));
|
|
window.localStorage.removeItem(LEGACY_KEY);
|
|
try {
|
|
writeSharedCookie(items);
|
|
} catch {
|
|
// ignore cookie failures
|
|
}
|
|
notify();
|
|
}
|
|
|
|
export function cartCount() {
|
|
return getCart().reduce((sum, item) => sum + item.quantity, 0);
|
|
}
|
|
|
|
export function cartTotal() {
|
|
return getCart().reduce((sum, item) => sum + item.price * item.quantity, 0);
|
|
}
|
|
|
|
export function addToCart(item: CartItem) {
|
|
const cart = getCart();
|
|
const quantity = Math.max(1, Math.floor(item.quantity) || 1);
|
|
const index = cart.findIndex((entry) => entry.id === item.id);
|
|
if (index >= 0) {
|
|
cart[index] = {
|
|
...cart[index],
|
|
...item,
|
|
quantity: cart[index].quantity + quantity,
|
|
};
|
|
} else {
|
|
cart.push({ ...item, quantity });
|
|
}
|
|
writeCart(cart);
|
|
}
|
|
|
|
export function setCartQuantity(id: string, quantity: number) {
|
|
if (quantity < 1) {
|
|
writeCart(getCart().filter((item) => item.id !== id));
|
|
return;
|
|
}
|
|
writeCart(getCart().map((item) => (item.id === id ? { ...item, quantity } : item)));
|
|
}
|
|
|
|
export function removeFromCart(id: string) {
|
|
writeCart(getCart().filter((item) => item.id !== id));
|
|
}
|
|
|
|
type CartContextValue = {
|
|
items: CartItem[];
|
|
itemCount: number;
|
|
total: number;
|
|
addItem: (item: CartItem) => void;
|
|
removeItem: (id: string) => void;
|
|
setQuantity: (id: string, quantity: number) => void;
|
|
};
|
|
|
|
const CartContext = createContext<CartContextValue | null>(null);
|
|
|
|
export function CartProvider({ children }: { children: ReactNode }) {
|
|
const [items, setItems] = useState<CartItem[]>([]);
|
|
|
|
const sync = useCallback(() => setItems(getCart()), []);
|
|
|
|
useEffect(() => {
|
|
sync();
|
|
window.addEventListener(CART_EVENT, sync);
|
|
window.addEventListener("storage", sync);
|
|
return () => {
|
|
window.removeEventListener(CART_EVENT, sync);
|
|
window.removeEventListener("storage", sync);
|
|
};
|
|
}, [sync]);
|
|
|
|
const addItem = useCallback((item: CartItem) => addToCart(item), []);
|
|
const removeItem = useCallback((id: string) => removeFromCart(id), []);
|
|
const setQuantity = useCallback((id: string, quantity: number) => setCartQuantity(id, quantity), []);
|
|
|
|
const itemCount = useMemo(() => items.reduce((sum, item) => sum + item.quantity, 0), [items]);
|
|
const total = useMemo(() => items.reduce((sum, item) => sum + item.price * item.quantity, 0), [items]);
|
|
|
|
const value = useMemo(
|
|
() => ({ items, itemCount, total, addItem, removeItem, setQuantity }),
|
|
[items, itemCount, total, addItem, removeItem, setQuantity],
|
|
);
|
|
|
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
}
|
|
|
|
export function useCart() {
|
|
const ctx = useContext(CartContext);
|
|
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
|
return ctx;
|
|
}
|