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:
parent
d9d97da7da
commit
ce4c33d46d
22
index.html
22
index.html
|
|
@ -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
27
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "لطفاً نظری بنویسید"
|
||||||
|
}
|
||||||
|
|
|
||||||
80
src/core/components/base/base-drop-down.tsx
Normal file
80
src/core/components/base/base-drop-down.tsx
Normal 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";
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
147
src/core/components/base/image-uploader.tsx
Normal file
147
src/core/components/base/image-uploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
45
src/core/components/base/text-area.tsx
Normal file
45
src/core/components/base/text-area.tsx
Normal 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;
|
||||||
1
src/core/components/others/index.ts
Normal file
1
src/core/components/others/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { MobileNavbar, type NavItem } from "./mobile-navbar";
|
||||||
212
src/core/components/others/mobile-navbar.demo.tsx
Normal file
212
src/core/components/others/mobile-navbar.demo.tsx
Normal 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
|
||||||
|
*/
|
||||||
133
src/core/components/others/mobile-navbar.tsx
Normal file
133
src/core/components/others/mobile-navbar.tsx
Normal 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;
|
||||||
81
src/core/layouts/root-layout.example.tsx
Normal file
81
src/core/layouts/root-layout.example.tsx
Normal 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
|
||||||
|
*/
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,12 @@ 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) {
|
||||||
|
const token = JSON.parse(localStorage?.token)?.AccessToken;
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers!.Authorization = `Bearer ${token}`;
|
config.headers!.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
|
|
|
||||||
17
src/core/service/user-info.service.ts
Normal file
17
src/core/service/user-info.service.ts
Normal 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();
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
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();
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
const RegisterPage = () => {
|
|
||||||
return <div>Register</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterPage;
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export const AUTH_ROUTE = {
|
export const AUTH_ROUTE = {
|
||||||
|
sub: "/auth",
|
||||||
LOGIN: "login",
|
LOGIN: "login",
|
||||||
REGISTER: "register",
|
REGISTER: "register",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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[] = [
|
||||||
|
|
|
||||||
18
src/modules/auth/service/auth.service.ts
Normal file
18
src/modules/auth/service/auth.service.ts
Normal 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;
|
||||||
|
};
|
||||||
54
src/modules/dashboard/components/campaign-card.tsx
Normal file
54
src/modules/dashboard/components/campaign-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/modules/dashboard/components/create-campaign-modal.tsx
Normal file
178
src/modules/dashboard/components/create-campaign-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/modules/dashboard/components/dashboard-card.tsx
Normal file
47
src/modules/dashboard/components/dashboard-card.tsx
Normal 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;
|
||||||
61
src/modules/dashboard/components/profile-card.tsx
Normal file
61
src/modules/dashboard/components/profile-card.tsx
Normal 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;
|
||||||
14
src/modules/dashboard/layouts/index.tsx
Normal file
14
src/modules/dashboard/layouts/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/modules/dashboard/pages/campaigns/campaigns.type.ts
Normal file
37
src/modules/dashboard/pages/campaigns/campaigns.type.ts
Normal 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";
|
||||||
264
src/modules/dashboard/pages/campaigns/detail.tsx
Normal file
264
src/modules/dashboard/pages/campaigns/detail.tsx
Normal 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;
|
||||||
195
src/modules/dashboard/pages/campaigns/index.tsx
Normal file
195
src/modules/dashboard/pages/campaigns/index.tsx
Normal 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;
|
||||||
134
src/modules/dashboard/pages/main-page/index.tsx
Normal file
134
src/modules/dashboard/pages/main-page/index.tsx
Normal 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;
|
||||||
374
src/modules/dashboard/pages/profile/index.tsx
Normal file
374
src/modules/dashboard/pages/profile/index.tsx
Normal 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;
|
||||||
19
src/modules/dashboard/pages/profile/profile.type.ts
Normal file
19
src/modules/dashboard/pages/profile/profile.type.ts
Normal 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;
|
||||||
|
}
|
||||||
6
src/modules/dashboard/routes/route.constant.ts
Normal file
6
src/modules/dashboard/routes/route.constant.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const DASHBOARD_ROUTE = {
|
||||||
|
sub: "/dashboard",
|
||||||
|
dashboard: "main",
|
||||||
|
profile: "profile",
|
||||||
|
campaigns: "campaigns",
|
||||||
|
};
|
||||||
32
src/modules/dashboard/routes/router.tsx
Normal file
32
src/modules/dashboard/routes/router.tsx
Normal 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 />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
110
src/modules/dashboard/service/campaigns.service.ts
Normal file
110
src/modules/dashboard/service/campaigns.service.ts
Normal 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;
|
||||||
|
};
|
||||||
81
src/modules/dashboard/service/user.service.ts
Normal file
81
src/modules/dashboard/service/user.service.ts
Normal 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;
|
||||||
|
};
|
||||||
18
src/modules/dashboard/types/dashboard.type.ts
Normal file
18
src/modules/dashboard/types/dashboard.type.ts
Normal 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";
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user