Compare commits
4 Commits
e60a03ac65
...
ba8e839959
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8e839959 | |||
| bb44ebf0e5 | |||
| a642fc0fae | |||
| 3502168a8b |
@ -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,7 +1,10 @@
|
|||||||
import { Link } from "@remix-run/react";
|
import { Link } from "@remix-run/react";
|
||||||
import AppInput from "../AppInput";
|
import AppInput from "../AppInput";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useGetUserThreads } from "../../requestHandler/use-chat-hooks";
|
import {
|
||||||
|
useGetUserThreads,
|
||||||
|
useGetStoreThreads,
|
||||||
|
} from "../../requestHandler/use-chat-hooks";
|
||||||
import {
|
import {
|
||||||
ThreadList,
|
ThreadList,
|
||||||
ThreadParticipant,
|
ThreadParticipant,
|
||||||
@ -20,7 +23,16 @@ interface ThreadsPageProps {
|
|||||||
|
|
||||||
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
export default function ThreadsPage({ type, storeId }: ThreadsPageProps) {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
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 sortedThreads = threads?.sort((a, b) => {
|
||||||
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
const dateA = new Date(a.lastMessageTime || "2025-01-18T00:00:00.000Z");
|
||||||
const dateB = new Date(b.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 { 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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Server-side function to get thread details
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,6 +15,110 @@ 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}` },
|
||||||
|
});
|
||||||
|
// 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
|
* 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={() => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
HandCoins,
|
HandCoins,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||||
|
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||||
|
|
||||||
export const meta: MetaFunction = () => {
|
export const meta: MetaFunction = () => {
|
||||||
return [
|
return [
|
||||||
@ -41,6 +42,7 @@ export const meta: MetaFunction = () => {
|
|||||||
|
|
||||||
export default function FinancialDashboard() {
|
export default function FinancialDashboard() {
|
||||||
const { storeId } = useParams();
|
const { storeId } = useParams();
|
||||||
|
useRequireOwner(storeId); // financial is owner-only
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
safeGoBack(navigate);
|
safeGoBack(navigate);
|
||||||
|
|||||||
@ -16,8 +16,9 @@ import {
|
|||||||
CarouselItem,
|
CarouselItem,
|
||||||
} from "../components/ui/carousel";
|
} from "../components/ui/carousel";
|
||||||
import { useCallback, useState, useRef, useEffect } from "react";
|
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 { useCarousel } from "../hooks/useCarousel";
|
||||||
|
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
@ -50,6 +51,8 @@ import { BankLogo } from "~/components/BankLogo";
|
|||||||
|
|
||||||
export default function CashingFunds() {
|
export default function CashingFunds() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { storeId } = useParams();
|
||||||
|
useRequireOwner(storeId); // financial is owner-only
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
safeGoBack(navigate);
|
safeGoBack(navigate);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,14 +2,17 @@ import { Sun, Waves, Loader2 } from "lucide-react";
|
|||||||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||||||
import PersianDatePicker from "../components/PersianDatePicker";
|
import PersianDatePicker from "../components/PersianDatePicker";
|
||||||
import { useEffect, useState } from "react";
|
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 { formatPrice, safeGoBack } from "../utils/helpers";
|
||||||
import { useServiceGetReport } from "../requestHandler/use-wallet-hooks";
|
import { useServiceGetReport } from "../requestHandler/use-wallet-hooks";
|
||||||
|
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||||
import jMoment from "moment-jalaali";
|
import jMoment from "moment-jalaali";
|
||||||
import { MetaFunction } from "@remix-run/node";
|
import { MetaFunction } from "@remix-run/node";
|
||||||
|
|
||||||
export default function Reports() {
|
export default function Reports() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { storeId } = useParams();
|
||||||
|
useRequireOwner(storeId); // financial is owner-only
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
safeGoBack(navigate);
|
safeGoBack(navigate);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,8 +12,9 @@ import {
|
|||||||
} from "../components/ui/drawer";
|
} from "../components/ui/drawer";
|
||||||
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
|
||||||
import PersianDatePicker from "../components/PersianDatePicker";
|
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 { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks";
|
||||||
|
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||||
import jMoment from "moment-jalaali";
|
import jMoment from "moment-jalaali";
|
||||||
import filterIcon from "~/assets/icons/filter.png";
|
import filterIcon from "~/assets/icons/filter.png";
|
||||||
import filterIconDark from "~/assets/icons/filter-dark.png";
|
import filterIconDark from "~/assets/icons/filter-dark.png";
|
||||||
@ -51,6 +52,8 @@ export const meta: MetaFunction = () => {
|
|||||||
|
|
||||||
export default function Transactions() {
|
export default function Transactions() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { storeId } = useParams();
|
||||||
|
useRequireOwner(storeId); // financial is owner-only
|
||||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { theme } = useRootData();
|
const { theme } = useRootData();
|
||||||
|
|||||||
@ -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}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types";
|
import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types";
|
||||||
import { useToast } from "../hooks/use-toast";
|
import { useToast } from "../hooks/use-toast";
|
||||||
import { useParams, useNavigate } from "@remix-run/react";
|
import { useParams, useNavigate } from "@remix-run/react";
|
||||||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
import { useRequireOwner } from "../hooks/useStoreOwnership";
|
||||||
import { MetaFunction } from "@remix-run/node";
|
import { MetaFunction } from "@remix-run/node";
|
||||||
import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers";
|
import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers";
|
||||||
|
|
||||||
@ -37,8 +37,8 @@ export default function ShippingMethod() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [savingCourierId, setSavingCourierId] = useState<string | null>(null);
|
const [savingCourierId, setSavingCourierId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check store ownership - this will redirect if user doesn't own the store
|
// Shipping/courier config is owner-only — redirect staff back to the dashboard.
|
||||||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
const { isLoading: isOwnershipLoading } = useRequireOwner(storeId);
|
||||||
|
|
||||||
// Fetch all available couriers
|
// Fetch all available couriers
|
||||||
const {
|
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