feat(seller): multi-user seller dashboard (owner + staff) #2

Merged
ArdaSamadi merged 3 commits from feat/seller-team into main 2026-07-01 14:04:20 +00:00
7 changed files with 350 additions and 40 deletions
Showing only changes of commit a642fc0fae - Show all commits

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,46 +1,40 @@
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 ?? [],
};
}

View File

@ -15,6 +15,106 @@ 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}` },
});
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

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

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

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