diff --git a/.env b/.env new file mode 100644 index 0000000..5edebca --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_URL=https://yarigaran-back.pelekan.org \ No newline at end of file diff --git a/public/locales/fa.json b/public/locales/fa.json index 9651864..e73a28a 100644 --- a/public/locales/fa.json +++ b/public/locales/fa.json @@ -43,40 +43,40 @@ } }, "campaigns": { - "title": "کمپین‌ها", - "subtitle": "برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید", - "search": "جستجوی کمپین...", - "create": "ایجاد کمپین", + "title": "کارزار‌ها", + "subtitle": "برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید", + "search": "جستجوی کارزار...", + "create": "ایجاد کارزار", "tabs": { - "all": "تمام کمپین‌ها", - "my": "کمپین‌های من", - "top": "کمپین‌های برتر", - "group": "کمپین‌های گروه" + "all": "تمام کارزار‌ها", + "my": "کارزار‌های من", + "top": "کارزار‌های برتر", + "group": "کارزار‌های گروه" }, "createModal": { - "title": "ایجاد کمپین جدید", - "titleLabel": "عنوان کمپین", - "titlePlaceholder": "عنوان کمپین را وارد کنید", + "title": "ایجاد کارزار جدید", + "titleLabel": "عنوان کارزار", + "titlePlaceholder": "عنوان کارزار را وارد کنید", "description": "توضیحات", - "descriptionPlaceholder": "توضیحات کمپین را وارد کنید (حداقل 20 کاراکتر)", - "image": "تصویر کمپین", + "descriptionPlaceholder": "توضیحات کارزار را وارد کنید (حداقل 20 کاراکتر)", + "image": "تصویر کارزار", "upload": "کلیک کنید یا تصویر را بکشید", "cancel": "لغو", - "submit": "ایجاد کمپین", + "submit": "ایجاد کارزار", "submitting": "در حال ایجاد...", "errors": { - "titleRequired": "عنوان کمپین الزامی است", + "titleRequired": "عنوان کارزار الزامی است", "descriptionRequired": "توضیحات الزامی است", "descriptionMinLength": "توضیحات باید حداقل 20 کاراکتر باشد", "imageRequired": "تصویر الزامی است" } }, "detail": { - "back": "بازگشت به کمپین‌ها", + "back": "بازگشت به کارزار‌ها", "by": "توسط", "signatures": "امضا", "comments": "نظر", - "sign": "امضای کمپین", + "sign": "امضای کارزار", "signing": "در حال امضا...", "signed": "شما امضا کرده‌اید ✓", "signers": "امضاکنندگان", @@ -85,17 +85,17 @@ "addComment": "نظر خود را بنویسید...", "send": "ارسال", "sending": "ارسال...", - "notFound": "کمپین یافت نشد" + "notFound": "کارزار یافت نشد" }, "empty": { - "my": "هنوز کمپینی ایجاد نکرده‌اید", - "noResults": "کمپینی یافت نشد", - "createFirst": "ایجاد اولین کمپین خود" + "my": "هنوز کارزاری ایجاد نکرده‌اید", + "noResults": "کارزاری یافت نشد", + "createFirst": "ایجاد اولین کارزار خود" }, "notifications": { - "created": "کمپین با موفقیت ایجاد شد", + "created": "کارزار با موفقیت ایجاد شد", "signed": "با موفقیت امضا کردید", "commentAdded": "نظر شما اضافه شد", - "alreadySigned": "شما قبلاً این کمپین را امضا کرده‌اید", + "alreadySigned": "شما قبلاً این کارزار را امضا کرده‌اید", "emptyComment": "لطفاً نظری بنویسید" } diff --git a/src/core/components/base/dashboard-header.tsx b/src/core/components/base/dashboard-header.tsx new file mode 100644 index 0000000..c08d458 --- /dev/null +++ b/src/core/components/base/dashboard-header.tsx @@ -0,0 +1,81 @@ +import { cn } from "@core/lib/utils"; +import { Coins, User } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +export interface DashboardHeaderProps { + profileImageUrl?: string; + fullName: string; + coins: number; + className?: string; + onProfileClick?: () => void; +} + +export function DashboardHeader({ + profileImageUrl, + fullName, + coins, + className, + onProfileClick, +}: DashboardHeaderProps) { + const navigate = useNavigate(); + + const handleProfileClick = () => { + if (onProfileClick) { + onProfileClick(); + } else { + navigate("/dashboard/profile", { replace: true }); + } + }; + + return ( +
+
+ {/* RIGHT SIDE: Profile Image + Name */} + + + {/* LEFT SIDE: Coins Badge */} +
+ {coins} + +
+
+
+ ); +} + +export default DashboardHeader; diff --git a/src/core/components/base/image-uploader.tsx b/src/core/components/base/image-uploader.tsx index a2731ba..c9f40d0 100644 --- a/src/core/components/base/image-uploader.tsx +++ b/src/core/components/base/image-uploader.tsx @@ -25,7 +25,6 @@ export function ImageUploader({ }: ImageUploaderProps) { const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const sizeClasses = { sm: "w-24 h-24", md: "w-32 h-32", @@ -85,22 +84,33 @@ export function ImageUploader({ )} {previewImage ? ( -
- پیش‌نمایش تصویر +
+
+ پیش‌نمایش تصویر + +
) : ( diff --git a/src/core/components/base/input.tsx b/src/core/components/base/input.tsx index 4e9690b..a7332d4 100644 --- a/src/core/components/base/input.tsx +++ b/src/core/components/base/input.tsx @@ -6,11 +6,20 @@ export interface CustomInputProps variant?: "primary" | "info" | "error"; error?: string; label?: string; + required?: boolean; } const CustomInput = React.forwardRef( ( - { className, variant = "primary", error, label, disabled, ...props }, + { + className, + variant = "primary", + error, + label, + disabled, + required, + ...props + }, ref ) => { const finalVariant = error ? "error" : variant; @@ -19,7 +28,7 @@ const CustomInput = React.forwardRef(
{label && ( )} { label?: string; error?: string; minLength?: number; + required?: boolean; } const TextAreaField = forwardRef( - ({ label, error, minLength = 40, className, placeholder, ...props }, ref) => { + ( + { + label, + error, + minLength = 40, + className, + placeholder, + required, + ...props + }, + ref + ) => { return (
{label && ( )} diff --git a/src/core/components/others/index.ts b/src/core/components/others/index.ts deleted file mode 100644 index c23736c..0000000 --- a/src/core/components/others/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MobileNavbar, type NavItem } from "./mobile-navbar"; diff --git a/src/core/components/others/mobile-navbar.demo.tsx b/src/core/components/others/mobile-navbar.demo.tsx deleted file mode 100644 index 6210f64..0000000 --- a/src/core/components/others/mobile-navbar.demo.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * MobileNavbar Component - Demo & Example Usage - * - * This file demonstrates how to use the MobileNavbar component - * in your React application. - */ - -import { MobileNavbar, type NavItem } from "@/core/components/others"; -import { Home, MessageCircle, Settings, Star } from "lucide-react"; - -// ============================================================================ -// EXAMPLE 1: Using Default Navigation Items -// ============================================================================ - -export function AppWithDefaultNavbar() { - return ( -
-
-
-

داشبورد

-

- سلام! روی دستگاه موبایل یک نوار ناویگیشن ثابت در پایین می‌بینید. -

-
-
- - {/* Default navbar with Profile, Group Chat, Ranking, Dashboard */} - -
- ); -} - -// ============================================================================ -// EXAMPLE 2: Using Custom Navigation Items -// ============================================================================ - -const customNavItems: NavItem[] = [ - { - id: "home", - label: "خانه", - icon: , - path: "/", - }, - { - id: "favorites", - label: "نشان‌شده‌ها", - icon: , - path: "/favorites", - }, - { - id: "messages", - label: "پیام‌ها", - icon: , - path: "/messages", - }, - { - id: "settings", - label: "تنظیمات", - icon: , - path: "/settings", - }, -]; - -export function AppWithCustomNavbar() { - return ( -
-
-
-

تطبیق‌شده

-

- این مثال از آیتم‌های ناویگیشن سفارشی استفاده می‌کند. -

-
-
- - {/* Custom navbar */} - -
- ); -} - -// ============================================================================ -// EXAMPLE 3: Using Custom Styling -// ============================================================================ - -export function AppWithCustomStyling() { - return ( -
-
-
-

سبک سفارشی

-

- نوار ناویگیشن می‌تواند با کلاس‌های اضافی سفارشی‌سازی شود. -

-
-
- - {/* Custom styling with dark theme */} - -
- ); -} - -// ============================================================================ -// EXAMPLE 4: Full Layout with Content Padding -// ============================================================================ - -export function FullAppLayout() { - return ( -
- {/* Header (optional) */} -
-
-

یاری‌گران

-
-
- - {/* Main Content - Important: Add padding to prevent navbar overlap */} -
-
-
-

محتوای اصلی

-
-
-

کارت ۱

-

محتوای نمونه

-
-
-

کارت ۲

-

محتوای نمونه

-
-
-
-
-
- - {/* Mobile Navbar - Fixed at bottom */} - -
- ); -} - -// ============================================================================ -// 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: - * - * - * 3. ✅ Add padding to main content: - *
...
- * - * 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 - */ diff --git a/src/core/components/others/require-Auth.tsx b/src/core/components/others/require-Auth.tsx new file mode 100644 index 0000000..cff0cc7 --- /dev/null +++ b/src/core/components/others/require-Auth.tsx @@ -0,0 +1,26 @@ +// src/components/RequireAuth.tsx +import { useAuth } from "@/core/context/auth-context"; +import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant"; +import type { ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +export function RequireAuth({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return
در حال بررسی...
; + } + + if (!isAuthenticated) { + return ( + + ); + } + + return <>{children}; +} diff --git a/src/core/context/auth-context.tsx b/src/core/context/auth-context.tsx new file mode 100644 index 0000000..4c4901a --- /dev/null +++ b/src/core/context/auth-context.tsx @@ -0,0 +1,63 @@ +import { AUTH_ROUTE } from "@/modules/auth/routes/route.constant"; +import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant"; +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; + +interface AuthContextType { + isAuthenticated: boolean; + isLoading: boolean; + login: (token: string) => void; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("token"); + setIsAuthenticated(!!token); + const checkIsLogin = + !window.location.pathname.includes(AUTH_ROUTE.LOGIN) && !token; + + const decideRedirect = !!token && window.location.pathname === "/"; + if (checkIsLogin) { + window.location.href = `${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`; + } + + if (decideRedirect) { + window.location.href = `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}`; + } + + setIsLoading(false); + }, []); + + const login = (token: string) => { + localStorage.setItem("access_token", token); + setIsAuthenticated(true); + }; + + const logout = () => { + localStorage.clear(); + setIsAuthenticated(false); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used within AuthProvider"); + return context; +}; diff --git a/src/core/service/api-address.ts b/src/core/service/api-address.ts index 6edb5f3..80e963e 100644 --- a/src/core/service/api-address.ts +++ b/src/core/service/api-address.ts @@ -6,6 +6,8 @@ export const API_ADDRESS = { }, select: "/api/select", save: "/api/save", + delete: "/api/delete", + uploadImage: "/workflow/uploadImage", // LOGIN: "/auth/login", // VERIFY_OTP: "/auth/verify-otp", // REGISTER: "/auth/register", diff --git a/src/core/service/user-info.service.ts b/src/core/service/user-info.service.ts index ef470c1..568420b 100644 --- a/src/core/service/user-info.service.ts +++ b/src/core/service/user-info.service.ts @@ -1,4 +1,5 @@ import type { RegistrationFormData } from "@/modules/dashboard/pages/profile/profile.type"; +import type { TokenInterface } from "@core/types/global.type"; class UserInfoService { getUserInfo(): RegistrationFormData { @@ -12,6 +13,15 @@ class UserInfoService { updateUserInfo(updatedInfo: RegistrationFormData): void { localStorage.setItem("person", JSON.stringify(updatedInfo)); } + + getToken(): TokenInterface | null { + const tokenStr = localStorage.getItem("token"); + if (!tokenStr) { + return null; + } + const tokenObj = JSON.parse(tokenStr); + return tokenObj || null; + } } export const userInfoService = new UserInfoService(); diff --git a/src/core/types/global.type.ts b/src/core/types/global.type.ts new file mode 100644 index 0000000..735e843 --- /dev/null +++ b/src/core/types/global.type.ts @@ -0,0 +1,6 @@ +export interface TokenInterface { + AccessToken: string; + ExpAccessToken: string; + RefreshToken: string; + ExpRefreshToken: string; +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 0000000..06e7f1c --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,45 @@ +import api from "@/core/service/axios"; +import { userInfoService } from "@core/service/user-info.service"; +import to from "await-to-js"; +import { toast } from "react-toastify"; +import { API_ADDRESS } from "../service/api-address"; + +export const getContactImageUrl = (stageID: Number): string => { + const token = userInfoService.getToken(); + if (!token) { + throw new Error("توکن یافت نشد"); + } + return `${ + import.meta.env.VITE_API_URL + }/api/getimage?stageID=${stageID}&nameOrID=image&token=${token.AccessToken}`; +}; + +export const uploadImage = async ({ + file, + name, +}: { + file: File; + name: string; +}): Promise<{ name: string; data: any }> => { + const formData = new FormData(); + formData.append("", file); + const [err, res] = await to( + api.post(API_ADDRESS.uploadImage, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }) + ); + + if (res?.data.resultType === 0) { + return { name, data: res.data }; + } + if (res?.data.resultType !== 0) { + toast.error("خطا در بارگذاری تصویر"); + throw new Error(res?.data.message || "Upload failed"); + } + + if (err) { + throw err; + } + + throw new Error(res?.data.message || "Upload failed"); +}; diff --git a/src/index.css b/src/index.css index 53d4094..4dab71a 100644 --- a/src/index.css +++ b/src/index.css @@ -141,7 +141,7 @@ @apply bg-background text-foreground; margin: 0 auto; max-width: 1280px; - padding: 1rem; + /* padding: 1rem; */ box-sizing: border-box; direction: rtl; font-family: "Vazir", "IranSans", -apple-system, BlinkMacSystemFont, diff --git a/src/main.tsx b/src/main.tsx index d0c51c3..cd46a29 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import { AuthProvider, useAuth } from "@core/context/auth-context.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRoot } from "react-dom/client"; import { RouterProvider, createBrowserRouter } from "react-router-dom"; @@ -9,22 +10,38 @@ import { rootRoutes } from "./router/rootRoutes.ts"; const router = createBrowserRouter(rootRoutes); const client = new QueryClient(); +function AppEntry() { + const { isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
در حال بارگذاری...
+
+ ); + } + + return ; +} + createRoot(document.getElementById("root")!).render( // - - + + + + // ); diff --git a/src/modules/dashboard/components/campaign-card.tsx b/src/modules/dashboard/components/campaign-card.tsx index e7ac515..a6153fe 100644 --- a/src/modules/dashboard/components/campaign-card.tsx +++ b/src/modules/dashboard/components/campaign-card.tsx @@ -1,3 +1,4 @@ +import { getContactImageUrl } from "@/core/utils"; import { Heart } from "lucide-react"; import { useNavigate } from "react-router-dom"; import type { Campaign } from "../pages/campaigns/campaigns.type"; @@ -11,17 +12,17 @@ export function CampaignCard({ campaign }: CampaignCardProps) { return (
navigate(`/dashboard/campaigns/${campaign.user_id}`)} + onClick={() => navigate(`/dashboard/campaigns/${campaign.WorkflowID}`)} className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-md hover:shadow-lg transition-shadow cursor-pointer h-full" > {/* Image */} - {/*
+
{campaign.title} -
*/} +
{/* Content */}
@@ -29,10 +30,9 @@ export function CampaignCard({ campaign }: CampaignCardProps) {

{campaign.title}

- {/* Creator */}

- از طرف: {campaign.user_id} + از طرف: {campaign.user_id_nickname}

{/* Stats */} diff --git a/src/modules/dashboard/components/create-campaign-modal.tsx b/src/modules/dashboard/components/create-campaign-modal.tsx index 6eac89b..3a67bfa 100644 --- a/src/modules/dashboard/components/create-campaign-modal.tsx +++ b/src/modules/dashboard/components/create-campaign-modal.tsx @@ -35,7 +35,7 @@ export function CreateCampaignModal({ const newErrors: Record = {}; if (!title.trim()) { - newErrors.title = "عنوان کمپین الزامی است"; + newErrors.title = "عنوان کارزار الزامی است"; } if (!description.trim()) { @@ -55,12 +55,12 @@ export function CreateCampaignModal({ const createMutation = useMutation({ mutationFn: (data: CreateCampaignData) => createCampaignService(data), onSuccess: () => { - toast.success("کمپین با موفقیت ایجاد شد"); + toast.success("کارزار با موفقیت ایجاد شد"); handleClose(); onSuccess(); }, onError: (error: any) => { - toast.error(error?.message || "خطا در ایجاد کمپین"); + toast.error(error?.message || "خطا در ایجاد کارزار"); }, }); @@ -100,16 +100,17 @@ export function CreateCampaignModal({
- ایجاد کمپین جدید + ایجاد کارزار جدید
{/* Title Input */} { setTitle(e.target.value); @@ -121,9 +122,10 @@ export function CreateCampaignModal({ {/* Description Input */} { setDescription(e.target.value); if (errors.description) @@ -135,7 +137,7 @@ export function CreateCampaignModal({ {/* Image Upload */} { if (file) { @@ -166,7 +168,7 @@ export function CreateCampaignModal({ disabled={createMutation.isPending} onClick={handleSubmit} > - {createMutation.isPending ? "در حال ایجاد..." : "ایجاد کمپین"} + {createMutation.isPending ? "در حال ایجاد..." : "ایجاد کارزار"} لغو diff --git a/src/modules/dashboard/layouts/index.tsx b/src/modules/dashboard/layouts/index.tsx index 7c44d08..99265f0 100644 --- a/src/modules/dashboard/layouts/index.tsx +++ b/src/modules/dashboard/layouts/index.tsx @@ -1,12 +1,22 @@ // layouts/DashboardLayout.tsx -import { MobileNavbar } from "@/core/components/others"; +import DashboardHeader from "@/core/components/base/dashboard-header"; +import { MobileNavbar } from "@/core/components/others/mobile-navbar"; +import { userInfoService } from "@/core/service/user-info.service"; import { Outlet } from "react-router-dom"; export function DashboardLayout() { + const user = userInfoService.getUserInfo(); return (
-
- +
+ +
+ +
diff --git a/src/modules/dashboard/pages/campaigns/campaigns.type.ts b/src/modules/dashboard/pages/campaigns/campaigns.type.ts index a7a9790..b629482 100644 --- a/src/modules/dashboard/pages/campaigns/campaigns.type.ts +++ b/src/modules/dashboard/pages/campaigns/campaigns.type.ts @@ -12,6 +12,7 @@ export interface Campaign { nickname?: String; signature_count: Number; comment_count?: Number; + user_id_nickname?: String; } export interface Signer { @@ -34,4 +35,21 @@ export interface CreateCampaignData { image: File; } -export type CampaignTab = "all" | "my" | "top" | "group"; +export type CampaignTab = "فعال" | "my" | "منتخب" | "group"; + +export interface CommentsItem { + ValueP1228S1959StageID: Number; + ValueP1228S1959ValueID: Number; + WorkflowID: Number; + comment_text: String; + user_id_nickname: String; + user_stage_id: String; +} + +export interface SignatureItem { + ValueP1227S1955StageID: Number; + ValueP1227S1955ValueID: Number; + WorkflowID: Number; + user_id_nickname: String; + user_stage_id: String; +} diff --git a/src/modules/dashboard/pages/campaigns/detail.tsx b/src/modules/dashboard/pages/campaigns/detail.tsx index 5408fbe..8d0a72a 100644 --- a/src/modules/dashboard/pages/campaigns/detail.tsx +++ b/src/modules/dashboard/pages/campaigns/detail.tsx @@ -1,42 +1,69 @@ "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 TextAreaField from "@/core/components/base/text-area"; +import { getContactImageUrl } from "@/core/utils"; import { addCommentService, - getCampaignDetailService, + getCommentsService, + getSelectedCampaignsService, + getSignsCampaignService, + removeCommentService, signCampaignService, -} from "../../service/campaigns.service"; +} from "@modules/dashboard/service/campaigns.service"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + ArrowRight, + Blinds, + Loader, + MessageCircle, + Trash2, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { toast } from "react-toastify"; +import type { CommentsItem } from "./campaigns.type"; export function CampaignDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [commentText, setCommentText] = useState(""); - const [hasSignedCampaign, setHasSignedCampaign] = useState(false); + const [hasSignedCampaign, setHasSignedCampaign] = useState(false); + const [currentComments, setCurrentComments] = useState([]); - const { - data: campaign, - isLoading, - refetch, - } = useQuery({ + const { data: campaign, isLoading } = useQuery({ queryKey: ["campaign", id], - queryFn: () => getCampaignDetailService(id!), + queryFn: () => getSelectedCampaignsService(Number(id!)), enabled: !!id, }); + const { data: signs, refetch: signRefetch } = useQuery({ + queryKey: ["campaign-signs", id], + queryFn: () => getSignsCampaignService(String(id)), + enabled: !!id, + }); + + const { data: comments, refetch } = useQuery({ + queryKey: ["campaign-comments", id], + queryFn: () => getCommentsService(String(id)), + enabled: !!id, + }); + + useEffect(() => { + if (comments) { + setCurrentComments(comments); + } + }, [comments]); + const signMutation = useMutation({ mutationFn: () => signCampaignService(id!), onSuccess: () => { toast.success("با موفقیت امضا کردید"); setHasSignedCampaign(true); - refetch(); + signRefetch(); }, onError: (error: any) => { - toast.error(error?.message || "خطا در امضای کمپین"); + toast.error(error?.message || "خطا در امضای کارزار"); }, }); @@ -52,9 +79,20 @@ export function CampaignDetailPage() { }, }); + const removeCommentMutation = useMutation({ + mutationFn: (commentId: Number) => removeCommentService(commentId), + onSuccess: () => { + toast.success("نظر با موفقیت حذف شد"); + refetch(); + }, + onError: (error: any) => { + toast.error(error?.message || "خطا در حذف نظر"); + }, + }); + const handleSignCampaign = () => { if (hasSignedCampaign) { - toast.info("شما قبلاً این کمپین را امضا کرده‌اید"); + toast.info("شما قبلاً این کارزار را امضا کرده‌اید"); return; } signMutation.mutate(); @@ -69,6 +107,17 @@ export function CampaignDetailPage() { commentMutation.mutate(commentText); }; + const removeCommentHandler = ( + commentId: Number, + e: React.MouseEvent + ): void => { + e.preventDefault(); + setCurrentComments((prevComments) => + prevComments.filter((comment) => comment.WorkflowID !== commentId) + ); + removeCommentMutation.mutate(commentId); + }; + if (isLoading) { return (
@@ -80,16 +129,16 @@ export function CampaignDetailPage() { if (!campaign) { return (
-

کمپین یافت نشد

+

کارزار یافت نشد

navigate("/dashboard/campaigns")}> - بازگشت به کمپین‌ها + بازگشت به کارزار‌ها
); } return ( -
+
{/* Back Button */} {/* Campaign Image */} -
+
{campaign.title}
{/* Campaign Header */} -
- {/* Title */} +

{campaign.title}

- {/* Creator Info */}

- توسط:{" "} - {campaign.creatorNickname} + توسط: {campaign.nickname}

- {/* Stats */}
- {campaign.signatures} + {signs?.length ?? 0} -
- - امضا -
+
- {campaign.comments.length} + {comments?.length ?? 0} -
- - نظر -
+
- {/* Description */}

{campaign.description}

- {/* Sign Campaign Button */}
@@ -170,32 +207,28 @@ export function CampaignDetailPage() { امضاکنندگان - {campaign.signers.length > 0 ? ( + {Array.isArray(signs) && signs.length > 0 ? (
- {campaign.signers.map((signer) => ( + {signs.map((signer) => (
-
- {signer.avatar ? ( - {signer.nickname} - ) : ( - signer.nickname.charAt(0).toUpperCase() - )} +
+ {`${signer.user_id_nickname}-avatar`}

- {signer.nickname} + {signer.user_id_nickname}

))}
) : ( -

+

هنوز کسی امضا نکرده است. شما می‌توانید اولین نفر باشید!

)} @@ -210,15 +243,16 @@ export function CampaignDetailPage() { {/* Add Comment Form */}
- setCommentText(e.target.value)} - className="flex-1 rounded-lg border-2 border-gray-300 px-4 py-2 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + className="flex-1 rounded-lg border-2 border-gray-300 px-4 py-2 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 resize-none" + rows={3} /> {/* Comments List */} -
- {campaign.comments.length > 0 ? ( - campaign.comments.map((comment) => ( +
+ {currentComments && currentComments.length > 0 ? ( + currentComments.map((comment) => (
-
- - {new Date(comment.createdAt).toLocaleDateString("fa-IR")} - - - {comment.authorNickname} - +
+

+ {comment.user_id_nickname} +

+
+

+ {comment.comment_text} +

+
-

{comment.text}

+ +
)) ) : ( diff --git a/src/modules/dashboard/pages/campaigns/index.tsx b/src/modules/dashboard/pages/campaigns/index.tsx index 6d19364..b5ddab4 100644 --- a/src/modules/dashboard/pages/campaigns/index.tsx +++ b/src/modules/dashboard/pages/campaigns/index.tsx @@ -5,14 +5,14 @@ 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 { useEffect, useState, type ChangeEvent } 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("all"); + const [activeTab, setActiveTab] = useState("فعال"); const [searchQuery, setSearchQuery] = useState(""); const [currentCampaign, setCurrentCampaign] = useState>([]); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); @@ -22,8 +22,8 @@ export function CampaignsPage() { isLoading, refetch, } = useQuery({ - queryKey: ["campaigns", searchQuery], - queryFn: () => getCampaignsService(activeTab, searchQuery), + queryKey: ["campaigns"], + queryFn: getCampaignsService, }); useEffect(() => { @@ -33,36 +33,43 @@ export function CampaignsPage() { }, [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: "کمپین‌های گروه" }, + { oreder: 1, value: "فعال", label: "تمام کارزار‌ها" }, + { oreder: 2, value: "my", label: "کارزار‌های من" }, + { oreder: 3, value: "منتخب", label: "کارزار‌های برتر" }, + { oreder: 4, value: "group", label: "کارزار‌های گروه" }, ]; - const handleSearchChange = (e: React.ChangeEvent) => { + const handleSearchChange = (e: ChangeEvent) => { setSearchQuery(e.target.value); + setActiveTab("فعال"); + const filteredCampaigns = campaigns.filter((campaign) => + campaign.title.toLowerCase().includes(e.target.value.toLowerCase()) + ); + if (e.target.value === "") { + handleTabChange(activeTab); + setCurrentCampaign(campaigns); + return; + } + setCurrentCampaign(filteredCampaigns); }; const handleTabChange = (tab: CampaignTab) => { setActiveTab(tab); const user = userInfoService.getUserInfo(); switch (tab) { - case "all": + case "فعال": setCurrentCampaign(campaigns); break; case "my": setCurrentCampaign( campaigns.filter( - (campaign) => - Number(campaign.WorkflowID) === Number(user.WorkflowID) + (campaign) => Number(campaign.user_id) === Number(user.WorkflowID) ) ); break; - case "top": + case "منتخب": setCurrentCampaign( - [...campaigns].sort( - (a, b) => (b.signature_count || 0) - (a.signature_count || 0) - ) + [...campaigns].filter((item, _) => item.status === "منتخب") ); break; case "group": @@ -80,15 +87,15 @@ export function CampaignsPage() { }; return ( -
+
{/* Header */}
-

- کمپین‌ها +

+ کارزار‌ها

- برای تغییر جهان، کمپین ایجاد کنید و دیگران را دعوت کنید + برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید

@@ -99,7 +106,7 @@ export function CampaignsPage() {
- ایجاد کمپین + ایجاد کارزار
@@ -157,7 +164,9 @@ export function CampaignsPage() { /> )) ) : ( -
کمپینی یافت نشد
+
+ کارزاری یافت نشد +
)}
)} @@ -167,15 +176,15 @@ export function CampaignsPage() {

{activeTab === "my" - ? "هنوز کمپینی ایجاد نکرده‌اید" - : "کمپینی یافت نشد"} + ? "هنوز کارزاری ایجاد نکرده‌اید" + : "کارزاری یافت نشد"}

{activeTab === "my" && ( setIsCreateModalOpen(true)} > - ایجاد اولین کمپین خود + ایجاد اولین کارزار خود )}
diff --git a/src/modules/dashboard/pages/profile/index.tsx b/src/modules/dashboard/pages/profile/index.tsx index 7167136..faf10db 100644 --- a/src/modules/dashboard/pages/profile/index.tsx +++ b/src/modules/dashboard/pages/profile/index.tsx @@ -10,13 +10,14 @@ import { } from "@/core/components/base/card"; import { ImageUploader } from "@/core/components/base/image-uploader"; import { CustomInput } from "@/core/components/base/input"; +import { getContactImageUrl } from "@/core/utils"; 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 { useEffect, useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import type { RegistrationFormData } from "./profile.type"; @@ -29,8 +30,6 @@ export function RegisterPage() { const { data } = useQuery({ queryKey: ["userProfile"], queryFn: fetchUserProfile, - refetchOnWindowFocus: false, - refetchOnMount: false, }); const [formData, setFormData] = useState({ @@ -58,11 +57,13 @@ export function RegisterPage() { nationalcode: data.nationalcode || "", base: data.base || "", })); + if (data.name) + setPreviewImage(getContactImageUrl((data as any).stageID) ?? ""); } }, [data]); const [errors, setErrors] = useState>({}); - const [previewImage, setPreviewImage] = useState(""); + const [previewImage, setPreviewImage] = useState(null); const validateForm = (): boolean => { const newErrors: Record = {}; @@ -147,7 +148,6 @@ export function RegisterPage() { toast.error(data.message || "خطایی رخ داد"); return; } - toast.success("ثبت نام با موفقیت انجام شد"); queryClient.invalidateQueries({ queryKey: ["userProfile"], @@ -188,7 +188,7 @@ export function RegisterPage() { })); }; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (!validateForm()) { @@ -199,9 +199,10 @@ export function RegisterPage() { registerMutation.mutate(formData); }; - const logOut = () => { + const logOut = (e: FormEvent) => { + e.preventDefault(); localStorage.clear(); - navigate(`${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`); + window.location.href = `${AUTH_ROUTE.sub}/${AUTH_ROUTE.LOGIN}`; }; return ( @@ -331,13 +332,14 @@ export function RegisterPage() { /> { if (!file) { setPreviewImage(""); return; } + setFormData((prev) => ({ ...prev, image: file })); const reader = new FileReader(); reader.onloadend = () => { setPreviewImage(reader.result as string); diff --git a/src/modules/dashboard/routes/router.tsx b/src/modules/dashboard/routes/router.tsx index 85c454b..766ed19 100644 --- a/src/modules/dashboard/routes/router.tsx +++ b/src/modules/dashboard/routes/router.tsx @@ -24,7 +24,7 @@ export const dashboardRoutes: AppRoute[] = [ element: , }, { - path: "campaigns/:id", + path: `${DASHBOARD_ROUTE.campaigns}/:id`, element: , }, ], diff --git a/src/modules/dashboard/service/campaigns.service.ts b/src/modules/dashboard/service/campaigns.service.ts index 646a7c1..a24c950 100644 --- a/src/modules/dashboard/service/campaigns.service.ts +++ b/src/modules/dashboard/service/campaigns.service.ts @@ -1,20 +1,17 @@ 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 { uploadImage } from "@/core/utils"; import type { Campaign, + CommentsItem, CreateCampaignData, -} from "../pages/campaigns/campaigns.type"; + SignatureItem, +} from "@modules/dashboard/pages/campaigns/campaigns.type"; +import to from "await-to-js"; +import { toast } from "react-toastify"; -export const getCampaignsService = async ( - tab: string, - search?: string -): Promise => { - const params = new URLSearchParams(); - params.append("tab", tab); - if (search) params.append("search", search); +export const getCampaignsService = async (): Promise => { const userStr = userInfoService.getUserInfo(); const query = { ProcessName: "campaign", @@ -28,11 +25,11 @@ export const getCampaignsService = async ( "status", "school_code", "signature_count", - // "comment_count", + "comment_count", ], conditions: [ ["school_code", "=", userStr.school_code, "or"], - ["user_id", "=", "", "and"], + // ["user_id", "=", "", "and"], ["status", "!=", "حذف شده", "and"], ["status", "!=", "غیر فعال"], ], @@ -47,35 +44,147 @@ export const getCampaignsService = async ( } if (res.data.resultType !== 0) { - toast.error("خطا در دریافت کمپین‌ها"); - throw new Error("خطا در دریافت کمپین‌ها"); + toast.error("خطا در دریافت کارزار‌ها"); + throw new Error("خطا در دریافت کارزار‌ها"); } const data = JSON.parse(res.data.data); return data; }; -export const getCampaignDetailService = async ( +export const getSignsCampaignService = async ( campaignId: string -): Promise => { - const [err, res] = await to(api.get(`/campaigns/${campaignId}`)); +): Promise => { + const query = { + ProcessName: "signature", + OutputFields: ["user_stage_id", "user_id.nickname"], + conditions: [["campaign", "=", campaignId]], + }; + const [err, res] = await to(api.post(API_ADDRESS.select, query)); if (err) { throw err; } - return res?.data; + + if (res.data.resultType !== 0) { + toast.error("خطا در امضای کارزار"); + throw new Error("خطا در امضای کارزار"); + } + + const data = JSON.parse(res.data.data); + if (!data.length) { + return [] as any; + } + return data; +}; + +export const getCommentsCampaignService = async ( + campaignId: string +): Promise => { + const query = { + ProcessName: "comment", + OutputFields: ["author_id.nickname", "text", "createdAt"], + conditions: [["campaign", "=", campaignId]], + }; + const [err, res] = await to(api.post(API_ADDRESS.select, query)); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error("خطا در دریافت نظرات کارزار"); + throw new Error("خطا در دریافت نظرات کارزار"); + } + + const data = JSON.parse(res.data.data); + return data.data; +}; + +export const getCommentsService = async ( + campaignId: string +): Promise => { + const query = { + ProcessName: "comment", + OutputFields: ["user_stage_id", "user_id.nickname", "comment_text"], + conditions: [ + ["campaign", "=", campaignId], + ["status", "!=", "حذف شده", "and"], + ["status", "!=", "غیر فعال"], + ], + }; + + const [err, res] = await to(api.post(API_ADDRESS.select, query)); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error("خطا در افزودن نظر"); + throw new Error("خطا در افزودن نظر"); + } + + const data = JSON.parse(res.data.data); + if (!data.length) { + return [] as any; + } + return data; +}; + +export const getSelectedCampaignsService = async ( + campaignId: Number +): Promise => { + const query = { + ProcessName: "campaign", + OutputFields: [ + "title", + "description", + "image", + "user_id", + "user_id.nickname", + "status", + "signature_count", + "comment_count", + ], + conditions: [ + ["WorkflowID", "=", campaignId, "and"], + ["status", "!=", "حذف شده", "and"], + ["status", "!=", "غیر فعال"], + ], + }; + + const [err, res] = await to(api.post(API_ADDRESS.select, query)); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error("خطا در دریافت کارزار‌"); + throw new Error("خطا در دریافت کارزار‌"); + } + + const data = JSON.parse(res.data.data); + return data[0]; }; export const createCampaignService = async ( data: CreateCampaignData ): Promise => { const user = userInfoService.getUserInfo(); + + let saveImage = null; + if (data.image) { + saveImage = await uploadImage({ + file: data.image as File, + name: "profile_picture", + }); + } const body = { ProcessName: "campaign", campaign: { title: data.title, description: data.description, - image: data.image, - user_id: user.username, // ورکفلو آی دی شخص + image: saveImage ? saveImage.data.data : undefined, + user_id: user.username, + status: "فعال", }, }; const [err, res] = await to(api.post(API_ADDRESS.select, body)); @@ -93,18 +202,66 @@ export const createCampaignService = async ( export const signCampaignService = async ( campaignId: string -): Promise => { - const response = await api.post(`/campaigns/${campaignId}/sign`); - return response.data; +): Promise => { + const user = userInfoService.getUserInfo(); + const body = { + ProcessName: "signature", + signature: { + campaign: campaignId, + user_id: user.username, + }, + }; + const [err, res] = await to(api.post(API_ADDRESS.select, body)); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error(res.data.message || "خطا در امضای کارزار"); + throw new Error("خطا در امضای کارزار"); + } }; export const addCommentService = async ( campaignId: string, text: string -): Promise => { - const response = await api.post(`/campaigns/${campaignId}/comments`, { - text, - }); +): Promise => { + const user = userInfoService.getUserInfo(); + const body = { + comment: { + campaign: campaignId, + user_id: user.username, + comment_text: text, + status: "فعال", + }, + }; + const [err, res] = await to(api.post(API_ADDRESS.save, body)); + if (err) { + throw err; + } - return response.data; + if (res.data.resultType !== 0) { + toast.error(res.data.message || "خطا در افزودن نظر"); + throw new Error("خطا در افزودن نظر"); + } +}; + +export const removeCommentService = async ( + commentId: Number +): Promise => { + const body = { + WorkflowID: commentId, + comment: { + status: "حذف شده", + }, + }; + const [err, res] = await to(api.post(API_ADDRESS.delete, body)); + if (err) { + throw err; + } + + if (res.data.resultType !== 0) { + toast.error(res.data.message || "خطا در حذف نظر"); + throw new Error("خطا در حذف نظر"); + } }; diff --git a/src/modules/dashboard/service/user.service.ts b/src/modules/dashboard/service/user.service.ts index a589aff..eb8387b 100644 --- a/src/modules/dashboard/service/user.service.ts +++ b/src/modules/dashboard/service/user.service.ts @@ -1,5 +1,6 @@ import { API_ADDRESS } from "@/core/service/api-address"; import api from "@/core/service/axios"; +import { uploadImage } from "@/core/utils"; import type { RegistrationFormData } from "@modules/dashboard/pages/profile/profile.type"; import { to } from "await-to-js"; @@ -20,7 +21,7 @@ export const fetchUserProfile = async () => { "invitor", "nationalcode", ], - conditions: [["username", "=", person.ID]], + conditions: [["username", "=", person.ID ? person.ID : person.username]], }; const res = await api.post(API_ADDRESS.select, query); @@ -32,16 +33,17 @@ export const fetchUserProfile = async () => { const user = JSON.parse(res.data.data)[0]; 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, + stageID: user?.ValueP1224S1943StageID, + 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, }; }; @@ -52,21 +54,29 @@ export const updateUserProfile = async (data: RegistrationFormData) => { } const person = JSON.parse(personStr); - const natinalCode = person.NationalCode; - + const nationalCode = person.nationalcode; + let saveImage = null; + if (data.image) { + saveImage = await uploadImage({ + file: data.image as File, + name: "profile_picture", + }); + } let payload = { + ...(nationalCode && { WorkflowID: person.WorkflowID }), user: { - username: String(person.ID), + username: person.ID ? String(person.ID) : person.username, name: data.name.trim(), family: data.family.trim(), nickname: data.nickname.trim() || undefined, education_level: data.education_level, base: data.base, + image: saveImage?.data?.data, account_type: "عادی", nationalcode: data.nationalcode, + ...(saveImage?.data?.data && { image: saveImage?.data?.data }), ...(data.school_code && { school_code: data.school_code.trim() }), ...(data.invitor && { invitor: data.invitor.trim() }), - ...(natinalCode && { WorkflowID: person.ID }), }, }; diff --git a/src/router/protectedRoute.tsx b/src/router/protectedRoute.tsx deleted file mode 100644 index 2d29115..0000000 --- a/src/router/protectedRoute.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; -import { Navigate } from "react-router-dom"; - -interface ProtectedRouteProps { - element: React.ReactNode; -} - -export default function ProtectedRoute({ element }: ProtectedRouteProps) { - const token = localStorage.getItem("token"); - - if (!token) return ; - - return <>{element}; -}