Files
guilan-ace-frontend/src/views/EventDetail.tsx
Amirhossein Khalili 18de81c173
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
F(frontend): add image lightbox and derivative fallbacks
2026-05-20 14:26:49 +03:30

654 lines
23 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 { 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 GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryLightbox";
import ProgressiveImage from "@/components/ProgressiveImage";
import { useToast } from "@/hooks/use-toast";
import Markdown from "@/components/Markdown";
import CouponDialogFa from "@/components/CouponDialogFa";
import {
formatJalali,
formatNumberPersian,
formatToman,
getEventCardImageUrl,
getEventHeroImageUrl,
getEventSeoImageUrl,
getGalleryImageBlurUrl,
getGalleryImageFullUrl,
getGalleryImagePreviewUrl,
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 ? getEventCardImageUrl(initialEvent) : null,
);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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(getEventSeoImageUrl(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(getEventCardImageUrl(initialEvent));
return;
}
const data = await api.getEventBySlug(slug);
if (cancelled) return;
setEvent(data);
setEventThumb(getEventCardImageUrl(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 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],
);
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">
<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 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>
{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">
{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>
<GalleryLightbox
items={galleryItems}
open={lightboxIndex !== null}
initialIndex={lightboxIndex ?? 0}
onOpenChange={(isOpen) => {
if (!isOpen) {
setLightboxIndex(null);
}
}}
/>
</div>
);
}