feat: enhance dashboard layout with user profile header and mobile navbar

feat: update campaign types to include user_id_nickname and add comments and signature item interfaces

feat: improve campaign detail page with comments functionality and remove comment feature

feat: refactor campaigns page to use new campaign service and update tab labels to Persian

feat: enhance user profile page with image upload functionality and improved form handling

fix: update router paths for campaign detail page

feat: implement user authentication context and protected route handling

feat: add global types for token management

feat: create utility functions for image handling and uploading
This commit is contained in:
MehrdadAdabi 2025-11-24 16:58:35 +03:30
parent ce4c33d46d
commit f9ced9349b
27 changed files with 768 additions and 457 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=https://yarigaran-back.pelekan.org

View File

@ -43,40 +43,40 @@
} }
}, },
"campaigns": { "campaigns": {
"title": مپینها", "title": ارزارها",
"subtitle": "برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید", "subtitle": "برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید",
"search": "جستجوی کمپین...", "search": "جستجوی کارزار...",
"create": "ایجاد کمپین", "create": "ایجاد کارزار",
"tabs": { "tabs": {
"all": "تمام کمپینها", "all": "تمام کارزارها",
"my": مپین‌های من", "my": ارزار‌های من",
"top": مپین‌های برتر", "top": ارزار‌های برتر",
"group": مپین‌های گروه" "group": ارزار‌های گروه"
}, },
"createModal": { "createModal": {
"title": "ایجاد کمپین جدید", "title": "ایجاد کارزار جدید",
"titleLabel": "عنوان کمپین", "titleLabel": "عنوان کارزار",
"titlePlaceholder": "عنوان کمپین را وارد کنید", "titlePlaceholder": "عنوان کارزار را وارد کنید",
"description": "توضیحات", "description": "توضیحات",
"descriptionPlaceholder": "توضیحات کمپین را وارد کنید (حداقل 20 کاراکتر)", "descriptionPlaceholder": "توضیحات کارزار را وارد کنید (حداقل 20 کاراکتر)",
"image": "تصویر کمپین", "image": "تصویر کارزار",
"upload": "کلیک کنید یا تصویر را بکشید", "upload": "کلیک کنید یا تصویر را بکشید",
"cancel": "لغو", "cancel": "لغو",
"submit": "ایجاد کمپین", "submit": "ایجاد کارزار",
"submitting": "در حال ایجاد...", "submitting": "در حال ایجاد...",
"errors": { "errors": {
"titleRequired": "عنوان کمپین الزامی است", "titleRequired": "عنوان کارزار الزامی است",
"descriptionRequired": "توضیحات الزامی است", "descriptionRequired": "توضیحات الزامی است",
"descriptionMinLength": "توضیحات باید حداقل 20 کاراکتر باشد", "descriptionMinLength": "توضیحات باید حداقل 20 کاراکتر باشد",
"imageRequired": "تصویر الزامی است" "imageRequired": "تصویر الزامی است"
} }
}, },
"detail": { "detail": {
"back": "بازگشت به کمپینها", "back": "بازگشت به کارزارها",
"by": "توسط", "by": "توسط",
"signatures": "امضا", "signatures": "امضا",
"comments": "نظر", "comments": "نظر",
"sign": "امضای کمپین", "sign": "امضای کارزار",
"signing": "در حال امضا...", "signing": "در حال امضا...",
"signed": "شما امضا کرده‌اید ✓", "signed": "شما امضا کرده‌اید ✓",
"signers": "امضاکنندگان", "signers": "امضاکنندگان",
@ -85,17 +85,17 @@
"addComment": "نظر خود را بنویسید...", "addComment": "نظر خود را بنویسید...",
"send": "ارسال", "send": "ارسال",
"sending": "ارسال...", "sending": "ارسال...",
"notFound": مپین یافت نشد" "notFound": ارزار یافت نشد"
}, },
"empty": { "empty": {
"my": "هنوز کمپینی ایجاد نکرده‌اید", "my": "هنوز کارزاری ایجاد نکرده‌اید",
"noResults": مپینی یافت نشد", "noResults": ارزاری یافت نشد",
"createFirst": "ایجاد اولین کمپین خود" "createFirst": "ایجاد اولین کارزار خود"
}, },
"notifications": { "notifications": {
"created": مپین با موفقیت ایجاد شد", "created": ارزار با موفقیت ایجاد شد",
"signed": "با موفقیت امضا کردید", "signed": "با موفقیت امضا کردید",
"commentAdded": "نظر شما اضافه شد", "commentAdded": "نظر شما اضافه شد",
"alreadySigned": "شما قبلاً این کمپین را امضا کرده‌اید", "alreadySigned": "شما قبلاً این کارزار را امضا کرده‌اید",
"emptyComment": "لطفاً نظری بنویسید" "emptyComment": "لطفاً نظری بنویسید"
} }

View File

@ -0,0 +1,81 @@
import { cn } from "@core/lib/utils";
import { Coins, User } from "lucide-react";
import { useNavigate } from "react-router-dom";
export interface DashboardHeaderProps {
profileImageUrl?: string;
fullName: string;
coins: number;
className?: string;
onProfileClick?: () => void;
}
export function DashboardHeader({
profileImageUrl,
fullName,
coins,
className,
onProfileClick,
}: DashboardHeaderProps) {
const navigate = useNavigate();
const handleProfileClick = () => {
if (onProfileClick) {
onProfileClick();
} else {
navigate("/dashboard/profile", { replace: true });
}
};
return (
<header
className={cn(
"sticky top-0 z-40",
"bg-white border-b border-gray-200 shadow-sm",
"w-full",
className
)}
dir="rtl"
>
<div className="flex items-center justify-between h-16 sm:px-6 lg:px-8">
{/* RIGHT SIDE: Profile Image + Name */}
<button
onClick={handleProfileClick}
className="flex items-center gap-3 hover:opacity-80 transition-opacity rounded-lg px-3 py-2 -ml-3"
aria-label={`رفتن به پروفایل ${fullName}`}
>
{/* Profile Image */}
<div className="shrink-0">
{profileImageUrl ? (
<img
src={profileImageUrl}
alt={fullName}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-10 h-10 rounded-full bg-linear-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white">
<User size={20} />
</div>
)}
</div>
{/* User Name */}
<div className="flex flex-col items-start">
<span className="text-sm font-semibold text-slate-800 truncate max-w-24 sm:max-w-40">
{fullName}
</span>
<span className="text-xs text-slate-500">کاربر</span>
</div>
</button>
{/* LEFT SIDE: Coins Badge */}
<div className="flex items-center gap-2 bg-yellow-50 rounded-full px-4 py-2 border border-yellow-200 hover:bg-yellow-100 transition-colors ml-2">
<span className="text-sm font-bold text-yellow-700">{coins}</span>
<Coins size={18} className="text-yellow-600" />
</div>
</div>
</header>
);
}
export default DashboardHeader;

View File

@ -25,7 +25,6 @@ export function ImageUploader({
}: ImageUploaderProps) { }: ImageUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const sizeClasses = { const sizeClasses = {
sm: "w-24 h-24", sm: "w-24 h-24",
md: "w-32 h-32", md: "w-32 h-32",
@ -85,22 +84,33 @@ export function ImageUploader({
)} )}
{previewImage ? ( {previewImage ? (
<div className="relative inline-block group"> <div className="flex flex-col items-start gap-3">
<img <div className="relative inline-block group">
src={previewImage} <img
alt="پیش‌نمایش تصویر" src={previewImage}
className={cn( alt="پیش‌نمایش تصویر"
"rounded-lg object-cover border-2 border-gray-300 shadow-sm", className={cn(
sizeClasses[imageSize] "rounded-lg object-cover border-2 border-gray-300 shadow-sm",
)} sizeClasses[imageSize]
/> )}
/>
<button
type="button"
onClick={handleRemove}
className="absolute -top-3 -right-3 bg-red-500 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-600 shadow-lg"
aria-label="حذف تصویر"
>
<X size={18} />
</button>
</div>
<button <button
type="button" type="button"
onClick={handleRemove} onClick={handleRemove}
className="absolute -top-3 -right-3 bg-red-500 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-600 shadow-lg" className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
aria-label="حذف تصویر" aria-label="حذف تصویر"
> >
<X size={18} /> <X size={16} />
<span>حذف تصویر</span>
</button> </button>
</div> </div>
) : ( ) : (

View File

@ -6,11 +6,20 @@ export interface CustomInputProps
variant?: "primary" | "info" | "error"; variant?: "primary" | "info" | "error";
error?: string; error?: string;
label?: string; label?: string;
required?: boolean;
} }
const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>( const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>(
( (
{ className, variant = "primary", error, label, disabled, ...props }, {
className,
variant = "primary",
error,
label,
disabled,
required,
...props
},
ref ref
) => { ) => {
const finalVariant = error ? "error" : variant; const finalVariant = error ? "error" : variant;
@ -19,7 +28,7 @@ const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>(
<div className="w-full"> <div className="w-full">
{label && ( {label && (
<label className="mb-2 block text-sm font-medium text-foreground text-right"> <label className="mb-2 block text-sm font-medium text-foreground text-right">
{label} {label} {required && <span className="text-red-500">*</span>}
</label> </label>
)} )}
<input <input

View File

@ -5,15 +5,27 @@ interface TextAreaFieldProps extends ComponentProps<"textarea"> {
label?: string; label?: string;
error?: string; error?: string;
minLength?: number; minLength?: number;
required?: boolean;
} }
const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>( const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(
({ label, error, minLength = 40, className, placeholder, ...props }, ref) => { (
{
label,
error,
minLength = 40,
className,
placeholder,
required,
...props
},
ref
) => {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{label && ( {label && (
<label className="mb-2 block text-sm font-medium text-foreground text-right"> <label className="mb-2 block text-sm font-medium text-foreground text-right">
{label} {label} {required && <span className="text-red-500">*</span>}
</label> </label>
)} )}

View File

@ -1 +0,0 @@
export { MobileNavbar, type NavItem } from "./mobile-navbar";

View File

@ -1,212 +0,0 @@
/**
* MobileNavbar Component - Demo & Example Usage
*
* This file demonstrates how to use the MobileNavbar component
* in your React application.
*/
import { MobileNavbar, type NavItem } from "@/core/components/others";
import { Home, MessageCircle, Settings, Star } from "lucide-react";
// ============================================================================
// EXAMPLE 1: Using Default Navigation Items
// ============================================================================
export function AppWithDefaultNavbar() {
return (
<div className="flex flex-col min-h-screen">
<main className="flex-1 pb-20 md:pb-0">
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">داشبورد</h1>
<p className="text-gray-600">
سلام! روی دستگاه موبایل یک نوار ناویگیشن ثابت در پایین میبینید.
</p>
</div>
</main>
{/* Default navbar with Profile, Group Chat, Ranking, Dashboard */}
<MobileNavbar />
</div>
);
}
// ============================================================================
// EXAMPLE 2: Using Custom Navigation Items
// ============================================================================
const customNavItems: NavItem[] = [
{
id: "home",
label: "خانه",
icon: <Home size={24} />,
path: "/",
},
{
id: "favorites",
label: "نشان‌شده‌ها",
icon: <Star size={24} />,
path: "/favorites",
},
{
id: "messages",
label: "پیام‌ها",
icon: <MessageCircle size={24} />,
path: "/messages",
},
{
id: "settings",
label: "تنظیمات",
icon: <Settings size={24} />,
path: "/settings",
},
];
export function AppWithCustomNavbar() {
return (
<div className="flex flex-col min-h-screen">
<main className="flex-1 pb-20 md:pb-0">
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">تطبیقشده</h1>
<p className="text-gray-600">
این مثال از آیتمهای ناویگیشن سفارشی استفاده میکند.
</p>
</div>
</main>
{/* Custom navbar */}
<MobileNavbar items={customNavItems} />
</div>
);
}
// ============================================================================
// EXAMPLE 3: Using Custom Styling
// ============================================================================
export function AppWithCustomStyling() {
return (
<div className="flex flex-col min-h-screen">
<main className="flex-1 pb-20 md:pb-0">
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">سبک سفارشی</h1>
<p className="text-gray-600">
نوار ناویگیشن میتواند با کلاسهای اضافی سفارشیسازی شود.
</p>
</div>
</main>
{/* Custom styling with dark theme */}
<MobileNavbar className="bg-slate-900 border-slate-800" />
</div>
);
}
// ============================================================================
// EXAMPLE 4: Full Layout with Content Padding
// ============================================================================
export function FullAppLayout() {
return (
<div className="flex flex-col min-h-screen" dir="rtl">
{/* Header (optional) */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 py-4">
<h1 className="text-xl font-bold text-slate-800">یاریگران</h1>
</div>
</header>
{/* Main Content - Important: Add padding to prevent navbar overlap */}
<main className="flex-1 pb-20 md:pb-0">
<div className="max-w-7xl mx-auto px-4 py-6">
<section className="mb-6">
<h2 className="text-2xl font-bold mb-4">محتوای اصلی</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<h3 className="font-semibold mb-2">کارت ۱</h3>
<p className="text-sm text-gray-600">محتوای نمونه</p>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h3 className="font-semibold mb-2">کارت ۲</h3>
<p className="text-sm text-gray-600">محتوای نمونه</p>
</div>
</div>
</section>
</div>
</main>
{/* Mobile Navbar - Fixed at bottom */}
<MobileNavbar />
</div>
);
}
// ============================================================================
// FEATURE SHOWCASE
// ============================================================================
/**
* Component Features:
*
* Mobile-First Design
* - Fixed positioning at bottom on mobile (md: hidden)
* - Responds to viewport size
*
* Automatic Route Tracking
* - Highlights active tab based on current URL path
* - Works with React Router DOM
*
* Touch-Friendly
* - Minimum 44×44px tap area (w-16 h-16 = 64×64px)
* - Haptic feedback on supported devices (10ms vibration)
*
* Visual Feedback
* - Active tab: Blue background + blue text
* - Icon scales to 110% when active
* - Smooth 200ms transitions
*
* Accessibility
* - ARIA labels on all buttons
* - aria-current for active page indication
* - RTL support for Persian text
*
* Safe Area Support
* - Accounts for notched devices (iPhone, etc.)
* - Automatically adjusts padding for safe insets
*
* Performance
* - Lightweight component
* - Minimal re-renders
* - GPU-accelerated CSS transitions
*/
// ============================================================================
// INTEGRATION CHECKLIST
// ============================================================================
/**
* 📋 To integrate MobileNavbar into your app:
*
* 1. Import component:
* import { MobileNavbar } from "@/core/components/others";
*
* 2. Add to layout:
* <MobileNavbar />
*
* 3. Add padding to main content:
* <main className="pb-20 md:pb-0">...</main>
*
* Explanation:
* - pb-20 = 5rem (80px) padding on mobile to accommodate navbar
* - md:pb-0 = no padding on desktop (navbar hidden)
*
* 4. Ensure routes exist:
* - /profile
* - /group-chat
* - /ranking
* - /dashboard
*
* 5. Test on mobile:
* - Browser DevTools mobile view
* - Real mobile device
* - Touch interactions
*/

View File

@ -0,0 +1,26 @@
// src/components/RequireAuth.tsx
import { useAuth } from "@/core/context/auth-context";
import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant";
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
export function RequireAuth({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div className="flex justify-center p-8">در حال بررسی...</div>;
}
if (!isAuthenticated) {
return (
<Navigate
to={`${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`}
state={{ from: location }}
replace
/>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,63 @@
import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant";
import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant";
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
const token = localStorage.getItem("token");
setIsAuthenticated(!!token);
const checkIsLogin =
!window.location.pathname.includes(AUTH_ROUTE.LOGIN) && !token;
const decideRedirect = !!token && window.location.pathname === "/";
if (checkIsLogin) {
window.location.href = `${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`;
}
if (decideRedirect) {
window.location.href = `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}`;
}
setIsLoading(false);
}, []);
const login = (token: string) => {
localStorage.setItem("access_token", token);
setIsAuthenticated(true);
};
const logout = () => {
localStorage.clear();
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error("useAuth must be used within AuthProvider");
return context;
};

View File

@ -6,6 +6,8 @@ export const API_ADDRESS = {
}, },
select: "/api/select", select: "/api/select",
save: "/api/save", save: "/api/save",
delete: "/api/delete",
uploadImage: "/workflow/uploadImage",
// LOGIN: "/auth/login", // LOGIN: "/auth/login",
// VERIFY_OTP: "/auth/verify-otp", // VERIFY_OTP: "/auth/verify-otp",
// REGISTER: "/auth/register", // REGISTER: "/auth/register",

View File

@ -1,4 +1,5 @@
import type { RegistrationFormData } from "@/modules/dashboard/pages/profile/profile.type"; import type { RegistrationFormData } from "@/modules/dashboard/pages/profile/profile.type";
import type { TokenInterface } from "@core/types/global.type";
class UserInfoService { class UserInfoService {
getUserInfo(): RegistrationFormData { getUserInfo(): RegistrationFormData {
@ -12,6 +13,15 @@ class UserInfoService {
updateUserInfo(updatedInfo: RegistrationFormData): void { updateUserInfo(updatedInfo: RegistrationFormData): void {
localStorage.setItem("person", JSON.stringify(updatedInfo)); localStorage.setItem("person", JSON.stringify(updatedInfo));
} }
getToken(): TokenInterface | null {
const tokenStr = localStorage.getItem("token");
if (!tokenStr) {
return null;
}
const tokenObj = JSON.parse(tokenStr);
return tokenObj || null;
}
} }
export const userInfoService = new UserInfoService(); export const userInfoService = new UserInfoService();

View File

@ -0,0 +1,6 @@
export interface TokenInterface {
AccessToken: string;
ExpAccessToken: string;
RefreshToken: string;
ExpRefreshToken: string;
}

45
src/core/utils/index.ts Normal file
View File

@ -0,0 +1,45 @@
import api from "@/core/service/axios";
import { userInfoService } from "@core/service/user-info.service";
import to from "await-to-js";
import { toast } from "react-toastify";
import { API_ADDRESS } from "../service/api-address";
export const getContactImageUrl = (stageID: Number): string => {
const token = userInfoService.getToken();
if (!token) {
throw new Error("توکن یافت نشد");
}
return `${
import.meta.env.VITE_API_URL
}/api/getimage?stageID=${stageID}&nameOrID=image&token=${token.AccessToken}`;
};
export const uploadImage = async ({
file,
name,
}: {
file: File;
name: string;
}): Promise<{ name: string; data: any }> => {
const formData = new FormData();
formData.append("", file);
const [err, res] = await to(
api.post(API_ADDRESS.uploadImage, formData, {
headers: { "Content-Type": "multipart/form-data" },
})
);
if (res?.data.resultType === 0) {
return { name, data: res.data };
}
if (res?.data.resultType !== 0) {
toast.error("خطا در بارگذاری تصویر");
throw new Error(res?.data.message || "Upload failed");
}
if (err) {
throw err;
}
throw new Error(res?.data.message || "Upload failed");
};

View File

@ -141,7 +141,7 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
margin: 0 auto; margin: 0 auto;
max-width: 1280px; max-width: 1280px;
padding: 1rem; /* padding: 1rem; */
box-sizing: border-box; box-sizing: border-box;
direction: rtl; direction: rtl;
font-family: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont, font-family: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont,

View File

@ -1,3 +1,4 @@
import { AuthProvider, useAuth } from "@core/context/auth-context.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { RouterProvider, createBrowserRouter } from "react-router-dom"; import { RouterProvider, createBrowserRouter } from "react-router-dom";
@ -9,22 +10,38 @@ import { rootRoutes } from "./router/rootRoutes.ts";
const router = createBrowserRouter(rootRoutes); const router = createBrowserRouter(rootRoutes);
const client = new QueryClient(); const client = new QueryClient();
function AppEntry() {
const { isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-xl text-slate-600">در حال بارگذاری...</div>
</div>
);
}
return <RouterProvider router={router} />;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
// <StrictMode> // <StrictMode>
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<RouterProvider router={router} /> <AuthProvider>
<ToastContainer <AppEntry />
position="top-right" <ToastContainer
autoClose={4000} position="top-right"
hideProgressBar={false} autoClose={4000}
newestOnTop={false} hideProgressBar={false}
closeOnClick newestOnTop={false}
rtl={true} closeOnClick
pauseOnFocusLoss rtl={true}
draggable pauseOnFocusLoss
pauseOnHover draggable
theme="light" pauseOnHover
/> theme="light"
/>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
// </StrictMode> // </StrictMode>
); );

View File

@ -1,3 +1,4 @@
import { getContactImageUrl } from "@/core/utils";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { Campaign } from "../pages/campaigns/campaigns.type"; import type { Campaign } from "../pages/campaigns/campaigns.type";
@ -11,17 +12,17 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
return ( return (
<div <div
onClick={() => navigate(`/dashboard/campaigns/${campaign.user_id}`)} onClick={() => navigate(`/dashboard/campaigns/${campaign.WorkflowID}`)}
className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md hover:shadow-lg transition-shadow cursor-pointer h-full" className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md hover:shadow-lg transition-shadow cursor-pointer h-full"
> >
{/* Image */} {/* Image */}
{/* <div className="relative h-48 bg-gray-200 overflow-hidden"> <div className="relative h-48 bg-gray-200 overflow-hidden">
<img <img
src={campaign.image} src={getContactImageUrl(campaign.ValueP1226S1951StageID)}
alt={campaign.title} alt={`${campaign.title}-image`}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300" className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/> />
</div> */} </div>
{/* Content */} {/* Content */}
<div className="p-4"> <div className="p-4">
@ -29,10 +30,9 @@ export function CampaignCard({ campaign }: CampaignCardProps) {
<h3 className="text-lg font-bold text-slate-800 mb-2 line-clamp-2 text-right"> <h3 className="text-lg font-bold text-slate-800 mb-2 line-clamp-2 text-right">
{campaign.title} {campaign.title}
</h3> </h3>
{/* Creator */} {/* Creator */}
<p className="text-sm text-slate-600 mb-3 text-right"> <p className="text-sm text-slate-600 mb-3 text-right">
از طرف: {campaign.user_id} از طرف: {campaign.user_id_nickname}
</p> </p>
{/* Stats */} {/* Stats */}

View File

@ -35,7 +35,7 @@ export function CreateCampaignModal({
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!title.trim()) { if (!title.trim()) {
newErrors.title = "عنوان کمپین الزامی است"; newErrors.title = "عنوان کارزار الزامی است";
} }
if (!description.trim()) { if (!description.trim()) {
@ -55,12 +55,12 @@ export function CreateCampaignModal({
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: CreateCampaignData) => createCampaignService(data), mutationFn: (data: CreateCampaignData) => createCampaignService(data),
onSuccess: () => { onSuccess: () => {
toast.success(مپین با موفقیت ایجاد شد"); toast.success(ارزار با موفقیت ایجاد شد");
handleClose(); handleClose();
onSuccess(); onSuccess();
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد کمپین"); toast.error(error?.message || "خطا در ایجاد کارزار");
}, },
}); });
@ -100,16 +100,17 @@ export function CreateCampaignModal({
<div className="flex flex-col gap-4 w-full max-w-md"> <div className="flex flex-col gap-4 w-full max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-lg font-semibold text-center sm:text-right"> <DialogTitle className="text-lg font-semibold text-center sm:text-right">
ایجاد کمپین جدید ایجاد کارزار جدید
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}> <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{/* Title Input */} {/* Title Input */}
<CustomInput <CustomInput
label="عنوان کمپین" label="عنوان کارزار"
type="text" type="text"
placeholder="عنوان کمپین را وارد کنید" placeholder="عنوان کارزار را وارد کنید"
required
value={title} value={title}
onChange={(e) => { onChange={(e) => {
setTitle(e.target.value); setTitle(e.target.value);
@ -121,9 +122,10 @@ export function CreateCampaignModal({
{/* Description Input */} {/* Description Input */}
<TextAreaField <TextAreaField
label="توضیحات کمپین" label="توضیحات کارزار"
placeholder="توضیحات کمپین را وارد کنید (حداقل 20 کاراکتر)" placeholder="توضیحات کارزار را وارد کنید (حداقل 20 کاراکتر)"
value={description} value={description}
required
onChange={(e) => { onChange={(e) => {
setDescription(e.target.value); setDescription(e.target.value);
if (errors.description) if (errors.description)
@ -135,7 +137,7 @@ export function CreateCampaignModal({
{/* Image Upload */} {/* Image Upload */}
<ImageUploader <ImageUploader
label="تصویر کمپین" label="تصویر کارزار"
previewImage={previewImage} previewImage={previewImage}
onImageChange={(file) => { onImageChange={(file) => {
if (file) { if (file) {
@ -166,7 +168,7 @@ export function CreateCampaignModal({
disabled={createMutation.isPending} disabled={createMutation.isPending}
onClick={handleSubmit} onClick={handleSubmit}
> >
{createMutation.isPending ? "در حال ایجاد..." : "ایجاد کمپین"} {createMutation.isPending ? "در حال ایجاد..." : "ایجاد کارزار"}
</CustomButton> </CustomButton>
<CustomButton variant="info" onClick={handleClose}> <CustomButton variant="info" onClick={handleClose}>
لغو لغو

View File

@ -1,12 +1,22 @@
// layouts/DashboardLayout.tsx // layouts/DashboardLayout.tsx
import { MobileNavbar } from "@/core/components/others"; import DashboardHeader from "@/core/components/base/dashboard-header";
import { MobileNavbar } from "@/core/components/others/mobile-navbar";
import { userInfoService } from "@/core/service/user-info.service";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
export function DashboardLayout() { export function DashboardLayout() {
const user = userInfoService.getUserInfo();
return ( return (
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto "> <main className="flex-1 overflow-y-auto pb-14">
<Outlet /> <DashboardHeader
profileImageUrl={""}
fullName={`${user.name || "کاربر جدید"} ${user.family || ""}`}
coins={100}
/>
<div className="p-2">
<Outlet />
</div>
<MobileNavbar /> <MobileNavbar />
</main> </main>
</div> </div>

View File

@ -12,6 +12,7 @@ export interface Campaign {
nickname?: String; nickname?: String;
signature_count: Number; signature_count: Number;
comment_count?: Number; comment_count?: Number;
user_id_nickname?: String;
} }
export interface Signer { export interface Signer {
@ -34,4 +35,21 @@ export interface CreateCampaignData {
image: File; image: File;
} }
export type CampaignTab = "all" | "my" | "top" | "group"; export type CampaignTab = "فعال" | "my" | "منتخب" | "group";
export interface CommentsItem {
ValueP1228S1959StageID: Number;
ValueP1228S1959ValueID: Number;
WorkflowID: Number;
comment_text: String;
user_id_nickname: String;
user_stage_id: String;
}
export interface SignatureItem {
ValueP1227S1955StageID: Number;
ValueP1227S1955ValueID: Number;
WorkflowID: Number;
user_id_nickname: String;
user_stage_id: String;
}

View File

@ -1,42 +1,69 @@
"use client"; "use client";
import { CustomButton } from "@/core/components/base/button"; import { CustomButton } from "@/core/components/base/button";
import { useMutation, useQuery } from "@tanstack/react-query"; import TextAreaField from "@/core/components/base/text-area";
import { ArrowRight, Heart, Loader, MessageCircle } from "lucide-react"; import { getContactImageUrl } from "@/core/utils";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { import {
addCommentService, addCommentService,
getCampaignDetailService, getCommentsService,
getSelectedCampaignsService,
getSignsCampaignService,
removeCommentService,
signCampaignService, signCampaignService,
} from "../../service/campaigns.service"; } from "@modules/dashboard/service/campaigns.service";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
Blinds,
Loader,
MessageCircle,
Trash2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import type { CommentsItem } from "./campaigns.type";
export function CampaignDetailPage() { export function CampaignDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [commentText, setCommentText] = useState(""); const [commentText, setCommentText] = useState("");
const [hasSignedCampaign, setHasSignedCampaign] = useState(false); const [hasSignedCampaign, setHasSignedCampaign] = useState<boolean>(false);
const [currentComments, setCurrentComments] = useState<CommentsItem[]>([]);
const { const { data: campaign, isLoading } = useQuery({
data: campaign,
isLoading,
refetch,
} = useQuery({
queryKey: ["campaign", id], queryKey: ["campaign", id],
queryFn: () => getCampaignDetailService(id!), queryFn: () => getSelectedCampaignsService(Number(id!)),
enabled: !!id, enabled: !!id,
}); });
const { data: signs, refetch: signRefetch } = useQuery({
queryKey: ["campaign-signs", id],
queryFn: () => getSignsCampaignService(String(id)),
enabled: !!id,
});
const { data: comments, refetch } = useQuery({
queryKey: ["campaign-comments", id],
queryFn: () => getCommentsService(String(id)),
enabled: !!id,
});
useEffect(() => {
if (comments) {
setCurrentComments(comments);
}
}, [comments]);
const signMutation = useMutation({ const signMutation = useMutation({
mutationFn: () => signCampaignService(id!), mutationFn: () => signCampaignService(id!),
onSuccess: () => { onSuccess: () => {
toast.success("با موفقیت امضا کردید"); toast.success("با موفقیت امضا کردید");
setHasSignedCampaign(true); setHasSignedCampaign(true);
refetch(); signRefetch();
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error?.message || "خطا در امضای کمپین"); toast.error(error?.message || "خطا در امضای کارزار");
}, },
}); });
@ -52,9 +79,20 @@ export function CampaignDetailPage() {
}, },
}); });
const removeCommentMutation = useMutation({
mutationFn: (commentId: Number) => removeCommentService(commentId),
onSuccess: () => {
toast.success("نظر با موفقیت حذف شد");
refetch();
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف نظر");
},
});
const handleSignCampaign = () => { const handleSignCampaign = () => {
if (hasSignedCampaign) { if (hasSignedCampaign) {
toast.info("شما قبلاً این کمپین را امضا کرده‌اید"); toast.info("شما قبلاً این کارزار را امضا کرده‌اید");
return; return;
} }
signMutation.mutate(); signMutation.mutate();
@ -69,6 +107,17 @@ export function CampaignDetailPage() {
commentMutation.mutate(commentText); commentMutation.mutate(commentText);
}; };
const removeCommentHandler = (
commentId: Number,
e: React.MouseEvent
): void => {
e.preventDefault();
setCurrentComments((prevComments) =>
prevComments.filter((comment) => comment.WorkflowID !== commentId)
);
removeCommentMutation.mutate(commentId);
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50"> <div className="flex items-center justify-center min-h-screen bg-gray-50">
@ -80,16 +129,16 @@ export function CampaignDetailPage() {
if (!campaign) { if (!campaign) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 gap-4"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 gap-4">
<p className="text-slate-600 text-lg">کمپین یافت نشد</p> <p className="text-slate-600 text-lg">کارزار یافت نشد</p>
<CustomButton onClick={() => navigate("/dashboard/campaigns")}> <CustomButton onClick={() => navigate("/dashboard/campaigns")}>
بازگشت به کمپینها بازگشت به کارزارها
</CustomButton> </CustomButton>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-8" dir="rtl"> <div className="min-h-screen bg-gray-50 p-4" dir="rtl">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Back Button */} {/* Back Button */}
<button <button
@ -97,59 +146,47 @@ export function CampaignDetailPage() {
className="flex items-center gap-2 text-blue-500 hover:text-blue-700 mb-6 transition-colors" className="flex items-center gap-2 text-blue-500 hover:text-blue-700 mb-6 transition-colors"
> >
<ArrowRight size={20} /> <ArrowRight size={20} />
<span>بازگشت به کمپینها</span> <span>بازگشت به کارزارها</span>
</button> </button>
{/* Campaign Image */} {/* Campaign Image */}
<div className="mb-8 rounded-lg overflow-hidden h-96 bg-gray-200"> <div className="relative h-48 bg-gray-200 overflow-hidden rounded-lg">
<img <img
src={campaign.image} src={getContactImageUrl(campaign.ValueP1226S1951StageID)}
alt={campaign.title} alt={`${campaign.title}-image`}
className="w-full h-full object-cover" className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/> />
</div> </div>
{/* Campaign Header */} {/* Campaign Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8"> <div className="bg-white rounded-lg shadow-md p-6 mb-8 mt-6">
{/* Title */}
<h1 className="text-3xl font-bold text-slate-800 text-right mb-4"> <h1 className="text-3xl font-bold text-slate-800 text-right mb-4">
{campaign.title} {campaign.title}
</h1> </h1>
{/* Creator Info */}
<p className="text-slate-600 text-right mb-6"> <p className="text-slate-600 text-right mb-6">
توسط:{" "} توسط: <span className="font-semibold">{campaign.nickname}</span>
<span className="font-semibold">{campaign.creatorNickname}</span>
</p> </p>
{/* Stats */}
<div className="flex items-center justify-end gap-8 py-4 border-t border-b border-gray-200 mb-6"> <div className="flex items-center justify-end gap-8 py-4 border-t border-b border-gray-200 mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-3xl font-bold text-red-500"> <span className="text-3xl font-bold text-red-500">
{campaign.signatures} {signs?.length ?? 0}
</span> </span>
<div className="flex flex-col"> <Blinds size={20} fill="currentColor" className="text-red-500" />
<Heart size={24} className="text-red-500" fill="currentColor" />
<span className="text-xs text-slate-600">امضا</span>
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-3xl font-bold text-blue-500"> <span className="text-3xl font-bold text-blue-500">
{campaign.comments.length} {comments?.length ?? 0}
</span> </span>
<div className="flex flex-col"> <MessageCircle size={20} className="text-blue-500" />
<MessageCircle size={24} className="text-blue-500" />
<span className="text-xs text-slate-600">نظر</span>
</div>
</div> </div>
</div> </div>
{/* Description */}
<p className="text-slate-700 text-right leading-relaxed mb-6"> <p className="text-slate-700 text-right leading-relaxed mb-6">
{campaign.description} {campaign.description}
</p> </p>
{/* Sign Campaign Button */}
<CustomButton <CustomButton
variant={hasSignedCampaign ? "info" : "primary"} variant={hasSignedCampaign ? "info" : "primary"}
className="w-full" className="w-full"
@ -160,7 +197,7 @@ export function CampaignDetailPage() {
? "در حال امضا..." ? "در حال امضا..."
: hasSignedCampaign : hasSignedCampaign
? "شما امضا کرده‌اید ✓" ? "شما امضا کرده‌اید ✓"
: "امضای کمپین"} : "امضای کارزار"}
</CustomButton> </CustomButton>
</div> </div>
@ -170,32 +207,28 @@ export function CampaignDetailPage() {
امضاکنندگان امضاکنندگان
</h2> </h2>
{campaign.signers.length > 0 ? ( {Array.isArray(signs) && signs.length > 0 ? (
<div className="flex flex-wrap gap-4 justify-end"> <div className="flex flex-wrap gap-4 justify-end">
{campaign.signers.map((signer) => ( {signs.map((signer) => (
<div <div
key={signer.id} key={`${signer.WorkflowID}-${signer.ValueP1227S1955StageID}`}
className="flex flex-col items-center gap-2" className="flex flex-col items-center gap-2"
> >
<div className="w-16 h-16 rounded-full bg-linear-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-bold text-xl"> <div className="w-16 h-16 rounded-full overflow-hidden bg-gradient-to-br from-blue-400 to-blue-600">
{signer.avatar ? ( <img
<img src={getContactImageUrl(signer.ValueP1227S1955StageID)}
src={signer.avatar} alt={`${signer.user_id_nickname}-avatar`}
alt={signer.nickname} className="w-full h-full object-cover"
className="w-full h-full rounded-full object-cover" />
/>
) : (
signer.nickname.charAt(0).toUpperCase()
)}
</div> </div>
<p className="text-sm text-slate-600 text-center max-w-16 truncate"> <p className="text-sm text-slate-600 text-center max-w-16 truncate">
{signer.nickname} {signer.user_id_nickname}
</p> </p>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-slate-600 text-center py-8"> <p className="text-slate-600 text-center py-4">
هنوز کسی امضا نکرده است. شما میتوانید اولین نفر باشید! هنوز کسی امضا نکرده است. شما میتوانید اولین نفر باشید!
</p> </p>
)} )}
@ -210,15 +243,16 @@ export function CampaignDetailPage() {
{/* Add Comment Form */} {/* Add Comment Form */}
<form <form
onSubmit={handleAddComment} onSubmit={handleAddComment}
className="mb-8 pb-8 border-b border-gray-200" className="mb-6 pb-6 border-b border-gray-200"
> >
<div className="flex gap-4 flex-col sm:flex-row"> <div className="flex gap-4 flex-col sm:flex-row">
<input <TextAreaField
type="text" required
placeholder="نظر خود را بنویسید..." placeholder="ثبت نظر شما..."
value={commentText} value={commentText}
onChange={(e) => setCommentText(e.target.value)} onChange={(e) => setCommentText(e.target.value)}
className="flex-1 rounded-lg border-2 border-gray-300 px-4 py-2 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" className="flex-1 rounded-lg border-2 border-gray-300 px-4 py-2 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 resize-none"
rows={3}
/> />
<CustomButton <CustomButton
variant="primary" variant="primary"
@ -231,22 +265,36 @@ export function CampaignDetailPage() {
</form> </form>
{/* Comments List */} {/* Comments List */}
<div className="space-y-4"> <div className="flex flex-col space-y-4">
{campaign.comments.length > 0 ? ( {currentComments && currentComments.length > 0 ? (
campaign.comments.map((comment) => ( currentComments.map((comment) => (
<div <div
key={comment.id} key={`${comment.WorkflowID}-${comment.ValueP1228S1959StageID}`}
className="p-4 bg-gray-50 rounded-lg border border-gray-200" className="flex flex-row items-start justify-between overflow-auto gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
> >
<div className="flex items-center justify-between mb-2"> <div className="flex-1 w-48">
<span className="text-sm text-slate-500"> <p className="font-semibold text-slate-800 text-right text-sm mb-2">
{new Date(comment.createdAt).toLocaleDateString("fa-IR")} {comment.user_id_nickname}
</span> </p>
<span className="font-semibold text-slate-800"> <div className="text-slate-700 text-right text-sm leading-relaxed">
{comment.authorNickname} <p className="whitespace-normal wrap-break-word">
</span> {comment.comment_text}
</p>
</div>
</div> </div>
<p className="text-slate-700 text-right">{comment.text}</p>
<button
className="text-gray-400 hover:text-red-500 transition-colors shrink-0"
aria-label="حذف نظر"
>
<Trash2
size={18}
color="red"
onClick={(e) =>
removeCommentHandler(comment.WorkflowID, e)
}
/>
</button>
</div> </div>
)) ))
) : ( ) : (

View File

@ -5,14 +5,14 @@ import { CustomInput } from "@/core/components/base/input";
import { userInfoService } from "@/core/service/user-info.service"; import { userInfoService } from "@/core/service/user-info.service";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Loader, Plus, Search } from "lucide-react"; import { Loader, Plus, Search } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState, type ChangeEvent } from "react";
import { CampaignCard } from "../../components/campaign-card"; import { CampaignCard } from "../../components/campaign-card";
import { CreateCampaignModal } from "../../components/create-campaign-modal"; import { CreateCampaignModal } from "../../components/create-campaign-modal";
import { getCampaignsService } from "../../service/campaigns.service"; import { getCampaignsService } from "../../service/campaigns.service";
import type { Campaign, CampaignTab } from "./campaigns.type"; import type { Campaign, CampaignTab } from "./campaigns.type";
export function CampaignsPage() { export function CampaignsPage() {
const [activeTab, setActiveTab] = useState<CampaignTab>("all"); const [activeTab, setActiveTab] = useState<CampaignTab>("فعال");
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]); const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
@ -22,8 +22,8 @@ export function CampaignsPage() {
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["campaigns", searchQuery], queryKey: ["campaigns"],
queryFn: () => getCampaignsService(activeTab, searchQuery), queryFn: getCampaignsService,
}); });
useEffect(() => { useEffect(() => {
@ -33,36 +33,43 @@ export function CampaignsPage() {
}, [campaigns]); }, [campaigns]);
const tabs: { value: CampaignTab; label: string; oreder: number }[] = [ const tabs: { value: CampaignTab; label: string; oreder: number }[] = [
{ oreder: 1, value: "all", label: "تمام کمپینها" }, { oreder: 1, value: "فعال", label: "تمام کارزارها" },
{ oreder: 2, value: "my", label: مپین‌های من" }, { oreder: 2, value: "my", label: ارزار‌های من" },
{ oreder: 3, value: "top", label: "کمپین‌های برتر" }, { oreder: 3, value: "منتخب", label: "کارزار‌های برتر" },
{ oreder: 4, value: "group", label: مپین‌های گروه" }, { oreder: 4, value: "group", label: ارزار‌های گروه" },
]; ];
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
setActiveTab("فعال");
const filteredCampaigns = campaigns.filter((campaign) =>
campaign.title.toLowerCase().includes(e.target.value.toLowerCase())
);
if (e.target.value === "") {
handleTabChange(activeTab);
setCurrentCampaign(campaigns);
return;
}
setCurrentCampaign(filteredCampaigns);
}; };
const handleTabChange = (tab: CampaignTab) => { const handleTabChange = (tab: CampaignTab) => {
setActiveTab(tab); setActiveTab(tab);
const user = userInfoService.getUserInfo(); const user = userInfoService.getUserInfo();
switch (tab) { switch (tab) {
case "all": case "فعال":
setCurrentCampaign(campaigns); setCurrentCampaign(campaigns);
break; break;
case "my": case "my":
setCurrentCampaign( setCurrentCampaign(
campaigns.filter( campaigns.filter(
(campaign) => (campaign) => Number(campaign.user_id) === Number(user.WorkflowID)
Number(campaign.WorkflowID) === Number(user.WorkflowID)
) )
); );
break; break;
case "top": case "منتخب":
setCurrentCampaign( setCurrentCampaign(
[...campaigns].sort( [...campaigns].filter((item, _) => item.status === "منتخب")
(a, b) => (b.signature_count || 0) - (a.signature_count || 0)
)
); );
break; break;
case "group": case "group":
@ -80,15 +87,15 @@ export function CampaignsPage() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-8" dir="rtl"> <div className="min-h-screen bg-gray-50 p-4 " dir="rtl">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-4xl font-bold text-slate-800 text-right mb-2"> <h1 className="text-4xl font-bold text-slate-800 text-right mb-6">
کمپینها کارزارها
</h1> </h1>
<p className="text-slate-600 text-right"> <p className="text-slate-600 text-right">
برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید
</p> </p>
</div> </div>
@ -99,7 +106,7 @@ export function CampaignsPage() {
<div className="relative"> <div className="relative">
<CustomInput <CustomInput
type="text" type="text"
placeholder="جستجوی کمپین..." placeholder="جستجوی کارزار..."
value={searchQuery} value={searchQuery}
onChange={handleSearchChange} onChange={handleSearchChange}
className="pr-10" className="pr-10"
@ -118,7 +125,7 @@ export function CampaignsPage() {
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Plus size={20} /> <Plus size={20} />
ایجاد کمپین ایجاد کارزار
</CustomButton> </CustomButton>
</div> </div>
@ -157,7 +164,9 @@ export function CampaignsPage() {
/> />
)) ))
) : ( ) : (
<div className="text-gray-500 mx-auto mt-20">کمپینی یافت نشد</div> <div className="text-gray-500 mx-auto mt-20">
کارزاری یافت نشد
</div>
)} )}
</div> </div>
)} )}
@ -167,15 +176,15 @@ export function CampaignsPage() {
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<p className="text-slate-600 text-lg mb-4"> <p className="text-slate-600 text-lg mb-4">
{activeTab === "my" {activeTab === "my"
? "هنوز کمپینی ایجاد نکرده‌اید" ? "هنوز کارزاری ایجاد نکرده‌اید"
: مپینی یافت نشد"} : ارزاری یافت نشد"}
</p> </p>
{activeTab === "my" && ( {activeTab === "my" && (
<CustomButton <CustomButton
variant="primary" variant="primary"
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
> >
ایجاد اولین کمپین خود ایجاد اولین کارزار خود
</CustomButton> </CustomButton>
)} )}
</div> </div>

View File

@ -10,13 +10,14 @@ import {
} from "@/core/components/base/card"; } from "@/core/components/base/card";
import { ImageUploader } from "@/core/components/base/image-uploader"; import { ImageUploader } from "@/core/components/base/image-uploader";
import { CustomInput } from "@/core/components/base/input"; import { CustomInput } from "@/core/components/base/input";
import { getContactImageUrl } from "@/core/utils";
import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant"; import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant";
import { import {
fetchUserProfile, fetchUserProfile,
updateUserProfile, updateUserProfile,
} from "@modules/dashboard/service/user.service"; } from "@modules/dashboard/service/user.service";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import type { RegistrationFormData } from "./profile.type"; import type { RegistrationFormData } from "./profile.type";
@ -29,8 +30,6 @@ export function RegisterPage() {
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["userProfile"], queryKey: ["userProfile"],
queryFn: fetchUserProfile, queryFn: fetchUserProfile,
refetchOnWindowFocus: false,
refetchOnMount: false,
}); });
const [formData, setFormData] = useState<RegistrationFormData>({ const [formData, setFormData] = useState<RegistrationFormData>({
@ -58,11 +57,13 @@ export function RegisterPage() {
nationalcode: data.nationalcode || "", nationalcode: data.nationalcode || "",
base: data.base || "", base: data.base || "",
})); }));
if (data.name)
setPreviewImage(getContactImageUrl((data as any).stageID) ?? "");
} }
}, [data]); }, [data]);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [previewImage, setPreviewImage] = useState<string>(""); const [previewImage, setPreviewImage] = useState<string | null>(null);
const validateForm = (): boolean => { const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
@ -147,7 +148,6 @@ export function RegisterPage() {
toast.error(data.message || "خطایی رخ داد"); toast.error(data.message || "خطایی رخ داد");
return; return;
} }
toast.success("ثبت نام با موفقیت انجام شد"); toast.success("ثبت نام با موفقیت انجام شد");
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["userProfile"], queryKey: ["userProfile"],
@ -188,7 +188,7 @@ export function RegisterPage() {
})); }));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!validateForm()) { if (!validateForm()) {
@ -199,9 +199,10 @@ export function RegisterPage() {
registerMutation.mutate(formData); registerMutation.mutate(formData);
}; };
const logOut = () => { const logOut = (e: FormEvent) => {
e.preventDefault();
localStorage.clear(); localStorage.clear();
navigate(`${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`); window.location.href = `${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`;
}; };
return ( return (
@ -331,13 +332,14 @@ export function RegisterPage() {
/> />
<ImageUploader <ImageUploader
label="عکس پروفایل (اختیاری)" label="عکس پروفایل "
previewImage={previewImage} previewImage={previewImage}
onImageChange={(file) => { onImageChange={(file) => {
if (!file) { if (!file) {
setPreviewImage(""); setPreviewImage("");
return; return;
} }
setFormData((prev) => ({ ...prev, image: file }));
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
setPreviewImage(reader.result as string); setPreviewImage(reader.result as string);

View File

@ -24,7 +24,7 @@ export const dashboardRoutes: AppRoute[] = [
element: <CampaignsPage />, element: <CampaignsPage />,
}, },
{ {
path: "campaigns/:id", path: `${DASHBOARD_ROUTE.campaigns}/:id`,
element: <CampaignDetailPage />, element: <CampaignDetailPage />,
}, },
], ],

View File

@ -1,20 +1,17 @@
import { API_ADDRESS } from "@/core/service/api-address"; import { API_ADDRESS } from "@/core/service/api-address";
import api from "@/core/service/axios"; import api from "@/core/service/axios";
import { userInfoService } from "@/core/service/user-info.service"; import { userInfoService } from "@/core/service/user-info.service";
import to from "await-to-js"; import { uploadImage } from "@/core/utils";
import { toast } from "react-toastify";
import type { import type {
Campaign, Campaign,
CommentsItem,
CreateCampaignData, CreateCampaignData,
} from "../pages/campaigns/campaigns.type"; SignatureItem,
} from "@modules/dashboard/pages/campaigns/campaigns.type";
import to from "await-to-js";
import { toast } from "react-toastify";
export const getCampaignsService = async ( export const getCampaignsService = async (): Promise<Campaign[]> => {
tab: string,
search?: string
): Promise<Campaign[]> => {
const params = new URLSearchParams();
params.append("tab", tab);
if (search) params.append("search", search);
const userStr = userInfoService.getUserInfo(); const userStr = userInfoService.getUserInfo();
const query = { const query = {
ProcessName: "campaign", ProcessName: "campaign",
@ -28,11 +25,11 @@ export const getCampaignsService = async (
"status", "status",
"school_code", "school_code",
"signature_count", "signature_count",
// "comment_count", "comment_count",
], ],
conditions: [ conditions: [
["school_code", "=", userStr.school_code, "or"], ["school_code", "=", userStr.school_code, "or"],
["user_id", "=", "", "and"], // ["user_id", "=", "", "and"],
["status", "!=", "حذف شده", "and"], ["status", "!=", "حذف شده", "and"],
["status", "!=", "غیر فعال"], ["status", "!=", "غیر فعال"],
], ],
@ -47,35 +44,147 @@ export const getCampaignsService = async (
} }
if (res.data.resultType !== 0) { if (res.data.resultType !== 0) {
toast.error("خطا در دریافت کمپینها"); toast.error("خطا در دریافت کارزارها");
throw new Error("خطا در دریافت کمپینها"); throw new Error("خطا در دریافت کارزارها");
} }
const data = JSON.parse(res.data.data); const data = JSON.parse(res.data.data);
return data; return data;
}; };
export const getCampaignDetailService = async ( export const getSignsCampaignService = async (
campaignId: string campaignId: string
): Promise<Campaign> => { ): Promise<SignatureItem[]> => {
const [err, res] = await to(api.get(`/campaigns/${campaignId}`)); const query = {
ProcessName: "signature",
OutputFields: ["user_stage_id", "user_id.nickname"],
conditions: [["campaign", "=", campaignId]],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) { if (err) {
throw err; throw err;
} }
return res?.data;
if (res.data.resultType !== 0) {
toast.error("خطا در امضای کارزار");
throw new Error("خطا در امضای کارزار");
}
const data = JSON.parse(res.data.data);
if (!data.length) {
return [] as any;
}
return data;
};
export const getCommentsCampaignService = async (
campaignId: string
): Promise<Campaign> => {
const query = {
ProcessName: "comment",
OutputFields: ["author_id.nickname", "text", "createdAt"],
conditions: [["campaign", "=", campaignId]],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error("خطا در دریافت نظرات کارزار");
throw new Error("خطا در دریافت نظرات کارزار");
}
const data = JSON.parse(res.data.data);
return data.data;
};
export const getCommentsService = async (
campaignId: string
): Promise<CommentsItem[]> => {
const query = {
ProcessName: "comment",
OutputFields: ["user_stage_id", "user_id.nickname", "comment_text"],
conditions: [
["campaign", "=", campaignId],
["status", "!=", "حذف شده", "and"],
["status", "!=", "غیر فعال"],
],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error("خطا در افزودن نظر");
throw new Error("خطا در افزودن نظر");
}
const data = JSON.parse(res.data.data);
if (!data.length) {
return [] as any;
}
return data;
};
export const getSelectedCampaignsService = async (
campaignId: Number
): Promise<Campaign> => {
const query = {
ProcessName: "campaign",
OutputFields: [
"title",
"description",
"image",
"user_id",
"user_id.nickname",
"status",
"signature_count",
"comment_count",
],
conditions: [
["WorkflowID", "=", campaignId, "and"],
["status", "!=", "حذف شده", "and"],
["status", "!=", "غیر فعال"],
],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error("خطا در دریافت کارزار‌");
throw new Error("خطا در دریافت کارزار‌");
}
const data = JSON.parse(res.data.data);
return data[0];
}; };
export const createCampaignService = async ( export const createCampaignService = async (
data: CreateCampaignData data: CreateCampaignData
): Promise<Campaign> => { ): Promise<Campaign> => {
const user = userInfoService.getUserInfo(); const user = userInfoService.getUserInfo();
let saveImage = null;
if (data.image) {
saveImage = await uploadImage({
file: data.image as File,
name: "profile_picture",
});
}
const body = { const body = {
ProcessName: "campaign", ProcessName: "campaign",
campaign: { campaign: {
title: data.title, title: data.title,
description: data.description, description: data.description,
image: data.image, image: saveImage ? saveImage.data.data : undefined,
user_id: user.username, // ورکفلو آی دی شخص user_id: user.username,
status: "فعال",
}, },
}; };
const [err, res] = await to(api.post(API_ADDRESS.select, body)); const [err, res] = await to(api.post(API_ADDRESS.select, body));
@ -93,18 +202,66 @@ export const createCampaignService = async (
export const signCampaignService = async ( export const signCampaignService = async (
campaignId: string campaignId: string
): Promise<Campaign> => { ): Promise<void> => {
const response = await api.post(`/campaigns/${campaignId}/sign`); const user = userInfoService.getUserInfo();
return response.data; const body = {
ProcessName: "signature",
signature: {
campaign: campaignId,
user_id: user.username,
},
};
const [err, res] = await to(api.post(API_ADDRESS.select, body));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error(res.data.message || "خطا در امضای کارزار");
throw new Error("خطا در امضای کارزار");
}
}; };
export const addCommentService = async ( export const addCommentService = async (
campaignId: string, campaignId: string,
text: string text: string
): Promise<Campaign> => { ): Promise<void> => {
const response = await api.post(`/campaigns/${campaignId}/comments`, { const user = userInfoService.getUserInfo();
text, const body = {
}); comment: {
campaign: campaignId,
user_id: user.username,
comment_text: text,
status: "فعال",
},
};
const [err, res] = await to(api.post(API_ADDRESS.save, body));
if (err) {
throw err;
}
return response.data; if (res.data.resultType !== 0) {
toast.error(res.data.message || "خطا در افزودن نظر");
throw new Error("خطا در افزودن نظر");
}
};
export const removeCommentService = async (
commentId: Number
): Promise<void> => {
const body = {
WorkflowID: commentId,
comment: {
status: "حذف شده",
},
};
const [err, res] = await to(api.post(API_ADDRESS.delete, body));
if (err) {
throw err;
}
if (res.data.resultType !== 0) {
toast.error(res.data.message || "خطا در حذف نظر");
throw new Error("خطا در حذف نظر");
}
}; };

View File

@ -1,5 +1,6 @@
import { API_ADDRESS } from "@/core/service/api-address"; import { API_ADDRESS } from "@/core/service/api-address";
import api from "@/core/service/axios"; import api from "@/core/service/axios";
import { uploadImage } from "@/core/utils";
import type { RegistrationFormData } from "@modules/dashboard/pages/profile/profile.type"; import type { RegistrationFormData } from "@modules/dashboard/pages/profile/profile.type";
import { to } from "await-to-js"; import { to } from "await-to-js";
@ -20,7 +21,7 @@ export const fetchUserProfile = async () => {
"invitor", "invitor",
"nationalcode", "nationalcode",
], ],
conditions: [["username", "=", person.ID]], conditions: [["username", "=", person.ID ? person.ID : person.username]],
}; };
const res = await api.post(API_ADDRESS.select, query); const res = await api.post(API_ADDRESS.select, query);
@ -32,16 +33,17 @@ export const fetchUserProfile = async () => {
const user = JSON.parse(res.data.data)[0]; const user = JSON.parse(res.data.data)[0];
if (user) localStorage.setItem("person", JSON.stringify(user)); if (user) localStorage.setItem("person", JSON.stringify(user));
return { return {
username: user.username, stageID: user?.ValueP1224S1943StageID,
name: user.name, username: user?.username,
family: user.family, name: user?.name,
educationLevel: user.education_level, family: user?.family,
base: user.base, educationLevel: user?.education_level,
userType: user.account_type, base: user?.base,
nickname: user.nickname, userType: user?.account_type,
schoolCode: user.school_code, nickname: user?.nickname,
invitor: user.invitor, schoolCode: user?.school_code,
nationalcode: user.nationalcode, invitor: user?.invitor,
nationalcode: user?.nationalcode,
}; };
}; };
@ -52,21 +54,29 @@ export const updateUserProfile = async (data: RegistrationFormData) => {
} }
const person = JSON.parse(personStr); const person = JSON.parse(personStr);
const natinalCode = person.NationalCode; const nationalCode = person.nationalcode;
let saveImage = null;
if (data.image) {
saveImage = await uploadImage({
file: data.image as File,
name: "profile_picture",
});
}
let payload = { let payload = {
...(nationalCode && { WorkflowID: person.WorkflowID }),
user: { user: {
username: String(person.ID), username: person.ID ? String(person.ID) : person.username,
name: data.name.trim(), name: data.name.trim(),
family: data.family.trim(), family: data.family.trim(),
nickname: data.nickname.trim() || undefined, nickname: data.nickname.trim() || undefined,
education_level: data.education_level, education_level: data.education_level,
base: data.base, base: data.base,
image: saveImage?.data?.data,
account_type: "عادی", account_type: "عادی",
nationalcode: data.nationalcode, nationalcode: data.nationalcode,
...(saveImage?.data?.data && { image: saveImage?.data?.data }),
...(data.school_code && { school_code: data.school_code.trim() }), ...(data.school_code && { school_code: data.school_code.trim() }),
...(data.invitor && { invitor: data.invitor.trim() }), ...(data.invitor && { invitor: data.invitor.trim() }),
...(natinalCode && { WorkflowID: person.ID }),
}, },
}; };

View File

@ -1,14 +0,0 @@
import React from "react";
import { Navigate } from "react-router-dom";
interface ProtectedRouteProps {
element: React.ReactNode;
}
export default function ProtectedRoute({ element }: ProtectedRouteProps) {
const token = localStorage.getItem("token");
if (!token) return <Navigate to="/auth/login" replace />;
return <>{element}</>;
}