feat(events): unify registration checkout and receipts
This commit is contained in:
93
public/fonts/OFL.txt
Normal file
93
public/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2015 The Vazirmatn Project Authors (https://github.com/rastikerdar/vazirmatn)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
public/fonts/Vazirmatn-Regular.ttf
Normal file
BIN
public/fonts/Vazirmatn-Regular.ttf
Normal file
Binary file not shown.
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import EventDetail from "@/views/EventDetail";
|
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 { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||||
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||||
@@ -16,7 +17,9 @@ function cleanText(value?: string | null) {
|
|||||||
|
|
||||||
async function loadEvent(slug: string) {
|
async function loadEvent(slug: string) {
|
||||||
try {
|
try {
|
||||||
return await getPublicEventBySlug(slug);
|
return /^\d+$/.test(slug)
|
||||||
|
? await getPublicEventById(Number(slug))
|
||||||
|
: await getPublicEventBySlug(slug);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PublicApiError && error.status === 404) {
|
if (error instanceof PublicApiError && error.status === 404) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -32,6 +35,7 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const event = await loadEvent(slug);
|
const event = await loadEvent(slug);
|
||||||
|
const eventPath = getEventPath(event);
|
||||||
const description = cleanText(event.description).slice(0, 160);
|
const description = cleanText(event.description).slice(0, 160);
|
||||||
const [siteContent, eventPageContent] = await Promise.all([
|
const [siteContent, eventPageContent] = await Promise.all([
|
||||||
getCmsSiteContent(),
|
getCmsSiteContent(),
|
||||||
@@ -50,12 +54,12 @@ export async function generateMetadata({
|
|||||||
return {
|
return {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description,
|
description,
|
||||||
alternates: { canonical: `/events/${event.slug}` },
|
alternates: { canonical: eventPath },
|
||||||
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
|
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: event.title,
|
title: event.title,
|
||||||
description,
|
description,
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}${eventPath}`,
|
||||||
siteName: siteContent.official_name,
|
siteName: siteContent.official_name,
|
||||||
type: "website",
|
type: "website",
|
||||||
images: [image],
|
images: [image],
|
||||||
@@ -81,6 +85,7 @@ export default async function EventDetailPage({
|
|||||||
getCmsSiteContent(),
|
getCmsSiteContent(),
|
||||||
getCmsPageContent("events"),
|
getCmsPageContent("events"),
|
||||||
]);
|
]);
|
||||||
|
const eventPath = getEventPath(event);
|
||||||
const description = cleanText(event.description).slice(0, 160);
|
const description = cleanText(event.description).slice(0, 160);
|
||||||
const image =
|
const image =
|
||||||
toAbsoluteUrl(
|
toAbsoluteUrl(
|
||||||
@@ -112,10 +117,10 @@ export default async function EventDetailPage({
|
|||||||
? "https://schema.org/OfflineEventAttendanceMode"
|
? "https://schema.org/OfflineEventAttendanceMode"
|
||||||
: "https://schema.org/MixedEventAttendanceMode",
|
: "https://schema.org/MixedEventAttendanceMode",
|
||||||
image: [image],
|
image: [image],
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}${eventPath}`,
|
||||||
organizer: organizationStructuredData(siteContent),
|
organizer: organizationStructuredData(siteContent),
|
||||||
location: event.event_type === "online"
|
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",
|
"@type": "Place",
|
||||||
name: event.location || event.address || siteContent.official_name,
|
name: event.location || event.address || siteContent.official_name,
|
||||||
@@ -123,7 +128,7 @@ export default async function EventDetailPage({
|
|||||||
},
|
},
|
||||||
offers: {
|
offers: {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}${eventPath}`,
|
||||||
priceCurrency: "IRR",
|
priceCurrency: "IRR",
|
||||||
price: String(event.price ?? 0),
|
price: String(event.price ?? 0),
|
||||||
...(event.registration_start_date ? { validFrom: event.registration_start_date } : {}),
|
...(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}
|
content={content}
|
||||||
initialPage={eventPage}
|
initialPage={eventPage}
|
||||||
initialFilters={parseEventCatalogParams(query, eventPage.price_ceiling)}
|
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' });
|
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) {
|
async getEventAdminDetail(eventId: number) {
|
||||||
return this.request<Types.EventAdminDetailSchema>(`/api/events/${eventId}/admin-detail`);
|
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) {
|
async verifyMyRegistration(ticket_id: string) {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
event_image: string;
|
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 =============
|
// ============= Payment Endpoints =============
|
||||||
async createPayment(input: {
|
async createPayment(input: {
|
||||||
event_id: number;
|
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) {
|
async getPaymentStatus(paymentId: number) {
|
||||||
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
|
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkDiscountCode(event_id: number, code: string) {
|
async checkDiscountCode(event_id: number, code: string) {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
|
|||||||
@@ -168,3 +168,7 @@ export async function getPublicEventBySlug(slug: string) {
|
|||||||
`/api/events/slug/${encodeURIComponent(slug)}`,
|
`/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 {
|
export interface UserPaymentSchema {
|
||||||
|
payment_id: number;
|
||||||
event: {
|
event: {
|
||||||
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
@@ -243,6 +245,7 @@ export interface UserPaymentSchema {
|
|||||||
discount_amount: number;
|
discount_amount: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||||
|
refund_status?: "none" | "pending" | "succeeded" | "manual_review";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
paid_at?: string | null;
|
paid_at?: string | null;
|
||||||
}
|
}
|
||||||
@@ -805,6 +808,8 @@ export interface EventRegistrationSchema {
|
|||||||
id: number;
|
id: number;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||||
ticket_id: string;
|
ticket_id: string;
|
||||||
|
final_price?: number | null;
|
||||||
|
discount_amount?: number;
|
||||||
registered_at: string;
|
registered_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -819,12 +824,92 @@ export interface EventRegistrationSchema {
|
|||||||
|
|
||||||
export interface RegistrationStatusSchema {
|
export interface RegistrationStatusSchema {
|
||||||
is_registered: boolean;
|
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 {
|
export interface MyEventRegistrationSchema {
|
||||||
id: number;
|
id: number;
|
||||||
|
ticket_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
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: {
|
event: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -1083,6 +1168,18 @@ export interface PaymentDetailSchema {
|
|||||||
amount: number;
|
amount: number;
|
||||||
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
|
||||||
verified_at: string | null;
|
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: {
|
event: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "@/lib/router";
|
import { CalendarDays, Clock3, MapPin, Ticket, UsersRound } from "lucide-react";
|
||||||
|
import { Link, useNavigate, useParams } from "@/lib/router";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 ProgressiveImage from "@/components/ProgressiveImage";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import Markdown from "@/components/Markdown";
|
import Markdown from "@/components/Markdown";
|
||||||
import CouponDialogFa from "@/components/CouponDialogFa";
|
|
||||||
import {
|
import {
|
||||||
formatJalali,
|
formatJalali,
|
||||||
formatNumberPersian,
|
formatNumberPersian,
|
||||||
formatToman,
|
formatToman,
|
||||||
getEventCardImageUrl,
|
|
||||||
getEventHeroImageUrl,
|
getEventHeroImageUrl,
|
||||||
getEventSeoImageUrl,
|
getEventSeoImageUrl,
|
||||||
getGalleryImageBlurUrl,
|
getGalleryImageBlurUrl,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { buildAuthPath } from "@/lib/auth-return";
|
import { buildAuthPath } from "@/lib/auth-return";
|
||||||
|
import { getEventPath, getEventRegistrationState } from "@/lib/event-ui";
|
||||||
|
|
||||||
const typeLabel: Record<string, string> = {
|
const typeLabel: Record<string, string> = {
|
||||||
online: "آنلاین",
|
online: "آنلاین",
|
||||||
@@ -34,32 +34,27 @@ const typeLabel: Record<string, string> = {
|
|||||||
hybrid: "آنلاین و حضوری",
|
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 = {
|
type EventDetailProps = {
|
||||||
initialEvent?: Types.EventDetailSchema | null;
|
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) {
|
export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -67,185 +62,21 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
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 [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(!initialEvent);
|
const [loading, setLoading] = useState(!initialEvent);
|
||||||
const [open, setOpen] = useState(false);
|
const [eventLoadError, setEventLoadError] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [eventLoadAttempt, setEventLoadAttempt] = useState(0);
|
||||||
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
|
|
||||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||||
const checkoutInProgressRef = useRef(false);
|
|
||||||
const checkoutIdempotencyKeyRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const basePrice = Number(event?.price ?? 0);
|
const basePrice = Number(event?.price ?? 0);
|
||||||
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
|
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
|
||||||
useEffect(() => {
|
const handleMainCTA = () => {
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function checkRegistration() {
|
|
||||||
if (!isAuthenticated || !event?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api.getRegistrationStatus(event.id);
|
|
||||||
if (!cancelled) {
|
|
||||||
setAlreadyRegistered(res.is_registered);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore registration status failures on the detail page.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkRegistration();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [event?.id, isAuthenticated]);
|
|
||||||
|
|
||||||
const goSuccess = (registrationId?: string) => {
|
|
||||||
if (!event) return;
|
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) {
|
if (!isAuthenticated) {
|
||||||
toast({
|
navigate(buildAuthPath(`${getEventPath(event)}/registration`));
|
||||||
title: "ابتدا وارد شوید",
|
|
||||||
description: "برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
navigate(buildAuthPath(`/events/${event.slug}`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
navigate(`${getEventPath(event)}/registration`);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -253,20 +84,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
|
|
||||||
async function loadEvent() {
|
async function loadEvent() {
|
||||||
try {
|
try {
|
||||||
|
setEventLoadError(false);
|
||||||
|
setLoading(true);
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
||||||
if (initialEvent && initialEvent.slug === slug) {
|
if (initialEvent && (initialEvent.slug === slug || String(initialEvent.id) === slug)) {
|
||||||
setEvent(initialEvent);
|
setEvent(initialEvent);
|
||||||
setEventThumb(getEventCardImageUrl(initialEvent));
|
|
||||||
return;
|
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;
|
if (cancelled) return;
|
||||||
setEvent(data);
|
setEvent(data);
|
||||||
setEventThumb(getEventCardImageUrl(data));
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setEventLoadError(true);
|
||||||
toast({
|
toast({
|
||||||
title: "خطا در بارگذاری رویداد",
|
title: "خطا در بارگذاری رویداد",
|
||||||
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
|
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
|
||||||
@@ -283,18 +119,13 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [initialEvent, slug, toast]);
|
}, [eventLoadAttempt, initialEvent, slug, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
|
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||||
return () => window.clearInterval(timer);
|
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>(
|
const deadlineTs = useMemo<number | null>(
|
||||||
() => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
|
() => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
|
||||||
[event?.registration_end_date],
|
[event?.registration_end_date],
|
||||||
@@ -323,17 +154,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
|
return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = useMemo(() => {
|
const registration = event ? getEventRegistrationState(event, nowTs) : null;
|
||||||
if (!event) return null;
|
|
||||||
const registrationOpen =
|
|
||||||
(rsTs == null || nowTs >= rsTs) && (deadlineTs == null || nowTs <= deadlineTs);
|
|
||||||
const unlimited = event.capacity == null;
|
|
||||||
const remaining = unlimited
|
|
||||||
? Number.POSITIVE_INFINITY
|
|
||||||
: Math.max(0, (event.capacity || 0) - (event.registration_count || 0));
|
|
||||||
const full = !unlimited && remaining <= 0;
|
|
||||||
return { registrationOpen, remaining, full };
|
|
||||||
}, [deadlineTs, event, nowTs, rsTs]);
|
|
||||||
|
|
||||||
const galleryItems = useMemo<GalleryLightboxItem[]>(
|
const galleryItems = useMemo<GalleryLightboxItem[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -353,12 +174,29 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
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 beforeStart = registration?.state === "not_started";
|
||||||
const ended = deadlineTs !== null && remainingMs === 0;
|
const ended = registration?.state === "closed";
|
||||||
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
|
const showCountdown = !beforeStart && !ended && deadlineTs !== null && (remainingMs ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
<div className="container mx-auto px-4 py-8" dir="rtl">
|
||||||
@@ -391,10 +229,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
</div>
|
</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">
|
<div className="lg:col-span-2">
|
||||||
<Card className="overflow-hidden">
|
<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
|
<ProgressiveImage
|
||||||
src={getEventHeroImageUrl(event)}
|
src={getEventHeroImageUrl(event)}
|
||||||
blurSrc={event.absolute_featured_image_thumbnail_url}
|
blurSrc={event.absolute_featured_image_thumbnail_url}
|
||||||
@@ -405,17 +243,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-2xl">{event.title}</CardTitle>
|
<CardTitle className="text-xl leading-8 md:text-2xl">{event.title}</CardTitle>
|
||||||
<CardDescription className="mt-1">
|
<CardDescription className="mt-2">
|
||||||
{formatJalali(event.start_time)}
|
{formatDateTimeRange(event.start_time, event.end_time)}
|
||||||
{event.end_time ? ` تا ${formatJalali(event.end_time)}` : null}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<Badge variant="outline" className="w-fit shrink-0">{typeLabel[event.event_type] || event.event_type}</Badge>
|
||||||
<Badge variant="default">{typeLabel[event.event_type] || event.event_type}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -456,55 +291,58 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
|
|||||||
<CardDescription>جزئیات دسترسی به رویداد</CardDescription>
|
<CardDescription>جزئیات دسترسی به رویداد</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent className="space-y-3 text-sm">
|
||||||
{event.address && <div>آدرس: {event.address}</div>}
|
<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>
|
<div>
|
||||||
ظرفیت کل: {event.capacity == null ? "نامحدود" : formatNumberPersian(event.capacity)}
|
<p className="text-xs text-muted-foreground">زمان برگزاری</p>
|
||||||
|
<p className="mt-1 font-medium">{formatDateTimeRange(event.start_time, event.end_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
{meta && event.capacity != null && (
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MapPin aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
ظرفیت باقیمانده:{" "}
|
<p className="text-xs text-muted-foreground">محل برگزاری</p>
|
||||||
{meta.remaining === Number.POSITIVE_INFINITY
|
<p className="mt-1 font-medium">{event.address || (event.event_type === "online" ? "برگزاری آنلاین" : event.event_type === "hybrid" ? "برگزاری آنلاین و حضوری" : "جزئیات محل برگزاری در توضیحات رویداد")}</p>
|
||||||
? "نامحدود"
|
</div>
|
||||||
: formatNumberPersian(meta.remaining)}
|
</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>
|
</div>
|
||||||
)}
|
|
||||||
<div>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleMainCTA}
|
onClick={handleMainCTA}
|
||||||
className="w-full mt-2"
|
className="w-full mt-2"
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
alreadyRegistered ||
|
|
||||||
event.status !== "published" ||
|
|
||||||
meta?.full === true ||
|
|
||||||
!meta?.registrationOpen
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{event.status !== "published"
|
{registration?.state === "open"
|
||||||
? "ثبتنام این رویداد فعال نیست"
|
? isFree ? "ثبتنام رایگان" : "ثبتنام و پرداخت"
|
||||||
: alreadyRegistered
|
: "مشاهدهٔ وضعیت ثبتنام"}
|
||||||
? "شما قبلاً ثبتنام کردهاید"
|
|
||||||
: !meta?.registrationOpen
|
|
||||||
? "ثبتنام هنوز آغاز نشده است"
|
|
||||||
: meta?.full
|
|
||||||
? "ظرفیت ثبتنام تکمیل شده است"
|
|
||||||
: submitting
|
|
||||||
? "در حال ثبتنام..."
|
|
||||||
: event.price === 0
|
|
||||||
? "ثبتنام (رایگان)"
|
|
||||||
: "ثبتنام و ادامه پرداخت"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{!isFree && (
|
|
||||||
<CouponDialogFa
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
basePrice={basePrice}
|
|
||||||
onVerifyCouponRaw={(code) => api.checkDiscountCode(event.id, code)}
|
|
||||||
onContinue={handleContinueFromModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 Image from "next/image";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { buildAuthPath } from "@/lib/auth-return";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { EventCard } from "@/components/events/EventCard";
|
import { EventCard } from "@/components/events/EventCard";
|
||||||
import { EventFiltersDrawer } from "@/components/events/EventFiltersDrawer";
|
import { EventFiltersDrawer } from "@/components/events/EventFiltersDrawer";
|
||||||
@@ -94,10 +96,12 @@ export default function Events({
|
|||||||
content,
|
content,
|
||||||
initialPage,
|
initialPage,
|
||||||
initialFilters,
|
initialFilters,
|
||||||
|
unmatchedPaymentCallback = false,
|
||||||
}: {
|
}: {
|
||||||
content: EventsContentPage;
|
content: EventsContentPage;
|
||||||
initialPage: EventBrowsePageSchema;
|
initialPage: EventBrowsePageSchema;
|
||||||
initialFilters: EventCatalogFilters;
|
initialFilters: EventCatalogFilters;
|
||||||
|
unmatchedPaymentCallback?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [pageData, setPageData] = useState(initialPage);
|
const [pageData, setPageData] = useState(initialPage);
|
||||||
const [filters, setFilters] = useState(initialFilters);
|
const [filters, setFilters] = useState(initialFilters);
|
||||||
@@ -257,6 +261,14 @@ export default function Events({
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background" dir="rtl">
|
<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">
|
<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">
|
<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>
|
<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}
|
{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 { api } from "@/lib/api";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { blogPostPath } from "@/lib/blog-routes";
|
import { blogPostPath } from "@/lib/blog-routes";
|
||||||
|
import { getEventPath } from "@/lib/event-ui";
|
||||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||||
import type * as Types from "@/lib/types";
|
import type * as Types from "@/lib/types";
|
||||||
import { formatJalali, formatNumberPersian, getBlogCardImageUrl, toPersianDigits } from "@/lib/utils";
|
import { formatJalali, formatNumberPersian, getBlogCardImageUrl, toPersianDigits } from "@/lib/utils";
|
||||||
@@ -86,6 +87,13 @@ const paymentStatusLabels: Record<Types.UserPaymentSchema["status"], string> = {
|
|||||||
canceled: "لغو شده",
|
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 }> = [
|
const sectionItems: Array<{ id: ProfileSection; label: string; icon: typeof Activity }> = [
|
||||||
{ id: "overview", label: "نمای کلی", icon: UserRound },
|
{ id: "overview", label: "نمای کلی", icon: UserRound },
|
||||||
{ id: "events", label: "رویدادها", icon: CalendarDays },
|
{ id: "events", label: "رویدادها", icon: CalendarDays },
|
||||||
@@ -154,24 +162,35 @@ function PagePagination({
|
|||||||
function ProfileEventCard({ registration }: { registration: Types.MyEventRegistrationSchema }) {
|
function ProfileEventCard({ registration }: { registration: Types.MyEventRegistrationSchema }) {
|
||||||
const event = registration.event;
|
const event = registration.event;
|
||||||
const imageUrl = toAbsoluteUrl(event.absolute_image_url, apiBaseUrl) || "/placeholder.svg";
|
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 (
|
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">
|
<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
|
<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"
|
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}`}
|
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" />
|
<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>
|
</Link>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<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}
|
{event.title}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<Badge variant="outline" className={eventStatusBadgeClasses[registration.status]}>{eventStatusLabels[registration.status]}</Badge>
|
<Badge variant="outline" className={eventStatusBadgeClasses[registration.status]}>{eventStatusLabels[registration.status]}</Badge>
|
||||||
<span className="text-xs text-muted-foreground">{formatJalali(event.start_date, false)}</span>
|
<span className="text-xs text-muted-foreground">{formatJalali(event.start_date, false)}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -467,7 +486,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
|
|||||||
{paymentsQuery.data?.results.map((payment, index) => (
|
{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">
|
<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">
|
<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>
|
<p className="mt-1 text-sm text-muted-foreground">{formatJalali(payment.paid_at || payment.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<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"}>
|
<Badge variant={payment.status === "paid" ? "secondary" : payment.status === "failed" || payment.status === "canceled" ? "destructive" : "outline"}>
|
||||||
{paymentStatusLabels[payment.status]}
|
{paymentStatusLabels[payment.status]}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
58
tests/registration-hub-display.test.mjs
Normal file
58
tests/registration-hub-display.test.mjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
getRegistrationHubGate,
|
||||||
|
getRegistrationHubDisplayState,
|
||||||
|
getRegistrationHubGateCopy,
|
||||||
|
getFinalPriceToman,
|
||||||
|
normalizeCouponAmounts,
|
||||||
|
} from "../src/lib/registration-hub-display.ts";
|
||||||
|
|
||||||
|
const now = Date.parse("2026-10-01T06:00:00.000Z");
|
||||||
|
|
||||||
|
test("closed and cancelled events use the registration-ended presentation", () => {
|
||||||
|
const closedRegistration = getRegistrationHubGate({
|
||||||
|
status: "published",
|
||||||
|
registration_end_date: "2026-09-30T00:00:00.000Z",
|
||||||
|
}, now);
|
||||||
|
const cancelledEvent = getRegistrationHubGate({ status: "cancelled" }, now);
|
||||||
|
|
||||||
|
assert.equal(closedRegistration, "registration_ended");
|
||||||
|
assert.equal(cancelledEvent, "registration_ended");
|
||||||
|
assert.deepEqual(getRegistrationHubGateCopy(closedRegistration), {
|
||||||
|
status: "پایان زمان ثبتنام",
|
||||||
|
title: "پایان مهلت ثبتنام",
|
||||||
|
subtitle: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full events use the capacity-complete presentation", () => {
|
||||||
|
const gate = getRegistrationHubGate({
|
||||||
|
status: "published",
|
||||||
|
capacity: 5,
|
||||||
|
registration_count: 5,
|
||||||
|
}, now);
|
||||||
|
|
||||||
|
assert.equal(gate, "capacity_full");
|
||||||
|
assert.deepEqual(getRegistrationHubGateCopy(gate), {
|
||||||
|
status: "تکمیل ظرفیت",
|
||||||
|
title: "ظرفیت رویداد تکمیل شده",
|
||||||
|
subtitle: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy unresolved checkout uses the unknown-payment presentation", () => {
|
||||||
|
assert.equal(getRegistrationHubDisplayState("legacy_pending"), "verification_pending");
|
||||||
|
assert.equal(getRegistrationHubDisplayState("paid_success"), "paid_success");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("coupon API amounts are converted from rial to toman for display", () => {
|
||||||
|
assert.deepEqual(normalizeCouponAmounts({ discount_amount: 100_000, final_price: 1_150_000 }), {
|
||||||
|
discount_amount: 10_000,
|
||||||
|
final_price: 115_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("final event price is converted from rial to toman when no coupon is applied", () => {
|
||||||
|
assert.equal(getFinalPriceToman(1_250_000), 125_000);
|
||||||
|
});
|
||||||
@@ -28,7 +28,8 @@
|
|||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user