feat(events): unify registration checkout and receipts
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import EventDetail from "@/views/EventDetail";
|
||||
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
|
||||
import { getEventPath } from "@/lib/event-ui";
|
||||
import { PublicApiError, getPublicEventById, getPublicEventBySlug } from "@/lib/public-api";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
@@ -16,7 +17,9 @@ function cleanText(value?: string | null) {
|
||||
|
||||
async function loadEvent(slug: string) {
|
||||
try {
|
||||
return await getPublicEventBySlug(slug);
|
||||
return /^\d+$/.test(slug)
|
||||
? await getPublicEventById(Number(slug))
|
||||
: await getPublicEventBySlug(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof PublicApiError && error.status === 404) {
|
||||
notFound();
|
||||
@@ -32,6 +35,7 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const event = await loadEvent(slug);
|
||||
const eventPath = getEventPath(event);
|
||||
const description = cleanText(event.description).slice(0, 160);
|
||||
const [siteContent, eventPageContent] = await Promise.all([
|
||||
getCmsSiteContent(),
|
||||
@@ -50,12 +54,12 @@ export async function generateMetadata({
|
||||
return {
|
||||
title: event.title,
|
||||
description,
|
||||
alternates: { canonical: `/events/${event.slug}` },
|
||||
alternates: { canonical: eventPath },
|
||||
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
|
||||
openGraph: {
|
||||
title: event.title,
|
||||
description,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
url: `${siteUrl}${eventPath}`,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
@@ -81,6 +85,7 @@ export default async function EventDetailPage({
|
||||
getCmsSiteContent(),
|
||||
getCmsPageContent("events"),
|
||||
]);
|
||||
const eventPath = getEventPath(event);
|
||||
const description = cleanText(event.description).slice(0, 160);
|
||||
const image =
|
||||
toAbsoluteUrl(
|
||||
@@ -112,10 +117,10 @@ export default async function EventDetailPage({
|
||||
? "https://schema.org/OfflineEventAttendanceMode"
|
||||
: "https://schema.org/MixedEventAttendanceMode",
|
||||
image: [image],
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
url: `${siteUrl}${eventPath}`,
|
||||
organizer: organizationStructuredData(siteContent),
|
||||
location: event.event_type === "online"
|
||||
? { "@type": "VirtualLocation", url: event.online_link || `${siteUrl}/events/${event.slug}` }
|
||||
? { "@type": "VirtualLocation", url: event.online_link || `${siteUrl}${eventPath}` }
|
||||
: {
|
||||
"@type": "Place",
|
||||
name: event.location || event.address || siteContent.official_name,
|
||||
@@ -123,7 +128,7 @@ export default async function EventDetailPage({
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
url: `${siteUrl}${eventPath}`,
|
||||
priceCurrency: "IRR",
|
||||
price: String(event.price ?? 0),
|
||||
...(event.registration_start_date ? { validFrom: event.registration_start_date } : {}),
|
||||
|
||||
34
src/app/events/[slug]/registration/page.tsx
Normal file
34
src/app/events/[slug]/registration/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import EventRegistrationHub from "@/views/EventRegistrationHub";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
function firstString(value?: string | string[]) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "وضعیت ثبتنام رویداد",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function EventRegistrationPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: SearchParams;
|
||||
}) {
|
||||
const [{ slug }, query] = await Promise.all([params, searchParams]);
|
||||
const requestedRegistrationId = firstString(query.registration_id);
|
||||
const requestedPaymentId = firstString(query.payment_id);
|
||||
|
||||
return (
|
||||
<EventRegistrationHub
|
||||
slug={slug}
|
||||
registrationId={requestedRegistrationId}
|
||||
paymentId={requestedPaymentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import EventFreeSuccessPage from "@/views/EventFreeSuccessPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "نتیجه ثبتنام رویداد",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function EventSuccessPage() {
|
||||
return <EventFreeSuccessPage />;
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export default async function EventsPage({
|
||||
content={content}
|
||||
initialPage={eventPage}
|
||||
initialFilters={parseEventCatalogParams(query, eventPage.price_ceiling)}
|
||||
unmatchedPaymentCallback={firstString(resolved.payment_callback) === "unmatched"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import PaymentResult from "@/views/PaymentResult";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "نتیجه پرداخت",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PaymentResultPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PaymentResult />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
295
src/components/OutcomePage.tsx
Normal file
295
src/components/OutcomePage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { Children, useState, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Check,
|
||||
Clock3,
|
||||
Copy,
|
||||
CircleHelp,
|
||||
LoaderCircle,
|
||||
Mail,
|
||||
Send,
|
||||
TicketCheck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type OutcomeTone = "success" | "pending" | "failed" | "canceled" | "unknown" | "checking";
|
||||
|
||||
export type OutcomeAction = {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
type OutcomeDetail = { label: string; value: ReactNode };
|
||||
|
||||
type OutcomePageProps = {
|
||||
tone: OutcomeTone;
|
||||
statusLabel: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
event?: { title: string; imageUrl?: string | null; href?: string; caption?: string };
|
||||
details?: OutcomeDetail[];
|
||||
reference?: string | null;
|
||||
referenceLabel?: string;
|
||||
support?: boolean;
|
||||
supportDescription?: string;
|
||||
refreshing?: boolean;
|
||||
primaryAction?: OutcomeAction;
|
||||
secondaryActions?: OutcomeAction[];
|
||||
receiptRef?: React.Ref<HTMLDivElement>;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const toneClasses: Record<OutcomeTone, { badge: string; halo: string; card: string }> = {
|
||||
success: {
|
||||
badge: "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/50 dark:text-emerald-200",
|
||||
halo: "text-emerald-600 dark:text-emerald-300",
|
||||
card: "border-emerald-200/80 dark:border-emerald-900/80",
|
||||
},
|
||||
pending: {
|
||||
badge: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
|
||||
halo: "text-amber-600 dark:text-amber-300",
|
||||
card: "border-amber-200/80 dark:border-amber-900/80",
|
||||
},
|
||||
failed: {
|
||||
badge: "border-destructive/25 bg-destructive/10 text-destructive",
|
||||
halo: "text-destructive",
|
||||
card: "border-destructive/20",
|
||||
},
|
||||
canceled: {
|
||||
badge: "border-destructive/25 bg-destructive/10 text-destructive",
|
||||
halo: "text-destructive",
|
||||
card: "border-destructive/20",
|
||||
},
|
||||
unknown: {
|
||||
badge: "border-primary/20 bg-primary/5 text-primary",
|
||||
halo: "text-primary",
|
||||
card: "border-border",
|
||||
},
|
||||
checking: {
|
||||
badge: "border-primary/20 bg-primary/5 text-primary",
|
||||
halo: "text-primary",
|
||||
card: "border-border",
|
||||
},
|
||||
};
|
||||
|
||||
function ToneIcon({ tone, className = "size-5" }: { tone: OutcomeTone; className?: string }) {
|
||||
if (tone === "success") return <Check className={className} strokeWidth={2.6} />;
|
||||
if (tone === "pending") return <Clock3 className={className} />;
|
||||
if (tone === "failed") return <X className={className} strokeWidth={2.6} />;
|
||||
if (tone === "canceled") return <Ban className={className} />;
|
||||
if (tone === "checking") return <LoaderCircle className={`${className} animate-spin`} />;
|
||||
return <CircleHelp className={className} />;
|
||||
}
|
||||
|
||||
function PaymentIllustration({ tone }: { tone: OutcomeTone }) {
|
||||
const colors = toneClasses[tone];
|
||||
|
||||
return (
|
||||
<div className={`order-first relative mx-auto flex aspect-square w-48 items-center justify-center sm:w-56 md:order-last ${colors.halo}`} aria-hidden="true">
|
||||
<div className="absolute inset-5 rounded-full bg-primary/[0.06] dark:bg-primary/[0.12]" />
|
||||
<div className="absolute inset-10 rounded-full border border-primary/15" />
|
||||
<svg viewBox="0 0 220 190" className="relative z-10 h-full w-full overflow-visible">
|
||||
<circle cx="38" cy="38" r="4" className="fill-accent" />
|
||||
<circle cx="183" cy="145" r="5" className="fill-primary/40" />
|
||||
<path d="M39 145c18-23 37-34 59-34" className="fill-none stroke-primary/20" strokeWidth="2" strokeDasharray="4 7" />
|
||||
<rect x="48" y="34" width="124" height="94" rx="16" className="fill-card stroke-primary/35" strokeWidth="2" />
|
||||
<rect x="48" y="52" width="124" height="19" className="fill-primary/10" />
|
||||
<path d="M63 87h34M63 101h21" className="stroke-primary/50" strokeWidth="4" strokeLinecap="round" />
|
||||
<rect x="117" y="87" width="34" height="23" rx="5" className="fill-primary/15 stroke-primary/35" strokeWidth="2" />
|
||||
<path d="M125 97h18" className="stroke-primary/55" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M73 143h55l-3 8a8 8 0 0 1-8 6H84a8 8 0 0 1-8-6z" className="fill-primary/15 stroke-primary/35" strokeWidth="2" />
|
||||
<path d="M82 143v14M119 143v14" className="stroke-primary/30" strokeWidth="2" strokeDasharray="3 4" />
|
||||
</svg>
|
||||
<div className={`absolute bottom-7 left-5 z-20 flex size-12 items-center justify-center rounded-2xl border bg-card shadow-sm ${colors.card}`}>
|
||||
<ToneIcon tone={tone} />
|
||||
</div>
|
||||
<div className="absolute right-7 top-7 z-20 flex size-10 items-center justify-center rounded-xl border border-border bg-card text-primary shadow-sm">
|
||||
<TicketCheck className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ action, primary = false }: { action: OutcomeAction; primary?: boolean }) {
|
||||
const content = (
|
||||
<>
|
||||
<span>{action.label}</span>
|
||||
{action.loading
|
||||
? <LoaderCircle className="size-4 animate-spin" aria-hidden="true" />
|
||||
: primary ? <ArrowLeft className="size-4" aria-hidden="true" /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (action.href) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={primary ? "default" : "outline"}
|
||||
className="h-11 min-w-36 gap-2"
|
||||
>
|
||||
<Link to={action.href}>{content}</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={primary ? "default" : "outline"}
|
||||
disabled={action.disabled || action.loading}
|
||||
aria-busy={action.loading || undefined}
|
||||
onClick={action.onClick}
|
||||
className="h-11 min-w-36 gap-2"
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OutcomePage({
|
||||
tone,
|
||||
statusLabel,
|
||||
title,
|
||||
subtitle,
|
||||
event,
|
||||
details = [],
|
||||
reference,
|
||||
referenceLabel = "کد پیگیری",
|
||||
support = false,
|
||||
supportDescription = "کد پیگیری را نگه دارید و برای بررسی وضعیت با انجمن در تماس باشید. تا روشنشدن نتیجه، پرداخت تازهای آغاز نکنید.",
|
||||
refreshing = false,
|
||||
primaryAction,
|
||||
secondaryActions = [],
|
||||
receiptRef,
|
||||
children,
|
||||
}: OutcomePageProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const colors = toneClasses[tone];
|
||||
const hasChildren = Children.toArray(children).length > 0;
|
||||
|
||||
const copyReference = async () => {
|
||||
if (!reference || !navigator.clipboard) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(reference);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-[65vh] bg-background px-4 pb-32 pt-8 sm:py-12 md:pb-12" dir="rtl">
|
||||
<article className={`mx-auto w-full max-w-3xl overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm ${colors.card}`}>
|
||||
<div ref={receiptRef} className="p-5 sm:p-8">
|
||||
<header className="grid items-center gap-5 border-b border-border/70 pb-6 md:grid-cols-[minmax(0,1fr)_240px] md:gap-8 md:pb-8">
|
||||
<div className="min-w-0">
|
||||
<div data-receipt-status-badge className={`mb-4 inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium ${colors.badge}`}>
|
||||
<ToneIcon tone={tone} className="size-4" />
|
||||
<span>{statusLabel}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold leading-relaxed sm:text-3xl">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 max-w-2xl text-sm leading-7 text-muted-foreground sm:text-base">{subtitle}</p> : null}
|
||||
</div>
|
||||
<PaymentIllustration tone={tone} />
|
||||
</header>
|
||||
|
||||
{event ? (
|
||||
<section className="mt-6 grid gap-4 rounded-xl border border-border bg-muted/30 p-3 sm:p-4 md:grid-cols-[minmax(0,1fr)_180px] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">رویداد</p>
|
||||
<h2 className="mt-1 break-words text-lg font-semibold leading-7">{event.title}</h2>
|
||||
{event.caption ? <p className="mt-1 text-sm leading-6 text-muted-foreground">{event.caption}</p> : null}
|
||||
{event.href ? (
|
||||
<Link to={event.href} className="registration-event-details-link mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
مشاهده جزئیات رویداد <ArrowLeft className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border border-border bg-background">
|
||||
{event.imageUrl ? (
|
||||
<Image
|
||||
src={event.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 768px) 180px, 100vw"
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-primary/60">
|
||||
<TicketCheck className="size-10" strokeWidth={1.4} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{reference ? (
|
||||
<section data-receipt-reference className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{referenceLabel}</p>
|
||||
<code dir="ltr" className="mt-1 block break-all text-left font-mono text-sm font-semibold text-foreground">{reference}</code>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={copyReference} className="registration-copy-reference gap-2" aria-label={`${referenceLabel} را کپی کنید`}>
|
||||
{copied ? <BadgeCheck className="size-4" /> : <Copy className="size-4" />}
|
||||
{copied ? "کپی شد" : "کپی کد"}
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<section className="mt-4 rounded-xl border border-border p-4 sm:p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold">جزئیات تراکنش</h2>
|
||||
<dl className="divide-y divide-border/70">
|
||||
{details.map((detail) => (
|
||||
<div key={detail.label} className="flex min-w-0 items-start justify-between gap-4 py-3 text-sm">
|
||||
<dt className="shrink-0 text-muted-foreground">{detail.label}</dt>
|
||||
<dd className="min-w-0 break-words text-left font-medium text-foreground">{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasChildren ? <div className="mt-5 rounded-xl border border-border bg-muted/25 p-4 text-sm leading-7">{children}</div> : null}
|
||||
|
||||
{support ? (
|
||||
<aside className="mt-5 rounded-xl border border-primary/20 bg-primary/[0.04] p-4 sm:p-5">
|
||||
<h2 className="font-semibold">نیاز به پیگیری دارید؟</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">{supportDescription}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-2 text-sm">
|
||||
<a className="inline-flex items-center gap-2 text-primary underline-offset-4 hover:underline" href="mailto:eastguilanceassociation@gmail.com">
|
||||
<Mail className="size-4" aria-hidden="true" />
|
||||
ایمیل انجمن
|
||||
</a>
|
||||
<a className="inline-flex items-center gap-2 text-primary underline-offset-4 hover:underline" href="https://t.me/guilancea" target="_blank" rel="noreferrer">
|
||||
<Send className="size-4" aria-hidden="true" />
|
||||
تلگرام انجمن
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-col gap-2 border-t border-border bg-muted/20 p-4 sm:flex-row sm:flex-wrap sm:items-center sm:p-5 print:hidden">
|
||||
{primaryAction ? <ActionButton action={primaryAction} primary /> : null}
|
||||
{secondaryActions.map((action) => (
|
||||
<ActionButton key={action.label} action={action} />
|
||||
))}
|
||||
{refreshing ? <span className="inline-flex items-center gap-2 px-1 text-xs text-muted-foreground" role="status"><LoaderCircle className="size-4 animate-spin" /> در حال بررسی وضعیت...</span> : null}
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -877,6 +877,10 @@ class ApiClient {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async getEventById(eventId: number) {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async getEventAdminDetail(eventId: number) {
|
||||
return this.request<Types.EventAdminDetailSchema>(`/api/events/${eventId}/admin-detail`);
|
||||
}
|
||||
@@ -1014,6 +1018,12 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelRegistrationCheckout(eventId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}/checkout/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async verifyMyRegistration(ticket_id: string) {
|
||||
return this.request<{
|
||||
event_image: string;
|
||||
@@ -1069,6 +1079,20 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getRegistrationHubStatus(
|
||||
eventSlug: string,
|
||||
identifiers: { registration_id?: string | null; payment_id?: number | null } = {},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (identifiers.registration_id) query.set("registration_id", identifiers.registration_id);
|
||||
if (identifiers.payment_id) query.set("payment_id", String(identifiers.payment_id));
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return this.request<Types.RegistrationHubSchema>(
|
||||
`/api/events/${encodeURIComponent(eventSlug)}/registration/status${suffix}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Payment Endpoints =============
|
||||
async createPayment(input: {
|
||||
event_id: number;
|
||||
@@ -1088,13 +1112,10 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getPaymentByRef(refId: string) {
|
||||
return this.request<Types.PaymentDetailSchema>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async getPaymentStatus(paymentId: number) {
|
||||
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async checkDiscountCode(event_id: number, code: string) {
|
||||
return this.request<{
|
||||
discount_amount: number;
|
||||
|
||||
@@ -168,3 +168,7 @@ export async function getPublicEventBySlug(slug: string) {
|
||||
`/api/events/slug/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPublicEventById(eventId: number) {
|
||||
return requestJson<Types.EventDetailSchema>(`/api/events/${eventId}`);
|
||||
}
|
||||
|
||||
80
src/lib/registration-hub-display.ts
Normal file
80
src/lib/registration-hub-display.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getEventRegistrationState, type EventAvailabilityInput } from "./event-ui.ts";
|
||||
import type { RegistrationHubState } from "./types.ts";
|
||||
|
||||
export type RegistrationHubGate =
|
||||
| "registration_ended"
|
||||
| "capacity_full"
|
||||
| "registration_not_started"
|
||||
| null;
|
||||
|
||||
export type RegistrationHubGateCopy = {
|
||||
status: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export function getRegistrationHubGate(
|
||||
event: EventAvailabilityInput,
|
||||
now = Date.now(),
|
||||
): RegistrationHubGate {
|
||||
if (event.status !== "published") return "registration_ended";
|
||||
|
||||
const state = getEventRegistrationState(event, now).state;
|
||||
if (state === "closed" || state === "unavailable") return "registration_ended";
|
||||
if (state === "full") return "capacity_full";
|
||||
if (state === "not_started") return "registration_not_started";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRegistrationHubGateCopy(
|
||||
gate: RegistrationHubGate,
|
||||
): RegistrationHubGateCopy | null {
|
||||
if (gate === "registration_ended") {
|
||||
return {
|
||||
status: "پایان زمان ثبتنام",
|
||||
title: "پایان مهلت ثبتنام",
|
||||
subtitle: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (gate === "capacity_full") {
|
||||
return {
|
||||
status: "تکمیل ظرفیت",
|
||||
title: "ظرفیت رویداد تکمیل شده",
|
||||
subtitle: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (gate === "registration_not_started") {
|
||||
return {
|
||||
status: "ثبتنام هنوز آغاز نشده",
|
||||
title: "مهلت ثبتنام هنوز آغاز نشده است",
|
||||
subtitle: "",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRegistrationHubDisplayState(
|
||||
state: RegistrationHubState,
|
||||
): RegistrationHubState {
|
||||
return state === "legacy_pending" ? "verification_pending" : state;
|
||||
}
|
||||
|
||||
export function normalizeCouponAmounts(raw: {
|
||||
discount_amount?: number | null;
|
||||
final_price?: number | null;
|
||||
}) {
|
||||
return {
|
||||
discount_amount: (raw.discount_amount ?? 0) / 10,
|
||||
final_price: (raw.final_price ?? 0) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFinalPriceToman(
|
||||
eventPriceInRials: number,
|
||||
couponFinalPriceInToman?: number | null,
|
||||
) {
|
||||
return couponFinalPriceInToman ?? Math.floor(eventPriceInRials / 10);
|
||||
}
|
||||
202
src/lib/registration-receipt-pdf.ts
Normal file
202
src/lib/registration-receipt-pdf.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import html2canvas from "html2canvas";
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
const fontFile = "Vazirmatn-Regular.ttf";
|
||||
const fontFamily = "VazirmatnReceipt";
|
||||
const pageMargin = 24;
|
||||
const pdfCaptureScale = 3;
|
||||
const jpegQuality = 0.94;
|
||||
const ignoredReceiptControls = ".registration-copy-reference, .registration-event-details-link";
|
||||
const omittedReceiptContent = `${ignoredReceiptControls}, [data-receipt-status-badge], [data-receipt-reference]`;
|
||||
|
||||
function toBase64(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function toUtf16Hex(value: string) {
|
||||
let result = "FEFF";
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result += value.charCodeAt(index).toString(16).padStart(4, "0");
|
||||
}
|
||||
return result.toUpperCase();
|
||||
}
|
||||
|
||||
async function addSelectableTextLayer(
|
||||
pdf: jsPDF,
|
||||
element: HTMLElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
image: { x: number; y: number; scale: number },
|
||||
) {
|
||||
const response = await fetch(`/fonts/${fontFile}`, { cache: "force-cache" });
|
||||
if (!response.ok) throw new Error("فونت رسید بارگذاری نشد.");
|
||||
const base64 = toBase64(await response.arrayBuffer());
|
||||
pdf.addFileToVFS(fontFile, base64);
|
||||
pdf.addFont(fontFile, fontFamily, "normal");
|
||||
pdf.setFont(fontFamily, "normal");
|
||||
pdf.setR2L(false);
|
||||
(pdf as unknown as { __private__: { setPdfVersion: (version: string) => void } }).__private__.setPdfVersion("1.4");
|
||||
|
||||
const rootRect = element.getBoundingClientRect();
|
||||
const captureScale = canvas.width / rootRect.width;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null = walker.nextNode();
|
||||
const write = (pdf.internal as unknown as { write: (value: string) => void }).write;
|
||||
|
||||
while (node) {
|
||||
const textNode = node as Text;
|
||||
const parent = textNode.parentElement;
|
||||
const value = textNode.nodeValue ?? "";
|
||||
if (
|
||||
parent
|
||||
&& value.trim()
|
||||
&& !parent.closest(`${omittedReceiptContent}, button, svg, [aria-hidden="true"]`)
|
||||
&& getComputedStyle(parent).display !== "none"
|
||||
&& getComputedStyle(parent).visibility !== "hidden"
|
||||
) {
|
||||
const words = value.matchAll(/\S+/gu);
|
||||
for (const match of words) {
|
||||
const word = match[0];
|
||||
const offset = match.index;
|
||||
if (!word) continue;
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, offset);
|
||||
range.setEnd(textNode, offset + word.length);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) continue;
|
||||
|
||||
const computedStyle = getComputedStyle(parent);
|
||||
const fontSize = Number.parseFloat(computedStyle.fontSize) || 14;
|
||||
const x = image.x + (rect.left - rootRect.left) * captureScale * image.scale;
|
||||
const y = image.y + (rect.bottom - rootRect.top) * captureScale * image.scale;
|
||||
const pdfFontSize = Math.max(4, fontSize * 0.75 * captureScale * image.scale);
|
||||
const following = value.slice(offset + word.length, offset + word.length + 1);
|
||||
const actualText = `${word}${following && /\s/u.test(following) ? " " : ""}`;
|
||||
|
||||
// The page image keeps the original visual layout; this invisible layer preserves selectable Unicode text.
|
||||
write(`/Span << /ActualText <${toUtf16Hex(actualText)}> >> BDC`);
|
||||
pdf.setFontSize(pdfFontSize);
|
||||
pdf.text(word, x, y, { align: "left", renderingMode: "invisible" });
|
||||
write("EMC");
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareReceiptImage(image: HTMLImageElement) {
|
||||
const source = image.currentSrc || image.src;
|
||||
if (!source) return;
|
||||
|
||||
const parsedSource = new URL(source, window.location.href);
|
||||
if (!["http:", "https:"].includes(parsedSource.protocol)) {
|
||||
await image.decode();
|
||||
return;
|
||||
}
|
||||
|
||||
image.removeAttribute("srcset");
|
||||
const encodeForReceipt = async (imageBlob: Blob) => {
|
||||
if (!imageBlob.type.startsWith("image/")) throw new Error("پاسخ تصویر رویداد معتبر نیست.");
|
||||
const bitmap = await createImageBitmap(imageBlob);
|
||||
const resize = Math.min(1, 640 / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * resize));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * resize));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
bitmap.close();
|
||||
throw new Error("آمادهسازی تصویر رویداد برای رسید انجام نشد.");
|
||||
}
|
||||
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
return canvas.toDataURL("image/jpeg", 0.88);
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(parsedSource.href, {
|
||||
mode: "cors",
|
||||
credentials: parsedSource.origin === window.location.origin ? "same-origin" : "omit",
|
||||
});
|
||||
if (!response.ok) throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
||||
image.src = await encodeForReceipt(await response.blob());
|
||||
} catch {
|
||||
if (parsedSource.origin === window.location.origin) {
|
||||
throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
||||
}
|
||||
|
||||
const optimizedUrl = new URL("/_next/image", window.location.origin);
|
||||
optimizedUrl.searchParams.set("url", parsedSource.href);
|
||||
optimizedUrl.searchParams.set("w", "640");
|
||||
optimizedUrl.searchParams.set("q", "75");
|
||||
const response = await fetch(optimizedUrl, { credentials: "same-origin" });
|
||||
if (!response.ok) throw new Error("تصویر رویداد برای رسید بارگذاری نشد.");
|
||||
image.src = await encodeForReceipt(await response.blob());
|
||||
}
|
||||
|
||||
await image.decode();
|
||||
}
|
||||
|
||||
export async function downloadRegistrationReceipt(element: HTMLElement, fileName: string) {
|
||||
await document.fonts.ready;
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
scale: pdfCaptureScale,
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
backgroundColor: "#ffffff",
|
||||
onclone: async (documentClone, clonedElement) => {
|
||||
documentClone.documentElement.classList.remove("dark");
|
||||
clonedElement.querySelectorAll(omittedReceiptContent).forEach((control) => control.remove());
|
||||
|
||||
const receiptHeader = clonedElement.querySelector("header");
|
||||
if (receiptHeader) {
|
||||
receiptHeader.style.alignItems = "start";
|
||||
const headerText = receiptHeader.firstElementChild as HTMLElement | null;
|
||||
if (headerText) headerText.style.alignSelf = "start";
|
||||
}
|
||||
|
||||
const eventCard = clonedElement.querySelector("section h2")?.closest("section");
|
||||
if (eventCard) {
|
||||
(eventCard as HTMLElement).style.alignItems = "start";
|
||||
const eventText = eventCard.firstElementChild as HTMLElement | null;
|
||||
if (eventText) eventText.style.alignSelf = "start";
|
||||
}
|
||||
|
||||
const images = Array.from(clonedElement.querySelectorAll("img"));
|
||||
await Promise.all(images.map(prepareReceiptImage));
|
||||
},
|
||||
});
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: "portrait",
|
||||
unit: "pt",
|
||||
format: "a4",
|
||||
compress: true,
|
||||
putOnlyUsedFonts: true,
|
||||
});
|
||||
const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
const scale = Math.min(
|
||||
(pageWidth - pageMargin * 2) / canvas.width,
|
||||
(pageHeight - pageMargin * 2) / canvas.height,
|
||||
);
|
||||
const width = canvas.width * scale;
|
||||
const height = canvas.height * scale;
|
||||
const x = (pageWidth - width) / 2;
|
||||
const y = pageMargin;
|
||||
const compressedImage = canvas.toDataURL("image/jpeg", jpegQuality);
|
||||
|
||||
pdf.setFillColor(255, 255, 255);
|
||||
pdf.rect(0, 0, pageWidth, pageHeight, "F");
|
||||
pdf.addImage(compressedImage, "JPEG", x, y, width, height, undefined, "FAST");
|
||||
await addSelectableTextLayer(pdf, element, canvas, { x, y, scale });
|
||||
pdf.setProperties({ title: "رسید ثبتنام رویداد", subject: "رسید ثبتنام", creator: "سامانهٔ انجمن" });
|
||||
pdf.save(fileName);
|
||||
}
|
||||
@@ -234,7 +234,9 @@ export type UserUpdateSchema = {
|
||||
};
|
||||
|
||||
export interface UserPaymentSchema {
|
||||
payment_id: number;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
image_url?: string | null;
|
||||
@@ -243,6 +245,7 @@ export interface UserPaymentSchema {
|
||||
discount_amount: number;
|
||||
amount: number;
|
||||
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||
refund_status?: "none" | "pending" | "succeeded" | "manual_review";
|
||||
created_at: string;
|
||||
paid_at?: string | null;
|
||||
}
|
||||
@@ -805,6 +808,8 @@ export interface EventRegistrationSchema {
|
||||
id: number;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||
ticket_id: string;
|
||||
final_price?: number | null;
|
||||
discount_amount?: number;
|
||||
registered_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -819,12 +824,92 @@ export interface EventRegistrationSchema {
|
||||
|
||||
export interface RegistrationStatusSchema {
|
||||
is_registered: boolean;
|
||||
checkout_state?: "none" | "confirmed" | "active" | "legacy_pending" | "expired" | "unresolved" | "cancelled";
|
||||
registration_id?: number | null;
|
||||
registration_status?: "pending" | "confirmed" | "cancelled" | "attended" | null;
|
||||
ticket_id?: string | null;
|
||||
checkout_expires_at?: string | null;
|
||||
payment_id?: number | null;
|
||||
payment_status?: "initiated" | "pending" | "paid" | "failed" | "canceled" | null;
|
||||
refund_status?: "none" | "pending" | "succeeded" | "manual_review" | null;
|
||||
can_resume?: boolean;
|
||||
can_cancel?: boolean;
|
||||
}
|
||||
|
||||
export type RegistrationHubState =
|
||||
| "ready"
|
||||
| "active_checkout"
|
||||
| "legacy_pending"
|
||||
| "verification_pending"
|
||||
| "manual_review"
|
||||
| "paid_success"
|
||||
| "free_success"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "expired"
|
||||
| "refund_pending"
|
||||
| "refund_manual_review"
|
||||
| "refund_succeeded";
|
||||
|
||||
export interface RegistrationHubSchema {
|
||||
state: RegistrationHubState;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
event_type: "online" | "on_site" | "hybrid";
|
||||
address?: string | null;
|
||||
location?: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
registration_start_date?: string | null;
|
||||
registration_end_date?: string | null;
|
||||
capacity?: number | null;
|
||||
price: number;
|
||||
status: "draft" | "published" | "cancelled" | "completed";
|
||||
registration_count: number;
|
||||
image_url?: string | null;
|
||||
registration_success_markdown?: string | null;
|
||||
};
|
||||
registration?: {
|
||||
ticket_id: string;
|
||||
status: "pending" | "confirmed" | "cancelled" | "attended";
|
||||
registered_at: string;
|
||||
checkout_started_at?: string | null;
|
||||
checkout_expires_at?: string | null;
|
||||
cancellation_reason: string;
|
||||
final_price?: number | null;
|
||||
discount_amount: number;
|
||||
} | null;
|
||||
payment?: {
|
||||
id: number;
|
||||
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||
verification_state: "awaiting_verification" | "checking" | "retry_scheduled" | "manual_review" | "verified" | "failed" | "canceled";
|
||||
refund_status: "none" | "pending" | "succeeded" | "manual_review";
|
||||
base_amount: number;
|
||||
discount_amount: number;
|
||||
amount: number;
|
||||
ref_id?: string | null;
|
||||
created_at: string;
|
||||
gateway_paid_at?: string | null;
|
||||
} | null;
|
||||
can_register: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
}
|
||||
|
||||
export interface MyEventRegistrationSchema {
|
||||
id: number;
|
||||
ticket_id: string;
|
||||
created_at: string;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||
checkout_state?: "none" | "active" | "legacy_pending" | "expired" | "unresolved" | "cancelled";
|
||||
checkout_expires_at?: string | null;
|
||||
payment_id?: number | null;
|
||||
payment_status?: "initiated" | "pending" | "paid" | "failed" | "canceled" | null;
|
||||
refund_status?: "none" | "pending" | "succeeded" | "manual_review" | null;
|
||||
can_resume?: boolean;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -1083,6 +1168,18 @@ export interface PaymentDetailSchema {
|
||||
amount: number;
|
||||
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||
verified_at: string | null;
|
||||
gateway_paid_at?: string | null;
|
||||
refund_status?: "none" | "pending" | "succeeded" | "manual_review";
|
||||
refund_attempts?: number;
|
||||
refund_last_attempt_at?: string | null;
|
||||
refund_next_attempt_at?: string | null;
|
||||
refund_reference?: string | null;
|
||||
refund_last_error?: string | null;
|
||||
verification_state: "awaiting_verification" | "checking" | "retry_scheduled" | "manual_review" | "verified" | "failed" | "canceled";
|
||||
verification_attempts: number;
|
||||
last_verification_at: string | null;
|
||||
next_verification_at: string | null;
|
||||
manual_review_at: string | null;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -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