Compare commits

..

4 Commits

Author SHA1 Message Date
ba8e839959 Merge pull request 'feat(seller): multi-user seller dashboard (owner + staff)' (#2) from feat/seller-team into main 2026-07-01 14:04:19 +00:00
bb44ebf0e5 fix(seller-team): guard owner-only routes + robust useMyStores
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>
2026-07-01 17:04:06 +03:30
a642fc0fae 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>
2026-07-01 16:43:53 +03:30
3502168a8b feat(seller-team): split buyer inbox vs store inbox in chat UI
Seller dashboard threads (type="store") now fetch the store's inbox via
useGetStoreThreads(storeId) -> /threads/list/?store_id=; the profile threads page
(type="user") keeps the personal buyer inbox. Depends on the seller-team backend
(store-aware threads + store_id-by-username), so it ships on this branch, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:00:23 +03:30
14 changed files with 427 additions and 48 deletions

View File

@ -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}

View File

@ -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);

View File

@ -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");

View File

@ -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" };
}

View File

@ -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
*/

View File

@ -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

View File

@ -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,14 +271,18 @@ 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 (target) {
navigate(`/store/${target.username}`);
} else if (user?.access_token) {
setStoreModalOpen(true);
} else {
if (user?.access_token) {
setStoreModalOpen(true);
} else {
setLoginModalOpen(true);
}
setLoginModalOpen(true);
}
}}
onLogoutClick={() => {

View File

@ -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);

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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();

View File

@ -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}`);
};

View File

@ -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 {

View 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" },
];