feat: Implement dashboard profile card and campaigns management

- Added ProfileCard component to display user profile information.
- Created DashboardLayout for consistent layout structure.
- Defined Campaign and related types for campaign management.
- Developed CampaignDetailPage for viewing individual campaign details.
- Implemented CampaignsPage for listing and filtering campaigns.
- Enhanced DashboardPage with user profile fetching and navigation.
- Built RegisterPage for user profile registration and updates.
- Added user service for fetching and updating user profiles.
- Established campaigns service for managing campaign data and interactions.
- Updated routing constants and router configuration for new pages.
This commit is contained in:
MehrdadAdabi 2025-11-23 18:10:30 +03:30
parent d9d97da7da
commit ce4c33d46d
43 changed files with 2670 additions and 78 deletions

View File

@ -1,10 +1,26 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yari-garan</title>
<!-- Persian Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazir:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<style>
:root {
font-family: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
</style>
<title>یاری گران - داشبورد</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

27
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.10",
"await-to-js": "^3.0.0", "await-to-js": "^3.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -2914,6 +2915,32 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.90.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.10.tgz",
"integrity": "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz",
"integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.10",
"await-to-js": "^3.0.0", "await-to-js": "^3.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -1 +1,101 @@
{} {
"dashboard": {
"title": "داشبورد کاربری",
"editInfo": "ویرایش اطلاعات",
"myGroup": "گروه من",
"schoolStudents": "دانش‌آموزان مدرسه",
"activities": "فعالیت‌ها",
"reports": "گزارش‌ها",
"logout": "خروج از حساب",
"profile": {
"fullName": "نام کامل",
"userType": "نوع کاربر",
"groupName": "نام گروه",
"schoolName": "نام مدرسه",
"student": "دانش‌آموز",
"school": "مدرسه"
}
},
"registration": {
"title": "ثبت نام",
"subtitle": "لطفاً اطلاعات خود را وارد کنید",
"firstName": "نام",
"lastName": "نام خانوادگی",
"nationalId": "شناسه ملی",
"nickname": "نام مستعار",
"schoolCode": "کد مدرسه",
"gradeLevel": "پایه و مقطع",
"referrerNickname": "نام مستعار معرف (اختیاری)",
"profilePicture": "عکس پروفایل (اختیاری)",
"submit": "ثبت نام",
"submitting": "در حال ثبت نام...",
"back": "بازگشت",
"errors": {
"firstNameRequired": "نام الزامی است",
"lastNameRequired": "نام خانوادگی الزامی است",
"nationalIdRequired": "شناسه ملی الزامی است",
"nationalIdInvalid": "شناسه ملی باید 10 تا 12 رقم باشد",
"nicknameRequired": "نام مستعار الزامی است",
"schoolCodeRequired": "کد مدرسه الزامی است",
"gradeLevelRequired": "پایه و مقطع الزامی است",
"invalidImage": "لطفاً فقط فایل تصویری انتخاب کنید",
"imageTooLarge": "حجم تصویر نباید بیشتر از 5 مگابایت باشد"
}
},
"campaigns": {
"title": "کمپین‌ها",
"subtitle": "برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید",
"search": "جستجوی کمپین...",
"create": "ایجاد کمپین",
"tabs": {
"all": "تمام کمپین‌ها",
"my": "کمپین‌های من",
"top": "کمپین‌های برتر",
"group": "کمپین‌های گروه"
},
"createModal": {
"title": "ایجاد کمپین جدید",
"titleLabel": "عنوان کمپین",
"titlePlaceholder": "عنوان کمپین را وارد کنید",
"description": "توضیحات",
"descriptionPlaceholder": "توضیحات کمپین را وارد کنید (حداقل 20 کاراکتر)",
"image": "تصویر کمپین",
"upload": "کلیک کنید یا تصویر را بکشید",
"cancel": "لغو",
"submit": "ایجاد کمپین",
"submitting": "در حال ایجاد...",
"errors": {
"titleRequired": "عنوان کمپین الزامی است",
"descriptionRequired": "توضیحات الزامی است",
"descriptionMinLength": "توضیحات باید حداقل 20 کاراکتر باشد",
"imageRequired": "تصویر الزامی است"
}
},
"detail": {
"back": "بازگشت به کمپین‌ها",
"by": "توسط",
"signatures": "امضا",
"comments": "نظر",
"sign": "امضای کمپین",
"signing": "در حال امضا...",
"signed": "شما امضا کرده‌اید ✓",
"signers": "امضاکنندگان",
"noSigners": "هنوز کسی امضا نکرده است. شما می‌توانید اولین نفر باشید!",
"noComments": "هنوز نظری وجود ندارد. اولین نظر را بنویسید!",
"addComment": "نظر خود را بنویسید...",
"send": "ارسال",
"sending": "ارسال...",
"notFound": "کمپین یافت نشد"
},
"empty": {
"my": "هنوز کمپینی ایجاد نکرده‌اید",
"noResults": "کمپینی یافت نشد",
"createFirst": "ایجاد اولین کمپین خود"
},
"notifications": {
"created": "کمپین با موفقیت ایجاد شد",
"signed": "با موفقیت امضا کردید",
"commentAdded": "نظر شما اضافه شد",
"alreadySigned": "شما قبلاً این کمپین را امضا کرده‌اید",
"emptyComment": "لطفاً نظری بنویسید"
}

View File

@ -0,0 +1,80 @@
// components/ui/BaseDropdown.tsx
import { cn } from "@/core/lib/utils";
import { ChevronDown } from "lucide-react";
import { type SelectHTMLAttributes, forwardRef } from "react";
type BaseDropdownProps = SelectHTMLAttributes<HTMLSelectElement> & {
label?: string;
error?: string;
variant?: "primary" | "error";
options: { value: string; label: string }[];
placeholder?: string;
};
export const BaseDropdown = forwardRef<HTMLSelectElement, BaseDropdownProps>(
(
{
label,
error,
variant = "primary",
options,
placeholder = "انتخاب کنید",
className,
...props
},
ref
) => {
const hasError = !!error;
return (
<div className="w-full">
{label && (
<label className="mb-2 block text-sm font-medium text-foreground text-right">
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
className={cn(
"flex h-12 w-full appearance-none rounded-lg border-2 bg-background px-4 py-2 pr-4 text-sm transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-1",
variant === "error" || hasError
? "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20"
: "border-gray-300 focus-visible:border-blue-600 focus-visible:ring-blue-600/20",
props.disabled && "opacity-60 cursor-not-allowed bg-gray-50",
className
)}
{...props}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* آیکون پایین */}
<div className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<ChevronDown
className={cn(
"h-5 w-5 transition-transform",
hasError ? "text-red-500" : "text-gray-500"
)}
/>
</div>
</div>
{hasError && (
<p className="mt-2 text-sm text-red-600 flex items-center gap-1 text-end">
{error}
</p>
)}
</div>
);
}
);
BaseDropdown.displayName = "BaseDropdown";

View File

@ -23,7 +23,7 @@ const CustomButton = React.forwardRef<HTMLButtonElement, CustomButtonProps>(
variant === "info" && !disabled, variant === "info" && !disabled,
// Error variant // Error variant
"bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600 active:bg-red-800": "bg-red-400 text-white hover:bg-red-700 focus-visible:ring-red-600 active:bg-red-800":
variant === "error" && !disabled, variant === "error" && !disabled,
// Disabled state // Disabled state

View File

@ -9,7 +9,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
ref={ref} ref={ref}
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-4 rounded-xl border py-6 shadow-sm",
className className
)} )}
{...props} {...props}

View File

@ -0,0 +1,147 @@
import { cn } from "@/core/lib/utils";
import { Upload, X } from "lucide-react";
import { type ChangeEvent, useRef, useState } from "react";
type ImageUploaderProps = {
label?: string;
previewImage?: string | null;
onImageChange: (file: File | null) => void;
onRemove?: () => void;
className?: string;
error?: string;
required?: boolean;
imageSize?: "sm" | "md" | "lg";
};
export function ImageUploader({
label = "عکس پروفایل (اختیاری)",
previewImage,
onImageChange,
onRemove,
className,
error,
required = false,
imageSize = "md",
}: ImageUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const sizeClasses = {
sm: "w-24 h-24",
md: "w-32 h-32",
lg: "w-48 h-48",
};
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
validateAndSetFile(file);
}
};
const validateAndSetFile = (file: File) => {
if (!file.type.startsWith("image/")) {
return;
}
if (file.size > 5 * 1024 * 1024) {
return;
}
onImageChange(file);
};
const handleRemove = () => {
onImageChange(null);
onRemove?.();
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) {
validateAndSetFile(file);
}
};
return (
<div className={cn("w-full", className)}>
{label && (
<label className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground text-right">
{label}
{required && <span className="text-red-500">*</span>}
</label>
)}
{previewImage ? (
<div className="relative inline-block group">
<img
src={previewImage}
alt="پیش‌نمایش تصویر"
className={cn(
"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>
) : (
<label
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"flex flex-col items-center justify-center gap-3 w-full h-32 rounded-lg border-2 border-dashed cursor-pointer transition-all duration-200",
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 bg-gray-50 hover:bg-gray-100 hover:border-gray-400"
)}
role="button"
aria-label="آپلود تصویر"
>
<Upload
size={28}
className={cn(isDragging ? "text-blue-500" : "text-gray-500")}
/>
<div className="text-center">
<p className="text-sm font-medium text-gray-700">
کلیک کنید یا تصویر را بکشید
</p>
<p className="text-xs text-gray-500 mt-1">PNG, JPG تا ۵ مگابایت</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</label>
)}
{error && (
<p className="mt-2 text-sm text-red-600 flex items-center gap-1 text-end">
{error}
</p>
)}
</div>
);
}

View File

@ -48,7 +48,7 @@ const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>(
{...props} {...props}
/> />
{error && ( {error && (
<p className="justify-end mt-2 text-sm text-red-600 flex items-center gap-1"> <p className="text-end mt-2 text-sm text-red-600 flex items-center gap-1">
{error} {error}
</p> </p>
)} )}

View File

@ -0,0 +1,45 @@
import { cn } from "@/core/lib/utils";
import { forwardRef, type ComponentProps } from "react";
interface TextAreaFieldProps extends ComponentProps<"textarea"> {
label?: string;
error?: string;
minLength?: number;
}
const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(
({ label, error, minLength = 40, className, placeholder, ...props }, ref) => {
return (
<div className="space-y-2">
{label && (
<label className="mb-2 block text-sm font-medium text-foreground text-right">
{label}
</label>
)}
<textarea
ref={ref}
placeholder={placeholder ?? `حداقل ${minLength} کاراکتر وارد کنید`}
className={cn(
"flex min-h-24 w-full rounded-lg border-2 bg-background px-4 py-2 text-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-blue-600 focus-visible:ring-1 focus-visible:ring-blue-600",
error
? "border-red-500 focus-visible:border-red-600"
: "border-gray-300",
className
)}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600 flex items-center gap-1 text-end">
{error}
</p>
)}
</div>
);
}
);
TextAreaField.displayName = "TextAreaField";
export default TextAreaField;

View File

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

View File

@ -0,0 +1,212 @@
/**
* 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,133 @@
import { cn } from "@/core/lib/utils";
import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant";
import { BarChart3, Home, MessageCircle, User } from "lucide-react";
import { useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
export interface NavItem {
id: string;
disabled?: boolean;
label: string;
icon: React.ReactNode;
path: string;
}
interface MobileNavbarProps {
items?: NavItem[];
className?: string;
}
const DEFAULT_NAV_ITEMS: NavItem[] = [
{
id: "profile",
disabled: false,
label: "پروفایل",
icon: <User size={24} />,
path: `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.profile}`,
},
{
id: "group-chat",
label: "گروه",
disabled: true,
icon: <MessageCircle size={24} />,
path: "#",
},
{
id: "ranking",
label: "رتبه‌بندی",
disabled: true,
icon: <BarChart3 size={24} />,
path: "#",
},
{
id: "dashboard",
label: "کارزار",
disabled: false,
icon: <Home size={24} />,
path: `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}`,
},
];
export function MobileNavbar({
items = DEFAULT_NAV_ITEMS,
className,
}: MobileNavbarProps) {
const navigate = useNavigate();
const location = useLocation();
// Determine active tab based on current path
const activeTabId = useMemo(() => {
const matchedItem = items.find((item) =>
location.pathname.startsWith(item.path)
);
return matchedItem?.id || items[0]?.id;
}, [location.pathname, items]);
const handleNavClick = (path: string) => {
navigate(path, { replace: true });
// Optional: Haptic feedback on mobile
if (navigator.vibrate) {
navigator.vibrate(10);
}
};
return (
<nav
className={cn(
"fixed bottom-0 left-0 right-0 z-10 md:hidden",
"bg-white border-t border-gray-200 shadow-lg",
"safe-area-inset-bottom",
className
)}
dir="rtl"
>
<div className="flex items-center justify-around h-16 px-2">
{items.map((item) => {
const isActive = activeTabId === item.id;
return (
<button
key={`${item.path}-${item.id}`}
disabled={item.disabled}
onClick={() => handleNavClick(item.path)}
className={cn(
"flex flex-col items-center justify-center gap-1",
"w-16 h-16 rounded-lg transition-all duration-200",
"touch-none select-none",
isActive
? "bg-blue-50 text-blue-600"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
)}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
>
<div
className={cn(
"transition-transform duration-200",
isActive ? "scale-110" : "scale-100"
)}
>
{item.icon}
</div>
<span
className={cn(
"text-xs font-medium text-center",
"line-clamp-1",
isActive ? "text-blue-600" : "text-gray-500"
)}
>
{item.label}
</span>
</button>
);
})}
</div>
{/* Padding for safe area on devices with notch */}
<div className="h-safe-area-inset-bottom" />
</nav>
);
}
export default MobileNavbar;

View File

@ -0,0 +1,81 @@
/**
* Example: Root Layout Integration with MobileNavbar
*
* This file shows how to integrate the MobileNavbar component
* into your main app layout structure.
*/
import { MobileNavbar } from "@/core/components/others";
import { Outlet } from "react-router-dom";
/**
* RootLayout Component
*
* This component should wrap your entire application.
* It provides:
* - Consistent layout structure
* - Fixed mobile navbar at bottom
* - Proper content padding on mobile
*/
export function RootLayout() {
return (
<div className="flex flex-col min-h-screen" dir="rtl">
{/* Optional: Global Header */}
{/* <header className="sticky top-0 z-40 bg-white border-b border-gray-200">
<nav>Navigation header here</nav>
</header> */}
{/* Main Content Area - Routes render here via Outlet */}
<main className="flex-1 pb-20 md:pb-0">
<Outlet />
</main>
{/* Mobile Bottom Navigation - Fixed at bottom on mobile only */}
<MobileNavbar />
</div>
);
}
/**
* Router Configuration Example
*
* Place this in your router configuration file:
*
* import { RootLayout } from "@/layouts/RootLayout";
*
* const router = createBrowserRouter([
* {
* element: <RootLayout />,
* children: [
* { path: "/dashboard", element: <DashboardPage /> },
* { path: "/profile", element: <ProfilePage /> },
* { path: "/group-chat", element: <GroupChatPage /> },
* { path: "/ranking", element: <RankingPage /> },
* ],
* },
* ]);
*/
/**
* Key Points:
*
* 1. pb-20 = padding-bottom on mobile
* - 5rem (80px) to accommodate navbar height + safe area
* - Prevents content from being hidden behind navbar
*
* 2. md:pb-0 = no padding on desktop
* - Navbar is hidden on desktop (md:hidden in navbar component)
* - So no padding needed
*
* 3. flex flex-col min-h-screen
* - Creates flexible column layout
* - Ensures navbar stays at bottom even with short content
*
* 4. dir="rtl"
* - Sets RTL direction for Persian text
* - Applies to all child elements
*
* 5. <Outlet />
* - React Router renders child routes here
* - Each page component renders inside the main tag
*/

View File

@ -4,6 +4,8 @@ export const API_ADDRESS = {
otp: "/api/SignUpLoginBySMS", otp: "/api/SignUpLoginBySMS",
verifyOtp: "/api/verifyloginbysms", verifyOtp: "/api/verifyloginbysms",
}, },
select: "/api/select",
save: "/api/save",
// LOGIN: "/auth/login", // LOGIN: "/auth/login",
// VERIFY_OTP: "/auth/verify-otp", // VERIFY_OTP: "/auth/verify-otp",
// REGISTER: "/auth/register", // REGISTER: "/auth/register",

View File

@ -22,9 +22,11 @@ const api: AxiosInstance = axios.create(axiosConfig);
// درخواست قبل از ارسال (مثلاً افزودن توکن) // درخواست قبل از ارسال (مثلاً افزودن توکن)
api.interceptors.request.use( api.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem("token"); // یا از cookie/session بگیر if (localStorage.length > 0) {
if (token) { const token = JSON.parse(localStorage?.token)?.AccessToken;
config.headers!.Authorization = `Bearer ${token}`; if (token) {
config.headers!.Authorization = `Bearer ${token}`;
}
} }
return config; return config;
}, },

View File

@ -0,0 +1,17 @@
import type { RegistrationFormData } from "@/modules/dashboard/pages/profile/profile.type";
class UserInfoService {
getUserInfo(): RegistrationFormData {
const userStr = localStorage.getItem("person");
if (!userStr) {
throw new Error("کاربر وارد سیستم نشده است");
}
return JSON.parse(userStr);
}
updateUserInfo(updatedInfo: RegistrationFormData): void {
localStorage.setItem("person", JSON.stringify(updatedInfo));
}
}
export const userInfoService = new UserInfoService();

View File

@ -3,6 +3,23 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* Persian Font Support */
@font-face {
font-family: "Vazir";
src: url("https://cdn.jsdelivr.net/npm/vazir-font@31.1.0/dist/Vazir.woff2")
format("woff2");
font-weight: normal;
font-display: swap;
}
@font-face {
font-family: "Vazir";
src: url("https://cdn.jsdelivr.net/npm/vazir-font@31.1.0/dist/Vazir-Bold.woff2")
format("woff2");
font-weight: bold;
font-display: swap;
}
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
@ -75,7 +92,8 @@
} }
@theme inline { @theme inline {
--font-sans: "Geist", "Geist Fallback"; --font-sans: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback"; --font-mono: "Geist Mono", "Geist Mono Fallback";
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@ -121,5 +139,12 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
margin: 0 auto;
max-width: 1280px;
padding: 1rem;
box-sizing: border-box;
direction: rtl;
font-family: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
} }
} }

View File

@ -1,4 +1,4 @@
import { StrictMode } from "react"; 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";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
@ -7,9 +7,11 @@ import "./index.css";
import { rootRoutes } from "./router/rootRoutes.ts"; import { rootRoutes } from "./router/rootRoutes.ts";
const router = createBrowserRouter(rootRoutes); const router = createBrowserRouter(rootRoutes);
const client = new QueryClient();
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> // <StrictMode>
<QueryClientProvider client={client}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<ToastContainer <ToastContainer
position="top-right" position="top-right"
@ -23,5 +25,6 @@ createRoot(document.getElementById("root")!).render(
pauseOnHover pauseOnHover
theme="light" theme="light"
/> />
</StrictMode> </QueryClientProvider>
// </StrictMode>
); );

View File

@ -28,7 +28,7 @@ export const OTPDialog: FC<OTPDialogProps> = ({
</DialogHeader> </DialogHeader>
<div className="my-4"> <div className="my-4">
<OTPReceiver length={4} onChange={onChange} /> <OTPReceiver length={5} onChange={onChange} />
</div> </div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end"> <DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">

View File

@ -4,45 +4,80 @@ import { useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import type { OTPReceiverProps } from "../../pages/login/login.type"; import type { OTPReceiverProps } from "../../pages/login/login.type";
export function OTPReceiver({ length = 5, onChange }: OTPReceiverProps) { export function OTPReceiver({ length = 5, onChange }: OTPReceiverProps) {
const [values, setValues] = useState(Array(length).fill("")); const [values, setValues] = useState<string[]>(() => Array(length).fill(""));
const inputsRef = useRef<HTMLInputElement[]>([]); const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => { const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
const val = e.target.value; const value = e.target.value;
if (!/^\d*$/.test(val)) return; const digit = value.replace(/\D/g, "").slice(-1);
if (digit === values[index]) return;
const newValues = [...values]; const newValues = [...values];
newValues[index] = val.slice(-1); newValues[index] = digit;
setValues(newValues); setValues(newValues);
onChange?.(newValues.join(""));
if (val && index < length - 1) { if (digit && index < length - 1) {
inputsRef.current[index + 1]?.focus(); inputsRef.current[index + 1]?.focus();
} }
onChange?.(newValues.join(""));
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === "Backspace" && !values[index] && index > 0) { if (e.key === "Backspace") {
inputsRef.current[index - 1]?.focus(); e.preventDefault();
if (values[index]) {
const newValues = [...values];
newValues[index] = "";
setValues(newValues);
onChange?.(newValues.join(""));
} else if (index > 0) {
const newValues = [...values];
newValues[index - 1] = "";
setValues(newValues);
onChange?.(newValues.join(""));
inputsRef.current[index - 1]?.focus();
}
} }
}; };
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const pasted = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length);
if (!pasted) return;
const newValues = pasted
.split("")
.concat(Array(length).fill(""))
.slice(0, length);
setValues(newValues);
onChange?.(newValues.join(""));
const nextFocusIndex = pasted.length < length ? pasted.length : length - 1;
inputsRef.current[nextFocusIndex]?.focus();
};
return ( return (
<div className="flex gap-2 justify-center"> <div className="flex gap-3 justify-center" dir="ltr" onPaste={handlePaste}>
{Array.from({ length }).map((_, index) => ( {Array.from({ length }).map((_, index) => (
<input <input
key={index} key={index}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code"
maxLength={1} maxLength={1}
value={values[index]} value={values[index]}
ref={(el) => { ref={(el) => {
inputsRef.current[index] = el!; inputsRef.current[index] = el;
}} }}
onChange={(e) => handleChange(e, index)} onChange={(e) => handleChange(e, index)}
onKeyDown={(e) => handleKeyDown(e, index)} onKeyDown={(e) => handleKeyDown(e, index)}
className="w-12 h-12 text-center text-xl border rounded-md focus:border-blue-500 focus:ring-1 focus:ring-blue-500" className="w-12 h-12 text-center text-xl font-medium border-2 rounded-lg focus:border-blue-500 focus:outline-none focus:ring-4 focus:ring-blue-100 transition-all duration-200"
style={{ caretColor: "transparent" }} // اختیاری: برای زیبایی بیشتر
/> />
))} ))}
</div> </div>

View File

@ -14,19 +14,21 @@ import {
DialogTitle, DialogTitle,
} from "@/core/components/base/dialog"; } from "@/core/components/base/dialog";
import { CustomInput } from "@/core/components/base/input"; import { CustomInput } from "@/core/components/base/input";
import { API_ADDRESS } from "@/core/service/api-address";
import API from "@/core/service/axios";
import { OTPDialog } from "@modules/auth/components/otp/opt-dialog"; import { OTPDialog } from "@modules/auth/components/otp/opt-dialog";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import to from "await-to-js"; import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { sendOtpService, verifyOtpService } from "../../service/auth.service";
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [otpDialog, setOtpDialog] = useState(false); const [otpDialog, setOtpDialog] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(""); const [phoneNumber, setPhoneNumber] = useState<string>("");
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState<string>("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitLoading, setSubmitLoading] = useState<boolean>(false); const [submitLoading, setSubmitLoading] = useState<boolean>(false);
@ -43,28 +45,65 @@ export function LoginPage() {
return true; return true;
}; };
const handleSubmit = async () => { const sendOtpMutation = useMutation({
if (validatePhoneNumber(phoneNumber)) { mutationFn: sendOtpService,
setSubmitLoading(true);
const [err, res] = await to(API.post(API_ADDRESS.auth.otp, phoneNumber)); onSuccess: (data) => {
setSubmitLoading(false); setSubmitLoading(false);
if (data.resultType !== 0) {
if (res?.data.resultType !== 0) { toast.error(data.message);
toast.error(res?.data.message);
}
if (res?.data.resultType === 0) {
toast.success("ورود با موفقیت انجام شد");
localStorage.setItem("token", res.data.data.token);
}
if (err) {
return; return;
} }
toast.success("کد یکبار مصرف ارسال شد");
setIsDialogOpen(false); setIsDialogOpen(false);
setOtpDialog(true); setOtpDialog(true);
setPhoneNumber(""); // setPhoneNumber("");
} },
onError: (error: any) => {
setSubmitLoading(false);
toast.error("مشکلی رخ داد");
console.log(error);
},
});
const verifyOtpMutation = useMutation({
mutationFn: verifyOtpService,
onSuccess: (data) => {
setSubmitLoading(false);
if (data.resultType !== 0) {
toast.error(data.message);
return;
}
toast.success("ورود با موفقیت انجام شد");
const person = JSON.parse(data.data).Person;
const token = JSON.parse(data.data).Token;
localStorage.setItem("token", JSON.stringify(token));
localStorage.setItem("person", JSON.stringify(person));
setOtpDialog(false);
if (person.NationalCode === "") {
navigate(`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.profile}`, {
replace: true,
});
} else {
navigate(`${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}`, {
replace: true,
});
}
},
onError: () => {
setSubmitLoading(false);
toast.error("خطا در تایید کد");
},
});
const handleSubmit = () => {
if (!validatePhoneNumber(phoneNumber)) return;
setSubmitLoading(true);
sendOtpMutation.mutate(phoneNumber);
}; };
const handleCancel = () => { const handleCancel = () => {
@ -72,25 +111,12 @@ export function LoginPage() {
setError(""); setError("");
}; };
const handleOtpSubmit = async () => { const handleOtpSubmit = () => {
setSubmitLoading(true); setSubmitLoading(true);
const [err, res] = await to( verifyOtpMutation.mutate({
API.post(API_ADDRESS.auth.verifyOtp, { phoneNumber, otp }) mobile: phoneNumber,
); code: otp,
if (res?.data.resultType !== 0) { });
toast.error(res?.data.message);
}
if (res?.data.resultType === 0) {
toast.success("ورود با موفقیت انجام شد");
localStorage.setItem("token", res.data.data.token);
}
setSubmitLoading(false);
if (err) {
return;
}
setOtpDialog(false);
}; };
return ( return (
@ -99,12 +125,12 @@ export function LoginPage() {
<Card className="w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-md sm:p-6"> <Card className="w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-md sm:p-6">
<CardHeader> <CardHeader>
<CardTitle className="text-center sm:text-left text-xl font-bold"> <CardTitle className="text-center sm:text-left text-xl font-bold">
تماس با ما ورود به سامانه
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="mb-4 text-center text-sm text-gray-600 sm:text-left"> <p className="mb-4 text-center text-sm text-gray-600 sm:text-left">
برای ارتباط با تیم پشتیبانی، شماره تلفن خود را وارد کنید برای ورود به سامانه بر روی دکمه ورود کلیک نمایید.{" "}
</p> </p>
<div className="flex justify-center sm:justify-start"> <div className="flex justify-center sm:justify-start">
<CustomButton <CustomButton
@ -112,7 +138,7 @@ export function LoginPage() {
className="w-full sm:w-auto" className="w-full sm:w-auto"
onClick={() => setIsDialogOpen(true)} onClick={() => setIsDialogOpen(true)}
> >
وارد کردن شماره تلفن ورود
</CustomButton> </CustomButton>
</div> </div>
</CardContent> </CardContent>

View File

@ -1,5 +0,0 @@
const RegisterPage = () => {
return <div>Register</div>;
};
export default RegisterPage;

View File

@ -1,4 +1,5 @@
export const AUTH_ROUTE = { export const AUTH_ROUTE = {
sub: "/auth",
LOGIN: "login", LOGIN: "login",
REGISTER: "register", REGISTER: "register",
}; };

View File

@ -1,6 +1,6 @@
import type { AppRoute } from "@core/types/router.type"; import type { AppRoute } from "@core/types/router.type";
import RegisterPage from "../../dashboard/pages/profile";
import LoginPage from "../pages/login"; import LoginPage from "../pages/login";
import RegisterPage from "../pages/register";
import { AUTH_ROUTE } from "./route.constant"; import { AUTH_ROUTE } from "./route.constant";
export const authRoutes: AppRoute[] = [ export const authRoutes: AppRoute[] = [

View File

@ -0,0 +1,18 @@
import { API_ADDRESS } from "@/core/service/api-address";
import api from "@/core/service/axios";
export const sendOtpService = async (phoneNumber: string) => {
const res = await api.post(API_ADDRESS.auth.otp, phoneNumber);
return res.data;
};
export const verifyOtpService = async ({
mobile,
code,
}: {
mobile: string;
code: string;
}) => {
const res = await api.post(API_ADDRESS.auth.verifyOtp, { mobile, code });
return res.data;
};

View File

@ -0,0 +1,54 @@
import { Heart } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { Campaign } from "../pages/campaigns/campaigns.type";
interface CampaignCardProps {
campaign: Campaign;
}
export function CampaignCard({ campaign }: CampaignCardProps) {
const navigate = useNavigate();
return (
<div
onClick={() => navigate(`/dashboard/campaigns/${campaign.user_id}`)}
className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md hover:shadow-lg transition-shadow cursor-pointer h-full"
>
{/* Image */}
{/* <div className="relative h-48 bg-gray-200 overflow-hidden">
<img
src={campaign.image}
alt={campaign.title}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
</div> */}
{/* Content */}
<div className="p-4">
{/* Title */}
<h3 className="text-lg font-bold text-slate-800 mb-2 line-clamp-2 text-right">
{campaign.title}
</h3>
{/* Creator */}
<p className="text-sm text-slate-600 mb-3 text-right">
از طرف: {campaign.user_id}
</p>
{/* Stats */}
<div className="flex items-center justify-between pt-3 border-t border-gray-200">
<div className="flex items-center gap-2 text-red-500">
<Heart size={18} fill="currentColor" />
<span className="text-sm font-semibold">{campaign.title}</span>
</div>
{/* <div className="flex items-center gap-2 text-blue-500">
<MessageCircle size={18} />
<span className="text-sm font-semibold">
{campaign.comments.length}
</span>
</div> */}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { CustomButton } from "@/core/components/base/button";
import {
Dialog,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/core/components/base/dialog";
import { ImageUploader } from "@/core/components/base/image-uploader";
import { CustomInput } from "@/core/components/base/input";
import TextAreaField from "@/core/components/base/text-area";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "react-toastify";
import type { CreateCampaignData } from "../pages/campaigns/campaigns.type";
import { createCampaignService } from "../service/campaigns.service";
interface CreateCampaignModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateCampaignModal({
isOpen,
onClose,
onSuccess,
}: CreateCampaignModalProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [imageFile, setImageFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string>("");
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!title.trim()) {
newErrors.title = "عنوان کمپین الزامی است";
}
if (!description.trim()) {
newErrors.description = "توضیحات الزامی است";
} else if (description.length < 20) {
newErrors.description = "توضیحات باید حداقل 20 کاراکتر باشد";
}
if (!imageFile) {
newErrors.image = "تصویر الزامی است";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const createMutation = useMutation({
mutationFn: (data: CreateCampaignData) => createCampaignService(data),
onSuccess: () => {
toast.success("کمپین با موفقیت ایجاد شد");
handleClose();
onSuccess();
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد کمپین");
},
});
const removeImage = () => {
setImageFile(null);
setPreviewImage("");
};
const handleClose = () => {
setTitle("");
setDescription("");
setImageFile(null);
setPreviewImage("");
setErrors({});
onClose();
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error("لطفاً تمام فیلدهای الزامی را پر کنید");
return;
}
if (imageFile) {
createMutation.mutate({
title,
description,
image: imageFile,
});
}
};
return (
<Dialog isOpen={isOpen} onClose={handleClose}>
<div className="flex flex-col gap-4 w-full max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-semibold text-center sm:text-right">
ایجاد کمپین جدید
</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{/* Title Input */}
<CustomInput
label="عنوان کمپین"
type="text"
placeholder="عنوان کمپین را وارد کنید"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (errors.title) setErrors((prev) => ({ ...prev, title: "" }));
}}
error={errors.title}
variant={errors.title ? "error" : "primary"}
/>
{/* Description Input */}
<TextAreaField
label="توضیحات کمپین"
placeholder="توضیحات کمپین را وارد کنید (حداقل 20 کاراکتر)"
value={description}
onChange={(e) => {
setDescription(e.target.value);
if (errors.description)
setErrors((prev) => ({ ...prev, description: "" }));
}}
error={errors.description}
minLength={20}
/>
{/* Image Upload */}
<ImageUploader
label="تصویر کمپین"
previewImage={previewImage}
onImageChange={(file) => {
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setPreviewImage(reader.result as string);
};
reader.readAsDataURL(file);
if (errors.image) {
setErrors((prev) => ({ ...prev, image: "" }));
}
} else {
setImageFile(null);
setPreviewImage("");
}
}}
onRemove={removeImage}
error={errors.image}
required
imageSize="lg"
/>
</form>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end px-4 pb-4">
<CustomButton
variant="primary"
disabled={createMutation.isPending}
onClick={handleSubmit}
>
{createMutation.isPending ? "در حال ایجاد..." : "ایجاد کمپین"}
</CustomButton>
<CustomButton variant="info" onClick={handleClose}>
لغو
</CustomButton>
</DialogFooter>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,47 @@
import type { LucideIcon } from "lucide-react";
import React from "react";
interface DashboardCardProps {
icon: LucideIcon;
label: string;
onClick: () => void;
variant?: "default" | "danger";
className?: string;
}
const DashboardCard: React.FC<DashboardCardProps> = ({
icon: Icon,
label,
onClick,
variant = "default",
className = "",
}) => {
const baseStyles =
"flex flex-col items-center justify-center gap-4 p-6 rounded-lg shadow-md transition-all duration-200 active:scale-95 h-40 w-full";
const variantStyles = {
default:
"bg-white border border-blue-100 hover:shadow-lg hover:border-blue-200",
danger:
"bg-rose-50 border border-rose-200 hover:shadow-lg hover:border-rose-300",
};
const textColor = variant === "danger" ? "text-rose-700" : "text-slate-700";
const iconColor = variant === "danger" ? "text-rose-600" : "text-blue-500";
return (
<button
onClick={onClick}
className={`${baseStyles} ${variantStyles[variant]} ${className}`}
type="button"
aria-label={label}
>
<Icon size={40} className={iconColor} strokeWidth={1.5} />
<span className={`text-center font-medium text-sm ${textColor}`}>
{label}
</span>
</button>
);
};
export default DashboardCard;

View File

@ -0,0 +1,61 @@
import { User } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import type { UserProfile } from "../types/dashboard.type";
interface ProfileCardProps {
profile: UserProfile;
}
const ProfileCard: React.FC<ProfileCardProps> = ({ profile }) => {
const { t } = useTranslation();
const userTypeLabel =
profile?.userType === "student"
? t("dashboard.profile.student")
: t("dashboard.profile.school");
const userInfo =
profile?.userType === "student" ? profile.groupName : profile.schoolName;
return (
<div className="bg-linear-to-br from-blue-50 to-white border border-blue-100 rounded-lg shadow-md p-6 mb-6">
<div className="flex gap-4">
{/* Profile Image */}
<div className="shrink-0">
{profile.profileImage ? (
<img
src={profile.profileImage}
alt={profile.fullName}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-200"
/>
) : (
<div className="w-16 h-16 rounded-full bg-blue-100 border-2 border-blue-200 flex items-center justify-center">
<User size={32} className="text-blue-500" strokeWidth={1.5} />
</div>
)}
</div>
{/* Profile Info */}
<div className="flex-1 flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-800 text-right mb-1">
{profile.fullName}
</h2>
<p className="text-sm text-slate-600 text-right mb-1">
{userTypeLabel}
</p>
{userInfo && (
<p className="text-xs text-slate-500 text-right">
{profile.userType === "student"
? t("dashboard.profile.groupName")
: t("dashboard.profile.schoolName")}
: {userInfo}
</p>
)}
</div>
</div>
</div>
);
};
export default ProfileCard;

View File

@ -0,0 +1,14 @@
// layouts/DashboardLayout.tsx
import { MobileNavbar } from "@/core/components/others";
import { Outlet } from "react-router-dom";
export function DashboardLayout() {
return (
<div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto ">
<Outlet />
<MobileNavbar />
</main>
</div>
);
}

View File

@ -0,0 +1,37 @@
export interface Campaign {
ValueP1226S1951StageID: Number;
ValueP1226S1951ValueID: Number;
WorkflowID: Number;
description: String;
image: String;
status: String;
title: String;
user_id: String;
volume: String;
school_code: String;
nickname?: String;
signature_count: Number;
comment_count?: Number;
}
export interface Signer {
id: string;
nickname: string;
avatar?: string;
}
export interface Comment {
id: string;
authorNickname: string;
authorId: string;
text: string;
createdAt: string;
}
export interface CreateCampaignData {
title: string;
description: string;
image: File;
}
export type CampaignTab = "all" | "my" | "top" | "group";

View File

@ -0,0 +1,264 @@
"use client";
import { CustomButton } from "@/core/components/base/button";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRight, Heart, Loader, MessageCircle } from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
addCommentService,
getCampaignDetailService,
signCampaignService,
} from "../../service/campaigns.service";
export function CampaignDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [commentText, setCommentText] = useState("");
const [hasSignedCampaign, setHasSignedCampaign] = useState(false);
const {
data: campaign,
isLoading,
refetch,
} = useQuery({
queryKey: ["campaign", id],
queryFn: () => getCampaignDetailService(id!),
enabled: !!id,
});
const signMutation = useMutation({
mutationFn: () => signCampaignService(id!),
onSuccess: () => {
toast.success("با موفقیت امضا کردید");
setHasSignedCampaign(true);
refetch();
},
onError: (error: any) => {
toast.error(error?.message || "خطا در امضای کمپین");
},
});
const commentMutation = useMutation({
mutationFn: (text: string) => addCommentService(id!, text),
onSuccess: () => {
toast.success("نظر شما اضافه شد");
setCommentText("");
refetch();
},
onError: (error: any) => {
toast.error(error?.message || "خطا در افزودن نظر");
},
});
const handleSignCampaign = () => {
if (hasSignedCampaign) {
toast.info("شما قبلاً این کمپین را امضا کرده‌اید");
return;
}
signMutation.mutate();
};
const handleAddComment = (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim()) {
toast.error("لطفاً نظری بنویسید");
return;
}
commentMutation.mutate(commentText);
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<Loader size={40} className="text-blue-500 animate-spin" />
</div>
);
}
if (!campaign) {
return (
<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>
<CustomButton onClick={() => navigate("/dashboard/campaigns")}>
بازگشت به کمپینها
</CustomButton>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-8" dir="rtl">
<div className="max-w-4xl mx-auto">
{/* Back Button */}
<button
onClick={() => navigate("/dashboard/campaigns")}
className="flex items-center gap-2 text-blue-500 hover:text-blue-700 mb-6 transition-colors"
>
<ArrowRight size={20} />
<span>بازگشت به کمپینها</span>
</button>
{/* Campaign Image */}
<div className="mb-8 rounded-lg overflow-hidden h-96 bg-gray-200">
<img
src={campaign.image}
alt={campaign.title}
className="w-full h-full object-cover"
/>
</div>
{/* Campaign Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
{/* Title */}
<h1 className="text-3xl font-bold text-slate-800 text-right mb-4">
{campaign.title}
</h1>
{/* Creator Info */}
<p className="text-slate-600 text-right mb-6">
توسط:{" "}
<span className="font-semibold">{campaign.creatorNickname}</span>
</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 gap-2">
<span className="text-3xl font-bold text-red-500">
{campaign.signatures}
</span>
<div className="flex flex-col">
<Heart size={24} className="text-red-500" fill="currentColor" />
<span className="text-xs text-slate-600">امضا</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-3xl font-bold text-blue-500">
{campaign.comments.length}
</span>
<div className="flex flex-col">
<MessageCircle size={24} className="text-blue-500" />
<span className="text-xs text-slate-600">نظر</span>
</div>
</div>
</div>
{/* Description */}
<p className="text-slate-700 text-right leading-relaxed mb-6">
{campaign.description}
</p>
{/* Sign Campaign Button */}
<CustomButton
variant={hasSignedCampaign ? "info" : "primary"}
className="w-full"
onClick={handleSignCampaign}
disabled={signMutation.isPending || hasSignedCampaign}
>
{signMutation.isPending
? "در حال امضا..."
: hasSignedCampaign
? "شما امضا کرده‌اید ✓"
: "امضای کمپین"}
</CustomButton>
</div>
{/* Signers Section */}
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 className="text-2xl font-bold text-slate-800 text-right mb-6">
امضاکنندگان
</h2>
{campaign.signers.length > 0 ? (
<div className="flex flex-wrap gap-4 justify-end">
{campaign.signers.map((signer) => (
<div
key={signer.id}
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">
{signer.avatar ? (
<img
src={signer.avatar}
alt={signer.nickname}
className="w-full h-full rounded-full object-cover"
/>
) : (
signer.nickname.charAt(0).toUpperCase()
)}
</div>
<p className="text-sm text-slate-600 text-center max-w-16 truncate">
{signer.nickname}
</p>
</div>
))}
</div>
) : (
<p className="text-slate-600 text-center py-8">
هنوز کسی امضا نکرده است. شما میتوانید اولین نفر باشید!
</p>
)}
</div>
{/* Comments Section */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold text-slate-800 text-right mb-6">
نظرات
</h2>
{/* Add Comment Form */}
<form
onSubmit={handleAddComment}
className="mb-8 pb-8 border-b border-gray-200"
>
<div className="flex gap-4 flex-col sm:flex-row">
<input
type="text"
placeholder="نظر خود را بنویسید..."
value={commentText}
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"
/>
<CustomButton
variant="primary"
type="submit"
disabled={commentMutation.isPending || !commentText.trim()}
>
{commentMutation.isPending ? "ارسال..." : "ارسال"}
</CustomButton>
</div>
</form>
{/* Comments List */}
<div className="space-y-4">
{campaign.comments.length > 0 ? (
campaign.comments.map((comment) => (
<div
key={comment.id}
className="p-4 bg-gray-50 rounded-lg border border-gray-200"
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-500">
{new Date(comment.createdAt).toLocaleDateString("fa-IR")}
</span>
<span className="font-semibold text-slate-800">
{comment.authorNickname}
</span>
</div>
<p className="text-slate-700 text-right">{comment.text}</p>
</div>
))
) : (
<p className="text-slate-600 text-center py-8">
هنوز نظری وجود ندارد. اولین نظر را بنویسید!
</p>
)}
</div>
</div>
</div>
</div>
);
}
export default CampaignDetailPage;

View File

@ -0,0 +1,195 @@
"use client";
import { CustomButton } from "@/core/components/base/button";
import { CustomInput } from "@/core/components/base/input";
import { userInfoService } from "@/core/service/user-info.service";
import { useQuery } from "@tanstack/react-query";
import { Loader, Plus, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { CampaignCard } from "../../components/campaign-card";
import { CreateCampaignModal } from "../../components/create-campaign-modal";
import { getCampaignsService } from "../../service/campaigns.service";
import type { Campaign, CampaignTab } from "./campaigns.type";
export function CampaignsPage() {
const [activeTab, setActiveTab] = useState<CampaignTab>("all");
const [searchQuery, setSearchQuery] = useState<string>("");
const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const {
data: campaigns = [],
isLoading,
refetch,
} = useQuery({
queryKey: ["campaigns", searchQuery],
queryFn: () => getCampaignsService(activeTab, searchQuery),
});
useEffect(() => {
if (campaigns) {
setCurrentCampaign(campaigns);
}
}, [campaigns]);
const tabs: { value: CampaignTab; label: string; oreder: number }[] = [
{ oreder: 1, value: "all", label: "تمام کمپین‌ها" },
{ oreder: 2, value: "my", label: "کمپین‌های من" },
{ oreder: 3, value: "top", label: "کمپین‌های برتر" },
{ oreder: 4, value: "group", label: "کمپین‌های گروه" },
];
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const handleTabChange = (tab: CampaignTab) => {
setActiveTab(tab);
const user = userInfoService.getUserInfo();
switch (tab) {
case "all":
setCurrentCampaign(campaigns);
break;
case "my":
setCurrentCampaign(
campaigns.filter(
(campaign) =>
Number(campaign.WorkflowID) === Number(user.WorkflowID)
)
);
break;
case "top":
setCurrentCampaign(
[...campaigns].sort(
(a, b) => (b.signature_count || 0) - (a.signature_count || 0)
)
);
break;
case "group":
setCurrentCampaign(
campaigns.filter(
(campaign) => campaign.school_code === user.school_code
)
);
break;
default:
setCurrentCampaign(campaigns);
}
setSearchQuery("");
};
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-8" dir="rtl">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-4xl font-bold text-slate-800 text-right mb-2">
کمپینها
</h1>
<p className="text-slate-600 text-right">
برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید
</p>
</div>
{/* Top Bar: Search and Create Button */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Search Box */}
<div className="flex-1 sm:max-w-md">
<div className="relative">
<CustomInput
type="text"
placeholder="جستجوی کمپین..."
value={searchQuery}
onChange={handleSearchChange}
className="pr-10"
/>
<Search
size={20}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
</div>
</div>
{/* Create Campaign Button */}
<CustomButton
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2"
>
<Plus size={20} />
ایجاد کمپین
</CustomButton>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-2 overflow-x-auto pb-2 border-b border-gray-200">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleTabChange(tab.value)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-lg transition-all ${
activeTab === tab.value
? "bg-blue-500 text-white border-b-2 border-blue-600"
: "text-slate-600 hover:text-slate-800 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader size={40} className="text-blue-500 animate-spin" />
</div>
)}
{/* Campaigns Grid */}
{!isLoading && campaigns.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-h-96 overflow-y-auto">
{currentCampaign.length > 0 ? (
currentCampaign.map((campaign) => (
<CampaignCard
key={`${campaign.WorkflowID}-${campaign.title}`}
campaign={campaign}
/>
))
) : (
<div className="text-gray-500 mx-auto mt-20">کمپینی یافت نشد</div>
)}
</div>
)}
{/* Empty State */}
{!isLoading && campaigns.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-slate-600 text-lg mb-4">
{activeTab === "my"
? "هنوز کمپینی ایجاد نکرده‌اید"
: "کمپینی یافت نشد"}
</p>
{activeTab === "my" && (
<CustomButton
variant="primary"
onClick={() => setIsCreateModalOpen(true)}
>
ایجاد اولین کمپین خود
</CustomButton>
)}
</div>
)}
</div>
{/* Create Campaign Modal */}
<CreateCampaignModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={() => refetch()}
/>
</div>
);
}
export default CampaignsPage;

View File

@ -0,0 +1,134 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
BarChart3,
Edit2,
Loader,
LogOut,
Users,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import DashboardCard from "../../components/dashboard-card";
import { fetchUserProfile } from "../../service/user.service";
const DashboardPage = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { data, isLoading, error } = useQuery({
queryKey: ["userProfile"],
queryFn: fetchUserProfile,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const handleEditInfo = () => {
// Navigate to edit profile page
navigate("/dashboard/edit-profile");
};
const handleMyGroup = () => {
if (data?.userType === "student") {
navigate("/dashboard/my-group");
} else {
navigate("/dashboard/school-students");
}
};
const handleActivities = () => {
navigate("/dashboard/activities");
};
const handleReports = () => {
navigate("/dashboard/reports");
};
const handleLogout = () => {
localStorage.removeItem("token");
navigate("/auth/login", { replace: true });
};
if (isLoading) {
return (
<div className="min-h-screen bg-linear-to-b from-blue-50 to-white flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader size={40} className="text-blue-500 animate-spin" />
<p className="text-slate-600">
{t("dashboard.loading") || "در حال بارگذاری..."}
</p>
</div>
</div>
);
}
return (
<div
dir="rtl"
className="min-h-screen bg-linear-to-b from-blue-50 to-white"
style={{
fontFamily:
'"Vazir", "IranSans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}}
>
{/* Main Content */}
<div className="max-w-4xl mx-auto px-4 py-6 sm:py-8">
{/* Page Title */}
<h1 className="text-3xl sm:text-4xl font-bold text-slate-800 text-right mb-8">
{t("dashboard.title")}
</h1>
{/* Profile Card */}
{/* <ProfileCard profile={data} /> */}
{/* Dashboard Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{/* Edit Information */}
<DashboardCard
icon={Edit2}
label={t("dashboard.editInfo")}
onClick={handleEditInfo}
/>
{/* My Group / School Students */}
<DashboardCard
icon={Users}
label={
data?.userType === "student"
? t("dashboard.myGroup")
: t("dashboard.schoolStudents")
}
onClick={handleMyGroup}
/>
{/* Activities */}
<DashboardCard
icon={Activity}
label={t("dashboard.activities")}
onClick={handleActivities}
/>
{/* Reports */}
<DashboardCard
icon={BarChart3}
label={t("dashboard.reports")}
onClick={handleReports}
/>
</div>
{/* Logout Button */}
<DashboardCard
icon={LogOut}
label={t("dashboard.logout")}
onClick={handleLogout}
variant="danger"
/>
{/* Safe spacing at bottom */}
<div className="h-6" />
</div>
</div>
);
};
export default DashboardPage;

View File

@ -0,0 +1,374 @@
"use client";
import { BaseDropdown } from "@/core/components/base/base-drop-down";
import { CustomButton } from "@/core/components/base/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/core/components/base/card";
import { ImageUploader } from "@/core/components/base/image-uploader";
import { CustomInput } from "@/core/components/base/input";
import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant";
import {
fetchUserProfile,
updateUserProfile,
} from "@modules/dashboard/service/user.service";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import type { RegistrationFormData } from "./profile.type";
export function RegisterPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["userProfile"],
queryFn: fetchUserProfile,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const [formData, setFormData] = useState<RegistrationFormData>({
name: data?.name || "",
family: data?.family || "",
nickname: data?.nickname || "",
school_code: data?.schoolCode || "",
education_level: data?.educationLevel || "",
invitor: data?.invitor || "",
image: undefined,
nationalcode: data?.nationalcode || "",
base: data?.base || "",
});
useEffect(() => {
if (data) {
setFormData((prev) => ({
...prev,
name: data.name || "",
family: data.family || "",
nickname: data.nickname || "",
school_code: data.schoolCode || "",
education_level: data.educationLevel || "",
invitor: data.invitor || "",
nationalcode: data.nationalcode || "",
base: data.base || "",
}));
}
}, [data]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [previewImage, setPreviewImage] = useState<string>("");
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// نام
if (!formData.name?.trim()) {
newErrors.name = "نام الزامی است";
}
// نام خانوادگی
if (!formData.family?.trim()) {
newErrors.family = "نام خانوادگی الزامی است";
}
// کد ملی (10 رقم + الگوریتم رسمی ایران)
const nationalCode = formData.nationalcode?.trim();
if (!nationalCode) {
newErrors.nationalcode = "کد ملی الزامی است";
} else if (!/^\d{10}$/.test(nationalCode)) {
newErrors.nationalcode = "کد ملی باید دقیقاً ۱۰ رقم باشد";
} else if (!isValidIranianNationalCode(nationalCode)) {
newErrors.nationalcode = "کد ملی وارد شده معتبر نیست";
}
// نام مستعار
if (!formData.nickname?.trim()) {
newErrors.nickname = "نام مستعار الزامی است";
} else if (formData.nickname.length < 3) {
newErrors.nickname = "نام مستعار باید حداقل ۳ کاراکتر باشد";
}
// کد مدرسه
if (!formData.school_code?.trim()) {
newErrors.school_code = "کد مدرسه الزامی است";
} else if (!/^\d{5,10}$/.test(formData.school_code)) {
newErrors.school_code = "کد مدرسه باید بین ۵ تا ۱۰ رقم باشد";
}
// مقطع تحصیلی
if (!formData.education_level) {
newErrors.education_level = "لطفاً مقطع تحصیلی را انتخاب کنید";
}
// پایه تحصیلی
if (!formData.base) {
newErrors.base = "لطفاً پایه تحصیلی را انتخاب کنید";
} else if (
formData.education_level === "متوسطه اول" &&
!["هفتم", "هشتم", "نهم"].includes(formData.base)
) {
newErrors.base = "پایه انتخاب‌شده با مقطع متوسطه اول سازگار نیست";
} else if (
formData.education_level === "متوسطه دوم" &&
!["دهم", "یازدهم", "دوازدهم"].includes(formData.base)
) {
newErrors.base = "پایه انتخاب‌شده با مقطع متوسطه دوم سازگار نیست";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const isValidIranianNationalCode = (input: string): boolean => {
if (!/^\d{10}$/.test(input)) return false;
const code = input.split("").map(Number);
const checkDigit = code[9];
const sum = code
.slice(0, 9)
.reduce((acc, digit, index) => acc + digit * (10 - index), 0);
const remainder = sum % 11;
return remainder < 2
? checkDigit === remainder
: checkDigit === 11 - remainder;
};
const registerMutation = useMutation({
mutationFn: updateUserProfile,
onSuccess: (data) => {
if (data.resultType !== 0) {
toast.error(data.message || "خطایی رخ داد");
return;
}
toast.success("ثبت نام با موفقیت انجام شد");
queryClient.invalidateQueries({
queryKey: ["userProfile"],
});
},
onError: (error: any) => {
console.error("Registration error:", error);
toast.error(
"خطا در ثبت نام: " + (error?.message || "لطفاً دوباره تلاش کنید")
);
},
});
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => {
if (name === "education_level") {
return {
...prev,
education_level: value as "متوسطه اول" | "متوسطه دوم" | "",
base: "",
};
}
return {
...prev,
[name]: value,
};
});
setErrors((prev) => ({
...prev,
[name]: "",
...(name === "education_level" && { base: "" }),
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
toast.error("لطفاً تمام فیلدهای الزامی را پر کنید");
return;
}
registerMutation.mutate(formData);
};
const logOut = () => {
localStorage.clear();
navigate(`${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`);
};
return (
<div
className="flex min-h-screen items-center justify-center bg-gray-50 "
dir="rtl"
>
<Card className="w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-md">
<CardHeader>
<CardTitle className="text-center sm:text-right text-2xl font-bold">
ثبت نام
</CardTitle>
<p className="text-center sm:text-right text-sm text-gray-600 mt-2">
لطفاً اطلاعات خود را وارد کنید
</p>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
{/* Row 1: First Name and Last Name */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<CustomInput
label="نام"
name="name"
type="text"
placeholder="نام خود را وارد کنید"
value={formData.name}
onChange={handleInputChange}
error={errors.name}
variant={errors.name ? "error" : "primary"}
/>
<CustomInput
label="نام خانوادگی"
name="family"
type="text"
placeholder="نام خانوادگی خود را وارد کنید"
value={formData.family}
onChange={handleInputChange}
error={errors.family}
variant={errors.family ? "error" : "primary"}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<CustomInput
label="کد ملی"
name="nationalcode"
type="text"
placeholder="کد ملی خود را وارد کنید"
value={formData.nationalcode}
onChange={handleInputChange}
error={errors.nationalcode}
variant={errors.nationalcode ? "error" : "primary"}
/>
<CustomInput
label="نام مستعار"
name="nickname"
type="text"
placeholder="نام مستعار خود را انتخاب کنید"
value={formData.nickname}
onChange={handleInputChange}
error={errors.nickname}
variant={errors.nickname ? "error" : "primary"}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* مقطع تحصیلی */}
<BaseDropdown
label="مقطع تحصیلی"
name="education_level"
value={formData.education_level}
onChange={handleInputChange}
error={errors.education_level}
options={[
{ value: "متوسطه اول", label: "متوسطه اول" },
{ value: "متوسطه دوم", label: "متوسطه دوم" },
]}
/>
<BaseDropdown
label="پایه تحصیلی"
name="base"
value={formData.base}
onChange={handleInputChange}
error={errors.base}
options={
formData.education_level === "متوسطه اول"
? [
{ value: "هفتم", label: "پایه هفتم" },
{ value: "هشتم", label: "پایه هشتم" },
{ value: "نهم", label: "پایه نهم" },
]
: formData.education_level === "متوسطه دوم"
? [
{ value: "دهم", label: "پایه دهم" },
{ value: "یازدهم", label: "پایه یازدهم" },
{ value: "دوازدهم", label: "پایه دوازدهم" },
]
: []
}
placeholder={
!formData.education_level
? "ابتدا مقطع را انتخاب کنید"
: "پایه را انتخاب کنید"
}
disabled={!formData.education_level}
/>
</div>
<CustomInput
label="نام مستعار معرف (اختیاری)"
name="invitor"
type="text"
placeholder="نام مستعار کسی که شما را معرفی کرد (اگر دارید)"
value={formData.invitor || ""}
onChange={handleInputChange}
/>
<CustomInput
label="کد مدرسه"
name="school_code"
type="text"
placeholder="کد مدرسه خود را وارد کنید"
value={formData.school_code || ""}
onChange={handleInputChange}
/>
<ImageUploader
label="عکس پروفایل (اختیاری)"
previewImage={previewImage}
onImageChange={(file) => {
if (!file) {
setPreviewImage("");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setPreviewImage(reader.result as string);
};
reader.readAsDataURL(file);
}}
/>
{/* Buttons */}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end ">
<CustomButton
variant="error"
className="w-full sm:w-auto"
onClick={logOut}
>
خروج
</CustomButton>
<CustomButton
variant="primary"
className="w-full sm:w-auto"
type="submit"
disabled={registerMutation.isPending}
>
{registerMutation.isPending ? "در حال ثبت نام..." : "ثبت نام"}
</CustomButton>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default RegisterPage;

View File

@ -0,0 +1,19 @@
export interface RegistrationFormData {
username?: string;
WorkflowID?: string;
name: string;
family: string;
nickname: string;
school_code: string;
education_level: string;
invitor: string;
image?: File | null;
nationalcode: string;
base: string;
}
export interface RegistrationResponse {
resultType: number;
message: string;
data?: string;
}

View File

@ -0,0 +1,6 @@
export const DASHBOARD_ROUTE = {
sub: "/dashboard",
dashboard: "main",
profile: "profile",
campaigns: "campaigns",
};

View File

@ -0,0 +1,32 @@
import type { AppRoute } from "@core/types/router.type";
import { DashboardLayout } from "../layouts";
import CampaignsPage from "../pages/campaigns";
import CampaignDetailPage from "../pages/campaigns/detail";
import DashboardPage from "../pages/main-page";
import ProfilePage from "../pages/profile";
import { DASHBOARD_ROUTE } from "./route.constant";
export const dashboardRoutes: AppRoute[] = [
{
path: DASHBOARD_ROUTE.sub,
element: <DashboardLayout />,
children: [
{
path: DASHBOARD_ROUTE.dashboard,
element: <DashboardPage />,
},
{
path: DASHBOARD_ROUTE.profile,
element: <ProfilePage />,
},
{
path: DASHBOARD_ROUTE.campaigns,
element: <CampaignsPage />,
},
{
path: "campaigns/:id",
element: <CampaignDetailPage />,
},
],
},
];

View File

@ -0,0 +1,110 @@
import { API_ADDRESS } from "@/core/service/api-address";
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 type {
Campaign,
CreateCampaignData,
} from "../pages/campaigns/campaigns.type";
export const getCampaignsService = async (
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 query = {
ProcessName: "campaign",
OutputFields: [
"title",
"description",
"image",
"user_id",
"user_id.nickname",
"volume",
"status",
"school_code",
"signature_count",
// "comment_count",
],
conditions: [
["school_code", "=", userStr.school_code, "or"],
["user_id", "=", "", "and"],
["status", "!=", "حذف شده", "and"],
["status", "!=", "غیر فعال"],
],
};
const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) {
throw err;
}
if (!res?.data?.data || res.data.data.length === 0) {
return [];
}
if (res.data.resultType !== 0) {
toast.error("خطا در دریافت کمپین‌ها");
throw new Error("خطا در دریافت کمپین‌ها");
}
const data = JSON.parse(res.data.data);
return data;
};
export const getCampaignDetailService = async (
campaignId: string
): Promise<Campaign> => {
const [err, res] = await to(api.get(`/campaigns/${campaignId}`));
if (err) {
throw err;
}
return res?.data;
};
export const createCampaignService = async (
data: CreateCampaignData
): Promise<Campaign> => {
const user = userInfoService.getUserInfo();
const body = {
ProcessName: "campaign",
campaign: {
title: data.title,
description: data.description,
image: data.image,
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("خطا در ثبت کارزار");
}
return res.data;
};
export const signCampaignService = async (
campaignId: string
): Promise<Campaign> => {
const response = await api.post(`/campaigns/${campaignId}/sign`);
return response.data;
};
export const addCommentService = async (
campaignId: string,
text: string
): Promise<Campaign> => {
const response = await api.post(`/campaigns/${campaignId}/comments`, {
text,
});
return response.data;
};

View File

@ -0,0 +1,81 @@
import { API_ADDRESS } from "@/core/service/api-address";
import api from "@/core/service/axios";
import type { RegistrationFormData } from "@modules/dashboard/pages/profile/profile.type";
import { to } from "await-to-js";
export const fetchUserProfile = async () => {
const person = JSON.parse(localStorage.getItem("person") || "{}");
const query = {
ProcessName: "user",
OutputFields: [
"username",
"name",
"family",
"education_level",
"base",
"account_type",
"nickname",
"school_code",
"school_code.title",
"invitor",
"nationalcode",
],
conditions: [["username", "=", person.ID]],
};
const res = await api.post(API_ADDRESS.select, query);
if (!res.data || res.data.length === 0) {
throw new Error("User not found");
}
const user = JSON.parse(res.data.data)[0];
if (user) localStorage.setItem("person", JSON.stringify(user));
return {
username: user.username,
name: user.name,
family: user.family,
educationLevel: user.education_level,
base: user.base,
userType: user.account_type,
nickname: user.nickname,
schoolCode: user.school_code,
invitor: user.invitor,
nationalcode: user.nationalcode,
};
};
export const updateUserProfile = async (data: RegistrationFormData) => {
const personStr = localStorage.getItem("person");
if (!personStr) {
throw new Error("کاربر وارد سیستم نشده است");
}
const person = JSON.parse(personStr);
const natinalCode = person.NationalCode;
let payload = {
user: {
username: String(person.ID),
name: data.name.trim(),
family: data.family.trim(),
nickname: data.nickname.trim() || undefined,
education_level: data.education_level,
base: data.base,
account_type: "عادی",
nationalcode: data.nationalcode,
...(data.school_code && { school_code: data.school_code.trim() }),
...(data.invitor && { invitor: data.invitor.trim() }),
...(natinalCode && { WorkflowID: person.ID }),
},
};
const [error, response] = await to(api.post(API_ADDRESS.save, payload));
if (error) {
console.error("خطا در ارسال اطلاعات پروفایل:", error.message || error);
throw error;
}
return response?.data;
};

View File

@ -0,0 +1,18 @@
export interface UserProfile {
id: string;
fullName: string;
userType: "student" | "school";
groupName?: string;
schoolName?: string;
profileImage?: string;
email?: string;
phone?: string;
}
export interface DashboardCard {
id: string;
label: string;
icon: string;
action: () => void;
variant?: "default" | "danger";
}

View File

@ -1,4 +1,5 @@
import type { AppRoute } from "@/core/types/router.type"; import type { AppRoute } from "@/core/types/router.type";
import { authRoutes } from "@/modules/auth/routes/router"; import { authRoutes } from "@/modules/auth/routes/router";
import { dashboardRoutes } from "@/modules/dashboard/routes/router";
export const rootRoutes: AppRoute[] = [...authRoutes]; export const rootRoutes: AppRoute[] = [...authRoutes, ...dashboardRoutes];