feat(events): unify registration checkout and receipts

This commit is contained in:
2026-10-01 12:21:27 +03:30
parent 9f3f3631ac
commit 0f5ec429d0
21 changed files with 1593 additions and 840 deletions

View File

@@ -1,7 +1,8 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "@/lib/router";
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";
@@ -11,12 +12,10 @@ import GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryL
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,
@@ -27,6 +26,7 @@ import {
} 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: "آنلاین",
@@ -34,32 +34,27 @@ const typeLabel: Record<string, string> = {
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;
};
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();
@@ -67,185 +62,21 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
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 [eventLoadError, setEventLoadError] = useState(false);
const [eventLoadAttempt, setEventLoadAttempt] = useState(0);
const [nowTs, setNowTs] = useState(() => Date.now());
const checkoutInProgressRef = useRef(false);
const checkoutIdempotencyKeyRef = useRef<string | null>(null);
const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
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) => {
const handleMainCTA = () => {
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(buildAuthPath(`/events/${event.slug}`));
navigate(buildAuthPath(`${getEventPath(event)}/registration`));
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(buildAuthPath(`/events/${event.slug}`));
return;
}
if (checkoutInProgressRef.current) return;
checkoutInProgressRef.current = true;
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,
idempotency_key: checkoutIdempotencyKeyRef.current ??= crypto.randomUUID(),
});
if (Number(result?.amount ?? 0) === 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;
}
if (result.status === "failed" || result.status === "canceled") {
checkoutIdempotencyKeyRef.current = null;
setOpen(false);
navigate(`/payments/result?status=${result.status}&event_id=${event.id}&payment_id=${result.payment_id ?? ""}`);
return;
}
if (!result.start_pay_url) {
setOpen(false);
const resultStatus = result.status === "paid" ? "success" : "pending";
navigate(`/payments/result?status=${resultStatus}&event_id=${event.id}&payment_id=${result.payment_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);
checkoutInProgressRef.current = false;
setOpen(false);
}
navigate(`${getEventPath(event)}/registration`);
};
useEffect(() => {
@@ -253,20 +84,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
async function loadEvent() {
try {
setEventLoadError(false);
setLoading(true);
if (!slug) return;
if (initialEvent && initialEvent.slug === slug) {
if (initialEvent && (initialEvent.slug === slug || String(initialEvent.id) === slug)) {
setEvent(initialEvent);
setEventThumb(getEventCardImageUrl(initialEvent));
return;
}
const data = await api.getEventBySlug(slug);
setEvent(null);
const data = /^\d+$/.test(slug)
? await api.getEventById(Number(slug))
: await api.getEventBySlug(slug);
if (cancelled) return;
setEvent(data);
setEventThumb(getEventCardImageUrl(data));
} catch (error: unknown) {
if (cancelled) return;
setEventLoadError(true);
toast({
title: "خطا در بارگذاری رویداد",
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
@@ -283,18 +119,13 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return () => {
cancelled = true;
};
}, [initialEvent, slug, toast]);
}, [eventLoadAttempt, 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],
@@ -323,17 +154,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
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 registration = event ? getEventRegistrationState(event, nowTs) : null;
const galleryItems = useMemo<GalleryLightboxItem[]>(
() =>
@@ -353,12 +174,29 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
}
if (!event) {
return <div className="min-h-[60vh] flex items-center justify-center">رویداد مورد نظر یافت نشد.</div>;
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 = rsTs != null && nowTs < rsTs;
const ended = deadlineTs !== null && remainingMs === 0;
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
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">
@@ -391,10 +229,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<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="w-full aspect-video overflow-hidden rounded-lg">
<div className="aspect-video w-full overflow-hidden bg-muted">
<ProgressiveImage
src={getEventHeroImageUrl(event)}
blurSrc={event.absolute_featured_image_thumbnail_url}
@@ -405,17 +243,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
</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}
<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>
<div className="flex flex-col items-end gap-2">
<Badge variant="default">{typeLabel[event.event_type] || event.event_type}</Badge>
</div>
<Badge variant="outline" className="w-fit shrink-0">{typeLabel[event.event_type] || event.event_type}</Badge>
</div>
</CardHeader>
<CardContent>
@@ -456,55 +291,58 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
<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 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>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</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"
disabled={
submitting ||
alreadyRegistered ||
event.status !== "published" ||
meta?.full === true ||
!meta?.registrationOpen
}
>
{event.status !== "published"
? "ثبت‌نام این رویداد فعال نیست"
: alreadyRegistered
? "شما قبلاً ثبت‌نام کرده‌اید"
: !meta?.registrationOpen
? "ثبت‌نام هنوز آغاز نشده است"
: meta?.full
? "ظرفیت ثبت‌نام تکمیل شده است"
: submitting
? "در حال ثبت‌نام..."
: event.price === 0
? "ثبت‌نام (رایگان)"
: "ثبت‌نام و ادامه پرداخت"}
{registration?.state === "open"
? isFree ? "ثبت‌نام رایگان" : "ثبت‌نام و پرداخت"
: "مشاهدهٔ وضعیت ثبت‌نام"}
</Button>
{!isFree && (
<CouponDialogFa
open={open}
onOpenChange={setOpen}
basePrice={basePrice}
onVerifyCouponRaw={(code) => api.checkDiscountCode(event.id, code)}
onContinue={handleContinueFromModal}
/>
)}
</CardContent>
</Card>
</div>