Review fixes: - useMyStores throws on a failed response instead of resolving to [] — a transient 401/blip no longer looks like "no stores" and bounces an owner off their own dashboard (query stays in error/retry, keeps prior data). - Add useRequireOwner() guard and apply it to the owner-only routes (financial-dashboard + its cash-funds/reports/transactions sub-routes and shipping-method) so staff who hit those URLs directly are redirected to the dashboard. (Financial data was already backend-protected; this is the matching client-side gate.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import {
|
|
useQuery,
|
|
useMutation,
|
|
useQueryClient,
|
|
useInfiniteQuery,
|
|
} from "@tanstack/react-query";
|
|
import { useAuthToken } from "../hooks/use-root-data";
|
|
import {
|
|
SellerApplication,
|
|
InstagramVerification,
|
|
} from "../../src/api/types/models";
|
|
import { toast } from "~/hooks/use-toast";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
createFollowsManagementApi,
|
|
createSellerManagementApi,
|
|
} from "~/utils/api-client-factory";
|
|
import { getApiBaseUrl } from "../utils/api-config";
|
|
|
|
// ── Multi-user team ─────────────────────────────────────────────────────────
|
|
export interface MyStore {
|
|
username: string;
|
|
storeName: string;
|
|
storeLogo: string | null;
|
|
role: "owner" | "staff" | null;
|
|
}
|
|
|
|
export interface StoreMember {
|
|
id: string;
|
|
userId: string | null;
|
|
name: string | null;
|
|
phone: string;
|
|
role: string;
|
|
status: "active" | "pending";
|
|
createdAt: string;
|
|
}
|
|
|
|
/** Stores the current user can manage (owned + memberships), with their role. */
|
|
export function useMyStores() {
|
|
const token = useAuthToken();
|
|
return useQuery({
|
|
queryKey: ["myStores"],
|
|
queryFn: async (): Promise<MyStore[]> => {
|
|
if (!token) return [];
|
|
const res = await fetch(`${getApiBaseUrl()}/api/seller/my-stores/`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
// Throw on failure so a transient error stays an ERROR state (data kept,
|
|
// retried) instead of resolving to [] — otherwise a blip would look like
|
|
// "no stores" and bounce an owner off their own dashboard.
|
|
if (!res.ok) throw new Error(`my-stores failed: ${res.status}`);
|
|
return res.json();
|
|
},
|
|
enabled: !!token,
|
|
retry: 1,
|
|
staleTime: 60 * 1000,
|
|
});
|
|
}
|
|
|
|
/** A store's team members (owner only). */
|
|
export function useStoreMembers(username?: string) {
|
|
const token = useAuthToken();
|
|
return useQuery({
|
|
queryKey: ["storeMembers", username],
|
|
queryFn: async (): Promise<StoreMember[]> => {
|
|
const res = await fetch(
|
|
`${getApiBaseUrl()}/api/seller/members/${username}/`,
|
|
{ headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
if (!res.ok) throw new Error("failed to load members");
|
|
return res.json();
|
|
},
|
|
enabled: !!token && !!username,
|
|
});
|
|
}
|
|
|
|
/** Invite a team member by phone number (owner only). */
|
|
export function useInviteMember(username?: string) {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (phone: string) => {
|
|
const res = await fetch(
|
|
`${getApiBaseUrl()}/api/seller/members/${username}/`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ phone }),
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || "خطا در دعوت عضو جدید");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: () =>
|
|
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
|
|
});
|
|
}
|
|
|
|
/** Remove a team member (owner only). */
|
|
export function useRemoveMember(username?: string) {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (memberId: string) => {
|
|
const res = await fetch(
|
|
`${getApiBaseUrl()}/api/seller/members/${username}/${memberId}/`,
|
|
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } }
|
|
);
|
|
if (!res.ok) throw new Error("خطا در حذف عضو");
|
|
return res.json();
|
|
},
|
|
onSuccess: () =>
|
|
queryClient.invalidateQueries({ queryKey: ["storeMembers", username] }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Custom hook to fetch seller data by sellerId using React Query
|
|
*/
|
|
export const useSellerData = (sellerId: string | undefined) => {
|
|
const token = useAuthToken();
|
|
|
|
return useQuery({
|
|
queryKey: ["seller", sellerId],
|
|
queryFn: async () => {
|
|
if (!sellerId) throw new Error("Seller ID is required");
|
|
|
|
const sellerApi = createSellerManagementApi(token);
|
|
return await sellerApi.sellerRead({ username: sellerId });
|
|
},
|
|
enabled: !!sellerId, // Only run the query if sellerId exists
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to check if the current user is following a seller
|
|
*/
|
|
export const useSellerFollowStatus = (sellerId: string | undefined) => {
|
|
const token = useAuthToken();
|
|
|
|
return useQuery({
|
|
queryKey: ["sellerFollowStatus", sellerId],
|
|
queryFn: async () => {
|
|
if (!sellerId) throw new Error("Seller ID is required");
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
try {
|
|
const followsApi = createFollowsManagementApi(token);
|
|
const response = await followsApi.apiEngagementV1FollowsCheckRead({
|
|
sellerId,
|
|
});
|
|
return response?.following;
|
|
} catch (error) {
|
|
return false; // If error is thrown, user is not following the seller
|
|
}
|
|
},
|
|
enabled: !!sellerId && !!token, // Only run the query if sellerId and token exist
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to follow a seller
|
|
*/
|
|
export const useSellerFollow = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ sellerId }: { sellerId: string }) => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const followsApi = createFollowsManagementApi(token);
|
|
await followsApi.apiEngagementV1FollowsCreate({
|
|
data: { followingId: sellerId },
|
|
});
|
|
return { success: true };
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
// Invalidate and refetch follow status after mutation
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["sellerFollowStatus", variables.sellerId],
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["getFollowedSellersService"],
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to unfollow a seller
|
|
*/
|
|
export const useSellerUnfollow = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async ({ sellerId }: { sellerId: string }) => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const followsApi = createFollowsManagementApi(token);
|
|
await followsApi.apiEngagementV1FollowsDelete({ sellerId });
|
|
return { success: true };
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
// Invalidate and refetch follow status after mutation
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["sellerFollowStatus", variables.sellerId],
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["getFollowedSellersService"],
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
export const useGetSellerProducts = (
|
|
sellerId: string,
|
|
options?: {
|
|
q?: string;
|
|
priceMin?: number;
|
|
priceMax?: number;
|
|
ordering?: string;
|
|
}
|
|
) => {
|
|
const token = useAuthToken();
|
|
return useInfiniteQuery({
|
|
queryKey: ["sellerProducts", sellerId, options],
|
|
queryFn: async ({ pageParam = 1 }) => {
|
|
const sellerApi = createSellerManagementApi(token);
|
|
return await sellerApi.sellerProductList({
|
|
username: sellerId,
|
|
page: pageParam,
|
|
q: options?.q,
|
|
priceMin: options?.priceMin,
|
|
priceMax: options?.priceMax,
|
|
ordering: options?.ordering,
|
|
});
|
|
},
|
|
getNextPageParam: (lastPage) => {
|
|
if (!lastPage.next) return undefined;
|
|
// Extract page number from the next URL
|
|
const url = new URL(lastPage.next);
|
|
const page = url.searchParams.get("page");
|
|
return page ? parseInt(page, 10) : undefined;
|
|
},
|
|
initialPageParam: 1,
|
|
enabled: !!sellerId,
|
|
// Public storefront: prices/discounts must reflect the seller's latest
|
|
// edits. Always treat as stale and revalidate on mount/refocus so the
|
|
// store list can't keep showing a pre-edit price (the detail page is SSR
|
|
// and already fresh, which caused the list-vs-detail mismatch).
|
|
staleTime: 0,
|
|
refetchOnMount: true,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch seller categories
|
|
*/
|
|
export const useSellerCategories = () => {
|
|
return useQuery({
|
|
queryKey: ["sellerCategories"],
|
|
queryFn: async () => {
|
|
const sellerApi = createSellerManagementApi();
|
|
return await sellerApi.apiSellerV1CategoriesList();
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to fetch state and city data
|
|
*/
|
|
export const useStateCityData = () => {
|
|
return useQuery({
|
|
queryKey: ["stateCityData"],
|
|
queryFn: async () => {
|
|
const sellerApi = createSellerManagementApi();
|
|
return await sellerApi.apiSellerV1StateCityList();
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to create seller application
|
|
*/
|
|
export const useCreateSellerApplication = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (data: SellerApplication) => {
|
|
if (!token) throw new Error("Authentication required");
|
|
const sellerApi = createSellerManagementApi(token);
|
|
return await sellerApi.apiSellerV1ApplyCreate({ data });
|
|
},
|
|
onSuccess: () => {
|
|
// Invalidate profile query to refresh sellerUsername
|
|
queryClient.invalidateQueries({ queryKey: ["getProfile"] });
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to apply Instagram verification
|
|
*/
|
|
export const useInstagramVerification = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (
|
|
data: InstagramVerification
|
|
): Promise<InstagramVerification> => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const sellerApi = createSellerManagementApi(token);
|
|
return await sellerApi.apiSellerV1InstapplyCreate({ data });
|
|
},
|
|
onSuccess: (data: InstagramVerification) => {
|
|
if (data?.isVerified) {
|
|
toast({
|
|
title: "احراز اکانت اینستاگرام با موفقیت انجام شد",
|
|
description: "اکانت اینستاگرام شما با موفقیت احراز شد",
|
|
});
|
|
// Invalidate seller data after successful verification
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["seller"],
|
|
});
|
|
} else {
|
|
toast({
|
|
title: "احراز اکانت اینستاگرام با موفقیت انجام نشد",
|
|
description: "لطفا مجددا تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "احراز اکانت اینستاگرام با موفقیت انجام نشد",
|
|
description: "لطفا مجددا تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Custom hook to update seller information
|
|
*/
|
|
export const useSellerUpdate = () => {
|
|
const token = useAuthToken();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
username,
|
|
data,
|
|
}: {
|
|
username: string;
|
|
data: {
|
|
storeName: string;
|
|
storeDescription?: string | null;
|
|
storeLogo?: string | null;
|
|
instagramId?: string | null;
|
|
bannerType?: string | null;
|
|
bannerColor?: string | null;
|
|
bannerImage?: string | null;
|
|
};
|
|
}) => {
|
|
if (!token) throw new Error("Authentication required");
|
|
|
|
const sellerApi = createSellerManagementApi(token);
|
|
return await sellerApi.sellerPartialUpdate({ username, data });
|
|
},
|
|
onSuccess: (_, variables) => {
|
|
toast({
|
|
title: "اطلاعات فروشگاه با موفقیت بهروزرسانی شد",
|
|
description: "تغییرات شما ذخیره شد",
|
|
});
|
|
// Invalidate seller data after successful update
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["seller", variables.username],
|
|
});
|
|
navigate(`/store/${variables.username}`);
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "خطا در بهروزرسانی اطلاعات",
|
|
description: "لطفا مجددا تلاش کنید",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
};
|