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:
parent
024b268000
commit
d725c1b7d7
5
.env
5
.env
|
|
@ -1 +1,4 @@
|
|||
VITE_API_URL=https://yarigaran-back.pelekan.org
|
||||
VITE_API_URL=https://yarigaran-back.pelekan.org
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,30 @@
|
|||
// components/ui/BaseDropdown.tsx
|
||||
import { cn } from "@/core/lib/utils";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type SelectHTMLAttributes, forwardRef } from "react";
|
||||
import { ChevronDown, Search } from "lucide-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;
|
||||
error?: string;
|
||||
variant?: "primary" | "error";
|
||||
options: { value: string; label: string }[];
|
||||
options: Option[];
|
||||
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,
|
||||
|
|
@ -20,51 +33,124 @@ export const BaseDropdown = forwardRef<HTMLSelectElement, BaseDropdownProps>(
|
|||
options,
|
||||
placeholder = "انتخاب کنید",
|
||||
className,
|
||||
...props
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
onInputChange,
|
||||
},
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full" ref={ref}>
|
||||
{label && (
|
||||
<label className="mb-2 block text-sm font-medium text-foreground text-right">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
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",
|
||||
variant === "error" || hasError
|
||||
? "border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500/20"
|
||||
: "border-gray-300 focus-visible:border-blue-600 focus-visible:ring-blue-600/20",
|
||||
props.disabled && "opacity-60 cursor-not-allowed bg-gray-50",
|
||||
disabled && "opacity-60 cursor-not-allowed bg-gray-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* آیکون پایین */}
|
||||
<div className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2">
|
||||
<span className={selectedOption ? "text-black" : "text-gray-400"}>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-5 w-5 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
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>
|
||||
|
||||
{hasError && (
|
||||
|
|
|
|||
|
|
@ -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: <User size={24} />,
|
||||
path: `${DASHBOARD_ROUTE.sub}/${DASHBOARD_ROUTE.profile}`,
|
||||
},
|
||||
{
|
||||
id: "group-chat",
|
||||
label: "گروه",
|
||||
disabled: true,
|
||||
icon: <MessageCircle size={24} />,
|
||||
path: "#",
|
||||
},
|
||||
{
|
||||
id: "ranking",
|
||||
label: "رتبهبندی",
|
||||
|
|
@ -39,6 +25,13 @@ const DEFAULT_NAV_ITEMS: NavItem[] = [
|
|||
icon: <BarChart3 size={24} />,
|
||||
path: "#",
|
||||
},
|
||||
{
|
||||
id: "chat-group",
|
||||
label: "چت گروهی",
|
||||
disabled: true,
|
||||
icon: <Bot size={24} />,
|
||||
path: "#",
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "کارزار",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<main className="flex-1 overflow-y-auto pb-14">
|
||||
<DashboardHeader
|
||||
profileImageUrl={""}
|
||||
profileImageUrl={
|
||||
user.ValueP1224S1943StageID
|
||||
? getContactImageUrl(user.ValueP1224S1943StageID!)
|
||||
: ""
|
||||
}
|
||||
fullName={`${user.name || "کاربر جدید"} ${user.family || ""}`}
|
||||
coins={100}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean>(false);
|
||||
const [currentComments, setCurrentComments] = useState<CommentsItem[]>([]);
|
||||
|
||||
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() {
|
|||
>
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden bg-gradient-to-br from-blue-400 to-blue-600">
|
||||
<img
|
||||
src={getContactImageUrl(signer.ValueP1227S1955StageID)}
|
||||
src={getContactImageUrl(Number(signer.user_stage_id))}
|
||||
alt={`${signer.user_id_nickname}-avatar`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<CampaignTab>("فعال");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [currentCampaign, setCurrentCampaign] = useState<Array<Campaign>>([]);
|
||||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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 = () => (
|
||||
<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 (
|
||||
<div className="min-h-screen bg-gray-50 p-4 " dir="rtl">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-4xl font-bold text-slate-800 text-right mb-6">
|
||||
<div className="min-h-screen bg-gray-50 p-4" dir="rtl">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-slate-800 text-right mb-2">
|
||||
کارزارها
|
||||
</h1>
|
||||
<p className="text-slate-600 text-right">
|
||||
برای تغییر جهان، کارزار ایجاد کنید و دیگران را دعوت کنید
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Top Bar: Search and Create Button */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Search Box */}
|
||||
<div className="flex-1 sm:max-w-md">
|
||||
<div className="relative">
|
||||
<CustomInput
|
||||
type="text"
|
||||
placeholder="جستجوی کارزار..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1">
|
||||
<CustomInput
|
||||
type="text"
|
||||
placeholder="جستجوی کارزار..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pr-10 w-full"
|
||||
/>
|
||||
<Search
|
||||
size={20}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Campaign Button */}
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
ایجاد کارزار
|
||||
<span>ایجاد کارزار</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex gap-2 overflow-x-auto pb-2 border-b border-gray-200">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-lg transition-all ${
|
||||
activeTab === tab.value
|
||||
? "bg-blue-500 text-white border-b-2 border-blue-600"
|
||||
: "text-slate-600 hover:text-slate-800 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 border-b border-gray-200">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
className={`px-4 py-2 text-sm font-medium whitespace-nowrap rounded-t-lg transition-colors ${
|
||||
activeTab === tab.value
|
||||
? "bg-white border-gray-200 border-t border-x text-blue-600"
|
||||
: "text-slate-600 hover:text-slate-800 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader size={40} className="text-blue-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<main>
|
||||
{isLoading ? (
|
||||
renderSkeleton()
|
||||
) : currentCampaign.length > 0 ? (
|
||||
<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 */}
|
||||
{!isLoading && campaigns.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-h-96 overflow-y-auto">
|
||||
{currentCampaign.length > 0 ? (
|
||||
currentCampaign.map((campaign) => (
|
||||
<CampaignCard
|
||||
key={`${campaign.WorkflowID}-${campaign.title}`}
|
||||
campaign={campaign}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-500 mx-auto mt-20">
|
||||
کارزاری یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-right">
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
{campaign.title}
|
||||
</h3>
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && campaigns.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-slate-600 text-lg mb-4">
|
||||
{activeTab === "my"
|
||||
? "هنوز کارزاری ایجاد نکردهاید"
|
||||
: "کارزاری یافت نشد"}
|
||||
</p>
|
||||
{activeTab === "my" && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
ایجاد اولین کارزار خود
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row gap-0.5">
|
||||
<span className="text-sm text-gray-500 ">
|
||||
ایجاد کننده :
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{campaign.user_id_nickname}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{Number(campaign.signature_count)} عضو
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeTab === "منتخب" && (
|
||||
<CustomButton
|
||||
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>
|
||||
|
||||
{/* Create Campaign Modal */}
|
||||
<CreateCampaignModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
|
|
|
|||
150
src/modules/dashboard/pages/join-to-campaing/index.tsx
Normal file
150
src/modules/dashboard/pages/join-to-campaing/index.tsx
Normal 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;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export type User = {
|
||||
user_id: string;
|
||||
nickname: string;
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export interface RegistrationFormData {
|
||||
ValueP1224S1943StageID?: Number;
|
||||
username?: string;
|
||||
WorkflowID?: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ export const DASHBOARD_ROUTE = {
|
|||
dashboard: "main",
|
||||
profile: "profile",
|
||||
campaigns: "campaigns",
|
||||
campaing: "campaing",
|
||||
joinToCampaing: "join-to-campaing",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: <CampaignsPage />,
|
||||
},
|
||||
{
|
||||
path: `${DASHBOARD_ROUTE.campaigns}/:id`,
|
||||
path: `${DASHBOARD_ROUTE.campaing}/:id`,
|
||||
element: <CampaignDetailPage />,
|
||||
},
|
||||
{
|
||||
path: `${DASHBOARD_ROUTE.joinToCampaing}/:id`,
|
||||
element: <JoinToCampaing />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<SignatureItem[]> => {
|
||||
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<void> => {
|
||||
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<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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user