From a642fc0faeaf5446a91e9d97bba5d03ebfbd82d2 Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Wed, 1 Jul 2026 16:43:53 +0330 Subject: [PATCH] =?UTF-8?q?feat(seller-team):=20multi-user=20dashboard=20?= =?UTF-8?q?=E2=80=94=20store=20switcher,=20role=20gating,=20team=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/components/MyLayout.tsx | 1 + app/components/store/StoreForm.tsx | 9 +- app/hooks/useStoreOwnership.ts | 54 ++++---- app/requestHandler/use-seller-hooks.ts | 100 +++++++++++++ app/routes/profile._index.tsx | 16 ++- app/routes/store.$storeId.setting.tsx | 25 +++- app/routes/store.$storeId.team.tsx | 185 +++++++++++++++++++++++++ 7 files changed, 350 insertions(+), 40 deletions(-) create mode 100644 app/routes/store.$storeId.team.tsx diff --git a/app/components/MyLayout.tsx b/app/components/MyLayout.tsx index 8454816..d7354af 100644 --- a/app/components/MyLayout.tsx +++ b/app/components/MyLayout.tsx @@ -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} diff --git a/app/components/store/StoreForm.tsx b/app/components/store/StoreForm.tsx index 79739c1..12b4f13 100644 --- a/app/components/store/StoreForm.tsx +++ b/app/components/store/StoreForm.tsx @@ -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(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); diff --git a/app/hooks/useStoreOwnership.ts b/app/hooks/useStoreOwnership.ts index 29867aa..48d10ad 100644 --- a/app/hooks/useStoreOwnership.ts +++ b/app/hooks/useStoreOwnership.ts @@ -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 ?? [], }; } diff --git a/app/requestHandler/use-seller-hooks.ts b/app/requestHandler/use-seller-hooks.ts index e6f62d1..8cf39a9 100644 --- a/app/requestHandler/use-seller-hooks.ts +++ b/app/requestHandler/use-seller-hooks.ts @@ -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 => { + 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 => { + 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 diff --git a/app/routes/profile._index.tsx b/app/routes/profile._index.tsx index 1216d85..bdbe81d 100644 --- a/app/routes/profile._index.tsx +++ b/app/routes/profile._index.tsx @@ -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() { !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={() => { diff --git a/app/routes/store.$storeId.setting.tsx b/app/routes/store.$storeId.setting.tsx index d903acd..53f43e5 100644 --- a/app/routes/store.$storeId.setting.tsx +++ b/app/routes/store.$storeId.setting.tsx @@ -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: , }, { @@ -78,12 +81,14 @@ export default function StoreSetting() { title: "داشبورد مالی", pageName: "financial-dashboard", link: `/store/${storeId}/financial-dashboard`, + ownerOnly: true, image: , }, { title: "تعیین نحوه ارسال", pageName: "shipping-method", link: `/store/${storeId}/shipping-method`, + ownerOnly: true, image: , }, { @@ -98,10 +103,18 @@ export default function StoreSetting() { link: `/store/${storeId}/products`, image: , }, + { + title: "مدیریت اعضای تیم", + pageName: "team", + link: `/store/${storeId}/team`, + ownerOnly: true, + image: , + }, { title: "ویرایش اطلاعات فروشگاه", pageName: "edit-store", link: `/store/${storeId}/edit`, + ownerOnly: true, image: , }, { @@ -113,6 +126,10 @@ export default function StoreSetting() { }, ]; + const storeMenuItems = allStoreMenuItems.filter( + (item) => !item.ownerOnly || role === "owner" + ); + const handleBack = () => { navigate(`/store/${storeId}`); }; diff --git a/app/routes/store.$storeId.team.tsx b/app/routes/store.$storeId.team.tsx new file mode 100644 index 0000000..b74bffa --- /dev/null +++ b/app/routes/store.$storeId.team.tsx @@ -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 ( +
+
+ navigate(`/store/${storeId}/setting`)} + /> +
+

+ مدیریت اعضای تیم +

+ +
+ {/* Invite by phone */} +
+

دعوت عضو جدید با شماره موبایل

+
+
+ setPhone(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleInvite(); + }} + /> +
+ +
+

+ عضو دعوت‌شده می‌تواند محصولات، سفارش‌ها و پیام‌های فروشگاه را مدیریت + کند اما به بخش مالی، تنظیمات فروشگاه و مدیریت تیم دسترسی ندارد. اگر + حساب کاربری با این شماره وجود نداشته باشد، دعوت تا اولین ورود او در + حالت «در انتظار» می‌ماند. +

+
+ + {/* Members list */} +
+

اعضای تیم

+ +
+ {members?.map((m) => ( +
+
+

+ {m.name || m.phone} +

+

+ {m.phone} +

+
+
+ {m.status === "pending" ? ( + + در انتظار + + ) : ( + + فعال + + )} + +
+
+ ))} +
+
+
+
+
+ ); +} + +export const meta: MetaFunction = () => [ + { title: "مدیریت اعضای تیم - ویترون" }, + { name: "robots", content: "noindex, nofollow" }, +];