525 lines
13 KiB
TypeScript
525 lines
13 KiB
TypeScript
import {
|
|
useQuery,
|
|
useMutation,
|
|
useQueryClient,
|
|
UseQueryOptions,
|
|
} from "@tanstack/react-query";
|
|
import { apiClient } from "@/lib/api";
|
|
import type { CartItem } from "@/lib/types/api";
|
|
import { useEffect } from "react";
|
|
|
|
interface CartResponse {
|
|
message: string;
|
|
data: CartItem[];
|
|
errorDetails?: string;
|
|
}
|
|
|
|
const pendingUpdates = new Map<number, number>();
|
|
let updateLock = false;
|
|
|
|
class CartEventEmitter {
|
|
private listeners: Set<() => void> = new Set();
|
|
|
|
subscribe(callback: () => void) {
|
|
this.listeners.add(callback);
|
|
return () => {
|
|
this.listeners.delete(callback);
|
|
};
|
|
}
|
|
|
|
emit() {
|
|
this.listeners.forEach((cb) => cb());
|
|
}
|
|
}
|
|
|
|
export const cartEvents = new CartEventEmitter();
|
|
|
|
function transformCartResponse(response: any): CartResponse {
|
|
if (
|
|
typeof response === "string" &&
|
|
(response.trim().startsWith("<!DOCTYPE") ||
|
|
response.trim().startsWith("<html"))
|
|
) {
|
|
return { message: "error", data: [], errorDetails: "Server returned HTML instead of JSON." };
|
|
}
|
|
|
|
const normalize = (raw: any): CartItem[] => {
|
|
if (Array.isArray(raw)) return raw;
|
|
|
|
// GET response: data = { tmpost: [...], ... } — merge all channel arrays
|
|
if (typeof raw === "object" && raw !== null) {
|
|
return Object.values(raw).flat() as CartItem[];
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
if (typeof response === "object" && response !== null) {
|
|
return {
|
|
message: response.message || "success",
|
|
data: normalize(response.data),
|
|
};
|
|
}
|
|
|
|
if (typeof response === "string") {
|
|
try {
|
|
const parsed = JSON.parse(response);
|
|
return {
|
|
message: parsed.message || "success",
|
|
data: normalize(parsed.data),
|
|
};
|
|
} catch {
|
|
return { message: "error", data: [] };
|
|
}
|
|
}
|
|
|
|
return { message: "unknown", data: [] };
|
|
}
|
|
|
|
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
|
|
const queryClient = useQueryClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: ["cart"],
|
|
queryFn: async () => {
|
|
const response = await apiClient.get("/carts");
|
|
const transformed = transformCartResponse(response.data);
|
|
return transformed;
|
|
},
|
|
refetchOnMount: false,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: true,
|
|
staleTime: Infinity,
|
|
gcTime: 1000 * 60 * 5,
|
|
retry: 2,
|
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
|
...options,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = cartEvents.subscribe(() => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["cart"],
|
|
refetchType: "none",
|
|
});
|
|
});
|
|
return () => {
|
|
unsubscribe();
|
|
};
|
|
}, [queryClient]);
|
|
|
|
return query;
|
|
}
|
|
|
|
export function useAddToCart() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
productId,
|
|
quantity = 1,
|
|
}: {
|
|
productId: number;
|
|
quantity?: number;
|
|
}) => {
|
|
const params = new URLSearchParams({
|
|
product_id: String(productId),
|
|
product_quantity: String(quantity),
|
|
});
|
|
|
|
const response = await apiClient.post("/carts", params.toString(), {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
});
|
|
|
|
if (typeof response.data === "object" && response.data.data) {
|
|
return response.data;
|
|
}
|
|
|
|
if (typeof response.data === "string") {
|
|
try {
|
|
return JSON.parse(response.data);
|
|
} catch {
|
|
return { message: "success", data: "Added to cart" };
|
|
}
|
|
}
|
|
|
|
return { message: "success", data: "Added to cart" };
|
|
},
|
|
onMutate: async ({ productId, quantity }) => {
|
|
while (updateLock) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
updateLock = true;
|
|
|
|
await queryClient.cancelQueries({ queryKey: ["cart"] });
|
|
|
|
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
|
|
|
|
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
|
|
if (!old) return old;
|
|
|
|
let updated = { ...old, data: [...old.data] };
|
|
|
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
|
const idx = updated.data.findIndex(
|
|
(item: any) => item.product?.id === pendingId,
|
|
);
|
|
if (idx !== -1) {
|
|
updated.data[idx] = {
|
|
...updated.data[idx],
|
|
product_quantity: pendingQty,
|
|
};
|
|
}
|
|
});
|
|
|
|
const existingItem = updated.data.find(
|
|
(item: any) => item.product?.id === productId,
|
|
);
|
|
|
|
if (existingItem) {
|
|
updated.data = updated.data.map((item: any) =>
|
|
item.product?.id === productId
|
|
? {
|
|
...item,
|
|
product_quantity: item.product_quantity + quantity,
|
|
}
|
|
: item,
|
|
);
|
|
} else {
|
|
updated.data = [
|
|
...updated.data,
|
|
{
|
|
product: { id: productId },
|
|
product_quantity: quantity,
|
|
} as any,
|
|
];
|
|
}
|
|
|
|
const finalItem = updated.data.find(
|
|
(item: any) => item.product?.id === productId,
|
|
);
|
|
if (finalItem) {
|
|
pendingUpdates.set(productId, finalItem.product_quantity);
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
|
|
cartEvents.emit();
|
|
updateLock = false;
|
|
|
|
return { previousCart };
|
|
},
|
|
onError: (error, variables, context) => {
|
|
if (context?.previousCart) {
|
|
queryClient.setQueryData(["cart"], context.previousCart);
|
|
pendingUpdates.delete(variables.productId);
|
|
cartEvents.emit();
|
|
}
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
pendingUpdates.delete(variables.productId);
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["cart"],
|
|
refetchType: "active",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRemoveFromCart() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (productId: number) => {
|
|
const params = new URLSearchParams({ product_id: String(productId) });
|
|
|
|
const response = await apiClient.patch("/carts", params.toString(), {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
});
|
|
|
|
if (typeof response.data === "object" && response.data.data) {
|
|
return response.data.data;
|
|
}
|
|
|
|
if (typeof response.data === "string") {
|
|
try {
|
|
const parsed = JSON.parse(response.data);
|
|
return parsed.data || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
},
|
|
onMutate: async (productId) => {
|
|
while (updateLock) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
updateLock = true;
|
|
|
|
await queryClient.cancelQueries({ queryKey: ["cart"] });
|
|
|
|
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
|
|
|
|
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
|
|
if (!old) return old;
|
|
|
|
let updated = { ...old, data: [...old.data] };
|
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
|
if (pendingId !== productId) {
|
|
const idx = updated.data.findIndex(
|
|
(item: any) => item.product?.id === pendingId,
|
|
);
|
|
if (idx !== -1) {
|
|
updated.data[idx] = {
|
|
...updated.data[idx],
|
|
product_quantity: pendingQty,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
updated.data = updated.data.filter(
|
|
(item: any) => item.product?.id !== productId,
|
|
);
|
|
|
|
pendingUpdates.delete(productId);
|
|
return updated;
|
|
});
|
|
|
|
cartEvents.emit();
|
|
updateLock = false;
|
|
|
|
return { previousCart };
|
|
},
|
|
onError: (error, variables, context) => {
|
|
if (context?.previousCart) {
|
|
queryClient.setQueryData(["cart"], context.previousCart);
|
|
cartEvents.emit();
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["cart"],
|
|
refetchType: "active",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCleanCart() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
const response = await apiClient.delete("/carts", {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
});
|
|
|
|
if (typeof response.data === "object" && response.data.data) {
|
|
return response.data.data;
|
|
}
|
|
|
|
if (typeof response.data === "string") {
|
|
try {
|
|
const parsed = JSON.parse(response.data);
|
|
return parsed.data || [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
},
|
|
onMutate: async () => {
|
|
while (updateLock) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
updateLock = true;
|
|
|
|
await queryClient.cancelQueries({ queryKey: ["cart"] });
|
|
|
|
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
|
|
|
|
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
|
|
if (!old) return old;
|
|
pendingUpdates.clear();
|
|
return { ...old, data: [] };
|
|
});
|
|
|
|
cartEvents.emit();
|
|
updateLock = false;
|
|
|
|
return { previousCart };
|
|
},
|
|
onError: (error, variables, context) => {
|
|
if (context?.previousCart) {
|
|
queryClient.setQueryData(["cart"], context.previousCart);
|
|
cartEvents.emit();
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["cart"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateCartItemQuantity() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
productId,
|
|
quantity,
|
|
}: {
|
|
productId: number;
|
|
quantity: number;
|
|
}) => {
|
|
const params = new URLSearchParams({
|
|
product_id: String(productId),
|
|
product_quantity: String(quantity),
|
|
});
|
|
|
|
const response = await apiClient.post("/carts", params.toString(), {
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
timeout: 15000,
|
|
});
|
|
|
|
if (typeof response.data === "object" && response.data.data) {
|
|
return response.data;
|
|
}
|
|
|
|
if (typeof response.data === "string") {
|
|
try {
|
|
return JSON.parse(response.data);
|
|
} catch {
|
|
return { message: "success", data: "Updated cart" };
|
|
}
|
|
}
|
|
|
|
return { message: "success", data: "Updated cart" };
|
|
},
|
|
onMutate: async ({ productId, quantity }) => {
|
|
while (updateLock) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
updateLock = true;
|
|
|
|
await queryClient.cancelQueries({ queryKey: ["cart"] });
|
|
|
|
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
|
|
|
|
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
|
|
if (!old) return old;
|
|
|
|
let updated = { ...old, data: [...old.data] };
|
|
|
|
pendingUpdates.forEach((pendingQty, pendingId) => {
|
|
const idx = updated.data.findIndex(
|
|
(item: any) => item.product?.id === pendingId,
|
|
);
|
|
if (idx !== -1) {
|
|
updated.data[idx] = {
|
|
...updated.data[idx],
|
|
product_quantity: pendingQty,
|
|
};
|
|
}
|
|
});
|
|
|
|
updated.data = updated.data.map((item: any) =>
|
|
item.product?.id === productId
|
|
? { ...item, product_quantity: quantity }
|
|
: item,
|
|
);
|
|
|
|
pendingUpdates.set(productId, quantity);
|
|
|
|
return updated;
|
|
});
|
|
|
|
cartEvents.emit();
|
|
updateLock = false;
|
|
|
|
return { previousCart };
|
|
},
|
|
onError: (error, variables, context) => {
|
|
if (context?.previousCart) {
|
|
queryClient.setQueryData(["cart"], context.previousCart);
|
|
pendingUpdates.delete(variables.productId);
|
|
cartEvents.emit();
|
|
}
|
|
throw error;
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
pendingUpdates.delete(variables.productId);
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["cart"],
|
|
refetchType: "none",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCreateOrder() {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (payload: {
|
|
customer_name?: string;
|
|
customer_phone: number;
|
|
customer_address: string;
|
|
shipping_method: string;
|
|
payment_type_id: number;
|
|
delivery_time?: string;
|
|
delivery_at?: string;
|
|
region: string;
|
|
notes?: string;
|
|
product_ids: number[];
|
|
}) => {
|
|
const response = await apiClient.post("/orders", payload);
|
|
return response.data;
|
|
},
|
|
onSuccess: (data) => {
|
|
// Handle payment URL - check both data.payment_url and data.data.payment_url
|
|
const paymentUrl = data?.data?.payment_url || data?.payment_url;
|
|
if (paymentUrl) {
|
|
window.open(paymentUrl, "_blank")?.focus();
|
|
}
|
|
|
|
pendingUpdates.clear();
|
|
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
|
|
if (!old) return old;
|
|
return { ...old, data: [] };
|
|
});
|
|
cartEvents.emit();
|
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
|
},
|
|
onError: (error: any) => {
|
|
console.error(
|
|
"Create order error:",
|
|
error.response?.data?.message || error.message,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useCartCount() {
|
|
const { data } = useCart();
|
|
return (
|
|
data?.data?.reduce(
|
|
(sum: number, item: any) => sum + (item.product_quantity || 0),
|
|
0,
|
|
) || 0
|
|
);
|
|
}
|