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 `<select>` element to a custom component built with `div` and `input` elements. This allows for:
- **Search functionality**: Users can now type to filter dropdown options.
- **Improved accessibility**: Custom handling of focus and keyboard navigation.
- **Enhanced styling**: More control over the visual appearance.

The campaign listing page (`src/pages/campaigns/index.tsx`) was updated to:
- Replace the previous dropdowns with the new `BaseDropdown` component.
- Adjust the layout of the header, search bar, and filter section for better responsiveness and visual appeal.
- Update the `VITE_API_URL` in `.env` to ensure a newline at the end of the file.

These changes enhance the user experience by providing a more interactive and user-friendly way to select options and navigate the campaign page.
This commit is contained in:
MehrdadAdabi 2025-11-25 13:42:19 +03:30
parent 024b268000
commit d725c1b7d7
13 changed files with 588 additions and 190 deletions

3
.env
View File

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

View File

@ -1,17 +1,30 @@
// components/ui/BaseDropdown.tsx
import { cn } from "@/core/lib/utils"; import { cn } from "@/core/lib/utils";
import { ChevronDown } from "lucide-react"; import { ChevronDown, Search } from "lucide-react";
import { type SelectHTMLAttributes, forwardRef } from "react"; import {
forwardRef,
type InputHTMLAttributes,
useEffect,
useRef,
useState,
} from "react";
type BaseDropdownProps = SelectHTMLAttributes<HTMLSelectElement> & { type Option = { value: string; label: string };
type BaseDropdownProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
"onChange"
> & {
label?: string; label?: string;
error?: string; error?: string;
variant?: "primary" | "error"; variant?: "primary" | "error";
options: { value: string; label: string }[]; options: Option[];
placeholder?: string; placeholder?: string;
value?: string;
onChange?: (value: string) => void;
onInputChange?: (inputValue: string) => void;
}; };
export const BaseDropdown = forwardRef<HTMLSelectElement, BaseDropdownProps>( export const BaseDropdown = forwardRef<HTMLDivElement, BaseDropdownProps>(
( (
{ {
label, label,
@ -20,51 +33,124 @@ export const BaseDropdown = forwardRef<HTMLSelectElement, BaseDropdownProps>(
options, options,
placeholder = "انتخاب کنید", placeholder = "انتخاب کنید",
className, className,
...props value,
onChange,
disabled,
onInputChange,
}, },
ref ref
) => { ) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedOption = options.find((option) => option.value === value);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [isOpen]);
const handleSelect = (option: Option) => {
if (onChange) {
onChange(option.value);
}
setIsOpen(false);
setSearchQuery("");
};
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
);
const hasError = !!error; const hasError = !!error;
return ( return (
<div className="w-full"> <div className="w-full" ref={ref}>
{label && ( {label && (
<label className="mb-2 block text-sm font-medium text-foreground text-right"> <label className="mb-2 block text-sm font-medium text-foreground text-right">
{label} {label}
</label> </label>
)} )}
<div className="relative"> <div className="relative" ref={dropdownRef}>
<select <button
ref={ref} type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={cn( 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", "flex h-12 w-full items-center justify-between rounded-lg border-2 bg-background px-4 py-2 text-sm transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-1", "focus-visible:outline-none focus-visible:ring-1",
variant === "error" || hasError variant === "error" || hasError
? "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20" ? "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", : "border-gray-300 focus-visible:border-blue-600 focus-visible:ring-blue-600/20",
props.disabled && "opacity-60 cursor-not-allowed bg-gray-50", disabled && "opacity-60 cursor-not-allowed bg-gray-50",
className className
)} )}
{...props}
> >
<option value="">{placeholder}</option> <span className={selectedOption ? "text-black" : "text-gray-400"}>
{options.map((option) => ( {selectedOption ? selectedOption.label : placeholder}
<option key={option.value} value={option.value}> </span>
{option.label}
</option>
))}
</select>
{/* آیکون پایین */}
<div className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
<ChevronDown <ChevronDown
className={cn( className={cn(
"h-5 w-5 transition-transform", "h-5 w-5 transition-transform",
isOpen && "rotate-180",
hasError ? "text-red-500" : "text-gray-500" hasError ? "text-red-500" : "text-gray-500"
)} )}
/> />
</div> </button>
{isOpen && (
<div className="absolute z-10 mt-1 w-full rounded-lg border-2 border-gray-200 bg-white shadow-lg max-h-60 overflow-auto">
<div className="p-2">
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder="جستجو..."
className="w-full rounded-md border border-gray-300 px-3 py-2 pl-8 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (onInputChange) {
onInputChange(e.target.value);
}
}}
/>
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<ul className="max-h-48 overflow-auto">
{filteredOptions.map((option) => (
<li
key={option.value}
onClick={() => handleSelect(option)}
className="cursor-pointer px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{option.label}
</li>
))}
</ul>
</div>
)}
</div> </div>
{hasError && ( {hasError && (

View File

@ -1,6 +1,6 @@
import { cn } from "@/core/lib/utils"; import { cn } from "@/core/lib/utils";
import { DASHBOARD_ROUTE } from "@/modules/dashboard/routes/route.constant"; 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 { useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@ -18,20 +18,6 @@ interface MobileNavbarProps {
} }
const DEFAULT_NAV_ITEMS: NavItem[] = [ 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", id: "ranking",
label: "رتبه‌بندی", label: "رتبه‌بندی",
@ -39,6 +25,13 @@ const DEFAULT_NAV_ITEMS: NavItem[] = [
icon: <BarChart3 size={24} />, icon: <BarChart3 size={24} />,
path: "#", path: "#",
}, },
{
id: "chat-group",
label: "چت گروهی",
disabled: true,
icon: <Bot size={24} />,
path: "#",
},
{ {
id: "dashboard", id: "dashboard",
label: "کارزار", label: "کارزار",

View File

@ -2,6 +2,7 @@
import DashboardHeader from "@/core/components/base/dashboard-header"; import DashboardHeader from "@/core/components/base/dashboard-header";
import { MobileNavbar } from "@/core/components/others/mobile-navbar"; import { MobileNavbar } from "@/core/components/others/mobile-navbar";
import { userInfoService } from "@/core/service/user-info.service"; import { userInfoService } from "@/core/service/user-info.service";
import { getContactImageUrl } from "@/core/utils";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
export function DashboardLayout() { export function DashboardLayout() {
@ -10,7 +11,11 @@ export function DashboardLayout() {
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto pb-14"> <main className="flex-1 overflow-y-auto pb-14">
<DashboardHeader <DashboardHeader
profileImageUrl={""} profileImageUrl={
user.ValueP1224S1943StageID
? getContactImageUrl(user.ValueP1224S1943StageID!)
: ""
}
fullName={`${user.name || "کاربر جدید"} ${user.family || ""}`} fullName={`${user.name || "کاربر جدید"} ${user.family || ""}`}
coins={100} coins={100}
/> />

View File

@ -1,18 +1,18 @@
export interface Campaign { export interface Campaign {
ValueP1226S1951StageID: Number; ValueP1226S1951StageID: number;
ValueP1226S1951ValueID: Number; ValueP1226S1951ValueID: number;
WorkflowID: Number; WorkflowID: number;
description: String; description: string;
image: String; image: string;
status: String; status: string;
title: String; title: string;
user_id: String; user_id: string;
volume: String; volume: string;
school_code: String; school_code: string;
nickname?: String; nickname?: string;
signature_count: Number; signature_count: number;
comment_count?: Number; comment_count?: number;
user_id_nickname?: String; user_id_nickname?: string;
} }
export interface Signer { export interface Signer {
@ -52,4 +52,5 @@ export interface SignatureItem {
WorkflowID: Number; WorkflowID: Number;
user_id_nickname: String; user_id_nickname: String;
user_stage_id: String; user_stage_id: String;
user_id_username: string;
} }

View File

@ -2,6 +2,7 @@
import { CustomButton } from "@/core/components/base/button"; import { CustomButton } from "@/core/components/base/button";
import TextAreaField from "@/core/components/base/text-area"; import TextAreaField from "@/core/components/base/text-area";
import { userInfoService } from "@/core/service/user-info.service";
import { getContactImageUrl } from "@/core/utils"; import { getContactImageUrl } from "@/core/utils";
import { import {
addCommentService, addCommentService,
@ -30,7 +31,6 @@ export function CampaignDetailPage() {
const [commentText, setCommentText] = useState(""); const [commentText, setCommentText] = useState("");
const [hasSignedCampaign, setHasSignedCampaign] = useState<boolean>(false); const [hasSignedCampaign, setHasSignedCampaign] = useState<boolean>(false);
const [currentComments, setCurrentComments] = useState<CommentsItem[]>([]); const [currentComments, setCurrentComments] = useState<CommentsItem[]>([]);
const { data: campaign, isLoading } = useQuery({ const { data: campaign, isLoading } = useQuery({
queryKey: ["campaign", id], queryKey: ["campaign", id],
queryFn: () => getSelectedCampaignsService(Number(id!)), queryFn: () => getSelectedCampaignsService(Number(id!)),
@ -55,6 +55,16 @@ export function CampaignDetailPage() {
} }
}, [comments]); }, [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({ const signMutation = useMutation({
mutationFn: () => signCampaignService(id!), mutationFn: () => signCampaignService(id!),
onSuccess: () => { onSuccess: () => {
@ -216,7 +226,7 @@ export function CampaignDetailPage() {
> >
<div className="w-16 h-16 rounded-full overflow-hidden bg-gradient-to-br from-blue-400 to-blue-600"> <div className="w-16 h-16 rounded-full overflow-hidden bg-gradient-to-br from-blue-400 to-blue-600">
<img <img
src={getContactImageUrl(signer.ValueP1227S1955StageID)} src={getContactImageUrl(Number(signer.user_stage_id))}
alt={`${signer.user_id_nickname}-avatar`} alt={`${signer.user_id_nickname}-avatar`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@ -3,15 +3,18 @@
import { CustomButton } from "@/core/components/base/button"; import { CustomButton } from "@/core/components/base/button";
import { CustomInput } from "@/core/components/base/input"; import { CustomInput } from "@/core/components/base/input";
import { userInfoService } from "@/core/service/user-info.service"; 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 { 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 { 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 { CreateCampaignModal } from "../../components/create-campaign-modal";
import { getCampaignsService } from "../../service/campaigns.service"; import { getCampaignsService } from "../../service/campaigns.service";
import type { Campaign, CampaignTab } from "./campaigns.type"; import type { Campaign, CampaignTab } from "./campaigns.type";
export function CampaignsPage() { export function CampaignsPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<CampaignTab>("فعال"); const [activeTab, setActiveTab] = useState<CampaignTab>("فعال");
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>("");
const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]); const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]);
@ -28,170 +31,247 @@ export function CampaignsPage() {
useEffect(() => { useEffect(() => {
if (campaigns) { if (campaigns) {
setCurrentCampaign(campaigns); const user = userInfoService.getUserInfo();
} let filtered = campaigns;
}, [campaigns]);
const tabs: { value: CampaignTab; label: string; oreder: number }[] = [ switch (activeTab) {
{ oreder: 1, value: "فعال", label: "تمام کارزار‌ها" }, case "my":
{ oreder: 2, value: "my", label: "کارزار‌های من" }, filtered = campaigns.filter(
{ oreder: 3, value: "منتخب", label: "کارزار‌های برتر" }, (c) => Number(c.user_id) === Number(user.WorkflowID)
{ oreder: 4, value: "group", label: "کارزار‌های گروه" }, );
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<HTMLInputElement>) => { const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value); const query = e.target.value;
setSearchQuery(query);
setActiveTab("فعال"); setActiveTab("فعال");
const filteredCampaigns = campaigns.filter((campaign) =>
campaign.title.toLowerCase().includes(e.target.value.toLowerCase()) if (query === "") {
); handleTabChange(activeTab, campaigns);
if (e.target.value === "") { } else {
handleTabChange(activeTab); const filteredCampaigns = campaigns.filter((campaign) =>
setCurrentCampaign(campaigns); campaign.title.toLowerCase().includes(query.toLowerCase())
return; );
setCurrentCampaign(filteredCampaigns);
} }
setCurrentCampaign(filteredCampaigns);
}; };
const handleTabChange = (tab: CampaignTab) => { const handleTabChange = (tab: CampaignTab, campaignData = campaigns) => {
setActiveTab(tab); setActiveTab(tab);
const user = userInfoService.getUserInfo(); const user = userInfoService.getUserInfo();
let filtered = campaignData;
switch (tab) { switch (tab) {
case "فعال":
setCurrentCampaign(campaigns);
break;
case "my": case "my":
setCurrentCampaign( filtered = campaignData.filter(
campaigns.filter( (c) => Number(c.user_id) === Number(user.WorkflowID)
(campaign) => Number(campaign.user_id) === Number(user.WorkflowID)
)
); );
break; break;
case "منتخب": case "منتخب":
setCurrentCampaign( filtered = campaignData.filter((c) => c.status === "منتخب");
[...campaigns].filter((item, _) => item.status === "منتخب")
);
break; 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(""); 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 = () => (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white rounded-xl shadow-md p-4 animate-pulse"
>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg bg-gray-200" />
<div className="flex-1 space-y-2">
<div className="w-3/4 h-4 bg-gray-200 rounded" />
<div className="w-1/2 h-3 bg-gray-200 rounded" />
</div>
<div className="w-24 h-10 bg-gray-200 rounded-lg" />
</div>
</div>
))}
</div>
);
return ( return (
<div className="min-h-screen bg-gray-50 p-4 " dir="rtl"> <div className="min-h-screen bg-gray-50 p-4" dir="rtl">
<div className="max-w-7xl mx-auto"> <div className="max-w-3xl mx-auto">
{/* Header */} <header className="mb-6">
<div className="mb-6"> <h1 className="text-3xl font-bold text-slate-800 text-right mb-2">
<h1 className="text-4xl font-bold text-slate-800 text-right mb-6">
کارزارها کارزارها
</h1> </h1>
<p className="text-slate-600 text-right"> <p className="text-slate-600 text-right">
برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید
</p> </p>
</div> </header>
{/* Top Bar: Search and Create Button */} <div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="relative flex-1">
{/* Search Box */} <CustomInput
<div className="flex-1 sm:max-w-md"> type="text"
<div className="relative"> placeholder="جستجوی کارزار..."
<CustomInput value={searchQuery}
type="text" onChange={handleSearchChange}
placeholder="جستجوی کارزار..." className="pr-10 w-full"
value={searchQuery} />
onChange={handleSearchChange} <Search
className="pr-10" size={20}
/> className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
<Search />
size={20}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
</div>
</div> </div>
{/* Create Campaign Button */}
<CustomButton <CustomButton
variant="primary" variant="primary"
onClick={() => setIsCreateModalOpen(true)} onClick={() => setIsCreateModalOpen(true)}
className="flex items-center gap-2" className="flex items-center justify-center gap-2"
> >
<Plus size={20} /> <Plus size={20} />
ایجاد کارزار <span>ایجاد کارزار</span>
</CustomButton> </CustomButton>
</div> </div>
{/* Tabs */} <div className="mb-6">
<div className="mb-6 flex gap-2 overflow-x-auto pb-2 border-b border-gray-200"> <div className="flex gap-2 overflow-x-auto pb-2 border-b border-gray-200">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.value} key={tab.value}
onClick={() => handleTabChange(tab.value)} onClick={() => handleTabChange(tab.value)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-lg transition-all ${ className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-t-lg transition-colors ${
activeTab === tab.value activeTab === tab.value
? "bg-blue-500 text-white border-b-2 border-blue-600" ? "bg-white border-gray-200 border-t border-x text-blue-600"
: "text-slate-600 hover:text-slate-800 hover:bg-gray-200" : "text-slate-600 hover:text-slate-800 hover:bg-gray-100"
}`} }`}
> >
{tab.label} {tab.label}
</button> </button>
))} ))}
</div>
</div> </div>
{/* Loading State */} <main>
{isLoading && ( {isLoading ? (
<div className="flex items-center justify-center py-12"> renderSkeleton()
<Loader size={40} className="text-blue-500 animate-spin" /> ) : currentCampaign.length > 0 ? (
</div> <ul className="space-y-4">
)} {currentCampaign.map((campaign) => (
<li
key={`${campaign.user_id}-${campaign.WorkflowID}`}
className="bg-white rounded-xl shadow-sm overflow-hidden transition-shadow hover:shadow-lg"
>
<div className="p-4 flex items-center gap-4">
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-100 shrink-0">
{campaign.image ? (
<img
src={getContactImageUrl(
campaign.ValueP1226S1951StageID
)}
alt={campaign.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center text-blue-500">
<Users size={24} />
</div>
)}
</div>
{/* Campaigns Grid */} <div className="flex-1 text-right">
{!isLoading && campaigns.length > 0 && ( <h3 className="text-lg font-semibold text-slate-800">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-h-96 overflow-y-auto"> {campaign.title}
{currentCampaign.length > 0 ? ( </h3>
currentCampaign.map((campaign) => (
<CampaignCard
key={`${campaign.WorkflowID}-${campaign.title}`}
campaign={campaign}
/>
))
) : (
<div className="text-gray-500 mx-auto mt-20">
کارزاری یافت نشد
</div>
)}
</div>
)}
{/* Empty State */} <div className="flex flex-row gap-0.5">
{!isLoading && campaigns.length === 0 && ( <span className="text-sm text-gray-500 ">
<div className="flex flex-col items-center justify-center py-12"> ایجاد کننده :
<p className="text-slate-600 text-lg mb-4"> </span>
{activeTab === "my" <p className="text-sm text-gray-500 truncate">
? "هنوز کارزاری ایجاد نکرده‌اید" {campaign.user_id_nickname}
: "کارزاری یافت نشد"} </p>
</p> </div>
{activeTab === "my" && ( <p className="text-sm text-gray-500 mt-1">
<CustomButton {Number(campaign.signature_count)} عضو
variant="primary" </p>
onClick={() => setIsCreateModalOpen(true)} </div>
>
ایجاد اولین کارزار خود <div className="flex flex-col gap-2">
</CustomButton> {activeTab === "منتخب" && (
)} <CustomButton
</div> onClick={() => handleJoin(campaign)}
)} variant="info"
className="shrink-0"
>
انتخاب{" "}
</CustomButton>
)}
<CustomButton
onClick={() => showCampaing(campaign.WorkflowID)}
variant="info"
className="shrink-0"
>
جزییات
</CustomButton>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="text-center py-12">
<p className="text-slate-600 text-lg">
{searchQuery
? "کارزاری با این مشخصات یافت نشد."
: "کارزاری در این دسته وجود ندارد."}
</p>
</div>
)}
</main>
</div> </div>
{/* Create Campaign Modal */}
<CreateCampaignModal <CreateCampaignModal
isOpen={isCreateModalOpen} isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)} onClose={() => setIsCreateModalOpen(false)}

View File

@ -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<User[]>([]);
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 (
<div className="w-full max-w-md mx-auto p-4" style={{ direction: "rtl" }}>
<h1 className="text-xl font-bold mb-4 text-right">ایجاد محفل جدید</h1>
<div className="space-y-4">
<CustomInput
label="نام محفل"
placeholder="نام محفل را وارد کنید"
value={circleName}
onChange={(e) => {
setCircleName(e.target.value);
if (errors.circleName) {
setErrors((prev) => ({ ...prev, circleName: undefined }));
}
}}
error={errors.circleName}
/>
<div>
<BaseDropdown
label="افزودن عضو"
placeholder="جستجوی لقب عضو..."
options={
users?.map((user) => ({
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}
/>
<div className="mt-2 text-xs text-gray-500 text-right">
برای جستجو، شروع به تایپ کنید.
</div>
</div>
{selectedMembers.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-2 text-right">
اعضای انتخاب شده
</h2>
<ul className="space-y-2">
{selectedMembers.map((member) => (
<li
key={member.user_id}
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
>
<span>{member.nickname}</span>
<button
onClick={() => handleRemoveMember(member.user_id)}
className="text-red-500 hover:text-red-700"
>
<X size={18} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
<div className="mt-6">
<CustomButton
onClick={handleSubmit}
className="w-full"
disabled={isCreating}
>
تایید و ایجاد محفل
</CustomButton>
</div>
</div>
);
};
export default JoinToCampaing;

View File

@ -0,0 +1,4 @@
export type User = {
user_id: string;
nickname: string;
};

View File

@ -1,4 +1,5 @@
export interface RegistrationFormData { export interface RegistrationFormData {
ValueP1224S1943StageID?: Number;
username?: string; username?: string;
WorkflowID?: string; WorkflowID?: string;
name: string; name: string;

View File

@ -3,4 +3,6 @@ export const DASHBOARD_ROUTE = {
dashboard: "main", dashboard: "main",
profile: "profile", profile: "profile",
campaigns: "campaigns", campaigns: "campaigns",
campaing: "campaing",
joinToCampaing: "join-to-campaing",
}; };

View File

@ -2,6 +2,7 @@ import type { AppRoute } from "@core/types/router.type";
import { DashboardLayout } from "../layouts"; import { DashboardLayout } from "../layouts";
import CampaignsPage from "../pages/campaigns"; import CampaignsPage from "../pages/campaigns";
import CampaignDetailPage from "../pages/campaigns/detail"; import CampaignDetailPage from "../pages/campaigns/detail";
import JoinToCampaing from "../pages/join-to-campaing";
import DashboardPage from "../pages/main-page"; import DashboardPage from "../pages/main-page";
import ProfilePage from "../pages/profile"; import ProfilePage from "../pages/profile";
import { DASHBOARD_ROUTE } from "./route.constant"; import { DASHBOARD_ROUTE } from "./route.constant";
@ -24,9 +25,13 @@ export const dashboardRoutes: AppRoute[] = [
element: <CampaignsPage />, element: <CampaignsPage />,
}, },
{ {
path: `${DASHBOARD_ROUTE.campaigns}/:id`, path: `${DASHBOARD_ROUTE.campaing}/:id`,
element: <CampaignDetailPage />, element: <CampaignDetailPage />,
}, },
{
path: `${DASHBOARD_ROUTE.joinToCampaing}/:id`,
element: <JoinToCampaing />,
},
], ],
}, },
]; ];

View File

@ -8,6 +8,7 @@ import type {
CreateCampaignData, CreateCampaignData,
SignatureItem, SignatureItem,
} from "@modules/dashboard/pages/campaigns/campaigns.type"; } 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 to from "await-to-js";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@ -57,7 +58,7 @@ export const getSignsCampaignService = async (
): Promise<SignatureItem[]> => { ): Promise<SignatureItem[]> => {
const query = { const query = {
ProcessName: "signature", ProcessName: "signature",
OutputFields: ["user_stage_id", "user_id.nickname"], OutputFields: ["user_stage_id", "user_id.nickname", "user_id.username"],
conditions: [["campaign", "=", campaignId]], conditions: [["campaign", "=", campaignId]],
}; };
const [err, res] = await to(api.post(API_ADDRESS.select, query)); const [err, res] = await to(api.post(API_ADDRESS.select, query));
@ -150,7 +151,6 @@ export const getSelectedCampaignsService = async (
["status", "!=", "غیر فعال"], ["status", "!=", "غیر فعال"],
], ],
}; };
const [err, res] = await to(api.post(API_ADDRESS.select, query)); const [err, res] = await to(api.post(API_ADDRESS.select, query));
if (err) { if (err) {
throw err; throw err;
@ -178,7 +178,6 @@ export const createCampaignService = async (
}); });
} }
const body = { const body = {
ProcessName: "campaign",
campaign: { campaign: {
title: data.title, title: data.title,
description: data.description, description: data.description,
@ -187,7 +186,7 @@ export const createCampaignService = async (
status: "فعال", 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) { if (err) {
throw err; throw err;
} }
@ -205,13 +204,13 @@ export const signCampaignService = async (
): Promise<void> => { ): Promise<void> => {
const user = userInfoService.getUserInfo(); const user = userInfoService.getUserInfo();
const body = { const body = {
ProcessName: "signature",
signature: { signature: {
campaign: campaignId, 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) { if (err) {
throw err; throw err;
} }
@ -265,3 +264,62 @@ export const removeCommentService = async (
throw new Error("خطا در حذف نظر"); throw new Error("خطا در حذف نظر");
} }
}; };
export const searchUsersService = async (nickname: string): Promise<User[]> => {
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<any> => {
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;
};