feat(events): unify registration checkout and receipts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useLocation, useParams, Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import PaymentResult from "@/components/PaymentResult";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
|
||||
export default function EventFreeSuccessPage() {
|
||||
const { slug } = useParams();
|
||||
const search = new URLSearchParams(useLocation().search);
|
||||
const registrationId = search.get("registration_id") || "";
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["registration-verify", registrationId],
|
||||
queryFn: () =>
|
||||
registrationId ? api.verifyMyRegistration(registrationId) : Promise.resolve(null),
|
||||
enabled: Boolean(registrationId),
|
||||
});
|
||||
|
||||
const registrationCodeFull = (data?.ticket_id || registrationId || "").trim();
|
||||
const registrationCodeShort = registrationCodeFull
|
||||
? (registrationCodeFull.split("-")[0] || registrationCodeFull).slice(0, 8)
|
||||
: "";
|
||||
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'East Guilan CE';
|
||||
const canonicalUrl = slug ? `${siteUrl}/events/${slug}/success` : `${siteUrl}/events`;
|
||||
const registrationTitle = data?.event_title || slug || 'Event registration';
|
||||
const ticketSummary = registrationCodeShort ? ` Ticket: ${registrationCodeShort}.` : '';
|
||||
const pageState = isLoading
|
||||
? 'Verifying registration'
|
||||
: isError || !data
|
||||
? 'Registration not found'
|
||||
: 'Registration confirmed';
|
||||
const pageTitle = `${pageState} | ${siteName}`;
|
||||
const registrationCode = registrationId || 'your registration';
|
||||
const pageDescription = data
|
||||
? `Registration confirmed for ${registrationTitle}.${ticketSummary}`
|
||||
: isError
|
||||
? `We could not verify ${registrationCode}.`
|
||||
: registrationId
|
||||
? `Verifying registration ${registrationId} for ${registrationTitle}.`
|
||||
: 'Review your registration status and ticket details.';
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<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={`${siteUrl}/favicon.ico`} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={`${siteUrl}/favicon.ico`} />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
const renderWithHelmet = (node: JSX.Element) => (
|
||||
<>
|
||||
{helmet}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// اگر بکاند چیزی برنگرداند یا خطا داد
|
||||
if (!data || isError) {
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
<PaymentResult
|
||||
title="اطلاعات ثبتنام در دسترس نیست"
|
||||
subtitle="امکان دریافت جزئیات ثبتنام فراهم نشد. اگر مبلغی پرداخت نشده، ثبتنام شما برای رویداد رایگان انجام شده است."
|
||||
details={[
|
||||
{ label: "کد ثبتنام", value: registrationCodeShort || "—" },
|
||||
{ label: "رویداد", value: slug || "—" },
|
||||
{ label: "مبلغ", value: "رایگان" },
|
||||
]}
|
||||
/>
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Link to={`/events/${slug || ""}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
</Link>
|
||||
<Link to="/events">
|
||||
<Button>مشاهده سایر رویدادها</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const details = [
|
||||
{ label: "عنوان رویداد", value: data.event_title || (slug || "—") },
|
||||
{ label: "شیوه برگزاری", value: data.event_type || "—" },
|
||||
{ label: "کد ثبتنام",
|
||||
value: <code dir="ltr" className="font-mono bg-muted px-2 py-0.5 rounded">{registrationCodeShort || "—"}</code>
|
||||
},
|
||||
{ label: "وضعیت", value: faStatus(data.status) },
|
||||
...(data.registered_at ? [{ label: "تاریخ ثبتنام", value: formatJalali(data.registered_at) }] : []),
|
||||
{ label: "مبلغ", value: "رایگان" },
|
||||
];
|
||||
|
||||
return renderWithHelmet(
|
||||
<div className="container py-10" dir="rtl">
|
||||
<PaymentResult
|
||||
title="ثبتنام با موفقیت انجام شد 🎉"
|
||||
subtitle={`شما با موفقیت برای «${data.event_title || "رویداد"}» ثبتنام کردهاید.`}
|
||||
details={details}
|
||||
/>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={data.success_markdown} justify size="base" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Link to={`/events/${slug || ""}`}>
|
||||
<Button variant="outline">بازگشت به رویداد</Button>
|
||||
</Link>
|
||||
<Link to="/events">
|
||||
<Button>مشاهده سایر رویدادها</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function faStatus(status?: string) {
|
||||
switch ((status || "").toUpperCase()) {
|
||||
case "CONFIRMED":
|
||||
case "APPROVED":
|
||||
return "تأیید شده";
|
||||
case "PENDING":
|
||||
return "در انتظار";
|
||||
case "CANCELLED":
|
||||
case "CANCELED":
|
||||
return "لغو شده";
|
||||
default:
|
||||
return status || "—";
|
||||
}
|
||||
}
|
||||
537
src/views/EventRegistrationHub.tsx
Normal file
537
src/views/EventRegistrationHub.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Navigate, useNavigate } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import OutcomePage, { type OutcomeAction, type OutcomeTone } from "@/components/OutcomePage";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import { buildAuthPath } from "@/lib/auth-return";
|
||||
import { getEventPath } from "@/lib/event-ui";
|
||||
import { downloadRegistrationReceipt } from "@/lib/registration-receipt-pdf";
|
||||
import {
|
||||
getFinalPriceToman,
|
||||
getRegistrationHubDisplayState,
|
||||
getRegistrationHubGate,
|
||||
getRegistrationHubGateCopy,
|
||||
normalizeCouponAmounts,
|
||||
} from "@/lib/registration-hub-display";
|
||||
import type { RegistrationHubSchema, RegistrationHubState } from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, resolveErrorMessage } from "@/lib/utils";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
registrationId?: string;
|
||||
paymentId?: string;
|
||||
};
|
||||
|
||||
type LoadState = "loading" | "ready" | "not_found" | "error";
|
||||
|
||||
type CouponResult = {
|
||||
valid: boolean;
|
||||
discountAmount: number;
|
||||
finalPrice: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const pollingStates = new Set<RegistrationHubState>([
|
||||
"active_checkout",
|
||||
"verification_pending",
|
||||
"manual_review",
|
||||
"refund_pending",
|
||||
"refund_manual_review",
|
||||
]);
|
||||
|
||||
const stateCopy: Record<RegistrationHubState, { status: string; title: string; subtitle: string }> = {
|
||||
ready: {
|
||||
status: "آمادهٔ ثبتنام",
|
||||
title: "برای ثبتنام در رویداد آمادهاید؟",
|
||||
subtitle: "",
|
||||
},
|
||||
active_checkout: {
|
||||
status: "پرداخت در حال انجام",
|
||||
title: "ثبتنام شما موقتاً ظرفیت را نگه داشته است",
|
||||
subtitle: "میتوانید پرداخت همین ثبتنام را ادامه دهید. مهلت از زمان رزرو اولیه محاسبه میشود و با بازگشت به صفحه تمدید نخواهد شد.",
|
||||
},
|
||||
legacy_pending: {
|
||||
status: "ثبتنام قدیمی در انتظار بررسی",
|
||||
title: "وضعیت این ثبتنام هنوز نهایی نیست",
|
||||
subtitle: "این ثبتنام پیش از فعالشدن مهلت پرداخت ایجاد شده است. برای جلوگیری از پرداخت تکراری، وضعیت آن را با پشتیبانی بررسی کنید.",
|
||||
},
|
||||
verification_pending: {
|
||||
status: "در انتظار تأیید درگاه",
|
||||
title: "در حال دریافت نتیجهٔ پرداخت...",
|
||||
subtitle: "در صورتی که هزینهٔ رویداد از حساب شما کم شده است، تا دریافت نتیجهٔ قطعی، پرداخت دیگری برای این رویداد ثبت نکنید.",
|
||||
},
|
||||
manual_review: {
|
||||
status: "نیازمند بررسی پشتیبانی",
|
||||
title: "خطا در هنگام تأیید تراکنش",
|
||||
subtitle: "امکان پیگیری خودکار وضعیت تراکنش از طریق درگاه پرداخت امکانپذیر نیست. لطفاً در صورتی که هزینهٔ رویداد از حساب شما کسر شده است، رسید پرداخت را برای بررسی تراکنش به پشتیبانی انجمن ارسال کنید.",
|
||||
},
|
||||
paid_success: {
|
||||
status: "پرداخت تأیید شد",
|
||||
title: "ثبتنام شما تکمیل شد",
|
||||
subtitle: "ثبتنام شما در این رویداد با موفقیت در سامانه ثبت شد. در صورت بروز هر گونه مشکل هنگام ورود به رویداد رسید پرداخت را همراه خود داشته باشید.",
|
||||
},
|
||||
free_success: {
|
||||
status: "ثبتنام تأیید شد",
|
||||
title: "ثبتنام شما تکمیل شد",
|
||||
subtitle: "ثبتنام شما در این رویداد با موفقیت در سامانه ثبت شد. در صورت بروز هر گونه مشکل هنگام ورود به رویداد رسید پرداخت را همراه خود داشته باشید.",
|
||||
},
|
||||
failed: {
|
||||
status: "پرداخت ناموفق",
|
||||
title: "این پرداخت تأیید نشد",
|
||||
subtitle: "این تلاش پرداخت بهصورت قطعی ناموفق ثبت شده است. اگر ثبتنام هنوز باز باشد، میتوانید یک تلاش تازه آغاز کنید.",
|
||||
},
|
||||
cancelled: {
|
||||
status: "ثبتنام لغو شده",
|
||||
title: "این ثبتنام لغو شده است",
|
||||
subtitle: "در صورت تمایل به ثبتنام مجدد میتوانید از طریق دکمهٔ پایین صفحه در رویداد ثبتنام کنید.",
|
||||
},
|
||||
expired: {
|
||||
status: "مهلت پرداخت پایان یافت",
|
||||
title: "مهلت ثبتنام شما به پایان رسید.",
|
||||
subtitle: "مهلت ۱۵ دقیقهای پرداخت به پایان رسیده و ظرفیت این رزرو آزاد شده است. در صورت تمایل میتوانید مجدداً ثبتنام کنید.",
|
||||
},
|
||||
refund_pending: {
|
||||
status: "بازگشت وجه در حال پیگیری",
|
||||
title: "پرداخت دریافت شد اما ثبتنام لغو است",
|
||||
subtitle: "بازگشت وجه از درگاه در حال پیگیری است. تا روشنشدن نتیجه، پرداخت دیگری آغاز نکنید.",
|
||||
},
|
||||
refund_manual_review: {
|
||||
status: "بازگشت وجه نیازمند بررسی",
|
||||
title: "بازگشت وجه هنوز تأیید نشده است",
|
||||
subtitle: "ثبتنام لغو شده و وضعیت بازگشت وجه نیاز به بررسی پشتیبانی دارد. کد پیگیری را برای تماس با انجمن نگه دارید.",
|
||||
},
|
||||
refund_succeeded: {
|
||||
status: "وجه بازگردانده شد",
|
||||
title: "بازگشت وجه تأیید شد",
|
||||
subtitle: "درگاه بازگشت وجه را تأیید کرده است. برای ثبتنام دوباره، وضعیت رویداد را بررسی کنید.",
|
||||
},
|
||||
};
|
||||
|
||||
function checkoutTimeRemaining(expiresAt?: string | null) {
|
||||
if (!expiresAt) return "—";
|
||||
const remaining = Math.max(0, Date.parse(expiresAt) - Date.now());
|
||||
const minutes = Math.floor(remaining / 60_000);
|
||||
const seconds = Math.floor((remaining % 60_000) / 1000);
|
||||
return `${minutes.toLocaleString("fa-IR")} دقیقه و ${seconds.toLocaleString("fa-IR")} ثانیه`;
|
||||
}
|
||||
|
||||
function formatEventDateTimeRange(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)}`;
|
||||
}
|
||||
|
||||
export default function EventRegistrationHub({ slug, registrationId, paymentId }: Props) {
|
||||
const { isAuthenticated, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const receiptRef = useRef<HTMLDivElement | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
const dataRef = useRef<RegistrationHubSchema | null>(null);
|
||||
const [data, setData] = useState<RegistrationHubSchema | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [receiptDownloading, setReceiptDownloading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [refreshError, setRefreshError] = useState(false);
|
||||
const [couponCode, setCouponCode] = useState("");
|
||||
const [couponResult, setCouponResult] = useState<CouponResult | null>(null);
|
||||
const [couponVerifying, setCouponVerifying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = null;
|
||||
setData(null);
|
||||
setLoadState("loading");
|
||||
setRefreshError(false);
|
||||
setActionError(null);
|
||||
setCouponCode("");
|
||||
setCouponResult(null);
|
||||
}, [paymentId, registrationId, slug]);
|
||||
|
||||
const hubPath = useMemo(() => {
|
||||
const query = new URLSearchParams();
|
||||
if (registrationId) query.set("registration_id", registrationId);
|
||||
else if (paymentId) query.set("payment_id", paymentId);
|
||||
return `/events/${encodeURIComponent(slug)}/registration${query.size ? `?${query}` : ""}`;
|
||||
}, [paymentId, registrationId, slug]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const result = await api.getRegistrationHubStatus(slug, {
|
||||
registration_id: registrationId,
|
||||
payment_id: paymentId ? Number(paymentId) : null,
|
||||
});
|
||||
dataRef.current = result;
|
||||
setData(result);
|
||||
setLoadState("ready");
|
||||
return result;
|
||||
}, [paymentId, registrationId, slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authLoading) return;
|
||||
let active = true;
|
||||
let timer: number | undefined;
|
||||
let delay = 5_000;
|
||||
let failedReads = 0;
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const result = await api.getRegistrationHubStatus(slug, {
|
||||
registration_id: registrationId,
|
||||
payment_id: paymentId ? Number(paymentId) : null,
|
||||
});
|
||||
if (!active) return;
|
||||
dataRef.current = result;
|
||||
setData(result);
|
||||
setLoadState("ready");
|
||||
setRefreshError(false);
|
||||
failedReads = 0;
|
||||
if (pollingStates.has(result.state)) {
|
||||
timer = window.setTimeout(load, delay);
|
||||
delay = Math.min(delay * 2, 30_000);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
failedReads += 1;
|
||||
const message = resolveErrorMessage(error, "");
|
||||
if (!dataRef.current && (message.includes("404") || message.includes("پیدا نشد"))) {
|
||||
setLoadState("not_found");
|
||||
} else if (dataRef.current) {
|
||||
setRefreshError(true);
|
||||
if (pollingStates.has(dataRef.current.state) && failedReads < 5) {
|
||||
timer = window.setTimeout(load, delay);
|
||||
delay = Math.min(delay * 2, 30_000);
|
||||
}
|
||||
} else {
|
||||
setLoadState("error");
|
||||
}
|
||||
} finally {
|
||||
if (active) setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
setLoadState((current) => current === "ready" ? current : "loading");
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [authLoading, isAuthenticated, paymentId, refreshToken, registrationId, slug]);
|
||||
|
||||
if (authLoading) {
|
||||
return <OutcomePage tone="checking" statusLabel="در حال بررسی حساب" title="در حال بارگذاری ثبتنام..." subtitle="" refreshing />;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={buildAuthPath(hubPath)} replace />;
|
||||
}
|
||||
|
||||
const event = data?.event;
|
||||
const state = data?.state;
|
||||
const presentationState = state ? getRegistrationHubDisplayState(state) : undefined;
|
||||
const gate = data && state === "ready" && !data.can_register ? getRegistrationHubGate(data.event) : null;
|
||||
const gateCopy = getRegistrationHubGateCopy(gate);
|
||||
const copy = gateCopy ?? (presentationState ? stateCopy[presentationState] : null);
|
||||
const canApplyCoupon = Boolean(
|
||||
data
|
||||
&& event
|
||||
&& data.can_register
|
||||
&& event.price > 0
|
||||
&& ["ready", "failed", "cancelled", "expired"].includes(state || ""),
|
||||
);
|
||||
const verifiedCoupon = couponResult?.valid ? couponCode.trim() : "";
|
||||
const tone: OutcomeTone = !data || loadState === "not_found" || loadState === "error"
|
||||
? loadState === "loading" ? "checking" : "unknown"
|
||||
: state === "paid_success" || state === "free_success"
|
||||
? "success"
|
||||
: state === "failed"
|
||||
? "failed"
|
||||
: state === "cancelled" || state === "expired"
|
||||
? "canceled"
|
||||
: state === "refund_succeeded"
|
||||
? "success"
|
||||
: state === "ready" && data.can_register
|
||||
? "unknown"
|
||||
: "pending";
|
||||
|
||||
const eventHref = event ? getEventPath({ id: event.id, slug: event.slug }) : "/events";
|
||||
const money = (amount?: number | null) => typeof amount === "number" ? formatToman(amount) : "—";
|
||||
const hasSettledRegistration = state === "paid_success" || state === "free_success";
|
||||
const refreshNow = () => setRefreshToken((value) => value + 1);
|
||||
|
||||
const startCheckout = async (coupon?: string) => {
|
||||
if (!data || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const registration = state === "active_checkout" && data.registration
|
||||
? data.registration
|
||||
: await api.registerForEvent(data.event.id, coupon || null);
|
||||
|
||||
if (registration.status === "confirmed" || (registration.final_price ?? data.event.price) <= 0) {
|
||||
navigate(`${getEventPath(data.event)}/registration?registration_id=${encodeURIComponent(registration.ticket_id)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const payment = await api.createPayment({
|
||||
event_id: data.event.id,
|
||||
description: `پرداخت رویداد: ${data.event.title}`,
|
||||
discount_code: coupon || null,
|
||||
idempotency_key: registration.ticket_id,
|
||||
});
|
||||
if (payment.start_pay_url) {
|
||||
window.location.href = payment.start_pay_url;
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`${getEventPath(data.event)}/registration?registration_id=${encodeURIComponent(registration.ticket_id)}`, { replace: true });
|
||||
setRefreshToken((value) => value + 1);
|
||||
} catch (error) {
|
||||
setActionError(resolveErrorMessage(error, "ادامهٔ ثبتنام انجام نشد. وضعیت را دوباره بررسی کنید."));
|
||||
setRefreshToken((value) => value + 1);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCoupon = async () => {
|
||||
if (!data || !couponCode.trim() || couponVerifying || submitting) return;
|
||||
setCouponVerifying(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const raw = await api.checkDiscountCode(data.event.id, couponCode.trim());
|
||||
const normalized = normalizeCouponAmounts(raw);
|
||||
setCouponResult({
|
||||
valid: true,
|
||||
discountAmount: normalized.discount_amount,
|
||||
finalPrice: normalized.final_price,
|
||||
message: "کد تخفیف با موفقیت اعمال شد.",
|
||||
});
|
||||
} catch (error) {
|
||||
setCouponResult({
|
||||
valid: false,
|
||||
discountAmount: 0,
|
||||
finalPrice: data.event.price,
|
||||
message: resolveErrorMessage(error, "کد تخفیف معتبر نیست."),
|
||||
});
|
||||
} finally {
|
||||
setCouponVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCheckout = async () => {
|
||||
if (!data || busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.cancelRegistrationCheckout(data.event.id);
|
||||
const result = await refresh();
|
||||
setActionError(result.state === "verification_pending" || result.state === "refund_pending" || result.state === "refund_manual_review"
|
||||
? "ظرفیت آزاد شد؛ تراکنش قبلی هنوز در حال تعیینتکلیف است. تا دریافت نتیجه، پرداخت دیگری آغاز نکنید."
|
||||
: null);
|
||||
} catch (error) {
|
||||
setActionError(resolveErrorMessage(error, "لغو رزرو انجام نشد. دوباره وضعیت را بررسی کنید."));
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReceipt = async () => {
|
||||
if (!receiptRef.current || !data || !hasSettledRegistration || receiptDownloading) return;
|
||||
setReceiptDownloading(true);
|
||||
try {
|
||||
await downloadRegistrationReceipt(
|
||||
receiptRef.current,
|
||||
`registration-${data.registration?.ticket_id || data.payment?.id || data.event.id}.pdf`,
|
||||
);
|
||||
} catch (error) {
|
||||
setActionError(resolveErrorMessage(error, "دانلود رسید انجام نشد. دوباره تلاش کنید."));
|
||||
} finally {
|
||||
setReceiptDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const primaryAction: OutcomeAction | undefined = (() => {
|
||||
if (!data || !state) return undefined;
|
||||
if (state === "ready" && data.can_register) {
|
||||
return data.event.price > 0
|
||||
? { label: submitting ? "در حال آمادهسازی..." : couponResult?.valid && couponResult.finalPrice <= 0 ? "ثبتنام رایگان با کد تخفیف" : "ثبتنام و ادامهٔ پرداخت", onClick: () => void startCheckout(verifiedCoupon || undefined), disabled: submitting || couponVerifying || Boolean(couponCode.trim() && !couponResult?.valid) }
|
||||
: { label: "ثبتنام رایگان", onClick: () => void startCheckout(), disabled: submitting };
|
||||
}
|
||||
if (state === "active_checkout") {
|
||||
return data.can_resume
|
||||
? { label: submitting ? "در حال ادامهٔ پرداخت…" : "ادامهٔ پرداخت", onClick: () => void startCheckout(), disabled: submitting }
|
||||
: { label: "بررسی دوباره", onClick: refreshNow, disabled: refreshing };
|
||||
}
|
||||
if (state === "paid_success" || state === "free_success") {
|
||||
return { label: "مشاهدهٔ جزئیات رویداد", href: eventHref };
|
||||
}
|
||||
if (state === "failed" || state === "cancelled" || state === "expired") {
|
||||
return data.can_register
|
||||
? data.event.price > 0
|
||||
? { label: submitting ? "در حال آمادهسازی..." : couponResult?.valid && couponResult.finalPrice <= 0 ? "ثبتنام رایگان با کد تخفیف" : "تلاش دوباره برای ثبتنام", onClick: () => void startCheckout(verifiedCoupon || undefined), disabled: submitting || couponVerifying || Boolean(couponCode.trim() && !couponResult?.valid) }
|
||||
: { label: "ثبتنام دوباره", onClick: () => void startCheckout(), disabled: submitting }
|
||||
: { label: "مشاهدهٔ جزئیات رویداد", href: eventHref };
|
||||
}
|
||||
if (state === "legacy_pending" || state === "manual_review" || state === "refund_manual_review") {
|
||||
return { label: "مشاهدهٔ پرداختهای من", href: "/profile?tab=payments" };
|
||||
}
|
||||
if (state === "refund_pending" || state === "refund_succeeded") {
|
||||
return { label: "مشاهدهٔ پرداختهای من", href: "/profile?tab=payments" };
|
||||
}
|
||||
if (state === "verification_pending") {
|
||||
return { label: refreshing ? "در حال بررسی…" : "بررسی دوباره", onClick: refreshNow, disabled: refreshing };
|
||||
}
|
||||
return {
|
||||
label: data.can_register ? "شروع ثبتنام" : "مشاهدهٔ رویدادها",
|
||||
...(data.can_register
|
||||
? { onClick: () => data.event.price > 0 ? void startCheckout(verifiedCoupon || undefined) : void startCheckout(), disabled: submitting || couponVerifying || Boolean(couponCode.trim() && !couponResult?.valid) }
|
||||
: { href: "/events" }),
|
||||
};
|
||||
})();
|
||||
|
||||
const secondaryActions: OutcomeAction[] = [];
|
||||
if (data?.can_cancel && state === "active_checkout") {
|
||||
secondaryActions.push({ label: "لغو پرداخت و آزادکردن ظرفیت", onClick: () => void cancelCheckout(), disabled: submitting });
|
||||
}
|
||||
if (state === "paid_success" || state === "free_success") {
|
||||
secondaryActions.push(
|
||||
{
|
||||
label: receiptDownloading ? "در حال ساخت فایل PDF..." : "دانلود رسید PDF",
|
||||
onClick: () => void downloadReceipt(),
|
||||
disabled: receiptDownloading,
|
||||
loading: receiptDownloading,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (["verification_pending", "manual_review", "refund_pending", "refund_manual_review"].includes(state || "")) {
|
||||
secondaryActions.push({ label: "جزئیات رویداد", href: eventHref });
|
||||
}
|
||||
if (loadState === "not_found" || loadState === "error") {
|
||||
secondaryActions.push({ label: "مشاهدهٔ رویدادها", href: "/events" });
|
||||
}
|
||||
|
||||
const details = data
|
||||
? [
|
||||
{ label: "رویداد", value: data.event.title },
|
||||
{ label: "زمان برگزاری", value: formatEventDateTimeRange(data.event.start_time, data.event.end_time) },
|
||||
{ label: "هزینهٔ رویداد", value: data.event.price <= 0 ? "رایگان" : money(data.event.price) },
|
||||
...(data.registration ? [{ label: "وضعیت ثبتنام", value: data.registration.status === "confirmed" || data.registration.status === "attended" ? "تأییدشده" : data.registration.status === "pending" ? "در انتظار" : "لغوشده" }] : []),
|
||||
...(data.registration?.checkout_expires_at && state === "active_checkout" ? [{ label: "زمان باقیماندهٔ رزرو", value: checkoutTimeRemaining(data.registration.checkout_expires_at) }] : []),
|
||||
...(data.payment ? [
|
||||
{ label: "وضعیت پرداخت", value: data.payment.status === "paid" ? "پرداخت تأییدشده" : data.payment.status === "failed" ? "ناموفق" : data.payment.status === "canceled" ? "لغوشده" : "در انتظار تأیید" },
|
||||
{ label: "مبلغ پرداخت", value: money(data.payment.amount) },
|
||||
...(data.payment.ref_id ? [{ label: "کد پیگیری درگاه", value: data.payment.ref_id }] : []),
|
||||
] : []),
|
||||
...(data.registration ? [{ label: "کد ثبتنام", value: data.registration.ticket_id }] : []),
|
||||
...(data.registration?.registered_at ? [{ label: "تاریخ درخواست", value: formatJalali(data.registration.registered_at) }] : []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const title = copy?.title ?? (loadState === "not_found" ? "ثبتنام یا پرداخت پیدا نشد" : loadState === "error" ? "وضعیت ثبتنام در دسترس نیست" : "در حال دریافت وضعیت ثبتنام...");
|
||||
const subtitle = copy?.subtitle ?? (loadState === "not_found"
|
||||
? "این شناسه برای حساب کاربری شما و این رویداد پیدا نشد. از صفحهٔ رویدادها وضعیت ثبتنامهای خود را بررسی کنید."
|
||||
: loadState === "error"
|
||||
? "ارتباط با سامانه برقرار نشد. این خطا بهتنهایی به معنی ناموفق یا لغوشدن پرداخت نیست؛ دوباره تلاش کنید."
|
||||
: "");
|
||||
const statusLabel = copy?.status ?? (loadState === "not_found" ? "شناسه پیدا نشد" : loadState === "error" ? "خطای ارتباط" : "در حال بررسی");
|
||||
const pageTitle = copy?.status ?? "وضعیت ثبتنام";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{`${pageTitle} | انجمن نمونه`}</title>
|
||||
<meta name="description" content={subtitle} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="canonical" href={`${siteUrl}/events/${encodeURIComponent(slug)}/registration`} />
|
||||
</Helmet>
|
||||
<OutcomePage
|
||||
tone={tone}
|
||||
statusLabel={statusLabel}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
event={event ? {
|
||||
title: event.title,
|
||||
imageUrl: event.image_url,
|
||||
href: eventHref,
|
||||
caption: state === "paid_success" || state === "free_success" ? "ثبتنام شما برای این رویداد تأیید شده است." : undefined,
|
||||
} : undefined}
|
||||
details={details}
|
||||
reference={data?.payment?.ref_id || data?.registration?.ticket_id || null}
|
||||
referenceLabel={data?.payment?.ref_id ? "کد پیگیری درگاه" : "کد ثبتنام"}
|
||||
support={presentationState === "verification_pending" || presentationState === "manual_review" || presentationState === "refund_manual_review" || loadState === "error" || loadState === "not_found"}
|
||||
supportDescription={presentationState === "verification_pending"
|
||||
? "اگر هزینهٔ رویداد از حساب شما کسر شده است، رسید پرداخت و کد پیگیری را برای بررسی وضعیت با انجمن در میان بگذارید. تا دریافت نتیجهٔ قطعی، پرداخت دیگری آغاز نکنید."
|
||||
: presentationState === "manual_review"
|
||||
? "رسید پرداخت و کد پیگیری را برای بررسی تراکنش به پشتیبانی انجمن ارسال کنید."
|
||||
: "شناسهٔ ثبتنام یا پرداخت را نگه دارید و برای بررسی با انجمن تماس بگیرید. تا روشنشدن وضعیت، پرداخت دیگری آغاز نکنید."}
|
||||
refreshing={refreshing}
|
||||
primaryAction={primaryAction}
|
||||
secondaryActions={secondaryActions}
|
||||
receiptRef={receiptRef}
|
||||
>
|
||||
{canApplyCoupon ? (
|
||||
<section className="space-y-3" aria-label="کد تخفیف">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<Input
|
||||
id="registration-coupon"
|
||||
value={couponCode}
|
||||
onChange={(eventChange) => {
|
||||
setCouponCode(eventChange.target.value);
|
||||
setCouponResult(null);
|
||||
setActionError(null);
|
||||
}}
|
||||
placeholder="کد تخفیف (اختیاری)"
|
||||
aria-label="کد تخفیف (اختیاری)"
|
||||
aria-describedby={couponResult ? "registration-coupon-result" : undefined}
|
||||
className="text-right"
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={!couponCode.trim() || couponVerifying || submitting} onClick={() => void verifyCoupon()}>
|
||||
{couponVerifying ? "در حال بررسی..." : "بررسی کد تخفیف"}
|
||||
</Button>
|
||||
</div>
|
||||
{couponResult ? (
|
||||
<p id="registration-coupon-result" role={couponResult.valid ? "status" : "alert"} className={`text-sm ${couponResult.valid ? "text-emerald-700 dark:text-emerald-300" : "text-destructive"}`}>
|
||||
{couponResult.message}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="space-y-2 rounded-lg border border-border/70 bg-background/60 p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3"><dt className="text-muted-foreground">قیمت اولیه</dt><dd>{money(event?.price)}</dd></div>
|
||||
{couponResult?.valid && couponResult.discountAmount > 0 ? <div className="flex items-center justify-between gap-3"><dt className="text-muted-foreground">تخفیف</dt><dd>− {formatNumberPersian(couponResult.discountAmount)} تومان</dd></div> : null}
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/70 pt-2 font-semibold"><dt>قیمت نهایی</dt><dd>{formatNumberPersian(getFinalPriceToman(event?.price ?? 0, couponResult?.valid ? couponResult.finalPrice : null))} تومان</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
{actionError ? <p role="alert" className="text-destructive">{actionError}</p> : null}
|
||||
{refreshError ? <p role="status" className="text-muted-foreground">آخرین وضعیت ذخیرهشده نمایش داده شده است؛ دریافت بهروزرسانی با خطا روبهرو شد.</p> : null}
|
||||
{data?.registration?.cancellation_reason === "checkout_expired" ? <p>ظرفیت رزرو قبلی آزاد شده است.</p> : null}
|
||||
{data?.payment?.refund_status === "manual_review" ? <p>بازگشت وجه هنوز از درگاه تأیید نشده است.</p> : null}
|
||||
{state === "free_success" && data?.event.registration_success_markdown ? (
|
||||
<div>
|
||||
<p className="mb-1 font-semibold">پیام برگزارکننده</p>
|
||||
<Markdown content={data?.event.registration_success_markdown ?? ""} justify size="base" />
|
||||
</div>
|
||||
) : null}
|
||||
</OutcomePage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/lib/router";
|
||||
import { buildAuthPath } from "@/lib/auth-return";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { EventFiltersDrawer } from "@/components/events/EventFiltersDrawer";
|
||||
@@ -94,10 +96,12 @@ export default function Events({
|
||||
content,
|
||||
initialPage,
|
||||
initialFilters,
|
||||
unmatchedPaymentCallback = false,
|
||||
}: {
|
||||
content: EventsContentPage;
|
||||
initialPage: EventBrowsePageSchema;
|
||||
initialFilters: EventCatalogFilters;
|
||||
unmatchedPaymentCallback?: boolean;
|
||||
}) {
|
||||
const [pageData, setPageData] = useState(initialPage);
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
@@ -257,6 +261,14 @@ export default function Events({
|
||||
return (
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto max-w-screen-2xl px-3 py-5 sm:px-4 sm:py-8 lg:px-6">
|
||||
{unmatchedPaymentCallback ? (
|
||||
<div role="status" className="mb-5 flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/[0.04] p-4 text-sm leading-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>نتیجهٔ بازگشت از درگاه به ثبتنام یا پرداختی در حساب شما متصل نشد. وارد حساب شوید و بخشهای ثبتنامها و پرداختها را بررسی کنید.</p>
|
||||
<Button asChild variant="outline" className="shrink-0">
|
||||
<Link to={buildAuthPath("/profile?tab=payments")}>بررسی پرداختها</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<header className="mb-6 border-b border-border pb-5 sm:mb-8 sm:pb-6">
|
||||
<h1 className="text-2xl font-bold leading-tight tracking-tight sm:text-3xl">{content.title}</h1>
|
||||
{content.intro ? <p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base">{content.intro}</p> : null}
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { useSearchParams, Link } from '@/lib/router';
|
||||
import QRCode from 'react-qr-code';
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { siteUrl } from '@/lib/site';
|
||||
import { getEventPath, getPaymentOutcome, type PaymentOutcome } from '@/lib/event-ui';
|
||||
|
||||
type SavedPayment = {
|
||||
ref_id?: string | null;
|
||||
event_id: number;
|
||||
slug?: string;
|
||||
title?: string;
|
||||
thumb?: string | null;
|
||||
base_amount?: number;
|
||||
discount_amount?: number;
|
||||
amount?: number;
|
||||
started_at?: string;
|
||||
success_markdown?: string | null;
|
||||
status?: string;
|
||||
|
||||
};
|
||||
|
||||
export default function PaymentResult() {
|
||||
const [params] = useSearchParams();
|
||||
const status = params.get('status');
|
||||
const requestedRefId = params.get('ref_id') || '';
|
||||
const paymentId = Number(params.get('payment_id') || '0');
|
||||
const [fallback, setFallback] = useState<SavedPayment | null>(null);
|
||||
const [manualReview, setManualReview] = useState(false);
|
||||
const refId = requestedRefId || fallback?.ref_id || '';
|
||||
const eventId = Number(params.get('event_id') || fallback?.event_id || '0');
|
||||
const humanEventId = eventId ? formatNumberPersian(eventId) : '—';
|
||||
const refIdDisplay = refId ? toPersianDigits(refId) : '';
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
let delay = 5_000;
|
||||
const pollingDeadline = Date.now() + 35 * 60 * 1_000;
|
||||
|
||||
const updatePayment = (payment: Awaited<ReturnType<typeof api.getPaymentStatus>>) => {
|
||||
setFallback({
|
||||
ref_id: payment.ref_id,
|
||||
event_id: payment.event.id,
|
||||
slug: payment.event.slug,
|
||||
title: payment.event.title,
|
||||
thumb: payment.event.image_url || null,
|
||||
base_amount: payment.base_amount,
|
||||
discount_amount: payment.discount_amount,
|
||||
amount: payment.amount,
|
||||
started_at: payment.verified_at || undefined,
|
||||
success_markdown: payment.event.success_markdown,
|
||||
status: payment.status,
|
||||
});
|
||||
|
||||
const pending = getPaymentOutcome(payment.status) === 'pending';
|
||||
const canVerifyWithGateway = Boolean(payment.authority);
|
||||
if (pending && !canVerifyWithGateway && payment.status !== 'initiated') {
|
||||
setManualReview(true);
|
||||
} else if (pending && Date.now() >= pollingDeadline) {
|
||||
setManualReview(true);
|
||||
} else {
|
||||
setManualReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPayment = async () => {
|
||||
try {
|
||||
const payment = paymentId
|
||||
? await api.getPaymentStatus(paymentId)
|
||||
: requestedRefId
|
||||
? await api.getPaymentByRef(requestedRefId)
|
||||
: null;
|
||||
if (!payment || cancelled) return;
|
||||
updatePayment(payment);
|
||||
|
||||
if (
|
||||
paymentId &&
|
||||
getPaymentOutcome(payment.status) === 'pending' &&
|
||||
(Boolean(payment.authority) || payment.status === 'initiated') &&
|
||||
Date.now() < pollingDeadline
|
||||
) {
|
||||
timer = window.setTimeout(refreshPayment, delay);
|
||||
delay = Math.min(delay * 2, 30_000);
|
||||
}
|
||||
} catch {
|
||||
if (paymentId && !cancelled && Date.now() < pollingDeadline) {
|
||||
timer = window.setTimeout(refreshPayment, delay);
|
||||
delay = Math.min(delay * 2, 30_000);
|
||||
} else if (paymentId && !cancelled) {
|
||||
setManualReview(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void refreshPayment();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [paymentId, requestedRefId]);
|
||||
|
||||
const data = fallback;
|
||||
|
||||
const outcome: PaymentOutcome = fallback?.status
|
||||
? getPaymentOutcome(fallback.status)
|
||||
: getPaymentOutcome(status);
|
||||
const ok = outcome === 'paid';
|
||||
const outcomeContent: Record<PaymentOutcome, { title: string; subtitle: string; pageState: string }> = {
|
||||
paid: {
|
||||
title: 'پرداخت تأیید شد',
|
||||
subtitle: 'درگاه پرداخت، تراکنش را تأیید کرده است و ثبتنام رویداد تکمیل شده است.',
|
||||
pageState: 'پرداخت تأیید شد',
|
||||
},
|
||||
pending: {
|
||||
title: 'وضعیت پرداخت هنوز نهایی نیست',
|
||||
subtitle: manualReview
|
||||
? 'وضعیت پرداخت هنوز قطعی نیست و برای بررسی پشتیبانی در انتظار مانده است. ثبتنام را نهاییشده تلقی نکنید.'
|
||||
: 'وضعیت پرداخت هنوز نهایی نیست. تا دریافت تأیید قطعی، ثبتنام را نهاییشده تلقی نکنید.',
|
||||
pageState: 'پرداخت در انتظار تأیید',
|
||||
},
|
||||
failed: {
|
||||
title: 'پرداخت تأیید نشد',
|
||||
subtitle: 'درگاه پرداخت این تراکنش را تأیید نکرده است؛ ثبتنام نهایی نشده است. میتوانید از صفحهٔ رویداد دوباره اقدام کنید.',
|
||||
pageState: 'پرداخت ناموفق',
|
||||
},
|
||||
canceled: {
|
||||
title: 'پرداخت لغو شد',
|
||||
subtitle: 'پرداخت کامل نشد و ثبتنام نهایی نشده است. برای شروع دوباره به صفحهٔ رویداد برگردید.',
|
||||
pageState: 'پرداخت لغوشده',
|
||||
},
|
||||
unknown: {
|
||||
title: 'وضعیت پرداخت مشخص نیست',
|
||||
subtitle: 'وضعیت قطعی تراکنش در دسترس نیست. تا دریافت تأیید سامانه، ثبتنام را نهاییشده تلقی نکنید.',
|
||||
pageState: 'وضعیت پرداخت نامشخص',
|
||||
},
|
||||
};
|
||||
const currentOutcome = outcomeContent[outcome];
|
||||
const money = (n?: number) => (typeof n === 'number' && Number.isFinite(n) ? formatToman(n) : '—');
|
||||
const receiptRef = useRef<HTMLDivElement | null>(null);
|
||||
const successMarkdown = data?.success_markdown ?? '';
|
||||
|
||||
const siteName = 'East Guilan CE';
|
||||
const canonicalUrl = `${siteUrl}/payments/result`;
|
||||
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 eventTitle = data?.title || (eventId ? `Event #${humanEventId}` : 'Event payment');
|
||||
const referenceFragment = refId ? ` Reference: ${refIdDisplay}.` : '';
|
||||
const pageTitle = `${currentOutcome.pageState} | ${siteName}`;
|
||||
const pageDescription = `${currentOutcome.subtitle} ${eventTitle}.${referenceFragment}`;
|
||||
const ogImage = toAbsoluteUrl(data?.thumb) ?? `${siteUrl}/favicon.ico`;
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<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} />
|
||||
</Helmet>
|
||||
);
|
||||
const renderWithHelmet = (node: JSX.Element) => (
|
||||
<>
|
||||
{helmet}
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
|
||||
const qrValue = useMemo(() => {
|
||||
// لینک قابل بررسی/اشتراکگذاری
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : siteUrl;
|
||||
const url = new URL(`${base}/payments/result`);
|
||||
if (refId) url.searchParams.set('ref_id', refId);
|
||||
if (eventId) url.searchParams.set('event_id', String(eventId));
|
||||
url.searchParams.set('status', outcome);
|
||||
return url.toString();
|
||||
}, [eventId, outcome, refId]);
|
||||
|
||||
const outcomeStyle = {
|
||||
paid: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
|
||||
pending: 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-100',
|
||||
failed: 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-900/30 dark:text-red-100',
|
||||
canceled: 'border-border bg-muted/50 text-foreground',
|
||||
unknown: 'border-border bg-muted/50 text-foreground',
|
||||
}[outcome];
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
const el = receiptRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Force a light snapshot for the PDF
|
||||
const prevBg = el.style.backgroundColor;
|
||||
const prevColor = el.style.color;
|
||||
el.style.backgroundColor = '#ffffff';
|
||||
el.style.color = '#000000';
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
});
|
||||
const imgData = canvas.toDataURL('image/png');
|
||||
const pdf = new jsPDF({ orientation: 'p', unit: 'pt', format: 'a4' });
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const ratio = Math.min(pageWidth / canvas.width, pageHeight / canvas.height);
|
||||
const imgWidth = canvas.width * ratio;
|
||||
const imgHeight = canvas.height * ratio;
|
||||
const x = (pageWidth - imgWidth) / 2;
|
||||
const y = 24;
|
||||
|
||||
// (Optional) paint white page background
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.rect(0, 0, pageWidth, pageHeight, 'F');
|
||||
|
||||
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
|
||||
pdf.save(`receipt-${refId || eventId}.pdf`);
|
||||
} finally {
|
||||
// restore colors for on-screen
|
||||
el.style.backgroundColor = prevBg;
|
||||
el.style.color = prevColor;
|
||||
}
|
||||
};
|
||||
|
||||
return renderWithHelmet(
|
||||
<div className="min-h-[60vh] flex items-center justify-center p-4 bg-background" dir="rtl">
|
||||
<Card className="w-full max-w-2xl bg-card text-card-foreground border-border">
|
||||
<CardHeader className="print:hidden">
|
||||
<CardTitle>نتیجهٔ پرداخت</CardTitle>
|
||||
<CardDescription>وضعیت تراکنش شما</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* RECEIPT AREA */}
|
||||
<div
|
||||
ref={receiptRef}
|
||||
className="rounded-lg border border-border p-4 md:p-6 bg-card text-card-foreground"
|
||||
>
|
||||
{/* Header (status + ref) */}
|
||||
<div
|
||||
className={[
|
||||
"mb-4 rounded-md border p-3 text-sm",
|
||||
outcomeStyle,
|
||||
].join(" ")}
|
||||
>
|
||||
<p className="font-semibold">{currentOutcome.title}</p>
|
||||
<p className="mt-1 leading-6">{currentOutcome.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Event + QR */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-start">
|
||||
{/* Thumb */}
|
||||
<div className="md:col-span-1">
|
||||
<div className="aspect-[16/9] overflow-hidden rounded-md bg-muted">
|
||||
{data?.thumb ? (
|
||||
<img
|
||||
src={data.thumb}
|
||||
alt={data?.title || ""}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
بدون تصویر
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="md:col-span-1 space-y-1">
|
||||
<div className="text-sm text-muted-foreground">رویداد</div>
|
||||
<div className="font-semibold">{data?.title || `#${humanEventId}`}</div>
|
||||
|
||||
{refId && (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground mt-3">کد پیگیری</div>
|
||||
<div className="font-mono break-all">{refIdDisplay}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR */}
|
||||
<div className="md:col-span-1 flex md:justify-end">
|
||||
<div className="p-3 border border-border rounded-md">
|
||||
<QRCode value={qrValue} size={112} />
|
||||
<div className="mt-2 text-[10px] text-center text-muted-foreground break-all">
|
||||
{qrValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ok && successMarkdown ? (
|
||||
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
|
||||
<Markdown content={successMarkdown} justify size="base" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Invoice */}
|
||||
<div className="mt-6 rounded-md border border-border p-3">
|
||||
<div className="text-sm font-medium mb-2">جزئیات پرداخت</div>
|
||||
<ul className="text-sm divide-y divide-border/60">
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">مبلغ پایه</span>
|
||||
<span>{money(data?.base_amount)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">تخفیف</span>
|
||||
<span>{money(data?.discount_amount)}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span className="text-muted-foreground">وضعیت</span>
|
||||
<span>{currentOutcome.title}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between py-2 font-semibold">
|
||||
<span>مبلغ نهایی</span>
|
||||
<span>{money(data?.amount)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-end print:hidden">
|
||||
{data?.event_id || eventId ? (
|
||||
<Link to={getEventPath({ id: data?.event_id || eventId, slug: data?.slug })}>
|
||||
<Button variant="outline">
|
||||
{ok ? "مشاهده رویداد" : outcome === "pending" ? "بازگشت به رویداد" : "بازگشت برای اقدام دوباره"}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/events">
|
||||
<Button variant="outline">رویدادها</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => window.print()}>
|
||||
چاپ
|
||||
</Button>
|
||||
<Button onClick={handleDownloadPdf}>دانلود PDF</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { buildAuthPath } from "@/lib/auth-return";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import { getEventPath } from "@/lib/event-ui";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, getBlogCardImageUrl, toPersianDigits } from "@/lib/utils";
|
||||
@@ -86,6 +87,13 @@ const paymentStatusLabels: Record<Types.UserPaymentSchema["status"], string> = {
|
||||
canceled: "لغو شده",
|
||||
};
|
||||
|
||||
const refundStatusLabels: Record<NonNullable<Types.UserPaymentSchema["refund_status"]>, string> = {
|
||||
none: "بدون بازگشت وجه",
|
||||
pending: "بازگشت وجه در حال پیگیری",
|
||||
succeeded: "وجه بازگردانده شد",
|
||||
manual_review: "بازگشت وجه در انتظار پشتیبانی",
|
||||
};
|
||||
|
||||
const sectionItems: Array<{ id: ProfileSection; label: string; icon: typeof Activity }> = [
|
||||
{ id: "overview", label: "نمای کلی", icon: UserRound },
|
||||
{ id: "events", label: "رویدادها", icon: CalendarDays },
|
||||
@@ -154,24 +162,35 @@ function PagePagination({
|
||||
function ProfileEventCard({ registration }: { registration: Types.MyEventRegistrationSchema }) {
|
||||
const event = registration.event;
|
||||
const imageUrl = toAbsoluteUrl(event.absolute_image_url, apiBaseUrl) || "/placeholder.svg";
|
||||
const eventPath = getEventPath(event);
|
||||
const registrationHubUrl = `${eventPath}/registration?registration_id=${encodeURIComponent(registration.ticket_id)}`;
|
||||
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right sm:flex-row sm:gap-4 sm:p-4">
|
||||
<Link
|
||||
to={`/events/${event.slug}`}
|
||||
to={eventPath}
|
||||
className="w-full shrink-0 overflow-hidden rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-32"
|
||||
aria-label={`مشاهده رویداد ${event.title}`}
|
||||
>
|
||||
<Image src={imageUrl} alt="" width={160} height={120} unoptimized className="aspect-video h-auto w-full object-cover sm:aspect-[4/3] sm:h-full" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link to={`/events/${event.slug}`} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Link to={eventPath} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
{event.title}
|
||||
</Link>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant="outline" className={eventStatusBadgeClasses[registration.status]}>{eventStatusLabels[registration.status]}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatJalali(event.start_date, false)}</span>
|
||||
</div>
|
||||
{registration.checkout_state === "active" && registration.can_resume ? (
|
||||
<Button asChild variant="default" size="sm" className="mt-3 min-h-10 w-fit">
|
||||
<Link to={registrationHubUrl}>ادامه پرداخت</Link>
|
||||
</Button>
|
||||
) : registration.checkout_state === "unresolved" ? (
|
||||
<Button asChild variant="outline" size="sm" className="mt-3 min-h-10 w-fit">
|
||||
<Link to={registrationHubUrl}>پیگیری پرداخت قبلی</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -467,7 +486,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
{paymentsQuery.data?.results.map((payment, index) => (
|
||||
<article key={`${payment.event.slug}-${payment.created_at}-${index}`} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right sm:flex-row sm:items-center sm:justify-between sm:p-4">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/events/${payment.event.slug}`} className="font-semibold hover:text-primary">{payment.event.title}</Link>
|
||||
<Link to={getEventPath(payment.event)} className="font-semibold hover:text-primary">{payment.event.title}</Link>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{formatJalali(payment.paid_at || payment.created_at)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
@@ -480,6 +499,14 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
||||
<Badge variant={payment.status === "paid" ? "secondary" : payment.status === "failed" || payment.status === "canceled" ? "destructive" : "outline"}>
|
||||
{paymentStatusLabels[payment.status]}
|
||||
</Badge>
|
||||
{payment.refund_status && payment.refund_status !== "none" ? (
|
||||
<Badge variant="outline">{refundStatusLabels[payment.refund_status]}</Badge>
|
||||
) : null}
|
||||
<Button asChild variant="outline" size="sm" className="min-h-10">
|
||||
<Link to={`${getEventPath(payment.event)}/registration?payment_id=${payment.payment_id}`}>
|
||||
مشاهدهٔ وضعیت
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user