365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|