Sellers can tap a price on the products page to edit it inline (Enter to
save / Escape to cancel), mirroring the existing stock quick-edit.
- new reusable InlinePriceEditor (optimistic display, Persian/Latin digit
input, thousands grouping, loading + toast feedback, a11y labels)
- base price editable on each product row (mobile + desktop column)
- per-variant price editable beside stock in the expanded panel; empty
clears the override and inherits the base price ("پیشفرض: <base>")
- useUpdateProductBasePrice + useUpdateVariantPrice hooks; extend generated
API client + types for the two new endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
534 lines
15 KiB
TypeScript
534 lines
15 KiB
TypeScript
import {
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
useInfiniteQuery,
|
|
} from "@tanstack/react-query";
|
|
import { useAuthToken } from "../hooks/use-root-data";
|
|
import {
|
|
FullProductCreate,
|
|
FullProductUpdate,
|
|
ProductDetail,
|
|
ProductOwnershipCheck,
|
|
ProductAttributeValue,
|
|
SellerProductListItem,
|
|
VariantStockUpdateRequest,
|
|
VariantStockUpdateResponse,
|
|
ProductBasePriceUpdateRequest,
|
|
ProductBasePriceUpdateResponse,
|
|
VariantPriceUpdateRequest,
|
|
VariantPriceUpdateResponse,
|
|
PaginatedSimilarProduct,
|
|
} from "../../src/api/types";
|
|
import { useToast } from "../hooks/use-toast";
|
|
import {
|
|
createProductManagementApi,
|
|
createApiApi,
|
|
} from "~/utils/api-client-factory";
|
|
|
|
/**
|
|
* Custom hook to fetch all attribute values
|
|
*/
|
|
export const useAttributeValues = () => {
|
|
const token = useAuthToken();
|
|
|
|
return useQuery({
|
|
queryKey: ["attributeValues"],
|
|
queryFn: async (): Promise<ProductAttributeValue[]> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1AttributevalueList();
|
|
},
|
|
enabled: !!token,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch color attributes
|
|
*/
|
|
export const useColorAttributes = () => {
|
|
const { data: attributeValues, isLoading, error } = useAttributeValues();
|
|
|
|
return {
|
|
data:
|
|
attributeValues?.filter(
|
|
(attr) => attr.attributeName?.toLowerCase() === "color"
|
|
) || [],
|
|
isLoading,
|
|
error,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch size attributes
|
|
*/
|
|
export const useSizeAttributes = () => {
|
|
const { data: attributeValues, isLoading, error } = useAttributeValues();
|
|
|
|
return {
|
|
data:
|
|
attributeValues?.filter(
|
|
(attr) => attr.attributeName?.toLowerCase() === "size"
|
|
) || [],
|
|
isLoading,
|
|
error,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Custom hook to create a full product with attributes and variants
|
|
*/
|
|
export const useCreateFullProduct = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async (
|
|
productData: FullProductCreate
|
|
): Promise<ProductDetail> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsFullCreateCreate({
|
|
data: productData,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "محصول با موفقیت ایجاد شد",
|
|
variant: "default",
|
|
});
|
|
// Invalidate relevant queries
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProducts"] });
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Product creation failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در ایجاد محصول. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
/**
|
|
* Custom hook to fetch seller categories
|
|
*/
|
|
export const useProductCategories = () => {
|
|
const token = useAuthToken();
|
|
return useQuery({
|
|
queryKey: ["sellerCategories"],
|
|
queryFn: async () => {
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1CategoriesList();
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch a is-owner product
|
|
*/
|
|
export const useIsOwnerProduct = (productId: string) => {
|
|
const token = useAuthToken();
|
|
|
|
return useQuery({
|
|
queryKey: ["is-owner-product", productId],
|
|
queryFn: async (): Promise<ProductOwnershipCheck> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsIsOwnerList({
|
|
id: productId,
|
|
});
|
|
},
|
|
enabled: !!token && !!productId,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch a single product details
|
|
*/
|
|
export const useProductDetails = (productId: string) => {
|
|
const token = useAuthToken();
|
|
|
|
return useQuery({
|
|
queryKey: ["product", productId],
|
|
queryFn: async (): Promise<ProductDetail> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsRead({ id: productId });
|
|
},
|
|
enabled: !!token && !!productId,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to delete a product
|
|
*/
|
|
export const useDeleteProduct = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async (productId: string): Promise<void> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsDelete({ id: productId });
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "محصول با موفقیت حذف شد",
|
|
variant: "default",
|
|
});
|
|
// Invalidate relevant queries
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProducts"] });
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Product deletion failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در حذف محصول. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to update a product
|
|
*/
|
|
export const useUpdateProduct = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
productId,
|
|
productData,
|
|
}: {
|
|
productId: string;
|
|
productData: FullProductUpdate;
|
|
}): Promise<ProductDetail> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsPartialUpdate({
|
|
id: productId,
|
|
data: productData,
|
|
});
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "محصول با موفقیت بهروزرسانی شد",
|
|
variant: "default",
|
|
});
|
|
// Invalidate relevant queries
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["product", variables.productId],
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProducts"] });
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Product update failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در بهروزرسانی محصول. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to toggle product active status
|
|
*/
|
|
export const useToggleProductStatus = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
productId,
|
|
isActive,
|
|
}: {
|
|
productId: string;
|
|
isActive: boolean;
|
|
}): Promise<ProductDetail> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
return await productApi.apiProductV1ProductsActivatePartialUpdate({
|
|
data: { id: productId, isActive },
|
|
});
|
|
},
|
|
onSuccess: (data, variables) => {
|
|
toast({
|
|
title: "موفق",
|
|
description: variables.isActive
|
|
? "محصول با موفقیت فعال شد"
|
|
: "محصول با موفقیت غیرفعال شد",
|
|
variant: "default",
|
|
});
|
|
// Invalidate relevant queries
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["product", variables.productId],
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProducts"] });
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Product status toggle failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در تغییر وضعیت محصول. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch seller products list using apiProductV1SellerProductsList
|
|
*/
|
|
export const useGetSellerProductsList = () => {
|
|
const token = useAuthToken();
|
|
const productApi = createProductManagementApi(token);
|
|
|
|
return useQuery({
|
|
queryKey: ["sellerProductsList"],
|
|
queryFn: async (): Promise<SellerProductListItem[]> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
return await productApi.apiProductV1SellerProductsList();
|
|
},
|
|
enabled: !!token,
|
|
staleTime: 30 * 60 * 1000, // 30 seconds - balance between freshness and performance
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to update product variant stock
|
|
*/
|
|
export const useUpdateVariantStock = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
variantId,
|
|
stock,
|
|
}: {
|
|
variantId: string;
|
|
stock: number;
|
|
}): Promise<VariantStockUpdateResponse> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
const data: VariantStockUpdateRequest = {
|
|
variantId,
|
|
stock,
|
|
};
|
|
return await productApi.apiProductV1ProductsVariantsStockPartialUpdate({
|
|
data,
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "موجودی با موفقیت بهروزرسانی شد",
|
|
variant: "default",
|
|
});
|
|
// Invalidate relevant queries to refresh the data
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
if (data.product) {
|
|
queryClient.invalidateQueries({ queryKey: ["product", data.product] });
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Variant stock update failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در بهروزرسانی موجودی. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to update a product's base price (quick edit).
|
|
*/
|
|
export const useUpdateProductBasePrice = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
productId,
|
|
basePrice,
|
|
}: {
|
|
productId: string;
|
|
basePrice: string;
|
|
}): Promise<ProductBasePriceUpdateResponse> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
const data: ProductBasePriceUpdateRequest = {
|
|
productId,
|
|
basePrice,
|
|
};
|
|
return await productApi.apiProductV1ProductsBasePricePartialUpdate({
|
|
data,
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "قیمت با موفقیت بهروزرسانی شد",
|
|
variant: "default",
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
if (data.id) {
|
|
queryClient.invalidateQueries({ queryKey: ["product", data.id] });
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Base price update failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در بهروزرسانی قیمت. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to update a single variant's price override (quick edit).
|
|
* Pass `price: null` to clear the override and inherit the base price.
|
|
*/
|
|
export const useUpdateVariantPrice = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
variantId,
|
|
price,
|
|
}: {
|
|
variantId: string;
|
|
price: string | null;
|
|
}): Promise<VariantPriceUpdateResponse> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const productApi = createProductManagementApi(token);
|
|
const data: VariantPriceUpdateRequest = {
|
|
variantId,
|
|
price,
|
|
};
|
|
return await productApi.apiProductV1ProductsVariantsPricePartialUpdate({
|
|
data,
|
|
});
|
|
},
|
|
onSuccess: (data) => {
|
|
toast({
|
|
title: "موفق",
|
|
description: "قیمت با موفقیت بهروزرسانی شد",
|
|
variant: "default",
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ["sellerProductsList"] });
|
|
queryClient.invalidateQueries({ queryKey: ["products"] });
|
|
if (data.product) {
|
|
queryClient.invalidateQueries({ queryKey: ["product", data.product] });
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
console.error("Variant price update failed:", error);
|
|
toast({
|
|
title: "خطا",
|
|
description: "خطا در بهروزرسانی قیمت. لطفاً دوباره تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch similar products for a given product ID
|
|
*/
|
|
export const useSimilarProducts = (
|
|
productId: string,
|
|
pageSize: number = 12
|
|
) => {
|
|
const apiApi = createApiApi();
|
|
|
|
return useQuery({
|
|
queryKey: ["similarProducts", productId, pageSize],
|
|
queryFn: async () => {
|
|
if (!productId) throw new Error("Product ID is required");
|
|
|
|
return await apiApi.apiProductV1ProductsSimilarList({
|
|
id: productId,
|
|
pageSize,
|
|
});
|
|
},
|
|
enabled: !!productId,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook for infinite similar products loading using cursor-based pagination
|
|
*/
|
|
export const useSimilarProductsInfinite = (
|
|
productId: string,
|
|
pageSize: number = 20
|
|
) => {
|
|
const apiApi = createApiApi();
|
|
|
|
return useInfiniteQuery<PaginatedSimilarProduct>({
|
|
queryKey: ["similarProductsInfinite", productId, pageSize],
|
|
queryFn: async ({ pageParam }): Promise<PaginatedSimilarProduct> => {
|
|
if (!productId) throw new Error("Product ID is required");
|
|
|
|
// If pageParam is a full URL, extract the cursor
|
|
let cursor: string | undefined = undefined;
|
|
if (typeof pageParam === "string" && pageParam.includes("cursor=")) {
|
|
const url = new URL(pageParam);
|
|
const cursorParam = url.searchParams.get("cursor");
|
|
if (cursorParam) cursor = cursorParam;
|
|
} else if (typeof pageParam === "string") {
|
|
cursor = pageParam;
|
|
}
|
|
|
|
return await apiApi.apiProductV1ProductsSimilarList({
|
|
id: productId,
|
|
cursor,
|
|
pageSize,
|
|
});
|
|
},
|
|
getNextPageParam: (lastPage) => {
|
|
return lastPage.next || undefined;
|
|
},
|
|
initialPageParam: undefined,
|
|
enabled: !!productId,
|
|
});
|
|
};
|