feat(seller): multi-user seller dashboard (owner + staff) #2
@ -139,6 +139,7 @@ const MobileBottomNavigation = ({
|
||||
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
||||
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
||||
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
|
||||
path.match(/^\/store\/[^/]+\/team$/) || // Team management: /store/{id}/team
|
||||
path.match(/^\/store\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
|
||||
path.match(/^\/store\/create$/) || // Create store: /store/create
|
||||
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
|
||||
|
||||
@ -116,11 +116,18 @@ export default function StoreForm({ mode, storeId }: StoreFormProps) {
|
||||
);
|
||||
|
||||
// Check store ownership for edit mode
|
||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(
|
||||
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(
|
||||
mode === "edit" ? storeId : undefined,
|
||||
mode
|
||||
);
|
||||
|
||||
// Editing store info is owner-only; send staff back to the dashboard.
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && !isOwnershipLoading && role && role !== "owner") {
|
||||
navigate(`/store/${storeId}`);
|
||||
}
|
||||
}, [mode, isOwnershipLoading, role, storeId, navigate]);
|
||||
|
||||
// Form state
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import AppInput from "../AppInput";
|
||||
import { useState } from "react";
|
||||
import { useGetUserThreads } from "../../requestHandler/use-chat-hooks";
|
||||
import {
|
||||
useGetUserThreads,
|
||||
useGetStoreThreads,
|
||||
} from "../../requestHandler/use-chat-hooks";
|
||||
import {
|
||||
ThreadList,
|
||||
ThreadParticipant,
|
||||
@ -20,7 +23,16 @@ interface ThreadsPageProps {
|
||||
|
||||
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const { data: threads, isLoading, isError, refetch } = useGetUserThreads();
|
||||
// Seller dashboard shows the STORE's inbox; the profile page shows the user's
|
||||
// personal (buyer) chats. Two different endpoints so the two never mix.
|
||||
const userThreads = useGetUserThreads();
|
||||
const storeThreads = useGetStoreThreads(type === "store" ? storeId : undefined);
|
||||
const {
|
||||
data: threads,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = type === "store" ? storeThreads : userThreads;
|
||||
const sortedThreads = threads?.sort((a, b) => {
|
||||
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
||||
const dateB = new Date(b.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
||||
|
||||
@ -1,46 +1,58 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useServiceGetProfile } from "../requestHandler/use-profile-hooks";
|
||||
import { useMyStores } from "../requestHandler/use-seller-hooks";
|
||||
|
||||
/**
|
||||
* Guards a store dashboard route. A user may enter a store they OWN or are an
|
||||
* active STAFF member of. Non-members are redirected away. Returns the user's
|
||||
* role for this store so callers can gate owner-only UI.
|
||||
*/
|
||||
export function useStoreOwnership(storeId: string | undefined, mode?: string) {
|
||||
const navigate = useNavigate();
|
||||
const { data: profileData, isLoading } = useServiceGetProfile();
|
||||
const { data: stores, isLoading } = useMyStores();
|
||||
|
||||
const current = stores?.find((s) => s.username === storeId);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for profile data to load
|
||||
if (isLoading) return;
|
||||
|
||||
// If no storeId provided, redirect to home
|
||||
if (!storeId && mode !== "create") {
|
||||
if (isLoading || mode === "create") return;
|
||||
if (!storeId) {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// If user doesn't have profile data, redirect to home
|
||||
if (!profileData) {
|
||||
navigate("/");
|
||||
return;
|
||||
if (stores === undefined) return; // not loaded yet
|
||||
if (!current) {
|
||||
// Not a member of this store → send them to a store they can manage, else home.
|
||||
if (stores.length > 0) navigate(`/store/${stores[0].username}`);
|
||||
else navigate("/");
|
||||
}
|
||||
}, [stores, current, storeId, isLoading, navigate, mode]);
|
||||
|
||||
// If user doesn't have a seller account, redirect to home
|
||||
if (!profileData.sellerUsername && mode !== "create") {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the storeId doesn't match the user's seller username, redirect to their own store
|
||||
if (
|
||||
profileData.sellerUsername !== storeId &&
|
||||
mode !== "create" &&
|
||||
storeId
|
||||
) {
|
||||
navigate(`/store/${profileData.sellerUsername}`);
|
||||
return;
|
||||
}
|
||||
}, [profileData, storeId, isLoading, navigate, mode]);
|
||||
const ownedOrFirst = stores?.find((s) => s.role === "owner") ?? stores?.[0];
|
||||
|
||||
return {
|
||||
isOwner: profileData?.sellerUsername === storeId,
|
||||
isLoading,
|
||||
userStoreId: profileData?.sellerUsername,
|
||||
isMember: !!current,
|
||||
role: current?.role ?? null,
|
||||
isOwner: current?.role === "owner",
|
||||
userStoreId: ownedOrFirst?.username,
|
||||
stores: stores ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for OWNER-ONLY store pages (financial, shipping, edit, team). Redirects
|
||||
* non-members (via useStoreOwnership) and staff members back to the dashboard.
|
||||
* Returns { isLoading, isOwner } so callers can render a spinner meanwhile.
|
||||
*/
|
||||
export function useRequireOwner(storeId: string | undefined) {
|
||||
const navigate = useNavigate();
|
||||
const { isLoading, role } = useStoreOwnership(storeId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && role && role !== "owner") {
|
||||
navigate(`/store/${storeId}`);
|
||||
}
|
||||
}, [isLoading, role, storeId, navigate]);
|
||||
|
||||
return { isLoading, isOwner: role === "owner" };
|
||||
}
|
||||
|
||||
@ -31,6 +31,30 @@ export function useGetUserThreads() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List a STORE's inbox (owner + staff), scoped to the store's own threads.
|
||||
* Distinct from useGetUserThreads(), which returns the user's personal buyer chats.
|
||||
*/
|
||||
export function useGetStoreThreads(storeId?: string) {
|
||||
const token = useAuthToken();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["getStoreThreads", storeId],
|
||||
queryFn: async () => {
|
||||
if (!token) {
|
||||
throw new Error("Authentication token is required");
|
||||
}
|
||||
const chatApi = createChatApi(token);
|
||||
return await chatApi.apiChatV1ThreadsListList({ storeId });
|
||||
},
|
||||
enabled: !!token && !!storeId,
|
||||
retry: 1,
|
||||
retryDelay: 3000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 20 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side function to get thread details
|
||||
*/
|
||||
|
||||
@ -15,6 +15,110 @@ 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
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Link, useNavigate } from "@remix-run/react";
|
||||
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
||||
import { useMyStores } from "~/requestHandler/use-seller-hooks";
|
||||
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
|
||||
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
@ -57,6 +58,7 @@ export default function Profile() {
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||
const { data: profileData } = useServiceGetProfile();
|
||||
const { data: myStores } = useMyStores();
|
||||
const { data: walletData } = useServiceGetWallet();
|
||||
const { data: badgeCounts } = useBadgeCounts();
|
||||
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
|
||||
@ -269,15 +271,19 @@ export default function Profile() {
|
||||
<MenuItems
|
||||
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
|
||||
onStoreClick={() => {
|
||||
// Enter a store the user can manage: their own if they own one,
|
||||
// otherwise the first store they're a staff member of.
|
||||
const owned = myStores?.find((s) => s.role === "owner");
|
||||
const target = owned || myStores?.[0];
|
||||
if (profileData?.sellerUsername) {
|
||||
navigate(`/store/${profileData.sellerUsername}`);
|
||||
} else {
|
||||
if (user?.access_token) {
|
||||
} else if (target) {
|
||||
navigate(`/store/${target.username}`);
|
||||
} else if (user?.access_token) {
|
||||
setStoreModalOpen(true);
|
||||
} else {
|
||||
setLoginModalOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onLogoutClick={() => {
|
||||
setLogoutModalOpen(true);
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
HandCoins,
|
||||
} from "lucide-react";
|
||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@ -41,6 +42,7 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export default function FinancialDashboard() {
|
||||
const { storeId } = useParams();
|
||||
useRequireOwner(storeId); // financial is owner-only
|
||||
const navigate = useNavigate();
|
||||
const handleBack = () => {
|
||||
safeGoBack(navigate);
|
||||
|
||||
@ -16,8 +16,9 @@ import {
|
||||
CarouselItem,
|
||||
} from "../components/ui/carousel";
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useNavigate, useParams } from "@remix-run/react";
|
||||
import { useCarousel } from "../hooks/useCarousel";
|
||||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTitle,
|
||||
@ -50,6 +51,8 @@ import { BankLogo } from "~/components/BankLogo";
|
||||
|
||||
export default function CashingFunds() {
|
||||
const navigate = useNavigate();
|
||||
const { storeId } = useParams();
|
||||
useRequireOwner(storeId); // financial is owner-only
|
||||
const handleBack = () => {
|
||||
safeGoBack(navigate);
|
||||
};
|
||||
|
||||
@ -2,14 +2,17 @@ import { Sun, Waves, Loader2 } from "lucide-react";
|
||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||
import PersianDatePicker from "../components/PersianDatePicker";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
|
||||
import { formatPrice, safeGoBack } from "../utils/helpers";
|
||||
import { useServiceGetReport } from "../requestHandler/use-wallet-hooks";
|
||||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||
import jMoment from "moment-jalaali";
|
||||
import { MetaFunction } from "@remix-run/node";
|
||||
|
||||
export default function Reports() {
|
||||
const navigate = useNavigate();
|
||||
const { storeId } = useParams();
|
||||
useRequireOwner(storeId); // financial is owner-only
|
||||
const handleBack = () => {
|
||||
safeGoBack(navigate);
|
||||
};
|
||||
|
||||
@ -12,8 +12,9 @@ import {
|
||||
} from "../components/ui/drawer";
|
||||
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
|
||||
import PersianDatePicker from "../components/PersianDatePicker";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams, useParams } from "react-router-dom";
|
||||
import { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks";
|
||||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||
import jMoment from "moment-jalaali";
|
||||
import filterIcon from "~/assets/icons/filter.png";
|
||||
import filterIconDark from "~/assets/icons/filter-dark.png";
|
||||
@ -51,6 +52,8 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export default function Transactions() {
|
||||
const navigate = useNavigate();
|
||||
const { storeId } = useParams();
|
||||
useRequireOwner(storeId); // financial is owner-only
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { theme } = useRootData();
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Code,
|
||||
Instagram,
|
||||
MessageCircle,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||||
@ -29,6 +30,7 @@ interface MenuItemType {
|
||||
link?: string;
|
||||
pageName: string;
|
||||
hasBorder?: boolean;
|
||||
ownerOnly?: boolean;
|
||||
image: React.ReactNode;
|
||||
}
|
||||
|
||||
@ -38,8 +40,8 @@ export default function StoreSetting() {
|
||||
const { data } = useSellerData(storeId);
|
||||
const { theme: initialTheme } = useRootData();
|
||||
|
||||
// Check store ownership - this will redirect if user doesn't own the store
|
||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
||||
// Store access guard (owner or staff). role gates owner-only menu items.
|
||||
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(storeId);
|
||||
|
||||
// State for Instagram sync drawer
|
||||
const [instagramSyncDrawerOpen, setInstagramSyncDrawerOpen] = useState(false);
|
||||
@ -54,12 +56,13 @@ export default function StoreSetting() {
|
||||
);
|
||||
}
|
||||
|
||||
// Menu items for store management
|
||||
const storeMenuItems: MenuItemType[] = [
|
||||
// Menu items for store management. `ownerOnly` items are hidden from staff.
|
||||
const allStoreMenuItems: MenuItemType[] = [
|
||||
{
|
||||
title: "وارد کردن پست های اینستاگرام",
|
||||
pageName: "instagram-sync",
|
||||
hasBorder: true,
|
||||
ownerOnly: true,
|
||||
image: <Instagram size={20} />,
|
||||
},
|
||||
{
|
||||
@ -78,12 +81,14 @@ export default function StoreSetting() {
|
||||
title: "داشبورد مالی",
|
||||
pageName: "financial-dashboard",
|
||||
link: `/store/${storeId}/financial-dashboard`,
|
||||
ownerOnly: true,
|
||||
image: <LayoutDashboard size={20} />,
|
||||
},
|
||||
{
|
||||
title: "تعیین نحوه ارسال",
|
||||
pageName: "shipping-method",
|
||||
link: `/store/${storeId}/shipping-method`,
|
||||
ownerOnly: true,
|
||||
image: <Truck size={20} />,
|
||||
},
|
||||
{
|
||||
@ -98,10 +103,18 @@ export default function StoreSetting() {
|
||||
link: `/store/${storeId}/products`,
|
||||
image: <TableProperties size={20} />,
|
||||
},
|
||||
{
|
||||
title: "مدیریت اعضای تیم",
|
||||
pageName: "team",
|
||||
link: `/store/${storeId}/team`,
|
||||
ownerOnly: true,
|
||||
image: <Users size={20} />,
|
||||
},
|
||||
{
|
||||
title: "ویرایش اطلاعات فروشگاه",
|
||||
pageName: "edit-store",
|
||||
link: `/store/${storeId}/edit`,
|
||||
ownerOnly: true,
|
||||
image: <PenLine size={20} />,
|
||||
},
|
||||
{
|
||||
@ -113,6 +126,10 @@ export default function StoreSetting() {
|
||||
},
|
||||
];
|
||||
|
||||
const storeMenuItems = allStoreMenuItems.filter(
|
||||
(item) => !item.ownerOnly || role === "owner"
|
||||
);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/store/${storeId}`);
|
||||
};
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types";
|
||||
import { useToast } from "../hooks/use-toast";
|
||||
import { useParams, useNavigate } from "@remix-run/react";
|
||||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||||
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||
import { MetaFunction } from "@remix-run/node";
|
||||
import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers";
|
||||
|
||||
@ -37,8 +37,8 @@ export default function ShippingMethod() {
|
||||
const navigate = useNavigate();
|
||||
const [savingCourierId, setSavingCourierId] = useState<string | null>(null);
|
||||
|
||||
// Check store ownership - this will redirect if user doesn't own the store
|
||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
||||
// Shipping/courier config is owner-only — redirect staff back to the dashboard.
|
||||
const { isLoading: isOwnershipLoading } = useRequireOwner(storeId);
|
||||
|
||||
// Fetch all available couriers
|
||||
const {
|
||||
|
||||
185
app/routes/store.$storeId.team.tsx
Normal file
185
app/routes/store.$storeId.team.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { MetaFunction, useNavigate, useParams } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash2, UserPlus, Loader2 } from "lucide-react";
|
||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||
import AppInput from "../components/AppInput";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||||
import {
|
||||
useStoreMembers,
|
||||
useInviteMember,
|
||||
useRemoveMember,
|
||||
} from "../requestHandler/use-seller-hooks";
|
||||
import { useToast } from "../hooks/use-toast";
|
||||
import UiProvider from "../components/UiProvider";
|
||||
|
||||
export default function StoreTeam() {
|
||||
const { storeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Team management is owner-only.
|
||||
const { isLoading: isAccessLoading, role } = useStoreOwnership(storeId);
|
||||
useEffect(() => {
|
||||
if (!isAccessLoading && role && role !== "owner") {
|
||||
navigate(`/store/${storeId}`);
|
||||
}
|
||||
}, [isAccessLoading, role, storeId, navigate]);
|
||||
|
||||
const {
|
||||
data: members,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useStoreMembers(storeId);
|
||||
const inviteMember = useInviteMember(storeId);
|
||||
const removeMember = useRemoveMember(storeId);
|
||||
|
||||
const [phone, setPhone] = useState("");
|
||||
|
||||
const handleInvite = () => {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) return;
|
||||
inviteMember.mutate(trimmed, {
|
||||
onSuccess: () => {
|
||||
setPhone("");
|
||||
toast({
|
||||
title: "دعوت انجام شد",
|
||||
description: "عضو جدید به تیم فروشگاه اضافه شد.",
|
||||
});
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: e instanceof Error ? e.message : "دعوت عضو ناموفق بود.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
removeMember.mutate(id, {
|
||||
onSuccess: () =>
|
||||
toast({ title: "حذف شد", description: "عضو از تیم حذف شد." }),
|
||||
onError: () =>
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "حذف عضو ناموفق بود.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-WHITE">
|
||||
<div className="lg:hidden">
|
||||
<HeaderWithSupport
|
||||
title="مدیریت اعضای تیم"
|
||||
onBack={() => navigate(`/store/${storeId}/setting`)}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="hidden lg:block text-[24px] font-extrabold mb-6">
|
||||
مدیریت اعضای تیم
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col gap-6 p-4 lg:max-w-[720px]">
|
||||
{/* Invite by phone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-R14">دعوت عضو جدید با شماره موبایل</p>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<AppInput
|
||||
inputTitle=""
|
||||
inputDir="ltr"
|
||||
inputValue={phone}
|
||||
inputPlaceholder="09xxxxxxxxx"
|
||||
inputOnChange={(e) => setPhone(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleInvite();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="dark"
|
||||
size="lg"
|
||||
className="shrink-0 !h-12"
|
||||
onClick={handleInvite}
|
||||
disabled={inviteMember.isPending || !phone.trim()}
|
||||
>
|
||||
{inviteMember.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<UserPlus size={18} />
|
||||
دعوت
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-R10 text-GRAY leading-5">
|
||||
عضو دعوتشده میتواند محصولات، سفارشها و پیامهای فروشگاه را مدیریت
|
||||
کند اما به بخش مالی، تنظیمات فروشگاه و مدیریت تیم دسترسی ندارد. اگر
|
||||
حساب کاربری با این شماره وجود نداشته باشد، دعوت تا اولین ورود او در
|
||||
حالت «در انتظار» میماند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-R12 font-bold text-GRAY">اعضای تیم</p>
|
||||
<UiProvider
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isEmpty={!!members && members.length === 0}
|
||||
onRetry={refetch}
|
||||
type="following"
|
||||
>
|
||||
<div className="flex flex-col divide-y divide-WHITE2 border border-WHITE3 rounded-2xl overflow-hidden">
|
||||
{members?.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between gap-3 p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-R14 font-bold">
|
||||
{m.name || m.phone}
|
||||
</p>
|
||||
<p className="text-R10 text-GRAY" dir="ltr">
|
||||
{m.phone}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{m.status === "pending" ? (
|
||||
<span className="text-R10 font-semibold text-amber-600 bg-amber-50 rounded-full px-2 py-1">
|
||||
در انتظار
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-R10 font-semibold text-green-600 bg-green-50 rounded-full px-2 py-1">
|
||||
فعال
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف عضو"
|
||||
onClick={() => handleRemove(m.id)}
|
||||
disabled={removeMember.isPending}
|
||||
className="text-RED disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</UiProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: "مدیریت اعضای تیم - ویترون" },
|
||||
{ name: "robots", content: "noindex, nofollow" },
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user