refactor(all): migrate from React to Next.js
This commit is contained in:
384
src/views/AboutUs.tsx
Normal file
384
src/views/AboutUs.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { Link } from '@/lib/router';
|
||||
|
||||
// ------ دادههای قابل تنظیم ------
|
||||
const SITE_URL = 'https://east-guilan-ce.ir';
|
||||
const SITE_NAME_FA = 'انجمن علمی کامپیوتر شرق گیلان';
|
||||
const ABOUT_CANONICAL = `${SITE_URL}/about`;
|
||||
const ABOUT_TITLE = `درباره ما | ${SITE_NAME_FA}`;
|
||||
const ABOUT_DESCRIPTION =
|
||||
'آشنایی با تاریخچه، مأموریتها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راههای مشارکت دانشجویان در برنامههای انجمن.';
|
||||
const ABOUT_KEYWORDS =
|
||||
'انجمن علمی کامپیوتر شرق گیلان, انجمن علمی مهندسی کامپیوتر دانشگاه گیلان، انجمن علمی علوم کامپیوتر، انجمن علمی شرق گیلان، انجمن علمی مهندسی کامپیوتر شرق گیلان، دانشکده فنی و مهندسی شرق گیلان، انجمن علمی کامپیوتر, فعالیتهای دانشجویی, انجمنهای علمی ایران, رویدادهای فناوری، برنامه نویسی، انجمن علمی دانشجویی، دانشگاه گیلان، فنی شرق';
|
||||
|
||||
const ABOUT_STRUCTURED_DATA = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AboutPage',
|
||||
name: ABOUT_TITLE,
|
||||
description: ABOUT_DESCRIPTION,
|
||||
url: ABOUT_CANONICAL,
|
||||
mainEntity: {
|
||||
'@type': 'Organization',
|
||||
name: SITE_NAME_FA,
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/favicon.ico`,
|
||||
sameAs: [
|
||||
'https://instagram.com/guilance.ir',
|
||||
'https://t.me/guilance',
|
||||
'https://t.me/guilancea'
|
||||
],
|
||||
areaServed: 'IR',
|
||||
contactPoint: [
|
||||
{
|
||||
'@type': 'ContactPoint',
|
||||
contactType: 'customer support',
|
||||
email: 'eastguilanceassociation@gmail.com',
|
||||
availableLanguage: ['fa'],
|
||||
areaServed: 'IR'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const ORG = {
|
||||
title: 'انجمنهای علمی کامپیوتر گیلان', // تیتر کلی
|
||||
subtitle:
|
||||
'پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاهها و نشستهای تخصصی — با محوریت رشد مهارتهای فنی و مسیرهای شغلی.',
|
||||
foundedYear: '۱۴۰۴',
|
||||
membersApprox: '۱۰۰+',
|
||||
eventsCount: '۱۰+',
|
||||
volunteersCount: '۲۰+',
|
||||
};
|
||||
|
||||
// اگر چند انجمن زیرمجموعه دارید، اینجا معرفی کنید
|
||||
const ASSOCIATIONS = [
|
||||
{
|
||||
name: 'انجمن علمی مهندسی کامپیوتر دانشکدهی فنی شرق',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رودسر',
|
||||
foundedYear: '۱۳۹۲',
|
||||
about:
|
||||
'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['مهندسی نرمافزار', 'هوش مصنوعی و داده', 'طراحی محصول', 'DevOps/امنیت'],
|
||||
links: {
|
||||
website: 'https://east-guilan-ce.ir',
|
||||
instagram: '"https://instagram.com/guilance.ir',
|
||||
telegram: 'https://t.me/guilance',
|
||||
email: 'eastguilanceassociation@gmail.com',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'انجمن علمی مهندسی کامپیوتر دانشکدهی فنی',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رشت',
|
||||
foundedYear: '۱۳۵۳',
|
||||
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['مهندسی نرمافزار', 'هوش مصنوعی و داده', 'DevOps'],
|
||||
links: {
|
||||
instagram: 'https://instagram.com/ce.guilan',
|
||||
telegram: 'https://t.me/CSAOEF',
|
||||
email: 'cesa@guilan.ac.ir'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'انجمن علمی علوم کامپیوتر دانشکدهی علومپایه',
|
||||
university: 'دانشگاه گیلان',
|
||||
city: 'رشت',
|
||||
foundedYear: '۱۳۵۳',
|
||||
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصتهای شغلی و پژوهشی.',
|
||||
focusAreas: ['امنیت', 'سیستمعامل', 'سختافزار'],
|
||||
links: {
|
||||
instagram: 'https://instagram.com/csguilan',
|
||||
telegram: 'https://t.me/guilanCS',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// کمیتهها/گروههای کاری
|
||||
const COMMITTEES = [
|
||||
{
|
||||
title: 'کمیته آموزش',
|
||||
desc: 'برنامهریزی و اجرای دورهها و کارگاههای مهارتی، هماهنگی با مدرسین و طراحی مسیرهای یادگیری.'
|
||||
},
|
||||
{
|
||||
title: 'کمیته صنعت و اشتغال',
|
||||
desc: 'تعامل با شرکتها، دعوت از متخصصان صنعت، و شبکهسازی برای فرصتهای کارآموزی و استخدام.'
|
||||
},
|
||||
{
|
||||
title: 'کمیته محتوای دیجیتال',
|
||||
desc: 'تولید محتوای آموزشی، خبرنامه، مدیریت شبکههای اجتماعی و پوشش رسانهای رویدادها.'
|
||||
},
|
||||
];
|
||||
|
||||
// راههای ارتباطی اصلی صفحه
|
||||
const CONTACTS = [
|
||||
{
|
||||
label: 'ایمیل',
|
||||
value: 'eastguilanceassociation@gmail.com',
|
||||
href: 'mailto:eastguilanceassociation@gmail.com',
|
||||
},
|
||||
{
|
||||
label: 'تلگرام',
|
||||
value: '@GuilanCEA',
|
||||
href: 'https://t.me/guilancea',
|
||||
},
|
||||
{
|
||||
label: 'اینستاگرام',
|
||||
value: '@GuilanCE.ir',
|
||||
href: 'https://instagram.com/guilance.ir',
|
||||
},
|
||||
{
|
||||
label: 'وبسایت',
|
||||
value: 'east-guilan-ce.ir',
|
||||
href: 'https://east-guilan-ce.ir',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AboutUs() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{ABOUT_TITLE}</title>
|
||||
<meta name="description" content={ABOUT_DESCRIPTION} />
|
||||
<meta name="keywords" content={ABOUT_KEYWORDS} />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href={ABOUT_CANONICAL} />
|
||||
<meta property="og:title" content={ABOUT_TITLE} />
|
||||
<meta property="og:description" content={ABOUT_DESCRIPTION} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={ABOUT_CANONICAL} />
|
||||
<meta property="og:site_name" content={SITE_NAME_FA} />
|
||||
<meta property="og:image" content={`${SITE_URL}/favicon.ico`} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={ABOUT_TITLE} />
|
||||
<meta name="twitter:description" content={ABOUT_DESCRIPTION} />
|
||||
<meta name="twitter:image" content={`${SITE_URL}/favicon.ico`} />
|
||||
<script type="application/ld+json">{JSON.stringify(ABOUT_STRUCTURED_DATA)}</script>
|
||||
</Helmet>
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-b from-muted/40 to-transparent">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight text-foreground">{ORG.title}</h1>
|
||||
<p className="mt-3 text-muted-foreground leading-7">{ORG.subtitle}</p>
|
||||
|
||||
{/* آمار کوتاه */}
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Stat label="سال تأسیس" value={ORG.foundedYear} />
|
||||
<Stat label="اعضا" value={ORG.membersApprox} />
|
||||
<Stat label="رویدادها" value={ORG.eventsCount} />
|
||||
<Stat label="داوطلبان" value={ORG.volunteersCount} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* درباره ما */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="md:col-span-2">
|
||||
<Card>
|
||||
<CardHeader title="ماموریت ما" subtitle="چرا اینجا هستیم؟" />
|
||||
<div className="p-6 pt-0 text-sm leading-7 text-muted-foreground">
|
||||
<p>
|
||||
توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت، ایجاد شبکههای حرفهای و فراهمکردن مسیرهای یادگیری
|
||||
پروژهمحور؛ از معرفی نقشها و مسیرهای شغلی تا تمرین مهارتهای نرم و فنی.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="ارزشهای ما" subtitle="چه چیزهایی برایمان مهم است؟" />
|
||||
<ul className="p-6 pt-0 grid grid-cols-1 gap-3 text-sm text-muted-foreground md:grid-cols-2">
|
||||
<li className="rounded-xl border p-3">یادگیری پیوسته و اشتراک دانش</li>
|
||||
<li className="rounded-xl border p-3">کیفیت، شفافیت و مسئولیتپذیری</li>
|
||||
<li className="rounded-xl border p-3">فرصت برابر برای مشارکت</li>
|
||||
<li className="rounded-xl border p-3">ارتباط مؤثر با صنعت و جامعه</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* تماس سریع */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader title="راههای ارتباطی" subtitle="با ما در ارتباط باشید" />
|
||||
<div className="p-6 pt-0 space-y-3">
|
||||
{CONTACTS.map((c) => (
|
||||
<a
|
||||
key={c.label}
|
||||
href={c.href}
|
||||
target={c.href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={c.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="flex items-center justify-between rounded-xl border p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm text-foreground">{c.label}</span>
|
||||
<span className="text-xs text-muted-foreground ltr:ml-2 rtl:mr-2 truncate">{c.value}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="ساعات پاسخگویی" subtitle="پشتیبانی داوطلبانه" />
|
||||
<div className="p-6 pt-0 text-sm text-muted-foreground">
|
||||
<p>روزهای شنبه تا چهارشنبه، ساعت ۱۰ تا ۱۸</p>
|
||||
<p className="mt-2">پاسخها توسط تیم داوطلبان انجام میشود.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* انجمنها */}
|
||||
<section className="bg-muted/20">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">انجمنها</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">اطلاعات انجمنهای زیرمجموعه/همکار</p>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
{ASSOCIATIONS.map((a) => (
|
||||
<Card key={a.name}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{a.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{a.university}{a.city ? ` • ${a.city}` : ''}{' '}
|
||||
{a.foundedYear && `• تأسیس: ${a.foundedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-7 text-muted-foreground">{a.about}</p>
|
||||
|
||||
{a.focusAreas?.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{a.focusAreas.map((f) => (
|
||||
<span key={f} className="rounded-full border px-3 py-1 text-xs text-muted-foreground">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{a.links?.website && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.website} target="_blank" rel="noreferrer">وبسایت</a>
|
||||
)}
|
||||
{a.links?.instagram && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.instagram} target="_blank" rel="noreferrer">اینستاگرام</a>
|
||||
)}
|
||||
{a.links?.telegram && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.telegram} target="_blank" rel="noreferrer">تلگرام</a>
|
||||
)}
|
||||
{a.links?.email && (
|
||||
<a className="rounded-xl border p-3 hover:bg-muted/50" href={`mailto:${a.links.email}`}>ایمیل</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* کمیتهها */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">کمیتهها و گروههای کاری</h2>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
{COMMITTEES.map((c) => (
|
||||
<Card key={c.title}>
|
||||
<div className="p-5">
|
||||
<h3 className="text-base font-semibold text-foreground">{c.title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-7">{c.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* پرسشهای متداول */}
|
||||
<section className="bg-muted/20">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">پرسشهای متداول</h2>
|
||||
<div className="mt-6 space-y-3">
|
||||
<FAQ q="چطور در رویدادها شرکت کنم؟" a="برای ثبتنام در رویدادها ابتدا باید حسابکاربری با کددانشجویی خود ایجاد کنید و سپس از طریق صفحهی ثبتنام در رویداد موردنظرتان شرکت کنید :)" />
|
||||
<FAQ q="آیا امکان همکاری انجمنها/شرکتها هم وجود دارد؟" a="بله، ما همیشه آمادهی همکاری با سازمانها و انجمنهای مختلف دانشگاهها جهت توانمندسازی دانشجویان با مهارتهای موردنیاز صنعت و ایجاد شبکههای حرفهای هستیم." />
|
||||
<FAQ q="برای سخنرانی/منتورینگ به چه چیزهایی نیاز است؟" a="رزومه کوتاه، موضوع پیشنهادی و زمانهای دسترسپذیرتان را ارسال کنید تا هماهنگ کنیم." />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* نقشه و آدرس اختیاری */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-10">
|
||||
<h2 className="text-2xl font-bold text-foreground">نشانی دانشکده</h2>
|
||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<div className="p-5 text-sm text-muted-foreground leading-7">
|
||||
<p>آدرس: گیلان، رودسر، واجارگاه، دانشکدهی فنی و مهندسی شرق گیلان</p>
|
||||
<p>کدپستی: ۴۴۹۱۸۹۸۵۶۶</p>
|
||||
<p>تلفن: ۰۱۳-۴۲۶۸۸۴۴۷</p>
|
||||
<p className="mt-3">برای هماهنگی حضوری از قبل پیام بدهید.</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="rounded-xl border overflow-hidden min-h-[280px] bg-muted/40 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<iframe
|
||||
src="https://maps.google.com/maps?q=37.06285,50.42324&hl=fa&z=16&output=embed"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="w-full aspect-video border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="bg-gradient-to-t from-muted/40 to-transparent">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-12 text-center">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-foreground">مایل به همکاری هستید؟</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">برای مشارکت داوطلبانه، سخنرانی، اسپانسری یا همکاری صنعتی به ما پیام دهید.</p>
|
||||
<div className="mt-4 inline-flex gap-3">
|
||||
<a href="mailto:eastguilanceassociation@gmail.com" className="rounded-xl border px-5 py-2 text-sm hover:bg-muted/50">ایجاد ارتباط</a>
|
||||
<a href="https://t.me/guilancea" target="_blank" rel="noreferrer" className="rounded-xl bg-primary px-5 py-2 text-sm text-primary-foreground">
|
||||
پیام در تلگرام
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --------- اجزای کوچک ---------
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-4 text-center">
|
||||
<div className="text-2xl font-extrabold text-foreground">{value}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className = '' }: React.PropsWithChildren<{ className?: string }>) {
|
||||
return <div className={`rounded-2xl border bg-card text-card-foreground shadow-sm ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<div className="p-6 pb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FAQ({ q, a }: { q: string; a: string }) {
|
||||
return (
|
||||
<details className="rounded-xl border bg-background p-4">
|
||||
<summary className="cursor-pointer select-none text-sm font-medium text-foreground">{q}</summary>
|
||||
<p className="mt-2 text-sm leading-7 text-muted-foreground">{a}</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
216
src/views/AdminEventDetail.tsx
Normal file
216
src/views/AdminEventDetail.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, Link, Navigate } from '@/lib/router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { formatJalali, formatToman, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const registrationStatusOptions = [
|
||||
{ value: 'confirmed', label: 'تایید شده' },
|
||||
{ value: 'pending', label: 'در انتظار' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'attended', label: 'حضور یافته' },
|
||||
] as const;
|
||||
const REGISTRATIONS_PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminEventDetail() {
|
||||
const { id } = useParams();
|
||||
const { toast } = useToast();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [statusFilter, setStatusFilter] = React.useState<typeof registrationStatusOptions[number]['value'] | 'all'>('all');
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [regPage, setRegPage] = React.useState(1);
|
||||
|
||||
const eventId = Number(id);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin', 'event-detail', eventId],
|
||||
queryFn: () => api.getEventAdminDetail(eventId),
|
||||
enabled: Number.isFinite(eventId),
|
||||
});
|
||||
|
||||
const registrationsQuery = useQuery({
|
||||
queryKey: ['admin', 'event', eventId, 'registrations', statusFilter, search, regPage],
|
||||
enabled: Number.isFinite(eventId),
|
||||
queryFn: () =>
|
||||
api.listEventRegistrationsAdmin(eventId, {
|
||||
statuses:
|
||||
statusFilter === 'all'
|
||||
? registrationStatusOptions.map((s) => s.value)
|
||||
: [statusFilter],
|
||||
search: search || undefined,
|
||||
limit: REGISTRATIONS_PAGE_SIZE,
|
||||
offset: (regPage - 1) * REGISTRATIONS_PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.error) {
|
||||
toast({ title: 'خطا در دریافت جزئیات رویداد', description: resolveErrorMessage(detailQuery.error), variant: 'destructive' });
|
||||
}
|
||||
}, [detailQuery.error, toast]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (registrationsQuery.error) {
|
||||
toast({ title: 'خطا در ثبتنامها', description: resolveErrorMessage(registrationsQuery.error), variant: 'destructive' });
|
||||
}
|
||||
}, [registrationsQuery.error, toast]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">در حال بارگذاری...</div>;
|
||||
}
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
if (!Number.isFinite(eventId)) {
|
||||
return <div className="min-h-screen flex items-center justify-center" dir="rtl">شناسه رویداد معتبر نیست.</div>;
|
||||
}
|
||||
|
||||
const event = detailQuery.data;
|
||||
const paged = registrationsQuery.data;
|
||||
const registrationPageCount = paged ? Math.max(1, Math.ceil(paged.count / REGISTRATIONS_PAGE_SIZE)) : 1;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{event?.title ?? 'جزئیات رویداد'}</h1>
|
||||
{event && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<Badge variant="secondary">{event.status_label ?? event.status}</Badge>
|
||||
{event.start_time ? <span>شروع: {formatJalali(event.start_time)}</span> : null}
|
||||
{event.event_type ? <span>نوع: {event.event_type_label ?? event.event_type}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/admin/events/${eventId}/edit`}>ویرایش پیشرفته</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/events">بازگشت</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>وضعیت</CardTitle>
|
||||
<CardDescription>اطلاعات پایه رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<div>ظرفیت: {event.capacity ?? 'نامحدود'}</div>
|
||||
<div>ثبتنامها: {toPersianDigits(event.registration_count ?? 0)}</div>
|
||||
<div>قیمت: {formatToman(event.price)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>توضیحات</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground leading-6">
|
||||
{event.description || 'توضیحی ثبت نشده است.'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ثبتنامها و پرداختها</CardTitle>
|
||||
<CardDescription>لیست ثبتنامهای مرتبط با این رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value as typeof statusFilter); setRegPage(1); }}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="وضعیت">وضعیت: {statusFilter === 'all' ? 'همه' : statusFilter}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{registrationStatusOptions.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
className="md:w-64"
|
||||
placeholder="جستجو نام/ایمیل/نامکاربری"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setRegPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{registrationsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری ثبتنامها...</p>
|
||||
) : !paged || paged.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">ثبتنامی یافت نشد.</p>
|
||||
) : (
|
||||
<ScrollArea className="rounded-md border max-h-[70vh]">
|
||||
<div className="divide-y">
|
||||
{paged.results.map((registration) => (
|
||||
<div key={registration.id} className="p-4">
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{registration.user.first_name} {registration.user.last_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{registration.user.email}</div>
|
||||
</div>
|
||||
<Badge variant={registration.status === 'confirmed' ? 'default' : 'outline'}>
|
||||
{registration.status_label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>نامکاربری: {registration.user.username}</div>
|
||||
<div>کد بلیت: {registration.ticket_id}</div>
|
||||
<div>تاریخ ثبتنام: {formatJalali(registration.registered_at)}</div>
|
||||
<div>مبلغ پرداختی: {formatToman(registration.final_price ?? 0)}</div>
|
||||
<div>تخفیف: {formatToman(registration.discount_amount ?? 0)}</div>
|
||||
</div>
|
||||
{registration.payments.length > 0 && (
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="font-medium">پرداختها</div>
|
||||
{registration.payments.map((payment) => (
|
||||
<div key={payment.id} className="flex flex-wrap items-center justify-between gap-2 rounded border px-2 py-1">
|
||||
<span className="text-muted-foreground">{payment.status_label}</span>
|
||||
<span>{formatToman(payment.amount)}</span>
|
||||
<span className="text-muted-foreground text-[11px]">Ref: {payment.ref_id ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {toPersianDigits(regPage)} از {toPersianDigits(registrationPageCount)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={regPage <= 1} onClick={() => setRegPage((p) => Math.max(1, p - 1))}>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={regPage >= registrationPageCount} onClick={() => setRegPage((p) => p + 1)}>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/views/AdminEventEdit.tsx
Normal file
284
src/views/AdminEventEdit.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate, useParams, Navigate } from '@/lib/router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/lib/api';
|
||||
import type { EventAdminDetailSchema, EventUpdateSchema } from '@/lib/types';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: 'پیشنویس' },
|
||||
{ value: 'published', label: 'منتشر شده' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'completed', label: 'برگزار شده' },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'online', label: 'آنلاین' },
|
||||
{ value: 'on_site', label: 'حضوری' },
|
||||
{ value: 'hybrid', label: 'ترکیبی' },
|
||||
];
|
||||
|
||||
const toInputDateTime = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear().toString().padStart(4, '0')}-${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}T${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export default function AdminEventEdit() {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const eventId = Number(id);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin', 'edit-event', eventId],
|
||||
queryFn: () => api.getEventAdminDetail(eventId),
|
||||
enabled: Boolean(eventId) && isAuthenticated,
|
||||
});
|
||||
|
||||
const [formData, setFormData] = React.useState({
|
||||
title: '',
|
||||
status: 'draft' as NonNullable<EventUpdateSchema['status']>,
|
||||
event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
|
||||
price: '',
|
||||
capacity: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
registration_start_date: '',
|
||||
registration_end_date: '',
|
||||
location: '',
|
||||
address: '',
|
||||
online_link: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.data) {
|
||||
const d: EventAdminDetailSchema = detailQuery.data;
|
||||
setFormData({
|
||||
title: d.title || '',
|
||||
status: d.status || 'draft',
|
||||
event_type: d.event_type || 'online',
|
||||
price: d.price ? Math.floor(Number(d.price) / 10).toString() : '',
|
||||
capacity: d.capacity != null ? String(d.capacity) : '',
|
||||
start_time: toInputDateTime(d.start_time),
|
||||
end_time: toInputDateTime(d.end_time),
|
||||
registration_start_date: toInputDateTime(d.registration_start_date),
|
||||
registration_end_date: toInputDateTime(d.registration_end_date),
|
||||
location: d.location || '',
|
||||
address: d.address || '',
|
||||
online_link: d.online_link || '',
|
||||
description: d.description || '',
|
||||
});
|
||||
}
|
||||
}, [detailQuery.data]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: EventUpdateSchema) => api.updateEvent(eventId, payload),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'رویداد بهروزرسانی شد', variant: 'success' });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'edit-event', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
||||
navigate(`/admin/events/${eventId}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'خطا در ذخیرهسازی رویداد',
|
||||
description: resolveErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (detailQuery.error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'خطا در دریافت رویداد',
|
||||
description: resolveErrorMessage(detailQuery.error),
|
||||
});
|
||||
}
|
||||
}, [detailQuery.error, toast]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<p className="text-muted-foreground">در حال بررسی دسترسی...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) {
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ویرایش رویداد</CardTitle>
|
||||
<CardDescription>فرم کامل برای ویرایش جزئیات رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{detailQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری جزئیات...</p>
|
||||
) : detailQuery.data ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate({
|
||||
title: formData.title,
|
||||
status: formData.status,
|
||||
event_type: formData.event_type,
|
||||
price: formData.price ? Number(formData.price) * 10 : 0,
|
||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
||||
start_time: formData.start_time || undefined,
|
||||
end_time: formData.end_time || null,
|
||||
registration_start_date: formData.registration_start_date || null,
|
||||
registration_end_date: formData.registration_end_date || null,
|
||||
location: formData.location || null,
|
||||
address: formData.address || null,
|
||||
online_link: formData.online_link || null,
|
||||
description: formData.description || '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="عنوان رویداد"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
status: value as NonNullable<EventUpdateSchema['status']>,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="وضعیت" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={formData.event_type}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
event_type: value as NonNullable<EventUpdateSchema['event_type']>,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="نوع رویداد" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{typeOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="قیمت (تومان)"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, price: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="ظرفیت"
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, capacity: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="تاریخ شروع"
|
||||
value={formData.start_time}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, start_time: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="تاریخ پایان"
|
||||
value={formData.end_time}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, end_time: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="شروع ثبتنام"
|
||||
value={formData.registration_start_date}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, registration_start_date: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
placeholder="پایان ثبتنام"
|
||||
value={formData.registration_end_date}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, registration_end_date: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="محل برگزاری"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, location: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="آدرس دقیق"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, address: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="لینک آنلاین"
|
||||
value={formData.online_link}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, online_link: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="توضیحات رویداد"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
rows={8}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">امکان دریافت رویداد وجود ندارد.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
src/views/AdminEvents.tsx
Normal file
302
src/views/AdminEvents.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@/lib/router';
|
||||
import type { EventListItemSchema } from '@/lib/types';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { formatJalali, formatToman, getThumbUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||
|
||||
const EVENTS_PAGE_SIZE = 30;
|
||||
|
||||
const eventStatusOptions = [
|
||||
{ value: 'all', label: 'همه وضعیتها' },
|
||||
{ value: 'draft', label: 'پیشنویس' },
|
||||
{ value: 'published', label: 'منتشر شده' },
|
||||
{ value: 'cancelled', label: 'لغو شده' },
|
||||
{ value: 'completed', label: 'برگزار شده' },
|
||||
];
|
||||
|
||||
const statusConfig: Record<
|
||||
EventListItemSchema['status'],
|
||||
{ label: string; variant: 'outline' | 'default' | 'destructive' | 'secondary' }
|
||||
> = {
|
||||
draft: { label: 'پیشنویس', variant: 'outline' },
|
||||
published: { label: 'منتشر شده', variant: 'default' },
|
||||
cancelled: { label: 'لغو شده', variant: 'destructive' },
|
||||
completed: { label: 'برگزار شده', variant: 'secondary' },
|
||||
};
|
||||
|
||||
const eventSortOptions = [
|
||||
{ value: 'newest', label: 'جدیدترین شروع' },
|
||||
{ value: 'oldest', label: 'قدیمیترین شروع' },
|
||||
{ value: 'priceAsc', label: 'قیمت صعودی' },
|
||||
{ value: 'priceDesc', label: 'قیمت نزولی' },
|
||||
];
|
||||
|
||||
const AdminEventsPage: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: '',
|
||||
status: 'all' as 'all' | EventListItemSchema['status'],
|
||||
type: 'all' as 'all' | EventListItemSchema['event_type'],
|
||||
sort: 'newest' as (typeof eventSortOptions)[number]['value'],
|
||||
});
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['admin', 'events', filters],
|
||||
queryFn: () =>
|
||||
api.getEvents({
|
||||
statuses:
|
||||
filters.status === 'all'
|
||||
? undefined
|
||||
: [filters.status as EventListItemSchema['status']],
|
||||
event_type:
|
||||
filters.type === 'all'
|
||||
? undefined
|
||||
: (filters.type as EventListItemSchema['event_type']),
|
||||
search: filters.search || undefined,
|
||||
limit: EVENTS_PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (eventId: number) => api.deleteEvent(eventId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'events'] });
|
||||
toast({ title: 'رویداد حذف شد', variant: 'success' });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEvents = React.useMemo(() => {
|
||||
const list = (eventsQuery.data ?? []).slice();
|
||||
switch (filters.sort) {
|
||||
case 'newest':
|
||||
return list.sort((a, b) => new Date(b.start_time).getTime() - new Date(a.start_time).getTime());
|
||||
case 'oldest':
|
||||
return list.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||
case 'priceAsc':
|
||||
return list.sort((a, b) => Number(a.price) - Number(b.price));
|
||||
case 'priceDesc':
|
||||
return list.sort((a, b) => Number(b.price) - Number(a.price));
|
||||
default:
|
||||
return list;
|
||||
}
|
||||
}, [eventsQuery.data, filters.sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-semibold">رویدادها</h2>
|
||||
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فیلترها</CardTitle>
|
||||
<CardDescription>پیدا کردن سریع رویدادها</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
placeholder="عنوان رویداد..."
|
||||
value={filters.search}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
|
||||
/>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: value as 'all' | EventListItemSchema['status'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{eventStatusOptions.find((option) => option.value === filters.status)?.label ||
|
||||
'وضعیت'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventStatusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.type}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
type: value as 'all' | EventListItemSchema['event_type'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{{
|
||||
all: 'همه انواع',
|
||||
online: 'آنلاین',
|
||||
on_site: 'حضوری',
|
||||
hybrid: 'ترکیبی',
|
||||
}[filters.type]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه انواع</SelectItem>
|
||||
<SelectItem value="online">آنلاین</SelectItem>
|
||||
<SelectItem value="on_site">حضوری</SelectItem>
|
||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.sort}
|
||||
onValueChange={(value) =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: value as (typeof eventSortOptions)[number]['value'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{eventSortOptions.find((option) => option.value === filters.sort)?.label ||
|
||||
'مرتبسازی'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventSortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>لیست رویدادها</CardTitle>
|
||||
<CardDescription>وضعیت، ظرفیت و قیمت هر رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{eventsQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : sortedEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">رویدادی یافت نشد.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="hidden md:block">
|
||||
<ScrollArea className="rounded-md border">
|
||||
<table dir="rtl" className="w-full min-w-[780px] text-sm">
|
||||
<thead className="text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-right">پوستر</th>
|
||||
<th className="px-3 py-2 text-right">عنوان</th>
|
||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||
<th className="px-3 py-2 text-right">تاریخ شروع</th>
|
||||
<th className="px-3 py-2 text-right">ثبتنامها</th>
|
||||
<th className="px-3 py-2 text-right">قیمت (تومان)</th>
|
||||
<th className="px-3 py-2 text-right">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEvents.map((event) => (
|
||||
<tr key={event.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2 text-right">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right cursor-pointer" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
{event.title}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge variant={statusConfig[event.status].variant}>
|
||||
{statusConfig[event.status].label}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{formatJalali(event.start_time)}</td>
|
||||
<td className="px-3 py-2 text-right">{toPersianDigits(event.registration_count)}</td>
|
||||
<td className="px-3 py-2 text-right">{formatToman(event.price)}</td>
|
||||
<td className="px-3 py-2 text-left flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
جزئیات
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(event.id)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{sortedEvents.map((event) => (
|
||||
<div key={event.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-right">{event.title}</div>
|
||||
<Badge variant={statusConfig[event.status].variant}>{statusConfig[event.status].label}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
||||
<div>تاریخ شروع: {formatJalali(event.start_time)}</div>
|
||||
<div>ثبتنامها: {toPersianDigits(event.registration_count)}</div>
|
||||
<div>قیمت: {formatToman(event.price)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/events/${event.id}`)}>
|
||||
جزئیات
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/admin/events/${event.id}/edit`}>ویرایش</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(event.id)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEventsPage;
|
||||
63
src/views/AdminLayout.tsx
Normal file
63
src/views/AdminLayout.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, NavLink, useLocation } from '@/lib/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin/users', label: 'مدیریت کاربران' },
|
||||
{ to: '/admin/events', label: 'مدیریت رویدادها' },
|
||||
] as const;
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const isAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser),
|
||||
[isAuthenticated, user?.is_staff, user?.is_superuser],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="border-b bg-muted/20">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
|
||||
<h1 className="text-2xl font-bold">پنل مدیریت</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'rounded-full px-4 py-2 text-sm transition',
|
||||
(isActive || location.pathname?.startsWith(item.to))
|
||||
? 'bg-primary text-primary-foreground shadow'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground border',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/views/AdminUsers.tsx
Normal file
275
src/views/AdminUsers.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import type { UserListSchema } from '@/lib/types';
|
||||
import { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
formatJalali,
|
||||
formatNumberPersian,
|
||||
resolveErrorMessage,
|
||||
} from '@/lib/utils';
|
||||
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
|
||||
const AdminUsersPage: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: '',
|
||||
studentId: '',
|
||||
university: 'all',
|
||||
major: 'all',
|
||||
isActive: 'all',
|
||||
});
|
||||
const [page, setPage] = React.useState(1);
|
||||
|
||||
const majorsQuery = useQuery({
|
||||
queryKey: ['majors'],
|
||||
queryFn: () => api.getMajors(),
|
||||
});
|
||||
const universitiesQuery = useQuery({
|
||||
queryKey: ['universities'],
|
||||
queryFn: () => api.getUniversities(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin', 'users', filters, page],
|
||||
queryFn: () =>
|
||||
api.listUsers({
|
||||
search: filters.search || undefined,
|
||||
student_id: filters.studentId || undefined,
|
||||
university: filters.university === 'all' ? undefined : filters.university,
|
||||
major: filters.major === 'all' ? undefined : filters.major,
|
||||
is_active:
|
||||
filters.isActive === 'all'
|
||||
? undefined
|
||||
: filters.isActive === 'active'
|
||||
? 'true'
|
||||
: 'false',
|
||||
limit: USERS_PAGE_SIZE,
|
||||
offset: (page - 1) * USERS_PAGE_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const users = usersQuery.data ?? [];
|
||||
const hasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (usersQuery.error) {
|
||||
toast({
|
||||
title: 'خطا در بارگذاری کاربران',
|
||||
description: resolveErrorMessage(usersQuery.error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [usersQuery.error, toast]);
|
||||
|
||||
const handleFilterChange = (field: keyof typeof filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [field]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">کاربران</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>فیلترها</CardTitle>
|
||||
<CardDescription>جستجو و محدود کردن نتایج</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Input
|
||||
placeholder="نام، نامکاربری یا ایمیل..."
|
||||
value={filters.search}
|
||||
onChange={(event) => handleFilterChange('search', event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="شماره دانشجویی"
|
||||
value={filters.studentId}
|
||||
onChange={(event) => handleFilterChange('studentId', event.target.value)}
|
||||
/>
|
||||
<Select value={filters.isActive} onValueChange={(value) => handleFilterChange('isActive', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="وضعیت">
|
||||
{{
|
||||
all: 'همه وضعیتها',
|
||||
active: 'فعال',
|
||||
inactive: 'غیرفعال',
|
||||
}[filters.isActive]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
<SelectItem value="active">فعال</SelectItem>
|
||||
<SelectItem value="inactive">غیرفعال</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Select
|
||||
value={filters.university}
|
||||
onValueChange={(value) => handleFilterChange('university', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="دانشگاه">
|
||||
{filters.university === 'all'
|
||||
? 'همه'
|
||||
: universitiesQuery.data?.find((item) => item.code === filters.university)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{universitiesQuery.data?.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filters.major} onValueChange={(value) => handleFilterChange('major', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="رشته">
|
||||
{filters.major === 'all'
|
||||
? 'همه'
|
||||
: majorsQuery.data?.find((item) => item.code === filters.major)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه</SelectItem>
|
||||
{majorsQuery.data?.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-0 md:pb-2">
|
||||
<CardTitle>لیست کاربران</CardTitle>
|
||||
<CardDescription>نمایش کاربران مطابق فیلترهای انتخابی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{usersQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ScrollArea className="rounded-md border hidden md:block">
|
||||
<table dir="rtl" className="w-full min-w-[700px] text-sm">
|
||||
<thead className="text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-right">نام کامل</th>
|
||||
<th className="px-3 py-2 text-right">نام کاربری</th>
|
||||
<th className="px-3 py-2 text-right">ایمیل</th>
|
||||
<th className="px-3 py-2 text-right">دانشگاه / گرایش</th>
|
||||
<th className="px-3 py-2 text-right">وضعیت</th>
|
||||
<th className="px-3 py-2 text-right">تاریخ عضویت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(() => {
|
||||
const parts = [user.first_name, user.last_name].filter(Boolean);
|
||||
if (parts.length) return parts.join(' ');
|
||||
return user.username;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{user.username}</td>
|
||||
<td className="px-3 py-2 text-right">{user.email}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{user.major || '—'} · {user.university || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Badge variant={user.is_active ? 'default' : 'outline'}>
|
||||
{user.is_active ? 'فعال' : 'غیرفعال'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{formatJalali(user.date_joined)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="grid gap-3 md:hidden">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="rounded-lg border p-3 space-y-2 bg-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-right">{user.first_name || user.last_name ? `${user.first_name || ''} ${user.last_name || ''}`.trim() : user.username}</div>
|
||||
<Badge variant={user.is_active ? 'default' : 'outline'}>{user.is_active ? 'فعال' : 'غیرفعال'}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-1">
|
||||
<div>نام کاربری: {user.username}</div>
|
||||
<div>ایمیل: {user.email}</div>
|
||||
<div>دانشگاه / گرایش: {user.university || '—'} · {user.major || '—'}</div>
|
||||
<div>تاریخ عضویت: {formatJalali(user.date_joined)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>صفحه {formatNumberPersian(page)}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage((prev) => prev + 1)}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsersPage;
|
||||
551
src/views/Auth.tsx
Normal file
551
src/views/Auth.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { Link, useNavigate } from '@/lib/router';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import SearchableCombobox from '@/components/SearchableCombobox'
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
|
||||
type RegisterErrors = {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
university?: string;
|
||||
};
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8; // ← در صورت نیاز تغییر بده
|
||||
const USERNAME_REGEX = /^[A-Za-z0-9._-]{3,30}$/; // ← کاراکترهای مجاز + حداقل 3 کاراکتر
|
||||
const DISALLOW_PERSIAN_OR_SPACE = /[\u0600-\u06FF\s]/g; // ← حروف فارسی + فاصله
|
||||
|
||||
const sanitizeUsername = (v: string) => v.replace(/[^A-Za-z0-9._-]/g, '');
|
||||
const sanitizeNoFaNoSpace = (v: string) => v.replace(DISALLOW_PERSIAN_OR_SPACE, '');
|
||||
|
||||
const isValidEmailBasic = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||||
|
||||
export default function Auth() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [unverified, setUnverified] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
|
||||
const initialLogin = { email: '', password: '' };
|
||||
const initialRegister = {
|
||||
email: '',
|
||||
password: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
student_id: '',
|
||||
year_of_study: '',
|
||||
major: null as string | null,
|
||||
university: null as string | null,
|
||||
};
|
||||
|
||||
const [loginData, setLoginData] = useState(initialLogin);
|
||||
const [registerData, setRegisterData] = useState(initialRegister);
|
||||
const [regErrors, setRegErrors] = useState<RegisterErrors>({});
|
||||
const [tab, setTab] = useState<'login' | 'register'>('login');
|
||||
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'انجمن علمی کامپیوتر شرق دانشگاه گیلان';
|
||||
const canonicalUrl = `${siteUrl}/auth`;
|
||||
const ogImage = `${siteUrl}/favicon.ico`;
|
||||
const metaRobots = 'noindex, nofollow';
|
||||
|
||||
const { pageTitle, pageDescription } = useMemo(() => {
|
||||
const variant = tab === 'register' ? 'ثبتنام' : 'ورود';
|
||||
const description =
|
||||
tab === 'register'
|
||||
? 'برای پیوستن به رویدادها، کارگاهها و برنامههای انجمن علمی کامپیوتر شرق گیلان حساب کاربری بسازید.'
|
||||
: 'برای مدیریت پروفایل و ثبتنام رویدادها وارد انجمن علمی کامپیوتر شرق گیلان شوید.';
|
||||
return {
|
||||
pageTitle: `${variant} | ${siteName}`,
|
||||
pageDescription: description,
|
||||
};
|
||||
}, [tab, siteName]);
|
||||
|
||||
const { data: majors, isLoading: majorsLoading } = useQuery({
|
||||
queryKey: ['majors'],
|
||||
queryFn: () => api.getMajors(), // expects [{ code, label }]
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: universities, isLoading: universitiesLoading } = useQuery({
|
||||
queryKey: ['universities'],
|
||||
queryFn: () => api.getUniversities(), // expects [{ code, label }]
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const majorItems = useMemo(
|
||||
() => (majors ?? []).map((m) => ({ value: String(m.code), label: m.label })),
|
||||
[majors]
|
||||
);
|
||||
const universityItems = useMemo(
|
||||
() => (universities ?? []).map((u) => ({ value: String(u.code), label: u.label })),
|
||||
[universities]
|
||||
);
|
||||
|
||||
// تبدیل ارقام فارسی/عربی به انگلیسی و حذف هرچیز غیر 0-9
|
||||
const toEnglishDigits = (v: string) =>
|
||||
v
|
||||
.replace(/[\u06F0-\u06F9]/g, (d) => String(d.charCodeAt(0) - 0x06F0)) // Persian ۰-۹
|
||||
.replace(/[\u0660-\u0669]/g, (d) => String(d.charCodeAt(0) - 0x0660)); // Arabic ٠-٩
|
||||
|
||||
const onlyAsciiDigits = (v: string) => toEnglishDigits(v).replace(/[^0-9]/g, '');
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
const email = sanitizeNoFaNoSpace(loginData.email.trim());
|
||||
if (!email) {
|
||||
toast({
|
||||
title: 'ایمیل لازم است',
|
||||
description: 'برای ارسال لینک تأیید، ابتدا ایمیل را وارد کنید.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isValidEmailBasic(email)) {
|
||||
toast({ title: 'ایمیل نامعتبر', description: 'فرمت ایمیل درست نیست.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setResendLoading(true);
|
||||
await api.resendVerification(email);
|
||||
toast({
|
||||
title: 'ایمیل ارسال شد',
|
||||
description: 'اگر در صندوق ورودی نیست، پوشهٔ هرزنامه (اسپم) را بررسی کنید.',
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا در ارسال',
|
||||
description: resolveErrorMessage(error, 'مشکلی رخ داد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const email = sanitizeNoFaNoSpace(loginData.email.trim());
|
||||
const password = sanitizeNoFaNoSpace(loginData.password);
|
||||
if (!email || !isValidEmailBasic(email)) {
|
||||
throw new Error('ایمیل نامعتبر است.');
|
||||
}
|
||||
if (!password || DISALLOW_PERSIAN_OR_SPACE.test(loginData.password)) {
|
||||
throw new Error('رمز عبور نباید شامل فاصله یا حروف فارسی باشد.');
|
||||
}
|
||||
await login(email, password);
|
||||
toast({ title: 'خوش آمدید', description: 'با موفقیت وارد شدید', variant: 'success' });
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const isUnverified =
|
||||
/please verify your email/i.test(msg) || // EN
|
||||
/ایمیل.*تایید نشده|لطفاً.*ایمیل.*را.*تأیید/i.test(msg); // FA
|
||||
|
||||
if (isUnverified) {
|
||||
setUnverified(true);
|
||||
toast({
|
||||
title: 'ایمیل شما تأیید نشده است',
|
||||
description: 'برای ورود باید ایمیل را تأیید کنید. میتوانید لینک تأیید را دوباره ارسال کنید.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({ title: 'خطا', description: msg || 'خطا در ورود', variant: 'destructive' });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateRegister = () => {
|
||||
const errs: RegisterErrors = {};
|
||||
const isBlank = (s: string) => !s || !s.trim();
|
||||
|
||||
const email = sanitizeNoFaNoSpace(registerData.email.trim());
|
||||
const username = registerData.username.trim();
|
||||
const password = registerData.password;
|
||||
|
||||
if (isBlank(email)) errs.email = 'ایمیل را وارد کنید';
|
||||
else if (!isValidEmailBasic(email)) errs.email = 'فرمت ایمیل نامعتبر است';
|
||||
else if (DISALLOW_PERSIAN_OR_SPACE.test(registerData.email)) errs.email = 'ایمیل نباید شامل فاصله یا حروف فارسی باشد';
|
||||
|
||||
if (isBlank(username)) errs.username = 'نام کاربری را وارد کنید';
|
||||
else if (!USERNAME_REGEX.test(username)) errs.username = 'فقط حروف لاتین، اعداد، نقطه، آندرلاین و خط تیره (حداقل ۳ کاراکتر)';
|
||||
|
||||
if (isBlank(password)) errs.password = 'رمز عبور را وارد کنید';
|
||||
else if (password.length < MIN_PASSWORD_LENGTH) errs.password = `حداقل ${MIN_PASSWORD_LENGTH} کاراکتر`;
|
||||
else if (DISALLOW_PERSIAN_OR_SPACE.test(password)) errs.password = 'رمز عبور نباید شامل فاصله یا حروف فارسی باشد';
|
||||
|
||||
if (isBlank(registerData.first_name)) errs.first_name = 'نام را وارد کنید';
|
||||
if (isBlank(registerData.last_name)) errs.last_name = 'نام خانوادگی را وارد کنید';
|
||||
if (!registerData.university) errs.university = 'دانشگاه را انتخاب کنید';
|
||||
|
||||
setRegErrors(errs);
|
||||
return Object.keys(errs).length === 0;
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validateRegister()) {
|
||||
toast({ title: 'اطلاعات ناقص/نامعتبر', description: 'فیلدهای اجباری را درست تکمیل کنید.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.register({
|
||||
email: sanitizeNoFaNoSpace(registerData.email.trim()),
|
||||
username: registerData.username.trim(),
|
||||
password: registerData.password, // سرور هم اعتبارسنجی کند
|
||||
first_name: registerData.first_name.trim(),
|
||||
last_name: registerData.last_name.trim(),
|
||||
student_id: registerData.student_id?.trim() || null,
|
||||
year_of_study: registerData.year_of_study ? parseInt(registerData.year_of_study, 10) : null,
|
||||
major: registerData.major || null,
|
||||
university: registerData.university || null,
|
||||
});
|
||||
toast({
|
||||
title: 'ثبتنام موفق',
|
||||
description: 'ثبتنام با موفقیت انجام شد. لطفاً ایمیل خود را تأیید کنید.',
|
||||
variant: 'success',
|
||||
});
|
||||
setTab('login');
|
||||
setLoginData(() => ({ ...initialLogin, email: registerData.email }));
|
||||
setRegisterData(initialRegister);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: error instanceof Error ? error.message : 'خطا در ثبتنام',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const invalidClass = 'border-destructive focus-visible:ring-destructive';
|
||||
|
||||
// فقط اعداد برای سال ورودی
|
||||
const onYearChange = (v: string) => v.replace(/\D/g, '');
|
||||
|
||||
useEffect(() => {
|
||||
setTab('login');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content={metaRobots} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
</Helmet>
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader dir="rtl">
|
||||
<CardTitle>انجمن علمی کامپیوتر گیلان</CardTitle>
|
||||
<CardDescription>ورود یا ثبتنام در سیستم</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as 'login' | 'register')} dir="rtl">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="login">ورود</TabsTrigger>
|
||||
<TabsTrigger value="register">ثبتنام</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ورود */}
|
||||
<TabsContent value="login">
|
||||
<form onSubmit={handleLogin} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<Label htmlFor="login-email">ایمیل</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
name="username"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={loginData.email}
|
||||
onChange={(e) => setLoginData({ ...loginData, email: sanitizeNoFaNoSpace(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="login-password">رمز عبور</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
name="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({ ...loginData, password: sanitizeNoFaNoSpace(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ورود...' : 'ورود'}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="block text-xs text-muted-foreground hover:text-foreground underline underline-offset-4 text-right"
|
||||
>
|
||||
فراموشی رمز عبور؟
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendLoading || !loginData.email}
|
||||
className="min-w-40"
|
||||
>
|
||||
{resendLoading ? 'در حال ارسال...' : 'ارسال مجدد ایمیل تأیید'}
|
||||
</Button>
|
||||
|
||||
{unverified && (
|
||||
<div className="mt-3 text-right space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
حساب شما هنوز تأیید نشده است. لطفاً پوشهی اسپم ایمیل خود را بررسی کنید یا لینک تأیید را دوباره دریافت کنید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
{/* ثبتنام */}
|
||||
<TabsContent value="register">
|
||||
<form onSubmit={handleRegister} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<Label htmlFor="register-email">ایمیل</Label>
|
||||
<Input
|
||||
id="register-email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={registerData.email}
|
||||
onChange={(e) => {
|
||||
const val = sanitizeNoFaNoSpace(e.target.value);
|
||||
setRegisterData({ ...registerData, email: val });
|
||||
if (regErrors.email) setRegErrors((p) => ({ ...p, email: undefined }));
|
||||
}}
|
||||
className={regErrors.email ? invalidClass : undefined}
|
||||
aria-invalid={!!regErrors.email}
|
||||
/>
|
||||
{regErrors.email && <p className="mt-1 text-xs text-destructive">{regErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="register-username">نام کاربری</Label>
|
||||
<Input
|
||||
id="register-username"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="username"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="فقط حروف لاتین، اعداد، . _ -"
|
||||
value={registerData.username}
|
||||
onChange={(e) => {
|
||||
const val = sanitizeUsername(e.target.value);
|
||||
setRegisterData({ ...registerData, username: val });
|
||||
if (regErrors.username) setRegErrors((p) => ({ ...p, username: undefined }));
|
||||
}}
|
||||
pattern="[A-Za-z0-9._-]{3,30}"
|
||||
className={regErrors.username ? invalidClass : undefined}
|
||||
aria-invalid={!!regErrors.username}
|
||||
/>
|
||||
{regErrors.username && <p className="mt-1 text-xs text-destructive">{regErrors.username}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="register-first-name">نام</Label>
|
||||
<Input
|
||||
id="register-first-name"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
value={registerData.first_name}
|
||||
onChange={(e) => {
|
||||
setRegisterData({ ...registerData, first_name: e.target.value });
|
||||
if (regErrors.first_name) setRegErrors((p) => ({ ...p, first_name: undefined }));
|
||||
}}
|
||||
className={regErrors.first_name ? invalidClass : undefined}
|
||||
aria-invalid={!!regErrors.first_name}
|
||||
/>
|
||||
{regErrors.first_name && <p className="mt-1 text-xs text-destructive">{regErrors.first_name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="register-last-name">نام خانوادگی</Label>
|
||||
<Input
|
||||
id="register-last-name"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
value={registerData.last_name}
|
||||
onChange={(e) => {
|
||||
setRegisterData({ ...registerData, last_name: e.target.value });
|
||||
if (regErrors.last_name) setRegErrors((p) => ({ ...p, last_name: undefined }));
|
||||
}}
|
||||
className={regErrors.last_name ? invalidClass : undefined}
|
||||
aria-invalid={!!regErrors.last_name}
|
||||
/>
|
||||
{regErrors.last_name && <p className="mt-1 text-xs text-destructive">{regErrors.last_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="register-university">دانشگاه</Label>
|
||||
{universitiesLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<>
|
||||
<SearchableCombobox
|
||||
items={universityItems}
|
||||
value={registerData.university}
|
||||
onChange={(v) => {
|
||||
setRegisterData({ ...registerData, university: v });
|
||||
if (regErrors.university) setRegErrors((p) => ({ ...p, university: undefined }));
|
||||
}}
|
||||
placeholder="انتخاب دانشگاه"
|
||||
searchPlaceholder="نام دانشگاه را بنویسید…"
|
||||
emptyText="دانشگاهی پیدا نشد"
|
||||
className={regErrors.university ? "border-destructive focus-visible:ring-destructive" : undefined}
|
||||
dir="rtl"
|
||||
/>
|
||||
{regErrors.university && (
|
||||
<p className="mt-1 text-xs text-destructive">{regErrors.university}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="register-student-id">شماره دانشجویی (اختیاری)</Label>
|
||||
<Input
|
||||
id="register-student-id"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
dir="ltr"
|
||||
value={registerData.student_id}
|
||||
onChange={(e) =>
|
||||
setRegisterData({ ...registerData, student_id: onlyAsciiDigits(e.target.value) })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
const allowed = ['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End'];
|
||||
if (/^[0-9]$/.test(e.key)) return; // فقط 0-9
|
||||
if (allowed.includes(e.key)) return; // کلیدهای کنترلی
|
||||
if ((e.ctrlKey || e.metaKey) && ['a','c','v','x'].includes(e.key.toLowerCase())) return; // میانبرها
|
||||
e.preventDefault(); // بقیه ممنوع
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="register-year">سال ورودی (اختیاری)</Label>
|
||||
<Input
|
||||
id="register-year"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={registerData.year_of_study}
|
||||
onChange={(e) => setRegisterData({ ...registerData, year_of_study: onYearChange(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="register-major">رشتهٔ تحصیلی (اختیاری)</Label>
|
||||
{majorsLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<SearchableCombobox
|
||||
items={majorItems}
|
||||
value={registerData.major}
|
||||
onChange={(v) => setRegisterData({ ...registerData, major: v })}
|
||||
placeholder="انتخاب رشته"
|
||||
searchPlaceholder="نام رشته را بنویسید…"
|
||||
emptyText="رشتهای پیدا نشد"
|
||||
dir="rtl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="register-password">رمز عبور</Label>
|
||||
<Input
|
||||
id="register-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={registerData.password}
|
||||
onChange={(e) => {
|
||||
const val = sanitizeNoFaNoSpace(e.target.value);
|
||||
setRegisterData({ ...registerData, password: val });
|
||||
if (regErrors.password) setRegErrors((p) => ({ ...p, password: undefined }));
|
||||
}}
|
||||
className={regErrors.password ? invalidClass : undefined}
|
||||
aria-invalid={!!regErrors.password}
|
||||
/>
|
||||
{regErrors.password ? (
|
||||
<p className="mt-1 text-xs text-destructive">{regErrors.password}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
حداقل {MIN_PASSWORD_LENGTH} کاراکتر — بدون فاصله و حروف فارسی
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ثبتنام...' : 'ثبتنام'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
src/views/Blog.tsx
Normal file
112
src/views/Blog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type * as Types from "@/lib/types";
|
||||
|
||||
type BlogProps = {
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
|
||||
export default function Blog({
|
||||
initialPosts = [],
|
||||
initialSearch = "",
|
||||
}: BlogProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [posts, setPosts] = useState<Types.PostListSchema[]>(initialPosts);
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialPosts.length && !initialSearch);
|
||||
|
||||
useEffect(() => {
|
||||
setPosts(initialPosts);
|
||||
}, [initialPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getPosts({ search: search || undefined });
|
||||
setPosts(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading posts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) {
|
||||
params.set("search", search.trim());
|
||||
}
|
||||
const basePath = location.pathname || "/blog";
|
||||
const nextPath = params.size
|
||||
? `${basePath}?${params.toString()}`
|
||||
: basePath;
|
||||
navigate(nextPath, { replace: true });
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">وبلاگ</h1>
|
||||
|
||||
<div className="mb-8">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در مقالات..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">مقالهای یافت نشد</p>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.id} to={`/blog/${post.slug}`}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2">{post.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{post.category?.name && (
|
||||
<span className="text-primary ml-2">{post.category.name}</span>
|
||||
)}
|
||||
{new Date(post.created_at).toLocaleDateString("fa-IR")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{post.excerpt && (
|
||||
<p className="text-muted-foreground line-clamp-3 mb-4">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
نویسنده: {post.author.first_name} {post.author.last_name}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
613
src/views/EventDetail.tsx
Normal file
613
src/views/EventDetail.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { useNavigate, useParams } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import CouponDialogFa from "@/components/CouponDialogFa";
|
||||
import {
|
||||
formatJalali,
|
||||
formatNumberPersian,
|
||||
formatToman,
|
||||
getThumbUrl,
|
||||
resolveErrorMessage,
|
||||
toPersianDigits,
|
||||
} from "@/lib/utils";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
online: "آنلاین",
|
||||
on_site: "حضوری",
|
||||
hybrid: "آنلاین و حضوری",
|
||||
};
|
||||
|
||||
type EventDetailProps = {
|
||||
initialEvent?: Types.EventDetailSchema | null;
|
||||
};
|
||||
|
||||
function buildPaymentSnapshot(
|
||||
event: Types.EventDetailSchema,
|
||||
eventThumb: string | null,
|
||||
payload: {
|
||||
baseAmount: number;
|
||||
discountAmount: number;
|
||||
amount: number;
|
||||
},
|
||||
) {
|
||||
return JSON.stringify({
|
||||
event_id: event.id,
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
thumb: eventThumb,
|
||||
base_amount: payload.baseAmount,
|
||||
discount_amount: payload.discountAmount,
|
||||
amount: payload.amount,
|
||||
started_at: new Date().toISOString(),
|
||||
success_markdown: event.registration_success_markdown,
|
||||
});
|
||||
}
|
||||
|
||||
export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
||||
const [eventThumb, setEventThumb] = useState<string | null>(
|
||||
initialEvent ? getThumbUrl(initialEvent) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(!initialEvent);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
|
||||
const basePrice = Number(event?.price ?? 0);
|
||||
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
|
||||
const siteName = "انجمن علمی کامپیوتر شرق گیلان";
|
||||
const defaultDescription =
|
||||
"جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبتنام.";
|
||||
|
||||
const toAbsoluteUrl = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
if (value.startsWith("http")) return value;
|
||||
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = value.startsWith("/") ? value.slice(1) : value;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
const sanitizeDescription = (value?: string | null) => {
|
||||
if (!value) return defaultDescription;
|
||||
const stripped = value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!stripped) return defaultDescription;
|
||||
if (stripped.length <= 160) return stripped;
|
||||
return `${stripped.slice(0, 157)}...`;
|
||||
};
|
||||
|
||||
const canonicalUrl = event ? `${siteUrl}/events/${event.slug}` : `${siteUrl}/events`;
|
||||
const primaryImage = event
|
||||
? toAbsoluteUrl(getThumbUrl(event)) ?? `${siteUrl}/favicon.ico`
|
||||
: `${siteUrl}/favicon.ico`;
|
||||
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
|
||||
const pageDescription = sanitizeDescription(event?.description);
|
||||
const pageRobots = event?.status === "draft" ? "noindex, nofollow" : "index, follow";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkRegistration() {
|
||||
if (!isAuthenticated || !event?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.getRegistrationStatus(event.id);
|
||||
if (!cancelled) {
|
||||
setAlreadyRegistered(res.is_registered);
|
||||
}
|
||||
} catch {
|
||||
// Ignore registration status failures on the detail page.
|
||||
}
|
||||
}
|
||||
|
||||
checkRegistration();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event?.id, isAuthenticated]);
|
||||
|
||||
const goSuccess = (registrationId?: string) => {
|
||||
if (!event) return;
|
||||
const query = registrationId ? `?registration_id=${registrationId}` : "";
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: "ثبتنام با موفقیت انجام شد!", variant: "success" });
|
||||
navigate(`/events/${event.slug}/success${query}`);
|
||||
};
|
||||
|
||||
const handleMainCTA = async () => {
|
||||
if (!event) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
toast({
|
||||
title: "ابتدا وارد شوید",
|
||||
description: "برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFree) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await api.registerForEvent(event.id);
|
||||
goSuccess(res.ticket_id);
|
||||
} catch (error: unknown) {
|
||||
const msg = resolveErrorMessage(error, "");
|
||||
if (msg.includes("already registered")) {
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: "شما قبلاً ثبتنام کردهاید", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "خطا در ثبتنام",
|
||||
description: msg || "لطفاً دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => {
|
||||
if (!event) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
toast({
|
||||
title: "ابتدا وارد شوید",
|
||||
description: "برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const reg = await api.registerForEvent(event.id, coupon);
|
||||
|
||||
if (finalAmount === 0) {
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: Number(event.price ?? 0),
|
||||
discountAmount: Number(event.price ?? 0),
|
||||
amount: 0,
|
||||
}),
|
||||
);
|
||||
await api.ChangeRegistrationStatus(reg.id, "confirmed");
|
||||
goSuccess(reg.ticket_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.createPayment({
|
||||
event_id: event.id,
|
||||
description: `پرداخت رویداد: ${event.title}`,
|
||||
discount_code: (coupon ?? "").trim() || null,
|
||||
});
|
||||
|
||||
if (!result?.start_pay_url || Number(result.amount) === 0) {
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: result.base_amount,
|
||||
discountAmount: result.discount_amount ?? result.base_amount,
|
||||
amount: 0,
|
||||
}),
|
||||
);
|
||||
goSuccess(reg.ticket_id);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: result.base_amount,
|
||||
discountAmount: result.discount_amount,
|
||||
amount: result.amount,
|
||||
}),
|
||||
);
|
||||
window.location.href = result.start_pay_url;
|
||||
} catch (error: unknown) {
|
||||
const msg = resolveErrorMessage(error, "");
|
||||
if (msg.includes("already registered")) {
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: "شما قبلاً ثبتنام کردهاید", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "خطا در پردازش پرداخت",
|
||||
description: msg || "لطفاً دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadEvent() {
|
||||
try {
|
||||
if (!slug) return;
|
||||
|
||||
if (initialEvent && initialEvent.slug === slug) {
|
||||
setEvent(initialEvent);
|
||||
setEventThumb(getThumbUrl(initialEvent));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api.getEventBySlug(slug);
|
||||
if (cancelled) return;
|
||||
setEvent(data);
|
||||
setEventThumb(getThumbUrl(data));
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
toast({
|
||||
title: "خطا در بارگذاری رویداد",
|
||||
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEvent();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialEvent, slug, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const rsTs = useMemo<number | null>(
|
||||
() => (event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null),
|
||||
[event?.registration_start_date],
|
||||
);
|
||||
|
||||
const deadlineTs = useMemo<number | null>(
|
||||
() => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
|
||||
[event?.registration_end_date],
|
||||
);
|
||||
|
||||
const remainingMs = useMemo<number | null>(
|
||||
() => (deadlineTs != null ? Math.max(0, deadlineTs - nowTs) : null),
|
||||
[deadlineTs, nowTs],
|
||||
);
|
||||
|
||||
const formatCountdownTwoDigit = (value: number) =>
|
||||
toPersianDigits(value.toString().padStart(2, "0"));
|
||||
const formatCountdownNumber = (value: number) => formatNumberPersian(value);
|
||||
|
||||
const formatRemainingWords = (ms: number) => {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(total / 86400);
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
|
||||
if (days === 0) {
|
||||
return `${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
|
||||
}
|
||||
|
||||
return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
|
||||
};
|
||||
|
||||
const meta = useMemo(() => {
|
||||
if (!event) return null;
|
||||
const registrationOpen =
|
||||
(rsTs == null || nowTs >= rsTs) && (deadlineTs == null || nowTs <= deadlineTs);
|
||||
const unlimited = event.capacity == null;
|
||||
const remaining = unlimited
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Math.max(0, (event.capacity || 0) - (event.registration_count || 0));
|
||||
const full = !unlimited && remaining <= 0;
|
||||
return { registrationOpen, remaining, full };
|
||||
}, [deadlineTs, event, nowTs, rsTs]);
|
||||
|
||||
const eventStructuredData = useMemo(() => {
|
||||
if (!event) return null;
|
||||
|
||||
const attendanceModeMap: Record<string, string> = {
|
||||
online: "https://schema.org/OnlineEventAttendanceMode",
|
||||
on_site: "https://schema.org/OfflineEventAttendanceMode",
|
||||
hybrid: "https://schema.org/MixedEventAttendanceMode",
|
||||
};
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
published: "https://schema.org/EventScheduled",
|
||||
completed: "https://schema.org/EventCompleted",
|
||||
cancelled: "https://schema.org/EventCancelled",
|
||||
draft: "https://schema.org/EventPostponed",
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: event.title,
|
||||
description: pageDescription,
|
||||
startDate: event.start_time,
|
||||
url: canonicalUrl,
|
||||
eventAttendanceMode: attendanceModeMap[event.event_type] ?? attendanceModeMap.hybrid,
|
||||
eventStatus: statusMap[event.status] ?? statusMap.published,
|
||||
organizer: {
|
||||
"@type": "Organization",
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
},
|
||||
};
|
||||
|
||||
if (event.end_time) {
|
||||
data.endDate = event.end_time;
|
||||
}
|
||||
|
||||
if (primaryImage) {
|
||||
data.image = [primaryImage];
|
||||
}
|
||||
|
||||
if (event.event_type === "online") {
|
||||
data.location = {
|
||||
"@type": "VirtualLocation",
|
||||
url: event.online_link || canonicalUrl,
|
||||
};
|
||||
} else {
|
||||
const location: Record<string, unknown> = {
|
||||
"@type": "Place",
|
||||
name: event.location || event.address || siteName,
|
||||
};
|
||||
if (event.address) {
|
||||
location.address = event.address;
|
||||
}
|
||||
if (event.location) {
|
||||
location.description = event.location;
|
||||
}
|
||||
data.location = location;
|
||||
}
|
||||
|
||||
const offers: Record<string, unknown> = {
|
||||
"@type": "Offer",
|
||||
url: canonicalUrl,
|
||||
priceCurrency: "IRR",
|
||||
price: String(event.price ?? 0),
|
||||
availability: meta?.full ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
|
||||
};
|
||||
|
||||
if (event.registration_start_date) {
|
||||
offers.validFrom = event.registration_start_date;
|
||||
}
|
||||
if (event.registration_end_date) {
|
||||
offers.validThrough = event.registration_end_date;
|
||||
}
|
||||
|
||||
data.offers = offers;
|
||||
return data;
|
||||
}, [canonicalUrl, event, meta?.full, pageDescription, primaryImage, siteName]);
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content={pageRobots} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="event" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={primaryImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
{event?.start_time && <meta property="event:start_time" content={event.start_time} />}
|
||||
{event?.end_time && <meta property="event:end_time" content={event.end_time} />}
|
||||
{event?.updated_at && <meta property="og:updated_time" content={event.updated_at} />}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={primaryImage} />
|
||||
{eventStructuredData && (
|
||||
<script type="application/ld+json">{JSON.stringify(eventStructuredData)}</script>
|
||||
)}
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
const withHelmet = (node: JSX.Element) => (
|
||||
<>
|
||||
{helmet}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return withHelmet(
|
||||
<div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">
|
||||
در حال بارگذاری رویداد...
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return withHelmet(
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
رویداد مورد نظر یافت نشد.
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
const beforeStart = rsTs != null && nowTs < rsTs;
|
||||
const ended = deadlineTs !== null && remainingMs === 0;
|
||||
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
|
||||
|
||||
return withHelmet(
|
||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
||||
{beforeStart && (
|
||||
<div className="mb-6">
|
||||
<div className="rounded-xl border p-4 text-center bg-sky-50 text-sky-900 border-sky-200 dark:bg-sky-900/30 dark:text-sky-100 dark:border-sky-800">
|
||||
ثبتنام از <strong className="font-semibold">{formatJalali(event.registration_start_date!)}</strong> آغاز میشود.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCountdown && remainingMs != null && (
|
||||
<div className="mb-6">
|
||||
<div className="rounded-xl border p-4 text-center bg-emerald-50 text-emerald-900 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-100 dark:border-emerald-800">
|
||||
<div className="flex flex-col items-center gap-1 sm:flex-row sm:justify-center">
|
||||
<span>زمان باقیمانده تا پایان ثبتنام:</span>
|
||||
<strong className="font-extrabold tracking-wider sm:ms-1">
|
||||
{formatRemainingWords(remainingMs)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ended && (
|
||||
<div className="mb-6">
|
||||
<div className="rounded-xl border p-4 text-center bg-rose-50 text-rose-900 border-rose-200 dark:bg-rose-900/30 dark:text-rose-100 dark:border-rose-800">
|
||||
مهلت ثبتنام به پایان رسیده است.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{event.title}</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{formatJalali(event.start_time)}
|
||||
{event.end_time ? ` تا ${formatJalali(event.end_time)}` : null}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="default">{typeLabel[event.event_type] || event.event_type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown content={event.description} justify size="base" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{event.gallery_images?.length ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{event.gallery_images.map((image) => (
|
||||
<img
|
||||
key={image.id}
|
||||
src={image.absolute_image_url || ""}
|
||||
alt={image.title || ""}
|
||||
className="w-full h-36 object-cover rounded-md"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">اطلاعات ثبتنام</CardTitle>
|
||||
<CardDescription>جزئیات دسترسی به رویداد</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{event.address && <div>آدرس: {event.address}</div>}
|
||||
<div>
|
||||
ظرفیت کل: {event.capacity == null ? "نامحدود" : formatNumberPersian(event.capacity)}
|
||||
</div>
|
||||
{meta && event.capacity != null && (
|
||||
<div>
|
||||
ظرفیت باقیمانده:{" "}
|
||||
{meta.remaining === Number.POSITIVE_INFINITY
|
||||
? "نامحدود"
|
||||
: formatNumberPersian(meta.remaining)}
|
||||
</div>
|
||||
)}
|
||||
<div>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</div>
|
||||
|
||||
<Button
|
||||
onClick={handleMainCTA}
|
||||
className="w-full mt-2"
|
||||
disabled={
|
||||
submitting ||
|
||||
alreadyRegistered ||
|
||||
event.status !== "published" ||
|
||||
meta?.full === true ||
|
||||
!meta?.registrationOpen
|
||||
}
|
||||
>
|
||||
{event.status !== "published"
|
||||
? "ثبتنام این رویداد فعال نیست"
|
||||
: alreadyRegistered
|
||||
? "شما قبلاً ثبتنام کردهاید"
|
||||
: !meta?.registrationOpen
|
||||
? "ثبتنام هنوز آغاز نشده است"
|
||||
: meta?.full
|
||||
? "ظرفیت ثبتنام تکمیل شده است"
|
||||
: submitting
|
||||
? "در حال ثبتنام..."
|
||||
: event.price === 0
|
||||
? "ثبتنام (رایگان)"
|
||||
: "ثبتنام و ادامه پرداخت"}
|
||||
</Button>
|
||||
|
||||
{!isFree && (
|
||||
<CouponDialogFa
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
basePrice={basePrice}
|
||||
onVerifyCouponRaw={(code) => api.checkDiscountCode(event.id, code)}
|
||||
onContinue={handleContinueFromModal}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/views/EventFreeSuccessPage.tsx
Normal file
156
src/views/EventFreeSuccessPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useLocation, useParams, Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import PaymentResult from "@/components/PaymentResult";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
|
||||
export default function EventFreeSuccessPage() {
|
||||
const { slug } = useParams();
|
||||
const search = new URLSearchParams(useLocation().search);
|
||||
const registrationId = search.get("registration_id") || "";
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["registration-verify", registrationId],
|
||||
queryFn: () =>
|
||||
registrationId ? api.verifyMyRegistration(registrationId) : Promise.resolve(null),
|
||||
enabled: Boolean(registrationId),
|
||||
});
|
||||
|
||||
const registrationCodeFull = (data?.ticket_id || registrationId || "").trim();
|
||||
const registrationCodeShort = registrationCodeFull
|
||||
? (registrationCodeFull.split("-")[0] || registrationCodeFull).slice(0, 8)
|
||||
: "";
|
||||
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'East Guilan CE';
|
||||
const canonicalUrl = slug ? `${siteUrl}/events/${slug}/success` : `${siteUrl}/events`;
|
||||
const registrationTitle = data?.event_title || slug || 'Event registration';
|
||||
const ticketSummary = registrationCodeShort ? ` Ticket: ${registrationCodeShort}.` : '';
|
||||
const pageState = isLoading
|
||||
? 'Verifying registration'
|
||||
: isError || !data
|
||||
? 'Registration not found'
|
||||
: 'Registration confirmed';
|
||||
const pageTitle = `${pageState} | ${siteName}`;
|
||||
const registrationCode = registrationId || 'your registration';
|
||||
const pageDescription = data
|
||||
? `Registration confirmed for ${registrationTitle}.${ticketSummary}`
|
||||
: isError
|
||||
? `We could not verify ${registrationCode}.`
|
||||
: registrationId
|
||||
? `Verifying registration ${registrationId} for ${registrationTitle}.`
|
||||
: 'Review your registration status and ticket details.';
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={`${siteUrl}/favicon.ico`} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={`${siteUrl}/favicon.ico`} />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
const renderWithHelmet = (node: JSX.Element) => (
|
||||
<>
|
||||
{helmet}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// اگر بکاند چیزی برنگرداند یا خطا داد
|
||||
if (!data || isError) {
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
<PaymentResult
|
||||
title="اطلاعات ثبتنام در دسترس نیست"
|
||||
subtitle="امکان دریافت جزئیات ثبتنام فراهم نشد. اگر مبلغی پرداخت نشده، ثبتنام شما برای رویداد رایگان انجام شده است."
|
||||
details={[
|
||||
{ label: "کد ثبتنام", value: registrationCodeShort || "—" },
|
||||
{ label: "رویداد", value: slug || "—" },
|
||||
{ label: "مبلغ", value: "رایگان" },
|
||||
]}
|
||||
/>
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Link to={`/events/${slug || ""}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
</Link>
|
||||
<Link to="/events">
|
||||
<Button>مشاهده سایر رویدادها</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const details = [
|
||||
{ label: "عنوان رویداد", value: data.event_title || (slug || "—") },
|
||||
{ label: "شیوه برگزاری", value: data.event_type || "—" },
|
||||
{ label: "کد ثبتنام",
|
||||
value: <code dir="ltr" className="font-mono bg-muted px-2 py-0.5 rounded">{registrationCodeShort || "—"}</code>
|
||||
},
|
||||
{ label: "وضعیت", value: faStatus(data.status) },
|
||||
...(data.registered_at ? [{ label: "تاریخ ثبتنام", value: formatJalali(data.registered_at) }] : []),
|
||||
{ label: "مبلغ", value: "رایگان" },
|
||||
];
|
||||
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
<PaymentResult
|
||||
title="ثبتنام با موفقیت انجام شد 🎉"
|
||||
subtitle={`شما با موفقیت برای «${data.event_title || "رویداد"}» ثبتنام کردهاید.`}
|
||||
details={details}
|
||||
/>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={data.success_markdown} justify size="base" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Link to={`/events/${slug || ""}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
</Link>
|
||||
<Link to="/events">
|
||||
<Button>مشاهده سایر رویدادها</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function faStatus(status?: string) {
|
||||
switch ((status || "").toUpperCase()) {
|
||||
case "CONFIRMED":
|
||||
case "APPROVED":
|
||||
return "تأیید شده";
|
||||
case "PENDING":
|
||||
return "در انتظار";
|
||||
case "CANCELLED":
|
||||
case "CANCELED":
|
||||
return "لغو شده";
|
||||
default:
|
||||
return status || "—";
|
||||
}
|
||||
}
|
||||
262
src/views/Events.tsx
Normal file
262
src/views/Events.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getThumbUrl } from "@/lib/utils";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
type EventsProps = {
|
||||
initialEvents?: Types.EventListItemSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
|
||||
function labelPrice(event: Types.EventListItemSchema) {
|
||||
const price = Number(event?.price ?? 0);
|
||||
return price <= 0 ? "رایگان" : formatToman(price);
|
||||
}
|
||||
|
||||
function modeFa(eventType: Types.EventListItemSchema["event_type"]) {
|
||||
return eventType === "online" ? "آنلاین" : "حضوری";
|
||||
}
|
||||
|
||||
function spotsLeft(event: Types.EventListItemSchema) {
|
||||
const cap = Number(event.capacity);
|
||||
const used = Number(event.registration_count);
|
||||
return cap - used;
|
||||
}
|
||||
|
||||
function isAvailable(event: Types.EventListItemSchema) {
|
||||
const now = new Date();
|
||||
const end = new Date(event.registration_end_date ?? event.start_time);
|
||||
const timeOk = end.getTime() > now.getTime();
|
||||
return timeOk && spotsLeft(event) > 0;
|
||||
}
|
||||
|
||||
function notAvailableReasonFa(event: Types.EventListItemSchema) {
|
||||
const now = new Date();
|
||||
const end = new Date(event.registration_end_date ?? event.start_time);
|
||||
if (end.getTime() <= now.getTime()) return "ثبتنام پایانیافته";
|
||||
if (spotsLeft(event) <= 0) return "ظرفیت تکمیل";
|
||||
return "غیرقابل ثبتنام";
|
||||
}
|
||||
|
||||
export default function Events({
|
||||
initialEvents = [],
|
||||
initialSearch = "",
|
||||
}: EventsProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [events, setEvents] = useState<Types.EventListItemSchema[]>(initialEvents);
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
|
||||
|
||||
const siteName = "East Guilan CE";
|
||||
const pageTitle = `Events | ${siteName}`;
|
||||
const pageDescription =
|
||||
"Discover upcoming and past events organized by the East Guilan Computer Engineering Association, including workshops, competitions, and community programs.";
|
||||
const canonicalUrl = `${siteUrl}/events`;
|
||||
|
||||
const toAbsoluteUrl = (url?: string | null) => {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("http")) return url;
|
||||
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = url.startsWith("/") ? url.slice(1) : url;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
const ogImage = useMemo(() => {
|
||||
if (!events.length) return `${siteUrl}/favicon.ico`;
|
||||
return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
||||
}, [events]);
|
||||
|
||||
const listStructuredData = useMemo(() => {
|
||||
if (!events.length) return null;
|
||||
|
||||
const itemListElement = events.map((eventItem, index) => {
|
||||
const listItem: Record<string, unknown> = {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: `${siteUrl}/events/${eventItem.slug}`,
|
||||
name: eventItem.title,
|
||||
description: eventItem.description,
|
||||
startDate: eventItem.start_time,
|
||||
};
|
||||
|
||||
if (eventItem.end_time) {
|
||||
listItem.endDate = eventItem.end_time;
|
||||
}
|
||||
|
||||
const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem));
|
||||
if (imageUrl) {
|
||||
listItem.image = imageUrl;
|
||||
}
|
||||
|
||||
const placeName = eventItem.location || eventItem.address;
|
||||
if (placeName) {
|
||||
const place: Record<string, unknown> = {
|
||||
"@type": "Place",
|
||||
name: placeName,
|
||||
};
|
||||
if (eventItem.address) {
|
||||
place.address = eventItem.address;
|
||||
}
|
||||
listItem.location = place;
|
||||
}
|
||||
|
||||
return listItem;
|
||||
});
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
url: canonicalUrl,
|
||||
numberOfItems: events.length,
|
||||
itemListElement,
|
||||
};
|
||||
}, [canonicalUrl, events, pageDescription, pageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
setEvents(initialEvents);
|
||||
}, [initialEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getEvents({
|
||||
search: search || undefined,
|
||||
statuses: ["published", "completed"],
|
||||
limit: 30,
|
||||
});
|
||||
setEvents(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading events:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) {
|
||||
params.set("search", search.trim());
|
||||
}
|
||||
const basePath = location.pathname || "/events";
|
||||
const nextPath = params.size
|
||||
? `${basePath}?${params.toString()}`
|
||||
: basePath;
|
||||
navigate(nextPath, { replace: true });
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
{listStructuredData && (
|
||||
<script type="application/ld+json">{JSON.stringify(listStructuredData)}</script>
|
||||
)}
|
||||
</Helmet>
|
||||
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold mb-8">رویدادها</h1>
|
||||
|
||||
<div className="mb-8">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در رویدادها..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{events.map((event) => (
|
||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={getThumbUrl(event)}
|
||||
alt={event.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
|
||||
<Badge variant="default">{modeFa(event.event_type)}</Badge>
|
||||
</div>
|
||||
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-1 text-sm" dir="rtl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">ظرفیت رویداد</span>
|
||||
<span className="font-medium">
|
||||
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}
|
||||
/
|
||||
{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">هزینهی ثبتنام</span>
|
||||
<span className="font-medium">{labelPrice(event)}</span>
|
||||
</div>
|
||||
{isAvailable(event) ? (
|
||||
<Button>جزئیات رویداد</Button>
|
||||
) : (
|
||||
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
src/views/Home.tsx
Normal file
130
src/views/Home.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Home page highlighting the association mission, key offerings, and primary calls to action.
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان";
|
||||
const heroDescription =
|
||||
"با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعهای پویا برای رشد شما فراهم است.";
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: heroTitle,
|
||||
url: "https://east-guilan-ce.ir",
|
||||
sameAs: ["https://east-guilan-ce.ir/blog", "https://east-guilan-ce.ir/events"],
|
||||
description: heroDescription,
|
||||
logo: "https://east-guilan-ce.ir/favicon.ico",
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: "admin@east-guilan-ce.ir",
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"]
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{heroTitle}</title>
|
||||
<meta name="description" content={heroDescription} />
|
||||
<meta property="og:title" content={heroTitle} />
|
||||
<meta property="og:description" content={heroDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://east-guilan-ce.ir" />
|
||||
<meta property="og:image" content="https://east-guilan-ce.ir/favicon.ico" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={heroTitle} />
|
||||
<meta name="twitter:description" content={heroDescription} />
|
||||
<script type="application/ld+json">{JSON.stringify(structuredData)}</script>
|
||||
</Helmet>
|
||||
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<header className="bg-gradient-to-b from-primary/10 to-background py-20">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h1 className="mb-6 text-4xl font-extrabold leading-tight text-primary sm:text-5xl">{heroTitle}</h1>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground sm:text-xl">{heroDescription}</p>
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<Link to="/events">
|
||||
<Button size="lg" aria-label="مشاهده رویدادهای انجمن">
|
||||
مشاهده رویدادها
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/blog">
|
||||
<Button size="lg" variant="outline" aria-label="مطالعه مقالات آموزشی">
|
||||
خواندن مقالات
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="py-16" aria-labelledby="about-section">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 id="about-section" className="mb-12 text-center text-3xl font-bold">
|
||||
درباره انجمن
|
||||
</h2>
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>رویدادهای تخصصی</CardTitle>
|
||||
<CardDescription>برگزاری کارگاهها، سمینارها و نشستهای علمی</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
با شرکت در رویدادهای ما، دانش و مهارتهای خود را در کنار اساتید و متخصصان ارتقا دهید.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>مقالات آموزشی</CardTitle>
|
||||
<CardDescription>دسترسی به مطالب آموزشی و پژوهشی باکیفیت</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
تازهترین مقالات تخصصی در حوزههای مختلف علوم کامپیوتر برای یادگیری و توسعه فردی.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>جامعه فعال</CardTitle>
|
||||
<CardDescription>ارتباط با دانشجویان و اساتید علاقهمند به فناوری</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
به جامعهای پویا بپیوندید و از فرصتهای همکاری، شبکهسازی و رشد شخصی بهرهمند شوید.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-primary/5 py-16" aria-labelledby="cta-section">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 id="cta-section" className="mb-4 text-3xl font-bold">
|
||||
آماده عضویت هستید؟
|
||||
</h2>
|
||||
<p className="mx-auto mb-8 max-w-xl text-muted-foreground">
|
||||
همین حالا به جمع ما بپیوندید و از برنامهها و فرصتهای یادگیری انجمن علمی کامپیوتر دانشگاه گیلان استفاده کنید.
|
||||
</p>
|
||||
<Link to="/auth">
|
||||
<Button size="lg" aria-label="ثبت نام در انجمن علمی کامپیوتر">
|
||||
ثبتنام کنید
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/views/Logout.tsx
Normal file
36
src/views/Logout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function Logout() {
|
||||
const { logout } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
await logout();
|
||||
if (!alive) return;
|
||||
toast({ title: "با موفقیت خارج شدید", variant: "destructive" });
|
||||
} catch (e) {
|
||||
// even if it fails, we still route to /auth
|
||||
} finally {
|
||||
if (alive) navigate("/auth", { replace: true });
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [logout, navigate, toast]);
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center gap-3 text-muted-foreground" dir="rtl">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<div>در حال خروج از حساب کاربری...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
src/views/PaymentResult.tsx
Normal file
277
src/views/PaymentResult.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { useSearchParams, Link } from '@/lib/router';
|
||||
import QRCode from 'react-qr-code';
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { siteUrl } from '@/lib/site';
|
||||
|
||||
type SavedPayment = {
|
||||
event_id: number;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
thumb?: string | null;
|
||||
base_amount?: number;
|
||||
discount_amount?: number;
|
||||
amount?: number;
|
||||
started_at?: string;
|
||||
success_markdown?: string | null;
|
||||
|
||||
};
|
||||
|
||||
export default function PaymentResult() {
|
||||
const [params] = useSearchParams();
|
||||
const status = params.get('status'); // success | failed
|
||||
const refId = params.get('ref_id') || '';
|
||||
const eventId = Number(params.get('event_id') || '0');
|
||||
const humanEventId = eventId ? formatNumberPersian(eventId) : '—';
|
||||
const refIdDisplay = refId ? toPersianDigits(refId) : '';
|
||||
|
||||
const [fallback, setFallback] = useState<SavedPayment | null>(null);
|
||||
|
||||
// 2) اگر saved نبود و refId هست، از بکاند اطلاعات را میگیریم (اندپوینت اختیاری by-ref)
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!refId) return;
|
||||
try {
|
||||
const p = await api.getPaymentByRef(refId);
|
||||
setFallback({
|
||||
event_id: p.event.id,
|
||||
slug: p.event.slug,
|
||||
title: p.event.title,
|
||||
thumb: p.event.image_url || null,
|
||||
base_amount: p.base_amount,
|
||||
discount_amount: p.discount_amount,
|
||||
amount: p.amount,
|
||||
started_at: p.verified_at || undefined,
|
||||
success_markdown: p.event?.success_markdown
|
||||
});
|
||||
} catch {
|
||||
// بیصدا؛ حداقل status/ref_id نمایش داده میشود
|
||||
}
|
||||
})();
|
||||
}, [refId]);
|
||||
|
||||
const data = fallback;
|
||||
|
||||
const ok = status === 'success';
|
||||
const money = (n?: number) => (typeof n === 'number' && Number.isFinite(n) ? formatToman(n) : '—');
|
||||
const receiptRef = useRef<HTMLDivElement | null>(null);
|
||||
const successMarkdown = data?.success_markdown ?? '';
|
||||
|
||||
const siteName = 'East Guilan CE';
|
||||
const canonicalUrl = `${siteUrl}/payments/result`;
|
||||
const toAbsoluteUrl = (url?: string | null) => {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('http')) return url;
|
||||
const normalizedSite = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
const eventTitle = data?.title || (eventId ? `Event #${humanEventId}` : 'Event payment');
|
||||
const referenceFragment = refId ? ` Reference: ${refIdDisplay}.` : '';
|
||||
const pageState =
|
||||
status === 'success'
|
||||
? 'Payment successful'
|
||||
: status === 'failed'
|
||||
? 'Payment failed'
|
||||
: 'Payment status';
|
||||
const pageTitle = `${pageState} | ${siteName}`;
|
||||
const pageDescription = `${ok ? 'Payment confirmed' : 'Review your payment status'} for ${eventTitle}.${referenceFragment}`;
|
||||
const ogImage = toAbsoluteUrl(data?.thumb) ?? `${siteUrl}/favicon.ico`;
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
</Helmet>
|
||||
);
|
||||
const renderWithHelmet = (node: JSX.Element) => (
|
||||
<>
|
||||
{helmet}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
|
||||
const qrValue = useMemo(() => {
|
||||
// لینک قابل بررسی/اشتراکگذاری
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : siteUrl;
|
||||
const url = new URL(`${base}/payments/result`);
|
||||
if (refId) url.searchParams.set('ref_id', refId);
|
||||
if (eventId) url.searchParams.set('event_id', String(eventId));
|
||||
url.searchParams.set('status', ok ? 'success' : 'failed');
|
||||
return url.toString();
|
||||
}, [eventId, ok, refId]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
const el = receiptRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Force a light snapshot for the PDF
|
||||
const prevBg = el.style.backgroundColor;
|
||||
const prevColor = el.style.color;
|
||||
el.style.backgroundColor = '#ffffff';
|
||||
el.style.color = '#000000';
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
});
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const ratio = Math.min(pageWidth / canvas.width, pageHeight / canvas.height);
|
||||
const imgWidth = canvas.width * ratio;
|
||||
const imgHeight = canvas.height * ratio;
|
||||
const x = (pageWidth - imgWidth) / 2;
|
||||
const y = 24;
|
||||
|
||||
// (Optional) paint white page background
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.rect(0, 0, pageWidth, pageHeight, 'F');
|
||||
|
||||
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
|
||||
pdf.save(`receipt-${refId || eventId}.pdf`);
|
||||
} finally {
|
||||
// restore colors for on-screen
|
||||
el.style.backgroundColor = prevBg;
|
||||
el.style.color = prevColor;
|
||||
}
|
||||
};
|
||||
|
||||
return renderWithHelmet(
|
||||
<div className="min-h-[60vh] flex items-center justify-center p-4 bg-background" dir="rtl">
|
||||
<Card className="w-full max-w-2xl bg-card text-card-foreground border-border">
|
||||
<CardHeader className="print:hidden">
|
||||
<CardTitle>نتیجهٔ پرداخت</CardTitle>
|
||||
<CardDescription>وضعیت تراکنش شما</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* RECEIPT AREA */}
|
||||
<div
|
||||
ref={receiptRef}
|
||||
className="rounded-lg border border-border p-4 md:p-6 bg-card text-card-foreground"
|
||||
>
|
||||
{/* Header (status + ref) */}
|
||||
<div
|
||||
className={[
|
||||
"rounded-md p-3 text-sm mb-4 border",
|
||||
ok
|
||||
? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-200 dark:border-emerald-800/50"
|
||||
: "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-200 dark:border-red-800/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{ok ? "پرداخت با موفقیت انجام شد." : "پرداخت ناموفق بود."}
|
||||
</div>
|
||||
|
||||
{/* Event + QR */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
||||
{/* Thumb */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="aspect-[16/9] overflow-hidden rounded-md bg-muted">
|
||||
{data?.thumb ? (
|
||||
<img
|
||||
src={data.thumb}
|
||||
alt={data?.title || ""}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
بدون تصویر
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="md:col-span-1 space-y-1">
|
||||
<div className="text-sm text-muted-foreground">رویداد</div>
|
||||
<div className="font-semibold">{data?.title || `#${humanEventId}`}</div>
|
||||
|
||||
{refId && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground mt-3">کد پیگیری</div>
|
||||
<div className="font-mono break-all">{refIdDisplay}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="md:col-span-1 flex md:justify-end">
|
||||
<div className="p-3 border border-border rounded-md">
|
||||
<QRCode value={qrValue} size={112} />
|
||||
<div className="mt-2 text-[10px] text-center text-muted-foreground break-all">
|
||||
{qrValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={successMarkdown} justify size="base" />
|
||||
</div>
|
||||
|
||||
{/* Invoice */}
|
||||
<div className="mt-6 rounded-md border border-border p-3">
|
||||
<div className="text-sm font-medium mb-2">جزئیات پرداخت</div>
|
||||
<ul className="text-sm divide-y divide-border/60">
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">مبلغ پایه</span>
|
||||
<span>{money(data?.base_amount)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">تخفیف</span>
|
||||
<span>{money(data?.discount_amount)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2 font-semibold">
|
||||
<span>مبلغ نهایی</span>
|
||||
<span>{money(data?.amount)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-end print:hidden">
|
||||
{data?.slug ? (
|
||||
<Link to={`/events/${data.slug}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/events">
|
||||
<Button variant="outline">رویدادها</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => window.print()}>
|
||||
چاپ
|
||||
</Button>
|
||||
<Button onClick={handleDownloadPdf}>دانلود PDF</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
609
src/views/Profile.tsx
Normal file
609
src/views/Profile.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
"use client";
|
||||
|
||||
import type * as Types from '@/lib/types';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Navigate, Link } from '@/lib/router';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { Loader2, Upload, Trash2 } from 'lucide-react';
|
||||
import { formatJalali, formatNumberPersian, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
|
||||
export default function Profile() {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
data: myRegs,
|
||||
isLoading: regsLoading,
|
||||
isError: regsError,
|
||||
} = useQuery({
|
||||
queryKey: ['my-registrations'],
|
||||
queryFn: () => api.getMyRegistrations(),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: majors, isLoading: majorsLoading } = useQuery({
|
||||
queryKey: ['majors'],
|
||||
queryFn: () => api.getMajors(),
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: universities, isLoading: universitiesLoading } = useQuery({
|
||||
queryKey: ['universities'],
|
||||
queryFn: () => api.getUniversities(),
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const confirmedRegistrations = useMemo(
|
||||
() => myRegs?.filter((reg) => reg.status === 'confirmed' || reg.status === 'attended') ?? [],
|
||||
[myRegs],
|
||||
);
|
||||
const pendingRegistrations = useMemo(
|
||||
() => myRegs?.filter((reg) => reg.status === 'pending') ?? [],
|
||||
[myRegs],
|
||||
);
|
||||
const canceledRegistrations = useMemo(
|
||||
() => myRegs?.filter((reg) => reg.status === 'cancelled') ?? [],
|
||||
[myRegs],
|
||||
);
|
||||
|
||||
const [me, setMe] = useState<Types.UserProfileSchema | null>(user ?? null);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<Types.UserUpdateSchema>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
bio: '',
|
||||
year_of_study: null,
|
||||
major: null,
|
||||
university: null,
|
||||
student_id: '',
|
||||
});
|
||||
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'انجمن علمی کامپیوتر شرق دانشگاه گیلان';
|
||||
const canonicalUrl = `${siteUrl}/profile`;
|
||||
const toAbsoluteSiteUrl = (url?: string | null) => {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('http')) return url;
|
||||
const normalizedSite = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
const statusLabels: Record<Types.MyEventRegistrationSchema['status'], string> = {
|
||||
confirmed: 'تایید شده',
|
||||
cancelled: 'لغو شده',
|
||||
pending: 'در انتظار',
|
||||
attended: 'حضور یافت',
|
||||
};
|
||||
|
||||
const renderRegistrationRow = (registration: Types.MyEventRegistrationSchema) => {
|
||||
const eventWithOptionalDate = registration.event as Types.EventListItemSchema & {
|
||||
start_date?: string;
|
||||
};
|
||||
const rawDate = eventWithOptionalDate.start_date ?? eventWithOptionalDate.start_time;
|
||||
const dateLabel = rawDate ? `تاریخ شروع: ${formatJalali(rawDate)}` : '';
|
||||
const statusLabel = statusLabels[registration.status] ?? registration.status;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={registration.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{registration.event.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{statusLabel}
|
||||
{dateLabel ? ` • ${dateLabel}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/events/${registration.event.slug}`} className="text-primary text-sm">
|
||||
مشاهده رویداد
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { pageTitle, pageDescription, ogImage } = useMemo(() => {
|
||||
const nameParts = [me?.first_name, me?.last_name].filter(Boolean) as string[];
|
||||
const displayName = nameParts.join(' ').trim();
|
||||
const identifier = displayName || me?.username || me?.email || 'عضو';
|
||||
const title = `پروفایل ${identifier} | ${siteName}`;
|
||||
const description = `مدیریت پروفایل ${identifier} در انجمن علمی کامپیوتر شرق گیلان؛ بهروزرسانی اطلاعات شخصی و مرور ثبتنام رویدادها.`;
|
||||
const image = toAbsoluteSiteUrl(me?.profile_picture) ?? `${siteUrl}/favicon.ico`;
|
||||
return { pageTitle: title, pageDescription: description, ogImage: image };
|
||||
}, [me?.first_name, me?.last_name, me?.username, me?.email, me?.profile_picture, siteName, siteUrl]);
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="profile" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setFetching(true);
|
||||
const profile = await api.getProfile();
|
||||
setMe(profile);
|
||||
setFormData({
|
||||
first_name: profile.first_name ?? '',
|
||||
last_name: profile.last_name ?? '',
|
||||
bio: profile.bio ?? '',
|
||||
year_of_study: typeof profile.year_of_study === 'number' ? profile.year_of_study : null,
|
||||
major: profile.major ?? null,
|
||||
university: profile.university ?? null,
|
||||
student_id: profile.student_id ?? null,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا در دریافت پروفایل',
|
||||
description: resolveErrorMessage(error, 'مشکلی پیش آمد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) loadProfile();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!majors) return;
|
||||
if (me?.major) {
|
||||
const found = majors.find(m => m.code === me.major || m.label === me.major);
|
||||
if (found && formData.major !== found.code) {
|
||||
setFormData(prev => ({ ...prev, major: found.code }));
|
||||
}
|
||||
}
|
||||
}, [majors, me?.major]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const majorLabel = useMemo(() => {
|
||||
if (!me?.major) return '—';
|
||||
if (majors) {
|
||||
const f = majors.find(m => m.code === me.major || m.label === me.major);
|
||||
return f?.label ?? me.major;
|
||||
}
|
||||
return me.major;
|
||||
}, [majors, me?.major]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!universities) return;
|
||||
if (me?.university) {
|
||||
const found = universities.find(u => u.code === me.university || u.label === me.university);
|
||||
if (found && formData.university !== found.code) {
|
||||
setFormData(prev => ({ ...prev, university: found.code }));
|
||||
}
|
||||
}
|
||||
}, [universities, me?.university]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const universityLabel = useMemo(() => {
|
||||
if (!me?.university) return '—';
|
||||
if (universities) {
|
||||
const f = universities.find(u => u.code === me.university || u.label === me.university);
|
||||
return f?.label ?? me.university;
|
||||
}
|
||||
return me.university;
|
||||
}, [universities, me?.university]);
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onUpload(f);
|
||||
e.currentTarget.value = ''; // allow picking the same file again later
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload: Types.UserUpdateSchema = {
|
||||
first_name: formData.first_name ?? '',
|
||||
last_name: formData.last_name ?? '',
|
||||
bio: formData.bio ?? '',
|
||||
year_of_study:
|
||||
formData.year_of_study === undefined || formData.year_of_study === null
|
||||
? null
|
||||
: Number(formData.year_of_study),
|
||||
major: formData.major || null,
|
||||
university: formData.university || null,
|
||||
student_id: formData.student_id || null,
|
||||
};
|
||||
|
||||
const updated = await api.updateProfile(payload);
|
||||
setMe(updated);
|
||||
setEditing(false);
|
||||
toast({ title: 'پروفایل بهروزرسانی شد', variant: 'success' });
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا در ذخیره پروفایل',
|
||||
description: resolveErrorMessage(error, 'مشکلی پیش آمد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onPickFile = () => fileInputRef.current?.click();
|
||||
|
||||
const onUpload = async (file: File) => {
|
||||
try {
|
||||
setUploading(true);
|
||||
toast({ title: 'در حال آپلود تصویر...' });
|
||||
await api.uploadProfilePicture(file); // POST /api/auth/profile/picture
|
||||
await loadProfile();
|
||||
toast({ title: 'تصویر پروفایل بهروزرسانی شد', variant: 'success' });
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا در آپلود تصویر',
|
||||
description: resolveErrorMessage(error, 'مشکلی پیش آمد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeletePicture = async () => {
|
||||
try {
|
||||
await api.deleteProfilePicture();
|
||||
await loadProfile();
|
||||
toast({ title: 'تصویر پروفایل حذف شد' });
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا در حذف تصویر',
|
||||
description: resolveErrorMessage(error, 'مشکلی پیش آمد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!loading && !isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<Navigate to="/auth" replace />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const kv = (label: string, value: React.ReactNode) => (
|
||||
<div className="grid grid-cols-3 gap-3 items-center py-2" dir="rtl">
|
||||
<div className="text-sm text-muted-foreground text-right">{label}</div>
|
||||
<div className="col-span-2 text-sm text-right">{value ?? '—'}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{helmet}
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader dir="rtl" className="text-right">
|
||||
<div className="flex flex-col gap-4 items-center sm:flex-row">
|
||||
|
||||
<div className="relative shrink-0">
|
||||
{/* CLICKABLE AVATAR */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickFile}
|
||||
aria-label="تغییر عکس پروفایل"
|
||||
className="
|
||||
group relative h-20 w-20 sm:h-20 sm:w-20 rounded-full overflow-hidden
|
||||
bg-muted flex items-center justify-center
|
||||
ring-0 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
cursor-pointer
|
||||
"
|
||||
title="تغییر عکس پروفایل"
|
||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||
>
|
||||
{me?.profile_picture ? (
|
||||
<img
|
||||
src={me.profile_picture}
|
||||
alt="avatar"
|
||||
className="h-full w-full object-cover transition-transform group-active:scale-95"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl transition-transform group-active:scale-95">
|
||||
{(me?.first_name?.[0] || me?.last_name?.[0] || me?.email?.[0] || '?').toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* SPINNER OVERLAY DURING UPLOAD */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* FLOATING TRASH ICON (bigger touch target, only if picture exists) */}
|
||||
{me?.profile_picture && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeletePicture}
|
||||
aria-label="حذف تصویر پروفایل"
|
||||
className="
|
||||
absolute -bottom-3
|
||||
rounded-full bg-destructive text-destructive-foreground
|
||||
p-2 shadow-lg hover:opacity-95 active:scale-95
|
||||
focus:outline-none focus:ring-2 focus:ring-destructive/70
|
||||
"
|
||||
title="حذف تصویر"
|
||||
style={{ WebkitTapHighlightColor: 'transparent', touchAction: 'manipulation' }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 text-center sm:text-start">
|
||||
<div className="text-xl font-semibold truncate">{me?.first_name || '—'} { me?.last_name }</div>
|
||||
<div className="text-sm text-muted-foreground truncate">{me?.email || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent>
|
||||
{loading || fetching ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>در حال بارگذاری پروفایل...</span>
|
||||
</div>
|
||||
) : !editing ? (
|
||||
/* حالت مشاهده */
|
||||
<div className="space-y-2" dir="rtl">
|
||||
<div className="mb-2">
|
||||
{me?.bio && ( <Markdown content={me.bio} justify /> )}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="border-none shadow-none">
|
||||
<CardHeader className="px-0 pt-0 pb-2">
|
||||
<CardTitle className="text-base text-right">اطلاعات شخصی</CardTitle>
|
||||
<CardDescription className="text-right">مشاهده اطلاعات شما</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{kv('نام', me?.first_name || '—')}
|
||||
{kv('نام خانوادگی', me?.last_name || '—')}
|
||||
{kv('شماره دانشجویی', me?.student_id ? toPersianDigits(me.student_id) : '—')}
|
||||
{kv('دانشگاه', universityLabel)}
|
||||
{kv('رشته', majorLabel)}
|
||||
{kv('سال ورود', typeof me?.year_of_study === 'number' ? toPersianDigits(String(me?.year_of_study)) : '—')}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-none shadow-none">
|
||||
<CardHeader className="px-0 pt-0 pb-2">
|
||||
<CardTitle className="text-base text-right">اطلاعات حساب</CardTitle>
|
||||
<CardDescription className="text-right">جزئیات مربوط به حساب کاربری</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{kv('ایمیل', me?.email || '—')}
|
||||
{kv('نام کاربری', me?.username || '—')}
|
||||
{kv('تاریخ عضویت', formatJalali(me?.date_joined))}
|
||||
{kv('تغییر رمز عبور', <Link to="/reset-password" className="block text-xs text-muted-foreground hover:text-foreground underline underline-offset-4 text-right">ارسال ایمیل فراموشی رمز عبور</Link>)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button onClick={() => setEditing(true)}>ویرایش پروفایل</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* حالت ویرایش: فقط فیلدهای UserUpdateSchema */
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-2 gap-4" dir="rtl">
|
||||
<div>
|
||||
<Label htmlFor="first_name" className="block text-right">نام</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
dir="rtl"
|
||||
value={formData.first_name ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="last_name" className="block text-right">نام خانوادگی</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
dir="rtl"
|
||||
value={formData.last_name ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="year_of_study" className="block text-right">سال ورود</Label>
|
||||
<Input
|
||||
id="year_of_study"
|
||||
dir="rtl"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={formData.year_of_study ?? ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
year_of_study: e.target.value === '' ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="university" className="block text-right">دانشگاه</Label>
|
||||
{universitiesLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<Select
|
||||
value={formData.university ?? ''}
|
||||
onValueChange={(v) => setFormData({ ...formData, university: v || null })}
|
||||
>
|
||||
<SelectTrigger id="university" dir="rtl" className="justify-between">
|
||||
<SelectValue placeholder="انتخاب دانشگاه" />
|
||||
</SelectTrigger>
|
||||
<SelectContent dir="rtl" className="max-h-64">
|
||||
{universities?.map((u) => (
|
||||
<SelectItem key={u.code} value={u.code}>
|
||||
{u.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="major" className="block text-right">رشته</Label>
|
||||
|
||||
{majorsLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<Select
|
||||
value={formData.major ?? ''}
|
||||
onValueChange={(v) => setFormData({ ...formData, major: v || null })}
|
||||
>
|
||||
<SelectTrigger id="major" dir="rtl" className="justify-between">
|
||||
<SelectValue placeholder="انتخاب رشته" />
|
||||
</SelectTrigger>
|
||||
<SelectContent dir="rtl" className="max-h-64">
|
||||
{majors?.map((m) => (
|
||||
<SelectItem key={m.code} value={m.code}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="student_id" className="block text-right">شماره دانشجویی</Label>
|
||||
<Input
|
||||
id="student_id"
|
||||
dir="rtl"
|
||||
value={formData.student_id ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, student_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="bio" className="block text-right">بیو</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
dir="rtl"
|
||||
rows={10}
|
||||
className="resize-y"
|
||||
value={formData.bio ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex gap-2 mt-2 justify-end">
|
||||
<Button type="submit">ذخیره</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ثبتنامهای من</CardTitle>
|
||||
<CardDescription>رویدادهایی که در آنها ثبتنام کردهاید</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{regsLoading && <div className="text-sm text-muted-foreground">در حال بارگذاری…</div>}
|
||||
{regsError && <div className="text-sm text-red-500">خطا در دریافت ثبتنامها</div>}
|
||||
{!regsLoading && !regsError && (!myRegs || myRegs.length === 0) && (
|
||||
<div className="text-sm text-muted-foreground">هنوز در رویدادی ثبتنام نکردهاید.</div>
|
||||
)}
|
||||
{!regsLoading && !regsError && myRegs && myRegs.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{confirmedRegistrations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
ثبتنامهای تأیید شده
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{confirmedRegistrations.map(renderRegistrationRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{pendingRegistrations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
ثبتنامهای در انتظار
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingRegistrations.map(renderRegistrationRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{canceledRegistrations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="text-sm font-semibold text-muted-foreground">
|
||||
ثبتنامهای لغو شده
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{canceledRegistrations.map(renderRegistrationRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/views/ResetPasswordConfirm.tsx
Normal file
77
src/views/ResetPasswordConfirm.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from '@/lib/router';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ResetPasswordConfirm() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
toast({ title: 'توکن نامعتبر است', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
toast({ title: 'رمز عبور کوتاه است', description: 'حداقل ۸ کاراکتر', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
toast({ title: 'عدم تطابق', description: 'تکرار رمز با رمز جدید یکسان نیست', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.resetPasswordConfirm(token, password);
|
||||
toast({ title: 'رمز عبور با موفقیت تغییر کرد', variant: 'success' });
|
||||
navigate('/auth');
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error, 'مشکلی رخ داد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" dir="rtl">
|
||||
<CardHeader>
|
||||
<CardTitle>تعیین رمز جدید</CardTitle>
|
||||
<CardDescription>رمز عبور جدید را وارد کنید</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="password">رمز عبور جدید</Label>
|
||||
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirm">تکرار رمز</Label>
|
||||
<Input id="confirm" type="password" required value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ثبت...' : 'ثبت رمز جدید'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
src/views/ResetPasswordRequest.tsx
Normal file
60
src/views/ResetPasswordRequest.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function ResetPasswordRequest() {
|
||||
const { toast } = useToast();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
await api.requestPasswordReset(email);
|
||||
toast({
|
||||
title: 'اگر ایمیلی ثبت شده باشد، لینک بازیابی ارسال شد',
|
||||
description: 'ایمیل خود را بررسی کنید.',
|
||||
variant: 'success'
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// بکاند 200 میدهد حتی اگر ایمیل نباشد؛ اما اگر اروری بیاید، نشان بده
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: resolveErrorMessage(error, 'مشکلی رخ داد'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" dir="rtl">
|
||||
<CardHeader>
|
||||
<CardTitle>بازیابی رمز عبور</CardTitle>
|
||||
<CardDescription>ایمیلتان را وارد کنید تا لینک بازیابی برای شما ارسال شود</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">ایمیل</Label>
|
||||
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? 'در حال ارسال...' : 'ارسال لینک بازیابی'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/views/VerifyEmail.tsx
Normal file
122
src/views/VerifyEmail.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckCircle2, Info, XCircle } from "lucide-react";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
type State =
|
||||
| { kind: "loading" }
|
||||
| { kind: "success"; message: string }
|
||||
| { kind: "already"; message: string }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["verify-email", token],
|
||||
queryFn: async (): Promise<State> => {
|
||||
if (!token) throw new Error("توکن تأیید یافت نشد.");
|
||||
try {
|
||||
const res = await api.verifyEmail(token);
|
||||
return { kind: "success", message: "ایمیل شما با موفقیت تأیید شد." };
|
||||
} catch (error: unknown) {
|
||||
const msg: string = resolveErrorMessage(error, "").toLowerCase();
|
||||
if (msg.includes("already verified")) {
|
||||
return { kind: "already", message: "ایمیل شما قبلاً تأیید شده است." };
|
||||
}
|
||||
if (msg.includes("invalid verification token")) {
|
||||
return { kind: "error", message: "توکن تأیید نامعتبر است." };
|
||||
}
|
||||
return {
|
||||
kind: "error",
|
||||
message: "متأسفانه خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||
};
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "تأیید ایمیل";
|
||||
}, []);
|
||||
|
||||
const renderBody = () => {
|
||||
if (query.isLoading || query.data?.kind === "loading") {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>در حال تأیید ایمیل...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || query.data?.kind === "error") {
|
||||
const message =
|
||||
(query.data && "message" in query.data && query.data.message) ||
|
||||
"خطای ناشناخته رخ داد";
|
||||
return (
|
||||
<Alert variant="destructive" dir="rtl" className="text-right">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<AlertTitle>خطا</AlertTitle>
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.data?.kind === "already") {
|
||||
return (
|
||||
<Alert dir="rtl" className="text-right">
|
||||
<Info className="h-5 w-5" />
|
||||
<AlertTitle>توجه</AlertTitle>
|
||||
<AlertDescription>{query.data.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// success
|
||||
return (
|
||||
<Alert dir="rtl" className="text-right">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<AlertTitle>تبریک!</AlertTitle>
|
||||
<AlertDescription>ایمیل شما با موفقیت تأیید شد.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-lg" dir="rtl">
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>تأیید ایمیل</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">{renderBody()}</CardContent>
|
||||
<CardFooter className="flex items-center justify-between gap-3">
|
||||
<Button asChild variant="secondary" className="min-w-32">
|
||||
<Link to="/">رفتن به صفحهٔ اصلی</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild className="min-w-32">
|
||||
<Link to="/auth">ورود به حساب</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="min-w-32">
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user