From d725c1b7d7a1283b8afcf66fe2af1c0736fc065f Mon Sep 17 00:00:00 2001 From: MehrdadAdabi <126083584+mehrdadAdabi@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:42:19 +0330 Subject: [PATCH] feat: Implement searchable dropdown component and refactor campaign page This commit introduces a new `BaseDropdown` component with search functionality and refactors the campaign listing page to utilize this new component and improve its layout. The `BaseDropdown` component was significantly refactored from a native ` + + + {isOpen && ( +
+
+
+ { + setSearchQuery(e.target.value); + if (onInputChange) { + onInputChange(e.target.value); + } + }} + /> + +
+
+ +
+ )} {hasError && ( diff --git a/src/core/components/others/mobile-navbar.tsx b/src/core/components/others/mobile-navbar.tsx index 71e6301..f330f39 100644 --- a/src/core/components/others/mobile-navbar.tsx +++ b/src/core/components/others/mobile-navbar.tsx @@ -1,6 +1,6 @@ import { cn } from "@/core/lib/utils"; import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant"; -import { BarChart3, Home, MessageCircle, User } from "lucide-react"; +import { BarChart3, Bot, Home } from "lucide-react"; import { useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; @@ -18,20 +18,6 @@ interface MobileNavbarProps { } const DEFAULT_NAV_ITEMS: NavItem[] = [ - { - id: "profile", - disabled: false, - label: "پروفایل", - icon: , - path: `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.profile}`, - }, - { - id: "group-chat", - label: "گروه", - disabled: true, - icon: , - path: "#", - }, { id: "ranking", label: "رتبه‌بندی", @@ -39,6 +25,13 @@ const DEFAULT_NAV_ITEMS: NavItem[] = [ icon: , path: "#", }, + { + id: "chat-group", + label: "چت گروهی", + disabled: true, + icon: , + path: "#", + }, { id: "dashboard", label: "کارزار", diff --git a/src/modules/dashboard/layouts/index.tsx b/src/modules/dashboard/layouts/index.tsx index 99265f0..5880090 100644 --- a/src/modules/dashboard/layouts/index.tsx +++ b/src/modules/dashboard/layouts/index.tsx @@ -2,6 +2,7 @@ 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 { getContactImageUrl } from "@/core/utils"; import { Outlet } from "react-router-dom"; export function DashboardLayout() { @@ -10,7 +11,11 @@ export function DashboardLayout() {
diff --git a/src/modules/dashboard/pages/campaigns/campaigns.type.ts b/src/modules/dashboard/pages/campaigns/campaigns.type.ts index b629482..43e9dec 100644 --- a/src/modules/dashboard/pages/campaigns/campaigns.type.ts +++ b/src/modules/dashboard/pages/campaigns/campaigns.type.ts @@ -1,18 +1,18 @@ 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; - user_id_nickname?: String; + 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; + user_id_nickname?: string; } export interface Signer { @@ -52,4 +52,5 @@ export interface SignatureItem { WorkflowID: Number; user_id_nickname: String; user_stage_id: String; + user_id_username: string; } diff --git a/src/modules/dashboard/pages/campaigns/detail.tsx b/src/modules/dashboard/pages/campaigns/detail.tsx index 8d0a72a..db86840 100644 --- a/src/modules/dashboard/pages/campaigns/detail.tsx +++ b/src/modules/dashboard/pages/campaigns/detail.tsx @@ -2,6 +2,7 @@ import { CustomButton } from "@/core/components/base/button"; import TextAreaField from "@/core/components/base/text-area"; +import { userInfoService } from "@/core/service/user-info.service"; import { getContactImageUrl } from "@/core/utils"; import { addCommentService, @@ -30,7 +31,6 @@ export function CampaignDetailPage() { const [commentText, setCommentText] = useState(""); const [hasSignedCampaign, setHasSignedCampaign] = useState(false); const [currentComments, setCurrentComments] = useState([]); - const { data: campaign, isLoading } = useQuery({ queryKey: ["campaign", id], queryFn: () => getSelectedCampaignsService(Number(id!)), @@ -55,6 +55,16 @@ export function CampaignDetailPage() { } }, [comments]); + useEffect(() => { + if (signs && signs?.length > 0) { + const username = userInfoService.getUserInfo().username; + const findCurrentUser = signs.find( + (el) => el.user_id_username === username + ); + if (findCurrentUser) setHasSignedCampaign(true); + } + }, [signs]); + const signMutation = useMutation({ mutationFn: () => signCampaignService(id!), onSuccess: () => { @@ -216,7 +226,7 @@ export function CampaignDetailPage() { >
{`${signer.user_id_nickname}-avatar`} diff --git a/src/modules/dashboard/pages/campaigns/index.tsx b/src/modules/dashboard/pages/campaigns/index.tsx index b5ddab4..2557486 100644 --- a/src/modules/dashboard/pages/campaigns/index.tsx +++ b/src/modules/dashboard/pages/campaigns/index.tsx @@ -3,15 +3,18 @@ import { CustomButton } from "@/core/components/base/button"; import { CustomInput } from "@/core/components/base/input"; import { userInfoService } from "@/core/service/user-info.service"; +import { getContactImageUrl } from "@/core/utils"; +import { DASHBOARD_ROUTE } from "@modules/dashboard/routes/route.constant"; import { useQuery } from "@tanstack/react-query"; -import { Loader, Plus, Search } from "lucide-react"; +import { Plus, Search, Users } from "lucide-react"; import { useEffect, useState, type ChangeEvent } from "react"; -import { CampaignCard } from "../../components/campaign-card"; +import { useNavigate } from "react-router-dom"; import { CreateCampaignModal } from "../../components/create-campaign-modal"; import { getCampaignsService } from "../../service/campaigns.service"; import type { Campaign, CampaignTab } from "./campaigns.type"; export function CampaignsPage() { + const navigate = useNavigate(); const [activeTab, setActiveTab] = useState("فعال"); const [searchQuery, setSearchQuery] = useState(""); const [currentCampaign, setCurrentCampaign] = useState>([]); @@ -28,170 +31,247 @@ export function CampaignsPage() { useEffect(() => { if (campaigns) { - setCurrentCampaign(campaigns); - } - }, [campaigns]); + const user = userInfoService.getUserInfo(); + let filtered = campaigns; - const tabs: { value: CampaignTab; label: string; oreder: number }[] = [ - { oreder: 1, value: "فعال", label: "تمام کارزار‌ها" }, - { oreder: 2, value: "my", label: "کارزار‌های من" }, - { oreder: 3, value: "منتخب", label: "کارزار‌های برتر" }, - { oreder: 4, value: "group", label: "کارزار‌های گروه" }, + switch (activeTab) { + case "my": + filtered = campaigns.filter( + (c) => Number(c.user_id) === Number(user.WorkflowID) + ); + break; + case "منتخب": + filtered = campaigns.filter((c) => c.status === "منتخب"); + break; + case "group": + filtered = campaigns.filter( + (c) => c.school_code === user.school_code + ); + break; + case "فعال": + default: + filtered = campaigns; + break; + } + setCurrentCampaign(filtered); + } + }, [campaigns, activeTab]); // Added activeTab to dependencies + + const tabs: { value: CampaignTab; label: string }[] = [ + { value: "فعال", label: "تمام کارزار‌ها" }, + { value: "my", label: "کارزار‌های من" }, + { value: "منتخب", label: "کارزار‌های برتر" }, + { value: "group", label: "کارزار‌های گروه" }, ]; const handleSearchChange = (e: ChangeEvent) => { - setSearchQuery(e.target.value); + const query = e.target.value; + setSearchQuery(query); setActiveTab("فعال"); - const filteredCampaigns = campaigns.filter((campaign) => - campaign.title.toLowerCase().includes(e.target.value.toLowerCase()) - ); - if (e.target.value === "") { - handleTabChange(activeTab); - setCurrentCampaign(campaigns); - return; + + if (query === "") { + handleTabChange(activeTab, campaigns); + } else { + const filteredCampaigns = campaigns.filter((campaign) => + campaign.title.toLowerCase().includes(query.toLowerCase()) + ); + setCurrentCampaign(filteredCampaigns); } - setCurrentCampaign(filteredCampaigns); }; - const handleTabChange = (tab: CampaignTab) => { + const handleTabChange = (tab: CampaignTab, campaignData = campaigns) => { setActiveTab(tab); const user = userInfoService.getUserInfo(); + let filtered = campaignData; + switch (tab) { - case "فعال": - setCurrentCampaign(campaigns); - break; case "my": - setCurrentCampaign( - campaigns.filter( - (campaign) => Number(campaign.user_id) === Number(user.WorkflowID) - ) + filtered = campaignData.filter( + (c) => Number(c.user_id) === Number(user.WorkflowID) ); break; case "منتخب": - setCurrentCampaign( - [...campaigns].filter((item, _) => item.status === "منتخب") - ); + filtered = campaignData.filter((c) => c.status === "منتخب"); break; - case "group": - setCurrentCampaign( - campaigns.filter( - (campaign) => campaign.school_code === user.school_code - ) - ); - break; - default: - setCurrentCampaign(campaigns); - } + case "group": + filtered = campaignData.filter( + (c) => c.school_code === user.school_code + ); + break; + case "فعال": + default: + filtered = campaignData; + break; + } + setCurrentCampaign(filtered); setSearchQuery(""); }; + const handleJoin = (campaign: Campaign) => { + navigate( + `/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.joinToCampaing}?id=${campaign.WorkflowID}` + ); + }; + + const showCampaing = (cId: number) => { + navigate(`/${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.campaigns}/${cId}`); + }; + + const renderSkeleton = () => ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + return ( -
-
- {/* Header */} -
-

+
+
+
+

کارزار‌ها

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

-
+ - {/* Top Bar: Search and Create Button */} -
- {/* Search Box */} -
-
- - -
+
+
+ +
- - {/* Create Campaign Button */} setIsCreateModalOpen(true)} - className="flex items-center gap-2" + className="flex items-center justify-center gap-2" > - ایجاد کارزار + ایجاد کارزار
- {/* Tabs */} -
- {tabs.map((tab) => ( - - ))} +
+
+ {tabs.map((tab) => ( + + ))} +
- {/* Loading State */} - {isLoading && ( -
- -
- )} +
+ {isLoading ? ( + renderSkeleton() + ) : currentCampaign.length > 0 ? ( +
    + {currentCampaign.map((campaign) => ( +
  • +
    +
    + {campaign.image ? ( + {campaign.title} + ) : ( +
    + +
    + )} +
    - {/* Campaigns Grid */} - {!isLoading && campaigns.length > 0 && ( -
    - {currentCampaign.length > 0 ? ( - currentCampaign.map((campaign) => ( - - )) - ) : ( -
    - کارزاری یافت نشد -
    - )} -
    - )} +
    +

    + {campaign.title} +

    - {/* Empty State */} - {!isLoading && campaigns.length === 0 && ( -
    -

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

    - {activeTab === "my" && ( - setIsCreateModalOpen(true)} - > - ایجاد اولین کارزار خود - - )} -
    - )} +
    + + ایجاد کننده : + +

    + {campaign.user_id_nickname} +

    +
    +

    + {Number(campaign.signature_count)} عضو +

    +
    + +
    + {activeTab === "منتخب" && ( + handleJoin(campaign)} + variant="info" + className="shrink-0" + > + انتخاب{" "} + + )} + + showCampaing(campaign.WorkflowID)} + variant="info" + className="shrink-0" + > + جزییات + +
    +
    +
  • + ))} +
+ ) : ( +
+

+ {searchQuery + ? "کارزاری با این مشخصات یافت نشد." + : "کارزاری در این دسته وجود ندارد."} +

+
+ )} +
- {/* Create Campaign Modal */} setIsCreateModalOpen(false)} diff --git a/src/modules/dashboard/pages/join-to-campaing/index.tsx b/src/modules/dashboard/pages/join-to-campaing/index.tsx new file mode 100644 index 0000000..8746956 --- /dev/null +++ b/src/modules/dashboard/pages/join-to-campaing/index.tsx @@ -0,0 +1,150 @@ +import { BaseDropdown } from "@/core/components/base/base-drop-down"; + +import { CustomButton } from "@/core/components/base/button"; +import { CustomInput } from "@/core/components/base/input"; +import { + createCircleService, + searchUsersService, +} from "@modules/dashboard/service/campaigns.service"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { useState } from "react"; +import type { User } from "./join-to-campaing"; + +export const JoinToCampaing = () => { + const [circleName, setCircleName] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedMembers, setSelectedMembers] = useState([]); + const [errors, setErrors] = useState<{ + circleName?: string; + members?: string; + }>({}); + + const { data: users } = useQuery({ + queryKey: ["users", searchTerm], + queryFn: () => searchUsersService(searchTerm), + enabled: !!searchTerm, + }); + + const { mutate: createCircle, isPending: isCreating } = useMutation({ + mutationFn: () => + createCircleService( + circleName, + selectedMembers.map((m) => m.user_id) + ), + onSuccess: () => { + setCircleName(""); + setSelectedMembers([]); + }, + }); + + const handleAddMember = (userId: string) => { + const user = users?.find((u) => u.user_id === userId); + if (user && !selectedMembers.some((m) => m.user_id === userId)) { + setSelectedMembers([...selectedMembers, user]); + } + }; + + const handleRemoveMember = (userId: string) => { + setSelectedMembers(selectedMembers.filter((m) => m.user_id !== userId)); + }; + + const checkRequired = () => { + const newErrors: { circleName?: string; members?: string } = {}; + if (!circleName.trim()) { + newErrors.circleName = "نام محفل الزامی است"; + } + if (selectedMembers.length === 0) { + newErrors.members = "انتخاب حداقل یک عضو الزامی است"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (checkRequired()) { + createCircle(); + } + }; + + return ( +
+

ایجاد محفل جدید

+ +
+ { + setCircleName(e.target.value); + if (errors.circleName) { + setErrors((prev) => ({ ...prev, circleName: undefined })); + } + }} + error={errors.circleName} + /> + +
+ ({ + value: user.user_id, + label: user.nickname, + })) || [] + } + onInputChange={(inputValue) => setSearchTerm(inputValue)} + onChange={(value) => { + handleAddMember(value); + if (errors.members) { + setErrors((prev) => ({ ...prev, members: undefined })); + } + }} + error={errors.members} + /> +
+ برای جستجو، شروع به تایپ کنید. +
+
+ + {selectedMembers.length > 0 && ( +
+

+ اعضای انتخاب شده +

+
    + {selectedMembers.map((member) => ( +
  • + {member.nickname} + +
  • + ))} +
+
+ )} +
+ +
+ + تایید و ایجاد محفل + +
+
+ ); +}; + +export default JoinToCampaing; diff --git a/src/modules/dashboard/pages/join-to-campaing/join-to-campaing.ts b/src/modules/dashboard/pages/join-to-campaing/join-to-campaing.ts new file mode 100644 index 0000000..0843964 --- /dev/null +++ b/src/modules/dashboard/pages/join-to-campaing/join-to-campaing.ts @@ -0,0 +1,4 @@ +export type User = { + user_id: string; + nickname: string; +}; diff --git a/src/modules/dashboard/pages/profile/profile.type.ts b/src/modules/dashboard/pages/profile/profile.type.ts index 5a2498b..ed85d31 100644 --- a/src/modules/dashboard/pages/profile/profile.type.ts +++ b/src/modules/dashboard/pages/profile/profile.type.ts @@ -1,4 +1,5 @@ export interface RegistrationFormData { + ValueP1224S1943StageID?: Number; username?: string; WorkflowID?: string; name: string; diff --git a/src/modules/dashboard/routes/route.constant.ts b/src/modules/dashboard/routes/route.constant.ts index 5ea2a1a..88865a8 100644 --- a/src/modules/dashboard/routes/route.constant.ts +++ b/src/modules/dashboard/routes/route.constant.ts @@ -3,4 +3,6 @@ export const DASHBOARD_ROUTE = { dashboard: "main", profile: "profile", campaigns: "campaigns", + campaing: "campaing", + joinToCampaing: "join-to-campaing", }; diff --git a/src/modules/dashboard/routes/router.tsx b/src/modules/dashboard/routes/router.tsx index 766ed19..ffae5ba 100644 --- a/src/modules/dashboard/routes/router.tsx +++ b/src/modules/dashboard/routes/router.tsx @@ -2,6 +2,7 @@ import type { AppRoute } from "@core/types/router.type"; import { DashboardLayout } from "../layouts"; import CampaignsPage from "../pages/campaigns"; import CampaignDetailPage from "../pages/campaigns/detail"; +import JoinToCampaing from "../pages/join-to-campaing"; import DashboardPage from "../pages/main-page"; import ProfilePage from "../pages/profile"; import { DASHBOARD_ROUTE } from "./route.constant"; @@ -24,9 +25,13 @@ export const dashboardRoutes: AppRoute[] = [ element: , }, { - path: `${DASHBOARD_ROUTE.campaigns}/:id`, + path: `${DASHBOARD_ROUTE.campaing}/:id`, element: , }, + { + path: `${DASHBOARD_ROUTE.joinToCampaing}/:id`, + element: , + }, ], }, ]; diff --git a/src/modules/dashboard/service/campaigns.service.ts b/src/modules/dashboard/service/campaigns.service.ts index a24c950..6052f10 100644 --- a/src/modules/dashboard/service/campaigns.service.ts +++ b/src/modules/dashboard/service/campaigns.service.ts @@ -8,6 +8,7 @@ import type { CreateCampaignData, SignatureItem, } from "@modules/dashboard/pages/campaigns/campaigns.type"; +import type { User } from "@modules/dashboard/pages/join-to-campaing/join-to-campaing"; import to from "await-to-js"; import { toast } from "react-toastify"; @@ -57,7 +58,7 @@ export const getSignsCampaignService = async ( ): Promise => { const query = { ProcessName: "signature", - OutputFields: ["user_stage_id", "user_id.nickname"], + OutputFields: ["user_stage_id", "user_id.nickname", "user_id.username"], conditions: [["campaign", "=", campaignId]], }; const [err, res] = await to(api.post(API_ADDRESS.select, query)); @@ -150,7 +151,6 @@ export const getSelectedCampaignsService = async ( ["status", "!=", "غیر فعال"], ], }; - const [err, res] = await to(api.post(API_ADDRESS.select, query)); if (err) { throw err; @@ -178,7 +178,6 @@ export const createCampaignService = async ( }); } const body = { - ProcessName: "campaign", campaign: { title: data.title, description: data.description, @@ -187,7 +186,7 @@ export const createCampaignService = async ( status: "فعال", }, }; - const [err, res] = await to(api.post(API_ADDRESS.select, body)); + const [err, res] = await to(api.post(API_ADDRESS.save, body)); if (err) { throw err; } @@ -205,13 +204,13 @@ export const signCampaignService = async ( ): Promise => { const user = userInfoService.getUserInfo(); const body = { - ProcessName: "signature", signature: { campaign: campaignId, - user_id: user.username, + user_id: user.WorkflowID, + status: "فعال", }, }; - const [err, res] = await to(api.post(API_ADDRESS.select, body)); + const [err, res] = await to(api.post(API_ADDRESS.save, body)); if (err) { throw err; } @@ -265,3 +264,62 @@ export const removeCommentService = async ( throw new Error("خطا در حذف نظر"); } }; + +export const searchUsersService = async (nickname: string): Promise => { + if (!nickname) { + return []; + } + + const query = { + ProcessName: "users", + OutputFields: ["nickname", "user_id"], + conditions: [["nickname", "like", `%${nickname}%`]], + }; + + const [err, res] = await to(api.post(API_ADDRESS.select, query)); + + if (err) { + toast.error("خطا در جستجوی کاربر"); + throw err; + } + + if (res.data.resultType !== 0) { + toast.error("خطا در جستجوی کاربر"); + throw new Error("خطا در جستجوی کاربر"); + } + + const data = JSON.parse(res.data.data); + return data || []; +}; + +export const createCircleService = async ( + circleName: string, + memberIds: string[] +): Promise => { + const user = userInfoService.getUserInfo(); + + const body = { + ProcessName: "circle", + circle: { + circle_name: circleName, + creator_id: user.username, + status: "فعال", + }, + members: memberIds.map((id) => ({ user_id: id })), + }; + + const [err, res] = await to(api.post(API_ADDRESS.save, body)); + + if (err) { + toast.error("خطا در ایجاد محفل"); + throw err; + } + + if (res.data.resultType !== 0) { + toast.error(res.data.message || "خطا در ایجاد محفل"); + throw new Error("خطا در ایجاد محفل"); + } + + toast.success("محفل با موفقیت ایجاد شد"); + return res.data; +};