Files
sanihome/src/lib/cart.tsx
T
Alireza HassaniandCursor c53fb99330 Add shared customer auth, cart handoff, and account dropdown.
Mirror Meshkee customer sessions onto the storefront, continue checkout on the customer dashboard, and show profile/orders/admin links under the header name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 16:41:00 +03:30

340 lines
8.8 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useAuth } from "@/lib/auth";
import type { CartItem, CartSnapshot } from "@/lib/cart-types";
import { normalizeCart } from "@/lib/cart-types";
import {
clearGuestCart,
readGuestCart,
writeGuestCart,
} from "@/lib/guest-cart";
type ToastState = {
id: number;
message: string;
} | null;
export type AddCartItemInput = {
storeItemVariantId: string;
quantity?: number;
slug?: string;
name?: string;
image?: string | null;
price?: number;
optionLabel?: string;
};
type CartContextValue = {
items: CartItem[];
itemCount: number;
subtotal: number;
total: number;
ready: boolean;
loading: boolean;
addItem: (input: AddCartItemInput) => Promise<boolean>;
removeItem: (itemId: string) => Promise<void>;
setQuantity: (itemId: string, quantity: number) => Promise<void>;
refresh: () => Promise<void>;
toast: ToastState;
showToast: (message: string) => void;
dismissToast: () => void;
};
const CartContext = createContext<CartContextValue | null>(null);
function emptySnapshot(): CartSnapshot {
return { items: [], subtotal: 0, total: 0 };
}
function snapshotFromItems(items: CartItem[]): CartSnapshot {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return { items, subtotal, total: subtotal };
}
async function cartRequest(
path: string,
token: string,
init?: RequestInit,
): Promise<CartSnapshot> {
const res = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(init?.headers as Record<string, string> | undefined),
},
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
(data as { error?: string } | null)?.error ?? "خطا در ارتباط با سبد خرید",
);
}
return normalizeCart(data);
}
export function CartProvider({ children }: { children: ReactNode }) {
const { isAuthenticated, ready: authReady, getAccessToken } = useAuth();
const [snapshot, setSnapshot] = useState<CartSnapshot>(emptySnapshot());
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState<ToastState>(null);
const syncedGuestRef = useRef(false);
const showToast = useCallback((message: string) => {
setToast({ id: Date.now(), message });
}, []);
const dismissToast = useCallback(() => setToast(null), []);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 2800);
return () => window.clearTimeout(timer);
}, [toast]);
const refresh = useCallback(async () => {
const token = getAccessToken();
if (!token) {
setSnapshot(snapshotFromItems(readGuestCart()));
return;
}
setLoading(true);
try {
const next = await cartRequest("/api/cart", token);
setSnapshot(next);
} catch {
setSnapshot(emptySnapshot());
} finally {
setLoading(false);
}
}, [getAccessToken]);
// Load guest cart first; after login, push guest lines into the API cart once.
useEffect(() => {
if (!authReady) return;
const bootstrap = async () => {
const token = getAccessToken();
if (!token) {
syncedGuestRef.current = false;
setSnapshot(snapshotFromItems(readGuestCart()));
setReady(true);
return;
}
const guestItems = readGuestCart();
if (guestItems.length > 0 && !syncedGuestRef.current) {
syncedGuestRef.current = true;
setLoading(true);
try {
for (const item of guestItems) {
await cartRequest("/api/cart/items", token, {
method: "POST",
body: JSON.stringify({
storeItemVariantId: item.storeItemVariantId,
quantity: item.quantity,
}),
});
}
clearGuestCart();
} catch {
// keep guest cart if sync fails
syncedGuestRef.current = false;
} finally {
setLoading(false);
}
}
await refresh();
setReady(true);
};
void bootstrap();
}, [authReady, isAuthenticated, getAccessToken, refresh]);
const addItem = useCallback(
async (input: AddCartItemInput) => {
const quantity = Math.max(1, input.quantity ?? 1);
const token = getAccessToken();
if (token) {
setLoading(true);
try {
const next = await cartRequest("/api/cart/items", token, {
method: "POST",
body: JSON.stringify({
storeItemVariantId: input.storeItemVariantId,
quantity,
}),
});
setSnapshot(next);
showToast("به سبد خرید اضافه شد");
return true;
} finally {
setLoading(false);
}
}
const current = readGuestCart();
const existing = current.find(
(item) => item.storeItemVariantId === input.storeItemVariantId,
);
const nextItems = existing
? current.map((item) =>
item.storeItemVariantId === input.storeItemVariantId
? {
...item,
quantity: item.quantity + quantity,
price: input.price ?? item.price,
name: input.name ?? item.name,
image: input.image ?? item.image,
optionLabel: input.optionLabel ?? item.optionLabel,
}
: item,
)
: [
...current,
{
id: input.storeItemVariantId,
storeItemVariantId: input.storeItemVariantId,
slug: input.slug ?? "",
name: input.name ?? "",
image: input.image ?? null,
price: input.price ?? 0,
quantity,
optionLabel: input.optionLabel,
},
];
writeGuestCart(nextItems);
setSnapshot(snapshotFromItems(nextItems));
showToast("به سبد خرید اضافه شد");
return true;
},
[getAccessToken, showToast],
);
const removeItem = useCallback(
async (itemId: string) => {
const token = getAccessToken();
if (token) {
setLoading(true);
try {
const next = await cartRequest(`/api/cart/items/${itemId}`, token, {
method: "DELETE",
});
setSnapshot(next);
} finally {
setLoading(false);
}
return;
}
const nextItems = readGuestCart().filter(
(item) => item.id !== itemId && item.storeItemVariantId !== itemId,
);
writeGuestCart(nextItems);
setSnapshot(snapshotFromItems(nextItems));
},
[getAccessToken],
);
const setQuantity = useCallback(
async (itemId: string, quantity: number) => {
const token = getAccessToken();
if (token) {
setLoading(true);
try {
const next =
quantity < 1
? await cartRequest(`/api/cart/items/${itemId}`, token, {
method: "DELETE",
})
: await cartRequest(`/api/cart/items/${itemId}`, token, {
method: "PATCH",
body: JSON.stringify({ quantity }),
});
setSnapshot(next);
} finally {
setLoading(false);
}
return;
}
if (quantity < 1) {
const nextItems = readGuestCart().filter(
(item) => item.id !== itemId && item.storeItemVariantId !== itemId,
);
writeGuestCart(nextItems);
setSnapshot(snapshotFromItems(nextItems));
return;
}
const nextItems = readGuestCart().map((item) =>
item.id === itemId || item.storeItemVariantId === itemId
? { ...item, quantity }
: item,
);
writeGuestCart(nextItems);
setSnapshot(snapshotFromItems(nextItems));
},
[getAccessToken],
);
const itemCount = useMemo(
() => snapshot.items.reduce((sum, item) => sum + item.quantity, 0),
[snapshot.items],
);
const value = useMemo(
() => ({
items: snapshot.items,
itemCount,
subtotal: snapshot.subtotal,
total: snapshot.total,
ready,
loading,
addItem,
removeItem,
setQuantity,
refresh,
toast,
showToast,
dismissToast,
}),
[
snapshot,
itemCount,
ready,
loading,
addItem,
removeItem,
setQuantity,
refresh,
toast,
showToast,
dismissToast,
],
);
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;
}