refactor(all): migrate from React to Next.js

This commit is contained in:
2026-05-20 09:46:17 +03:30
parent dacbd3a328
commit f23108cda3
86 changed files with 2831 additions and 2679 deletions

262
src/views/Events.tsx Normal file
View 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>
</>
);
}