Files
guilan-ace-frontend/src/views/EventDetail.tsx

365 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
import { CalendarDays, Clock3, MapPin, Ticket, UsersRound } from "lucide-react";
import { Link, 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 GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryLightbox";
import ProgressiveImage from "@/components/ProgressiveImage";
import { useToast } from "@/hooks/use-toast";
import Markdown from "@/components/Markdown";
import {
formatJalali,
formatNumberPersian,
formatToman,
getEventHeroImageUrl,
getEventSeoImageUrl,
getGalleryImageBlurUrl,
getGalleryImageFullUrl,
getGalleryImagePreviewUrl,
resolveErrorMessage,
toPersianDigits,
} from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import { buildAuthPath } from "@/lib/auth-return";
import { getEventPath, getEventRegistrationState } from "@/lib/event-ui";
const typeLabel: Record<string, string> = {
online: "آنلاین",
on_site: "حضوری",
hybrid: "آنلاین و حضوری",
};
function formatDateTimeRange(start?: string | null, end?: string | null) {
if (!start) return end ? formatJalali(end) : "—";
if (!end) return formatJalali(start);
const startDate = formatJalali(start, false);
if (startDate !== "—" && startDate === formatJalali(end, false)) {
const formatTime = (value: string) => new Intl.DateTimeFormat("fa-IR", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
return `${startDate}، ساعت ${formatTime(start)} تا ${formatTime(end)}`;
}
return `${formatJalali(start)} تا ${formatJalali(end)}`;
}
type EventDetailProps = {
initialEvent?: Types.EventDetailSchema | null;
};
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 [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(!initialEvent);
const [eventLoadError, setEventLoadError] = useState(false);
const [eventLoadAttempt, setEventLoadAttempt] = useState(0);
const [nowTs, setNowTs] = useState(() => Date.now());
const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
const handleMainCTA = () => {
if (!event) return;
if (!isAuthenticated) {
navigate(buildAuthPath(`${getEventPath(event)}/registration`));
return;
}
navigate(`${getEventPath(event)}/registration`);
};
useEffect(() => {
let cancelled = false;
async function loadEvent() {
try {
setEventLoadError(false);
setLoading(true);
if (!slug) return;
if (initialEvent && (initialEvent.slug === slug || String(initialEvent.id) === slug)) {
setEvent(initialEvent);
return;
}
setEvent(null);
const data = /^\d+$/.test(slug)
? await api.getEventById(Number(slug))
: await api.getEventBySlug(slug);
if (cancelled) return;
setEvent(data);
} catch (error: unknown) {
if (cancelled) return;
setEventLoadError(true);
toast({
title: "خطا در بارگذاری رویداد",
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
variant: "destructive",
});
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadEvent();
return () => {
cancelled = true;
};
}, [eventLoadAttempt, initialEvent, slug, toast]);
useEffect(() => {
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
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 registration = event ? getEventRegistrationState(event, nowTs) : null;
const galleryItems = useMemo<GalleryLightboxItem[]>(
() =>
(event?.gallery_images ?? []).map((image) => ({
id: image.id,
alt: image.title || event?.title || "تصویر رویداد",
title: image.title,
previewSrc: getGalleryImagePreviewUrl(image),
blurSrc: getGalleryImageBlurUrl(image),
fullSrc: getGalleryImageFullUrl(image),
})),
[event],
);
if (loading) {
return <div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">در حال بارگذاری رویداد...</div>;
}
if (!event) {
return (
<div className="container mx-auto flex min-h-[60vh] items-center justify-center px-4 py-10" dir="rtl">
<Card className="w-full max-w-xl">
<CardContent className="py-10 text-center">
<h1 className="text-lg font-semibold">{eventLoadError ? "بارگذاری رویداد ناموفق بود" : "رویداد مورد نظر یافت نشد"}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{eventLoadError ? "اتصال را بررسی کنید و دوباره تلاش کنید." : "ممکن است نشانی رویداد تغییر کرده باشد یا رویداد در دسترس نباشد."}
</p>
<div className="mt-5 flex justify-center gap-2">
{eventLoadError ? (
<Button type="button" onClick={() => setEventLoadAttempt((attempt) => attempt + 1)}>تلاش دوباره</Button>
) : null}
<Button asChild variant="outline"><Link to="/events">بازگشت به رویدادها</Link></Button>
</div>
</CardContent>
</Card>
</div>
);
}
const beforeStart = registration?.state === "not_started";
const ended = registration?.state === "closed";
const showCountdown = !beforeStart && !ended && deadlineTs !== null && (remainingMs ?? 0) > 0;
return (
<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 gap-6 lg:grid-cols-3 lg:gap-8">
<div className="lg:col-span-2">
<Card className="overflow-hidden">
<div className="aspect-video w-full overflow-hidden bg-muted">
<ProgressiveImage
src={getEventHeroImageUrl(event)}
blurSrc={event.absolute_featured_image_thumbnail_url}
alt={event.title}
wrapperClassName="h-full w-full"
className="h-full w-full object-cover"
/>
</div>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="text-xl leading-8 md:text-2xl">{event.title}</CardTitle>
<CardDescription className="mt-2">
{formatDateTimeRange(event.start_time, event.end_time)}
</CardDescription>
</div>
<Badge variant="outline" className="w-fit shrink-0">{typeLabel[event.event_type] || event.event_type}</Badge>
</div>
</CardHeader>
<CardContent>
<Markdown content={event.description} justify size="base" />
</CardContent>
</Card>
{galleryItems.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">
{galleryItems.map((image, index) => (
<button
key={image.id}
type="button"
className="overflow-hidden rounded-md text-right transition-transform hover:scale-[1.01] focus:outline-none focus:ring-2 focus:ring-primary/40"
onClick={() => setLightboxIndex(index)}
>
<ProgressiveImage
src={image.previewSrc}
blurSrc={image.blurSrc}
alt={image.alt}
wrapperClassName="h-36 w-full"
className="h-36 w-full object-cover"
/>
</button>
))}
</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">
<div className="space-y-3 border-b border-border pb-4">
<div className="flex items-start gap-3">
<CalendarDays aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">زمان برگزاری</p>
<p className="mt-1 font-medium">{formatDateTimeRange(event.start_time, event.end_time)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPin aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">محل برگزاری</p>
<p className="mt-1 font-medium">{event.address || (event.event_type === "online" ? "برگزاری آنلاین" : event.event_type === "hybrid" ? "برگزاری آنلاین و حضوری" : "جزئیات محل برگزاری در توضیحات رویداد")}</p>
</div>
</div>
<div className="flex items-start gap-3">
<UsersRound aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">ظرفیت</p>
<p className="mt-1 font-medium">
{event.capacity == null
? "بدون محدودیت ظرفیت"
: `${formatNumberPersian(registration?.remaining ?? Math.max(0, event.capacity - (event.registration_count ?? 0)))} نفر از ${formatNumberPersian(event.capacity)} نفر باقی مانده`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Ticket aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">هزینه ثبت‌نام</p>
<p className="mt-1 font-medium">{isFree ? "رایگان" : formatToman(basePrice)}</p>
</div>
</div>
{event.registration_end_date ? (
<div className="flex items-start gap-3">
<Clock3 aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">مهلت ثبت‌نام</p>
<p className="mt-1 font-medium">{formatJalali(event.registration_end_date)}</p>
</div>
</div>
) : null}
</div>
<Button
onClick={handleMainCTA}
className="w-full mt-2"
>
{registration?.state === "open"
? isFree ? "ثبت‌نام رایگان" : "ثبت‌نام و پرداخت"
: "مشاهدهٔ وضعیت ثبت‌نام"}
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
<GalleryLightbox
items={galleryItems}
open={lightboxIndex !== null}
initialIndex={lightboxIndex ?? 0}
onOpenChange={(isOpen) => {
if (!isOpen) {
setLightboxIndex(null);
}
}}
/>
</div>
);
}