feat(seller-team): multi-user dashboard — store switcher, role gating, team UI
- useMyStores + team hooks (invite by phone / list / remove) in use-seller-hooks. - useStoreOwnership rewritten to allow staff (not just the owner) via my-stores; exposes `role` so callers can gate owner-only UI, and no longer redirects members away from a store they help manage. - Store settings: owner-only items (financial, shipping, edit store, instagram sync, team) hidden from staff; new "مدیریت اعضای تیم" entry (owner-only). - New /store/:storeId/team page: invite by phone + members list with pending/ active status + remove (owner-only, redirects staff). MyLayout keeps store mode on the team route. - StoreForm edit is owner-only (redirects staff). Profile "enter my store" now routes owners to their store and staff to the store they manage. - Chat already split earlier: seller inbox vs personal buyer inbox. Depends on the seller-team backend; ships on this branch, not main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3502168a8b
commit
a642fc0fae
@ -139,6 +139,7 @@ const MobileBottomNavigation = ({
|
|||||||
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
path.match(/^\/store\/[^/]+\/products$/) || // Products list: /store/{id}/products
|
||||||
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
path.match(/^\/store\/[^/]+\/product\/[^/]+$/) || // Product detail: /store/{id}/products/{id}
|
||||||
path.match(/^\/store\/[^/]+\/edit$/) || // Edit store: /store/{id}/edit
|
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\/add\/manual\/[^/]+$/) || // Add product: /store/add/manual/{id}
|
||||||
path.match(/^\/store\/create$/) || // Create store: /store/create
|
path.match(/^\/store\/create$/) || // Create store: /store/create
|
||||||
path.match(/^\/store\/authenticate\/instagram\/[^/]+$/) || // Instagram auth: /store/authenticate/instagram/{id}
|
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
|
// Check store ownership for edit mode
|
||||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(
|
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(
|
||||||
mode === "edit" ? storeId : undefined,
|
mode === "edit" ? storeId : undefined,
|
||||||
mode
|
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
|
// Form state
|
||||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||||
|
|||||||
@ -1,46 +1,40 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "@remix-run/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) {
|
export function useStoreOwnership(storeId: string | undefined, mode?: string) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: profileData, isLoading } = useServiceGetProfile();
|
const { data: stores, isLoading } = useMyStores();
|
||||||
|
|
||||||
|
const current = stores?.find((s) => s.username === storeId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for profile data to load
|
if (isLoading || mode === "create") return;
|
||||||
if (isLoading) return;
|
if (!storeId) {
|
||||||
|
|
||||||
// If no storeId provided, redirect to home
|
|
||||||
if (!storeId && mode !== "create") {
|
|
||||||
navigate("/");
|
navigate("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (stores === undefined) return; // not loaded yet
|
||||||
// If user doesn't have profile data, redirect to home
|
if (!current) {
|
||||||
if (!profileData) {
|
// Not a member of this store → send them to a store they can manage, else home.
|
||||||
navigate("/");
|
if (stores.length > 0) navigate(`/store/${stores[0].username}`);
|
||||||
return;
|
else navigate("/");
|
||||||
}
|
}
|
||||||
|
}, [stores, current, storeId, isLoading, navigate, mode]);
|
||||||
|
|
||||||
// If user doesn't have a seller account, redirect to home
|
const ownedOrFirst = stores?.find((s) => s.role === "owner") ?? stores?.[0];
|
||||||
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]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOwner: profileData?.sellerUsername === storeId,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
userStoreId: profileData?.sellerUsername,
|
isMember: !!current,
|
||||||
|
role: current?.role ?? null,
|
||||||
|
isOwner: current?.role === "owner",
|
||||||
|
userStoreId: ownedOrFirst?.username,
|
||||||
|
stores: stores ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,106 @@ import {
|
|||||||
createFollowsManagementApi,
|
createFollowsManagementApi,
|
||||||
createSellerManagementApi,
|
createSellerManagementApi,
|
||||||
} from "~/utils/api-client-factory";
|
} 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}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
|
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
|
* Custom hook to fetch seller data by sellerId using React Query
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useNavigate } from "@remix-run/react";
|
import { Link, useNavigate } from "@remix-run/react";
|
||||||
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
import { useServiceGetProfile } from "~/utils/RequestHandler";
|
||||||
|
import { useMyStores } from "~/requestHandler/use-seller-hooks";
|
||||||
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
|
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
|
||||||
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
|
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
|
||||||
import { Skeleton } from "~/components/ui/skeleton";
|
import { Skeleton } from "~/components/ui/skeleton";
|
||||||
@ -57,6 +58,7 @@ export default function Profile() {
|
|||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||||
const { data: profileData } = useServiceGetProfile();
|
const { data: profileData } = useServiceGetProfile();
|
||||||
|
const { data: myStores } = useMyStores();
|
||||||
const { data: walletData } = useServiceGetWallet();
|
const { data: walletData } = useServiceGetWallet();
|
||||||
const { data: badgeCounts } = useBadgeCounts();
|
const { data: badgeCounts } = useBadgeCounts();
|
||||||
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
|
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
|
||||||
@ -269,14 +271,18 @@ export default function Profile() {
|
|||||||
<MenuItems
|
<MenuItems
|
||||||
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
|
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
|
||||||
onStoreClick={() => {
|
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) {
|
if (profileData?.sellerUsername) {
|
||||||
navigate(`/store/${profileData.sellerUsername}`);
|
navigate(`/store/${profileData.sellerUsername}`);
|
||||||
|
} else if (target) {
|
||||||
|
navigate(`/store/${target.username}`);
|
||||||
|
} else if (user?.access_token) {
|
||||||
|
setStoreModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
if (user?.access_token) {
|
setLoginModalOpen(true);
|
||||||
setStoreModalOpen(true);
|
|
||||||
} else {
|
|
||||||
setLoginModalOpen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLogoutClick={() => {
|
onLogoutClick={() => {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
Code,
|
Code,
|
||||||
Instagram,
|
Instagram,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||||||
@ -29,6 +30,7 @@ interface MenuItemType {
|
|||||||
link?: string;
|
link?: string;
|
||||||
pageName: string;
|
pageName: string;
|
||||||
hasBorder?: boolean;
|
hasBorder?: boolean;
|
||||||
|
ownerOnly?: boolean;
|
||||||
image: React.ReactNode;
|
image: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,8 +40,8 @@ export default function StoreSetting() {
|
|||||||
const { data } = useSellerData(storeId);
|
const { data } = useSellerData(storeId);
|
||||||
const { theme: initialTheme } = useRootData();
|
const { theme: initialTheme } = useRootData();
|
||||||
|
|
||||||
// Check store ownership - this will redirect if user doesn't own the store
|
// Store access guard (owner or staff). role gates owner-only menu items.
|
||||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
const { isLoading: isOwnershipLoading, role } = useStoreOwnership(storeId);
|
||||||
|
|
||||||
// State for Instagram sync drawer
|
// State for Instagram sync drawer
|
||||||
const [instagramSyncDrawerOpen, setInstagramSyncDrawerOpen] = useState(false);
|
const [instagramSyncDrawerOpen, setInstagramSyncDrawerOpen] = useState(false);
|
||||||
@ -54,12 +56,13 @@ export default function StoreSetting() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu items for store management
|
// Menu items for store management. `ownerOnly` items are hidden from staff.
|
||||||
const storeMenuItems: MenuItemType[] = [
|
const allStoreMenuItems: MenuItemType[] = [
|
||||||
{
|
{
|
||||||
title: "وارد کردن پست های اینستاگرام",
|
title: "وارد کردن پست های اینستاگرام",
|
||||||
pageName: "instagram-sync",
|
pageName: "instagram-sync",
|
||||||
hasBorder: true,
|
hasBorder: true,
|
||||||
|
ownerOnly: true,
|
||||||
image: <Instagram size={20} />,
|
image: <Instagram size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -78,12 +81,14 @@ export default function StoreSetting() {
|
|||||||
title: "داشبورد مالی",
|
title: "داشبورد مالی",
|
||||||
pageName: "financial-dashboard",
|
pageName: "financial-dashboard",
|
||||||
link: `/store/${storeId}/financial-dashboard`,
|
link: `/store/${storeId}/financial-dashboard`,
|
||||||
|
ownerOnly: true,
|
||||||
image: <LayoutDashboard size={20} />,
|
image: <LayoutDashboard size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "تعیین نحوه ارسال",
|
title: "تعیین نحوه ارسال",
|
||||||
pageName: "shipping-method",
|
pageName: "shipping-method",
|
||||||
link: `/store/${storeId}/shipping-method`,
|
link: `/store/${storeId}/shipping-method`,
|
||||||
|
ownerOnly: true,
|
||||||
image: <Truck size={20} />,
|
image: <Truck size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -98,10 +103,18 @@ export default function StoreSetting() {
|
|||||||
link: `/store/${storeId}/products`,
|
link: `/store/${storeId}/products`,
|
||||||
image: <TableProperties size={20} />,
|
image: <TableProperties size={20} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "مدیریت اعضای تیم",
|
||||||
|
pageName: "team",
|
||||||
|
link: `/store/${storeId}/team`,
|
||||||
|
ownerOnly: true,
|
||||||
|
image: <Users size={20} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "ویرایش اطلاعات فروشگاه",
|
title: "ویرایش اطلاعات فروشگاه",
|
||||||
pageName: "edit-store",
|
pageName: "edit-store",
|
||||||
link: `/store/${storeId}/edit`,
|
link: `/store/${storeId}/edit`,
|
||||||
|
ownerOnly: true,
|
||||||
image: <PenLine size={20} />,
|
image: <PenLine size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,6 +126,10 @@ export default function StoreSetting() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const storeMenuItems = allStoreMenuItems.filter(
|
||||||
|
(item) => !item.ownerOnly || role === "owner"
|
||||||
|
);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
navigate(`/store/${storeId}`);
|
navigate(`/store/${storeId}`);
|
||||||
};
|
};
|
||||||
|
|||||||
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