Compare commits

...

10 Commits

70 changed files with 4849 additions and 1001 deletions

4
next-env.d.ts vendored
View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

93
public/fonts/OFL.txt Normal file
View 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.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

View File

@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import EventDetail from "@/views/EventDetail";
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
import { getEventPath } from "@/lib/event-ui";
import { PublicApiError, getPublicEventById, getPublicEventBySlug } from "@/lib/public-api";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
@@ -16,7 +17,9 @@ function cleanText(value?: string | null) {
async function loadEvent(slug: string) {
try {
return await getPublicEventBySlug(slug);
return /^\d+$/.test(slug)
? await getPublicEventById(Number(slug))
: await getPublicEventBySlug(slug);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
@@ -32,6 +35,7 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { slug } = await params;
const event = await loadEvent(slug);
const eventPath = getEventPath(event);
const description = cleanText(event.description).slice(0, 160);
const [siteContent, eventPageContent] = await Promise.all([
getCmsSiteContent(),
@@ -50,12 +54,12 @@ export async function generateMetadata({
return {
title: event.title,
description,
alternates: { canonical: `/events/${event.slug}` },
alternates: { canonical: eventPath },
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
openGraph: {
title: event.title,
description,
url: `${siteUrl}/events/${event.slug}`,
url: `${siteUrl}${eventPath}`,
siteName: siteContent.official_name,
type: "website",
images: [image],
@@ -81,6 +85,7 @@ export default async function EventDetailPage({
getCmsSiteContent(),
getCmsPageContent("events"),
]);
const eventPath = getEventPath(event);
const description = cleanText(event.description).slice(0, 160);
const image =
toAbsoluteUrl(
@@ -112,10 +117,10 @@ export default async function EventDetailPage({
? "https://schema.org/OfflineEventAttendanceMode"
: "https://schema.org/MixedEventAttendanceMode",
image: [image],
url: `${siteUrl}/events/${event.slug}`,
url: `${siteUrl}${eventPath}`,
organizer: organizationStructuredData(siteContent),
location: event.event_type === "online"
? { "@type": "VirtualLocation", url: event.online_link || `${siteUrl}/events/${event.slug}` }
? { "@type": "VirtualLocation", url: event.online_link || `${siteUrl}${eventPath}` }
: {
"@type": "Place",
name: event.location || event.address || siteContent.official_name,
@@ -123,7 +128,7 @@ export default async function EventDetailPage({
},
offers: {
"@type": "Offer",
url: `${siteUrl}/events/${event.slug}`,
url: `${siteUrl}${eventPath}`,
priceCurrency: "IRR",
price: String(event.price ?? 0),
...(event.registration_start_date ? { validFrom: event.registration_start_date } : {}),

View 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}
/>
);
}

View File

@@ -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 />;
}

View File

@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import Events from "@/views/Events";
import { getPublicEvents } from "@/lib/public-api";
import { getPublicEventBrowsePage } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
import { cmsPageMetadata, collectionPageStructuredData, safeJsonLd } from "@/lib/seo";
import { getEventPath } from "@/lib/event-ui";
import { parseEventCatalogParams } from "@/lib/event-catalog";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -41,8 +43,18 @@ export default async function EventsPage({
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const [events, content, siteContent] = await Promise.all([
getPublicEvents({ search: search || undefined }),
const query = new URLSearchParams();
Object.entries(resolved).forEach(([key, value]) => {
const firstValue = firstString(value);
if (firstValue !== "") query.set(key, firstValue);
});
const requestedFilters = parseEventCatalogParams(query, Number.MAX_SAFE_INTEGER);
const [eventPage, content, siteContent] = await Promise.all([
getPublicEventBrowsePage({
...requestedFilters,
priceMin: query.has("price_min") ? requestedFilters.priceMin : undefined,
priceMax: query.has("price_max") ? requestedFilters.priceMax : undefined,
}),
getCmsPageContent("events"),
getCmsSiteContent(),
]);
@@ -51,9 +63,9 @@ export default async function EventsPage({
description: content.seo.description || content.intro,
path: "/events",
siteContent,
items: events.filter((event) => event.slug).map((event) => ({
items: eventPage.results.map((event) => ({
name: event.title,
url: new URL(`/events/${encodeURIComponent(event.slug)}`, siteUrl).toString(),
url: new URL(getEventPath(event), siteUrl).toString(),
description: event.description,
})),
});
@@ -61,7 +73,12 @@ export default async function EventsPage({
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }} />
<Events content={content} initialEvents={events} initialSearch={search} />
<Events
content={content}
initialPage={eventPage}
initialFilters={parseEventCatalogParams(query, eventPage.price_ceiling)}
unmatchedPaymentCallback={firstString(resolved.payment_callback) === "unmatched"}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import RankGuide from "@/views/RankGuide";
export const metadata: Metadata = {
title: "راهنمای رتبه و نشان‌ها",
description: "مسیرهای دریافت نشان و ارتقای سطح در انجمن علمی مهندسی کامپیوتر دانشگاه گیلان.",
};
export default function RankGuidePage() {
return <RankGuide />;
}

View File

@@ -22,7 +22,10 @@ type Props = {
onOpenChange: (v: boolean) => void;
basePrice: number; // مبلغ اولیه رویداد
onVerifyCouponRaw: (code: string) => Promise<RawVerifyResult>;
onContinue: (coupon?: string, finalPrice?: number) => void; // ادامه‌ی جریان ثبت‌نام/پرداخت
onContinue: (coupon?: string, finalPrice?: number) => void | Promise<void>; // ادامه‌ی جریان ثبت‌نام/پرداخت
continueError?: string | null;
isSubmitting?: boolean;
onContinueErrorClear?: () => void;
};
export default function CouponDialogFa({
@@ -31,6 +34,9 @@ export default function CouponDialogFa({
basePrice,
onVerifyCouponRaw,
onContinue,
continueError,
isSubmitting = false,
onContinueErrorClear,
}: Props) {
const [code, setCode] = React.useState("");
const [verifying, setVerifying] = React.useState(false);
@@ -80,11 +86,15 @@ export default function CouponDialogFa({
<Input
id="coupon"
value={code}
onChange={(e) => setCode(e.target.value)}
onChange={(e) => {
setCode(e.target.value);
setRes(null);
onContinueErrorClear?.();
}}
placeholder="مثلاً OFF20"
className="text-right"
/>
<Button variant="secondary" disabled={!code || verifying} onClick={handleVerify}>
<Button type="button" variant="secondary" disabled={!code.trim() || verifying || isSubmitting} onClick={handleVerify}>
{verifying ? "در حال بررسی..." : "بررسی کد"}
</Button>
</div>
@@ -117,16 +127,22 @@ export default function CouponDialogFa({
<span className="font-semibold">{formatNumberPersian(finalPrice)} تومان</span>
</div>
</div>
{continueError ? (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm leading-6 text-foreground">
{continueError}
</p>
) : null}
</div>
<DialogFooter className="sm:justify-start">
<div className="flex w-full items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>انصراف</Button>
<Button type="button" variant="outline" disabled={isSubmitting} onClick={() => onOpenChange(false)}>انصراف</Button>
<Button
onClick={() => onContinue(code || undefined, finalPrice)}
disabled={verifying /* در حال بررسی که هستیم، ادامه غیرفعال باشد */}
type="button"
onClick={() => void onContinue(code.trim() || undefined, finalPrice)}
disabled={verifying || isSubmitting || Boolean(code.trim() && !res?.valid)}
>
ادامه و پرداخت
{isSubmitting ? "در حال آماده‌سازی..." : finalPrice === 0 ? "ثبت‌نام رایگان با کد تخفیف" : "ادامه و پرداخت"}
</Button>
</div>
</DialogFooter>

View File

@@ -23,6 +23,10 @@ type MarkdownProps = {
size?: MarkdownSize;
};
type MarkdownNodeWithPosition = {
position?: { start?: { line?: number } };
};
function CodeBlock({
className,
children,
@@ -92,7 +96,8 @@ export default function Markdown({
}: MarkdownProps) {
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
const headings = extractMarkdownHeadings(content);
let headingIndex = 0;
const headingIdsByLine = new Map<number, string>();
headings.forEach(({ line, id }) => headingIdsByLine.set(line, id));
const baseSizeClass =
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
@@ -108,13 +113,9 @@ export default function Markdown({
? { textAlign: "justify", textJustify: "inter-word" }
: undefined;
const nextHeadingId = (level: 1 | 2 | 3) => {
while (headingIndex < headings.length) {
const heading = headings[headingIndex];
headingIndex += 1;
if (heading.level === level) return heading.id;
}
return undefined;
const headingId = (node?: MarkdownNodeWithPosition) => {
const line = node?.position?.start?.line;
return line ? headingIdsByLine.get(line) : undefined;
};
return (
@@ -127,9 +128,9 @@ export default function Markdown({
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={{
h1: (p) => <h1 {...p} id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
h2: (p) => <h2 {...p} id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
h3: (p) => <h3 {...p} id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
h1: (p) => <h1 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
h2: (p) => <h2 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
h3: (p) => <h3 {...p} id={headingId(p.node)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
p: (p) => <p className="my-4" {...p} />,
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,

View File

@@ -28,8 +28,8 @@ export default function ModeToggle({ className }: { className?: string }) {
title="تغییر حالت نمایش"
onClick={handleToggle}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<Sun className="!size-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute !size-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);

View File

@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, ChevronDown, Loader2, Trash2 } from "lucide-react";
import { ArrowLeft, Loader2, Trash2 } from "lucide-react";
import Image from "next/image";
import ConfirmAction from "@/components/ConfirmAction";
import Markdown from "@/components/Markdown";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext";
import { useNotifications } from "@/contexts/NotificationsContext";
import { toast } from "@/hooks/use-toast";
@@ -170,26 +172,33 @@ export default function NotificationsPageContent() {
<h1 className="text-xl font-bold sm:text-2xl">اعلان‌های من</h1>
<label className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">نمایش:</span>
<span className="relative">
<select
value={filter}
onChange={(event) => setFilter(event.target.value as ReadFilter)}
className="min-w-28 appearance-none rounded-lg border border-transparent bg-transparent py-2 pe-8 ps-2 font-semibold outline-none focus-visible:border-primary"
aria-label="فیلتر اعلان‌ها"
>
<option value="all">همه</option>
<option value="unread">خوانده‌نشده</option>
<option value="read">خوانده‌شده</option>
</select>
<ChevronDown className="pointer-events-none absolute end-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
<Select dir="rtl" value={filter} onValueChange={(value) => setFilter(value as ReadFilter)}>
<SelectTrigger dir="rtl" aria-label="فیلتر اعلان‌ها" className="h-10 min-w-32 rounded-xl bg-background px-3 text-right font-semibold">
<SelectValue />
</SelectTrigger>
<SelectContent dir="rtl" align="end">
<SelectItem value="all" className="text-right">همه</SelectItem>
<SelectItem value="unread" className="text-right">خوانده‌نشده</SelectItem>
<SelectItem value="read" className="text-right">خوانده‌شده</SelectItem>
</SelectContent>
</Select>
</label>
</header>
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground"><Loader2 className="h-5 w-5 animate-spin" />در حال بارگذاری اعلان‌ها...</div>
) : items.length === 0 ? (
<div className="rounded-2xl bg-background px-6 py-16 text-center text-muted-foreground">اعلانی برای نمایش وجود ندارد.</div>
<div role="status" className="flex min-h-[22rem] flex-col items-center justify-center rounded-2xl border border-border bg-background px-5 py-10 text-center sm:py-12">
<Image
src="/images/notifications-empty-state.png"
alt=""
aria-hidden="true"
width={1490}
height={1056}
className="mb-5 h-auto w-full max-w-[280px] object-contain sm:max-w-[360px]"
/>
<p className="font-semibold text-muted-foreground">اعلانی برای نمایش وجود ندارد.</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => {

View 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>
);
}

View File

@@ -32,9 +32,11 @@ export default function ProgressiveImage({
}: ProgressiveImageProps) {
const resolvedSrc = src || blurSrc || fallbackSrc;
const [loaded, setLoaded] = React.useState(false);
const imageRef = React.useRef<HTMLImageElement>(null);
React.useEffect(() => {
setLoaded(false);
const image = imageRef.current;
setLoaded(Boolean(image?.complete && image.naturalWidth > 0));
}, [resolvedSrc]);
return (
@@ -54,6 +56,7 @@ export default function ProgressiveImage({
)}
<img
ref={imageRef}
src={resolvedSrc}
alt={alt}
sizes={sizes}

View File

@@ -0,0 +1,96 @@
"use client";
import { ArrowLeft, CalendarDays, MapPin, Ticket, UsersRound } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import ProgressiveImage from "@/components/ProgressiveImage";
import { Link } from "@/lib/router";
import type { EventListItemSchema } from "@/lib/types";
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
import {
getEventBrowseLabel,
getEventRegistrationMessage,
getEventRegistrationState,
getEventPath,
} from "@/lib/event-ui";
import { getEventLocationLabel } from "@/lib/event-catalog";
export function EventCard({ event, now }: { event: EventListItemSchema; now: number }) {
const statusLabel = getEventBrowseLabel(event, now);
const registration = getEventRegistrationState(event, now);
const registrationText = getEventRegistrationMessage(event, now);
const canRegister = registration.state === "open";
const imageEffect = canRegister
? ""
: "scale-[1.02] blur-sm group-hover:scale-100 group-hover:blur-none group-active:scale-100 group-active:blur-none group-focus-visible:scale-100 group-focus-visible:blur-none";
const statusOverlayEffect = "opacity-100 group-hover:opacity-0 group-active:opacity-0 group-focus-visible:opacity-0";
return (
<article className="h-full min-w-0">
<Link
to={getEventPath(event)}
className={`group flex h-full min-w-0 flex-col overflow-hidden rounded-xl border bg-card transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${canRegister ? "border-primary/45 shadow-sm ring-1 ring-primary/10" : "border-border"}`}
>
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
<ProgressiveImage
src={getEventCardImageUrl(event)}
alt={event.title}
wrapperClassName="h-full w-full"
className={`h-full w-full object-cover ${canRegister ? "" : "transition-[filter,transform] duration-300 motion-reduce:transition-none"} ${imageEffect}`}
/>
{canRegister ? (
<Badge className="absolute right-2 top-2 inline-flex items-center gap-1.5 border-primary/20 bg-primary text-xs text-primary-foreground shadow-sm">
<Ticket aria-hidden="true" className="size-3.5" />در حال ثبت‌نام
</Badge>
) : null}
{!canRegister ? (
<div className={`absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-200 motion-reduce:transition-none ${statusOverlayEffect}`}>
<span className="rounded-full bg-background/95 px-4 py-2 text-sm font-semibold text-foreground shadow-sm">
{statusLabel}
</span>
</div>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col p-3 md:p-4">
<h2 className="line-clamp-2 text-sm font-bold leading-6 transition-colors group-hover:text-primary md:text-base md:leading-7">
{event.title}
</h2>
<dl className="mt-2 grid gap-2 text-xs md:mt-4 md:gap-3 md:text-sm">
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<CalendarDays aria-hidden="true" className="size-4 shrink-0 text-primary" />
<dt className="sr-only">زمان</dt>
<dd className="truncate">{formatJalali(event.start_time, false)}</dd>
</div>
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<MapPin aria-hidden="true" className="size-4 shrink-0 text-primary" />
<dt className="sr-only">محل برگزاری</dt>
<dd className="truncate">{getEventLocationLabel(event)}</dd>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Ticket aria-hidden="true" className="size-4 shrink-0 text-primary" />
<dt className="sr-only">هزینهٔ ثبت‌نام</dt>
<dd>{Number(event.price ?? 0) <= 0 ? "رایگان" : formatToman(event.price)}</dd>
</div>
<div className="flex min-w-0 items-center gap-2 text-muted-foreground">
<UsersRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
<dt className="sr-only">وضعیت ثبت‌نام</dt>
<dd className="truncate">
{registration.state === "open" && registration.remaining !== null
? `${formatNumberPersian(registration.remaining)} نفر ظرفیت باقی‌مانده`
: registrationText}
</dd>
</div>
</dl>
<div className="mt-auto pt-5">
<span className={`inline-flex min-h-10 w-full items-center justify-center gap-2 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors md:min-h-11 md:text-sm ${canRegister ? "bg-primary text-primary-foreground group-hover:bg-primary/90" : "bg-muted text-muted-foreground group-hover:bg-muted/80 dark:bg-primary/15 dark:text-primary dark:group-hover:bg-primary/20"}`}>
{canRegister ? "ثبت‌نام در رویداد" : "مشاهده جزئیات رویداد"}
<ArrowLeft aria-hidden="true" className="size-4 shrink-0 transition-transform group-hover:-translate-x-0.5" />
</span>
</div>
</div>
</Link>
</article>
);
}

View File

@@ -0,0 +1,524 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { toPersianDigits } from "@/lib/utils";
const weekdays = [
{ label: "شن", name: "شنبه" },
{ label: "یک", name: "یکشنبه" },
{ label: "دو", name: "دوشنبه" },
{ label: "سه", name: "سه‌شنبه" },
{ label: "چه", name: "چهارشنبه" },
{ label: "پن", name: "پنجشنبه" },
{ label: "جم", name: "جمعه" },
];
const persianMonths = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
function toPersianDate(date: DateObject) {
return new DateObject({ date, calendar: persian, locale: persian_fa }).set({
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
}
function getToday() {
return toPersianDate(new DateObject({ calendar: persian, locale: persian_fa }));
}
function isSameDate(first: DateObject | null, second: DateObject | null) {
return Boolean(
first && second && first.year === second.year && first.monthIndex === second.monthIndex && first.day === second.day,
);
}
function getCalendarDays(month: DateObject) {
const firstDay = toPersianDate(month).toFirstOfMonth();
const monthLength = firstDay.month.length;
const offset = firstDay.weekDay.index;
// Reserve six rows for every month so the calendar modal keeps a steady height.
const rowCount = 6;
const days: (DateObject | null)[] = Array.from({ length: offset }, () => null);
for (let day = 1; day <= monthLength; day += 1) {
days.push(new DateObject(firstDay).setDay(day));
}
while (days.length < rowCount * 7) days.push(null);
return Array.from({ length: rowCount }, (_, row) => days.slice(row * 7, row * 7 + 7));
}
function getRangeDates(value: DateObject[]) {
if (value.length < 2) return null;
const [first, second] = value.map(toPersianDate).sort((a, b) => a.valueOf() - b.valueOf());
return { start: first, end: second };
}
function monthLabel(date: DateObject) {
return toPersianDate(date).toFirstOfMonth().format("MMMM YYYY");
}
function CalendarMonth({
month,
action,
selectedDates,
range,
hoveredDate,
focusedDate,
today,
onSelect,
onNavigate,
onHover,
onFocusDate,
onKeyDown,
selectionMode = "days",
yearPageStart = month.year,
onOpenMonthPicker,
onOpenYearPicker,
onSelectionNavigate,
onSelectYear,
onSelectMonth,
}: {
month: DateObject;
action: "left" | "right" | "both" | null;
selectedDates: DateObject[];
range: { start: DateObject; end: DateObject } | null;
hoveredDate: DateObject | null;
focusedDate: DateObject;
today: DateObject;
onSelect: (date: DateObject) => void;
onNavigate: (monthDelta: -2 | -1 | 1 | 2) => void;
onHover: (date: DateObject | null) => void;
onFocusDate: (date: DateObject) => void;
onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>, date: DateObject) => void;
selectionMode?: "days" | "months" | "years";
yearPageStart?: number;
onOpenMonthPicker?: () => void;
onOpenYearPicker?: () => void;
onSelectionNavigate?: (direction: -1 | 1) => void;
onSelectYear?: (year: number) => void;
onSelectMonth?: (monthIndex: number) => void;
}) {
const rows = getCalendarDays(month);
return (
<section className="event-range-month" role="group" aria-label={monthLabel(month)}>
<header className="event-range-month-header">
{action === "left" || action === "both" ? (
<button
type="button"
className="event-range-nav event-range-nav-left"
aria-label={selectionMode === "days" ? "ماه بعد" : selectionMode === "years" ? "۱۲ سال بعد" : "سال بعد"}
onClick={() => selectionMode === "days" ? onNavigate(action === "both" ? 1 : 2) : onSelectionNavigate?.(1)}
>
<ChevronLeft aria-hidden="true" />
</button>
) : null}
{onOpenMonthPicker && onOpenYearPicker ? (
<div className="event-range-month-title event-range-month-title-picker" dir="rtl">
<button
type="button"
className={selectionMode === "months" ? "is-active" : undefined}
aria-label={`انتخاب ماه، ${toPersianDate(month).format("MMMM")}`}
aria-pressed={selectionMode === "months"}
onClick={onOpenMonthPicker}
>
{toPersianDate(month).format("MMMM")}
</button>
<button
type="button"
className={selectionMode === "years" ? "is-active" : undefined}
aria-label={`انتخاب سال، ${toPersianDigits(month.year)}`}
aria-pressed={selectionMode === "years"}
onClick={onOpenYearPicker}
>
{toPersianDigits(month.year)}
</button>
</div>
) : (
<h4 className="event-range-month-title">{monthLabel(month)}</h4>
)}
{action === "right" || action === "both" ? (
<button
type="button"
className="event-range-nav event-range-nav-right"
aria-label={selectionMode === "days" ? "ماه قبل" : selectionMode === "years" ? "۱۲ سال قبل" : "سال قبل"}
onClick={() => selectionMode === "days" ? onNavigate(action === "both" ? -1 : -2) : onSelectionNavigate?.(-1)}
>
<ChevronRight aria-hidden="true" />
</button>
) : null}
</header>
<div className={`event-range-calendar-body is-${selectionMode}`}>
{selectionMode === "months" ? (
<div className="event-range-month-choices" role="group" aria-label="انتخاب ماه‌ها">
{persianMonths.map((name, monthIndex) => (
<button
type="button"
key={name}
aria-pressed={month.monthIndex === monthIndex}
onClick={() => onSelectMonth?.(monthIndex)}
>
{name}
</button>
))}
</div>
) : selectionMode === "years" ? (
<div className="event-range-year-choices" role="group" aria-label="انتخاب سال">
{Array.from({ length: 12 }, (_, index) => yearPageStart + index).map((year) => (
<button
type="button"
key={year}
aria-pressed={month.year === year}
onClick={() => onSelectYear?.(year)}
>
{toPersianDigits(year)}
</button>
))}
</div>
) : (
<>
<div className="event-range-weekdays" role="row">
{weekdays.map((weekday) => (
<span key={weekday.name} role="columnheader" title={weekday.name} aria-label={weekday.name}>
{weekday.label}
</span>
))}
</div>
<div className="event-range-days" role="grid" aria-label={monthLabel(month)}>
{rows.map((week, rowIndex) => (
<div className="event-range-week" role="row" key={`week-${rowIndex}`}>
{week.map((date, dayIndex) => {
if (!date) {
return <span aria-hidden="true" className="event-range-empty-day" key={`empty-${rowIndex}-${dayIndex}`} />;
}
const isStart = isSameDate(range?.start ?? selectedDates[0] ?? null, date);
const isEnd = Boolean(range && isSameDate(range.end, date));
const isInRange = Boolean(range && date.valueOf() >= range.start.valueOf() && date.valueOf() <= range.end.valueOf());
const isHoverPreview = selectedDates.length === 1 && isSameDate(hoveredDate, date) && !isStart;
const isToday = isSameDate(today, date);
const isFocused = isSameDate(focusedDate, date);
return (
<button
type="button"
role="gridcell"
aria-label={date.format("dddd، D MMMM YYYY")}
aria-pressed={isStart || isEnd}
tabIndex={isFocused ? 0 : -1}
data-event-calendar-day={date.valueOf()}
className={[
"event-range-day",
isInRange ? "is-in-range" : "",
isStart ? "is-range-start" : "",
isEnd ? "is-range-end" : "",
isHoverPreview ? "is-hover-preview" : "",
isToday ? "is-today" : "",
].filter(Boolean).join(" ")}
key={date.valueOf()}
onClick={() => onSelect(date)}
onMouseEnter={() => onHover(date)}
onMouseLeave={() => onHover(null)}
onFocus={() => onFocusDate(date)}
onKeyDown={(event) => onKeyDown(event, date)}
>
{date.format("D")}
</button>
);
})}
</div>
))}
</div>
</>
)}
</div>
</section>
);
}
export function EventDateRangePicker({
value,
onChange,
disabled = false,
compact = false,
}: {
value: DateObject[];
onChange: (value: DateObject[]) => void;
disabled?: boolean;
compact?: boolean;
}) {
const initialDate = value[0] ? toPersianDate(value[0]) : getToday();
const [open, setOpen] = useState(false);
const [draftValue, setDraftValue] = useState<DateObject[]>(value.map(toPersianDate));
const [visibleMonth, setVisibleMonth] = useState(() => initialDate.toFirstOfMonth());
const [hoveredDate, setHoveredDate] = useState<DateObject | null>(null);
const [selectionMode, setSelectionMode] = useState<"days" | "months" | "years">("days");
const [yearPageStart, setYearPageStart] = useState(() => Math.floor(initialDate.year / 12) * 12);
const [focusedDate, setFocusedDate] = useState(() => initialDate);
const [singleMonth, setSingleMonth] = useState(false);
const calendarRef = useRef<HTMLDivElement>(null);
const today = useMemo(getToday, []);
const selectedRange = getRangeDates(draftValue);
const followingMonth = new DateObject(visibleMonth).add(1, "month").toFirstOfMonth();
const canSave = draftValue.length !== 1;
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 640px)");
const updateLayout = () => setSingleMonth(mediaQuery.matches);
updateLayout();
mediaQuery.addEventListener("change", updateLayout);
return () => mediaQuery.removeEventListener("change", updateLayout);
}, []);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
calendarRef.current
?.querySelector<HTMLButtonElement>(`[data-event-calendar-day="${focusedDate.valueOf()}"]`)
?.focus();
});
return () => cancelAnimationFrame(frame);
}, [focusedDate, open, visibleMonth]);
const selectedLabel = value.length === 2
? `${toPersianDate(value[0]).format("YYYY/MM/DD")} تا ${toPersianDate(value[1]).format("YYYY/MM/DD")}`
: value.length === 1
? toPersianDate(value[0]).format("YYYY/MM/DD")
: "";
function changeVisibleMonth(date: DateObject) {
const nextDate = toPersianDate(date);
setVisibleMonth(new DateObject(nextDate).toFirstOfMonth());
setFocusedDate(nextDate);
}
function shiftVisibleMonths(monthDelta: -2 | -1 | 1 | 2) {
const nextMonth = new DateObject(visibleMonth).add(monthDelta, "month").toFirstOfMonth();
changeVisibleMonth(nextMonth);
}
function selectMonth(monthIndex: number) {
const nextMonth = new DateObject(visibleMonth).set({ month: monthIndex + 1, day: 1 }).toFirstOfMonth();
setVisibleMonth(nextMonth);
setFocusedDate(nextMonth);
setSelectionMode("days");
}
function selectYear(year: number) {
const nextMonth = new DateObject(visibleMonth)
.set({ year, month: visibleMonth.monthIndex + 1, day: 1 })
.toFirstOfMonth();
setVisibleMonth(nextMonth);
setFocusedDate(nextMonth);
setYearPageStart(Math.floor(year / 12) * 12);
setSelectionMode("months");
}
function showMonthPicker() {
setSelectionMode("months");
}
function showYearPicker() {
setYearPageStart(Math.floor(visibleMonth.year / 12) * 12);
setSelectionMode("years");
}
function navigateSelection(direction: -1 | 1) {
if (selectionMode === "years") {
setYearPageStart((current) => current + direction * 12);
return;
}
const nextMonth = new DateObject(visibleMonth)
.set({ year: visibleMonth.year + direction, day: 1 })
.toFirstOfMonth();
setVisibleMonth(nextMonth);
setFocusedDate(nextMonth);
}
function handleSelect(date: DateObject) {
const selectedDate = toPersianDate(date);
setHoveredDate(null);
if (draftValue.length !== 1) {
setFocusedDate(selectedDate);
setDraftValue([selectedDate]);
return;
}
const [start, end] = [toPersianDate(draftValue[0]), selectedDate].sort((a, b) => a.valueOf() - b.valueOf());
setDraftValue([start, end]);
}
function handleSave() {
if (!canSave) return;
if (draftValue.length === 2 || value.length > 0) onChange(draftValue);
setOpen(false);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, date: DateObject) {
const movement: Record<string, [number, string]> = {
ArrowLeft: [1, "day"],
ArrowRight: [-1, "day"],
ArrowUp: [-7, "day"],
ArrowDown: [7, "day"],
PageUp: [event.shiftKey ? -12 : -1, "month"],
PageDown: [event.shiftKey ? 12 : 1, "month"],
};
let nextDate: DateObject | null = null;
if (event.key === "Home") {
nextDate = new DateObject(date).subtract(date.weekDay.index, "day");
} else if (event.key === "End") {
nextDate = new DateObject(date).add(6 - date.weekDay.index, "day");
} else if (movement[event.key]) {
const [amount, unit] = movement[event.key];
nextDate = new DateObject(date).add(amount, unit);
}
if (!nextDate) return;
event.preventDefault();
nextDate = toPersianDate(nextDate);
setFocusedDate(nextDate);
const firstMonth = toPersianDate(visibleMonth).toFirstOfMonth();
if (singleMonth) {
if (nextDate.monthIndex !== firstMonth.monthIndex || nextDate.year !== firstMonth.year) {
changeVisibleMonth(nextDate);
}
return;
}
const secondMonth = new DateObject(firstMonth).add(1, "month").toFirstOfMonth();
if (nextDate < firstMonth) changeVisibleMonth(nextDate);
else if (nextDate > secondMonth.toLastOfMonth()) {
changeVisibleMonth(new DateObject(nextDate).subtract(1, "month"));
}
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) {
setDraftValue(value.map(toPersianDate));
setHoveredDate(null);
setSelectionMode("days");
setOpen(false);
return;
}
const selectedDate = value[0] ? toPersianDate(value[0]) : getToday();
setDraftValue(value.map(toPersianDate));
setFocusedDate(selectedDate);
setVisibleMonth(new DateObject(selectedDate).toFirstOfMonth());
setHoveredDate(null);
setSelectionMode("days");
setOpen(true);
}
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Trigger asChild>
<button
id="event-date-range"
type="button"
disabled={disabled}
aria-label={selectedLabel ? `تاریخ برگزاری: ${selectedLabel}` : "انتخاب بازه زمانی"}
aria-haspopup="dialog"
aria-expanded={open}
className="flex h-10 w-full items-center justify-between gap-3 rounded-md border border-input bg-background px-3 text-right text-sm text-muted-foreground transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50"
>
<span dir="rtl" className={selectedLabel ? "text-foreground" : "text-muted-foreground"}>
{selectedLabel || "انتخاب بازه زمانی"}
</span>
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
</button>
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="event-range-modal-overlay" />
<DialogPrimitive.Content
className={`event-date-range-modal${compact ? " is-compact" : ""}`}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">انتخاب بازه زمانی</DialogPrimitive.Title>
<div className={`event-range-calendars${singleMonth ? " is-single-month" : ""}`} dir="rtl" ref={calendarRef}>
<CalendarMonth
month={visibleMonth}
action={singleMonth ? "both" : "right"}
selectedDates={draftValue}
range={selectedRange}
hoveredDate={hoveredDate}
focusedDate={focusedDate}
today={today}
onSelect={handleSelect}
onNavigate={shiftVisibleMonths}
onHover={setHoveredDate}
onFocusDate={setFocusedDate}
onKeyDown={handleKeyDown}
selectionMode={singleMonth ? selectionMode : "days"}
yearPageStart={yearPageStart}
onOpenMonthPicker={singleMonth ? showMonthPicker : undefined}
onOpenYearPicker={singleMonth ? showYearPicker : undefined}
onSelectionNavigate={singleMonth ? navigateSelection : undefined}
onSelectYear={singleMonth ? selectYear : undefined}
onSelectMonth={singleMonth ? selectMonth : undefined}
/>
{!singleMonth ? (
<CalendarMonth
month={followingMonth}
action="left"
selectedDates={draftValue}
range={selectedRange}
hoveredDate={hoveredDate}
focusedDate={focusedDate}
today={today}
onSelect={handleSelect}
onNavigate={shiftVisibleMonths}
onHover={setHoveredDate}
onFocusDate={setFocusedDate}
onKeyDown={handleKeyDown}
/>
) : null}
</div>
<div className="event-range-actions" dir="rtl">
<button type="button" className="event-range-save" disabled={!canSave} onClick={handleSave}>
ذخیره
</button>
<button
type="button"
className="event-range-clear"
disabled={draftValue.length === 0}
onClick={() => {
setDraftValue([]);
setHoveredDate(null);
setFocusedDate(today);
}}
>
پاک‌کردن
</button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import { RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EventDateRangePicker } from "@/components/events/EventDateRangePicker";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { formatNumberPersian } from "@/lib/utils";
import type { EventCatalogFilters, EventCatalogState, EventCatalogType } from "@/lib/event-catalog";
const stateOptions: { value: EventCatalogState; label: string }[] = [
{ value: "all", label: "همه" },
{ value: "completed", label: "برگزار شده" },
{ value: "upcoming", label: "به‌زودی" },
{ value: "open", label: "درحال برگزاری" },
];
const typeOptions: { value: EventCatalogType; label: string }[] = [
{ value: "all", label: "همه" },
{ value: "online", label: "آنلاین" },
{ value: "on_site", label: "حضوری" },
{ value: "hybrid", label: "آنلاین/حضوری" },
];
function fromApiDate(value: string) {
if (!value) return null;
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) return null;
return new DateObject({
date: new Date(year, month - 1, day),
calendar: persian,
locale: persian_fa,
});
}
function toApiDate(value: DateObject) {
const date = value.toDate();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function getSliderStep(maximum: number) {
if (maximum <= 0) return 1;
return Math.max(10000, Math.ceil(maximum / 100 / 10000) * 10000);
}
function FilterSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="border-b border-border py-5 first:pt-0 last:border-b-0 last:pb-0">
<h3 className="mb-3 text-sm font-semibold">{title}</h3>
{children}
</section>
);
}
export function EventFilters({
filters,
priceCeiling,
onChange,
onReset,
disabled = false,
showHeader = true,
compactCalendar = false,
}: {
filters: EventCatalogFilters;
priceCeiling: number;
onChange: (patch: Partial<EventCatalogFilters>) => void;
onReset: () => void;
disabled?: boolean;
showHeader?: boolean;
compactCalendar?: boolean;
}) {
const rangeValue = useMemo(() => {
const from = fromApiDate(filters.dateFrom);
const to = fromApiDate(filters.dateTo);
return from && to ? [from, to] : [];
}, [filters.dateFrom, filters.dateTo]);
const sliderMax = Math.max(priceCeiling, 1);
const appliedSliderValues = useMemo(
() => priceCeiling > 0
? [Math.min(filters.priceMin, priceCeiling), Math.min(filters.priceMax, priceCeiling)]
: [0, 0],
[filters.priceMin, filters.priceMax, priceCeiling],
);
const [sliderValues, setSliderValues] = useState(appliedSliderValues);
useEffect(() => {
setSliderValues(appliedSliderValues);
}, [appliedSliderValues]);
return (
<div className="space-y-0" aria-busy={disabled}>
{showHeader ? (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">فیلترها</h2>
<Button type="button" variant="ghost" size="sm" onClick={onReset} disabled={disabled}>
<RotateCcw aria-hidden="true" className="size-4" />
پاک‌کردن
</Button>
</div>
) : null}
<FilterSection title="وضعیت">
<RadioGroup
value={filters.state}
disabled={disabled}
onValueChange={(value) => onChange({ state: value as EventCatalogState })}
className="gap-1"
aria-label="وضعیت رویداد"
dir="rtl"
>
{stateOptions.map((option) => (
<label
key={option.value}
htmlFor={`event-state-${option.value}`}
dir="rtl"
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted/70"
>
<RadioGroupItem id={`event-state-${option.value}`} value={option.value} />
<span>{option.label}</span>
</label>
))}
</RadioGroup>
</FilterSection>
<FilterSection title="نوع برگزاری">
<RadioGroup
value={filters.eventType}
disabled={disabled}
onValueChange={(value) => onChange({ eventType: value as EventCatalogType })}
className="gap-1"
aria-label="نوع برگزاری"
dir="rtl"
>
{typeOptions.map((option) => (
<label
key={option.value}
htmlFor={`event-type-${option.value}`}
dir="rtl"
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted/70"
>
<RadioGroupItem id={`event-type-${option.value}`} value={option.value} />
<span>{option.label}</span>
</label>
))}
</RadioGroup>
</FilterSection>
<FilterSection title="تاریخ برگزاری">
<div>
<Label htmlFor="event-date-range" className="sr-only">انتخاب بازه زمانی</Label>
<EventDateRangePicker
value={rangeValue}
disabled={disabled}
compact={compactCalendar}
onChange={(dates) => {
if (dates.length === 2) {
const [start, end] = dates;
const from = toApiDate(start);
const to = toApiDate(end);
onChange({ dateFrom: from <= to ? from : to, dateTo: from <= to ? to : from });
} else if (dates.length === 0) {
onChange({ dateFrom: "", dateTo: "" });
}
}}
/>
</div>
</FilterSection>
<FilterSection title="هزینه‌ ثبت‌نام">
<div className={filters.freeOnly ? "space-y-4 opacity-50" : "space-y-4"}>
<div dir="ltr" className="px-2 pt-1">
<Slider
min={0}
max={sliderMax}
step={getSliderStep(priceCeiling)}
value={sliderValues}
disabled={disabled || filters.freeOnly || priceCeiling <= 0}
onValueChange={setSliderValues}
onValueCommit={(values) => {
const [priceMin, priceMax] = values;
onChange({ priceMin, priceMax });
}}
aria-label="محدودهٔ قیمت رویدادها به تومان"
/>
</div>
<div dir="ltr" className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
<span dir="rtl" className="inline-flex items-center gap-1">
<bdi>{formatNumberPersian(sliderValues[0])}</bdi>
<span>تومان</span>
</span>
<span dir="rtl" className="inline-flex items-center gap-1">
<bdi>{formatNumberPersian(sliderValues[1])}</bdi>
<span>تومان</span>
</span>
</div>
{priceCeiling <= 0 ? <p className="text-xs text-muted-foreground">رویداد پولی برای فیلترکردن وجود ندارد.</p> : null}
</div>
</FilterSection>
<section aria-label="فیلتر هزینهٔ ثبت‌نام" className="pt-5">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="event-free-only" className="cursor-pointer text-sm">رویداد‌های رایگان</Label>
<Switch
id="event-free-only"
dir="rtl"
className="[&[data-state=checked]>span]:-translate-x-5"
checked={filters.freeOnly}
disabled={disabled}
onCheckedChange={(checked) => onChange({ freeOnly: checked })}
aria-label="رویداد‌های رایگان"
/>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { RotateCcw, SlidersHorizontal } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { EventFilters } from "@/components/events/EventFilters";
import type { EventCatalogFilters } from "@/lib/event-catalog";
import { formatNumberPersian } from "@/lib/utils";
export function EventFiltersDrawer({
open,
filters,
priceCeiling,
activeFilterCount,
disabled,
onOpenChange,
onChange,
onReset,
onApply,
}: {
open: boolean;
filters: EventCatalogFilters;
priceCeiling: number;
activeFilterCount: number;
disabled: boolean;
onOpenChange: (open: boolean) => void;
onChange: (patch: Partial<EventCatalogFilters>) => void;
onReset: () => void;
onApply: () => void;
}) {
const [isMobileViewport, setIsMobileViewport] = useState(true);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 639px)");
const updateViewport = () => setIsMobileViewport(mediaQuery.matches);
updateViewport();
mediaQuery.addEventListener("change", updateViewport);
return () => mediaQuery.removeEventListener("change", updateViewport);
}, []);
return (
<Sheet open={open} onOpenChange={(nextOpen) => { if (!disabled) onOpenChange(nextOpen); }}>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
className="relative h-11 shrink-0 gap-2 px-3"
disabled={disabled}
aria-label={activeFilterCount ? `فیلترها، ${formatNumberPersian(activeFilterCount)} مورد انتخاب‌شده` : "فیلترها"}
>
<SlidersHorizontal aria-hidden="true" className="size-4" />
<span className="hidden min-[640px]:inline">فیلترها</span>
{activeFilterCount > 0 ? (
<span
aria-hidden="true"
className="absolute -left-1.5 -top-1.5 inline-flex size-5 items-center justify-center rounded-full border-2 border-background bg-primary text-[0.625rem] font-bold leading-none text-primary-foreground"
>
{formatNumberPersian(activeFilterCount)}
</span>
) : null}
</Button>
</SheetTrigger>
<SheetContent
side={isMobileViewport ? "bottom" : "right"}
className={`event-filters-sheet flex flex-col gap-0 p-0 ${
isMobileViewport
? "inset-x-0 bottom-0 top-auto h-[min(88dvh,52rem)] w-full max-w-none rounded-t-2xl border-x-0 border-b-0"
: "inset-y-0 bottom-auto left-auto right-0 top-0 h-full !w-[min(92vw,26rem)] !max-w-none rounded-none border-l border-r-0 border-t-0"
}`}
aria-describedby="event-filters-description"
>
<SheetHeader className="border-b border-border px-5 py-4 !pr-14 text-right">
<SheetTitle className="text-right">فیلترهای رویدادها</SheetTitle>
<SheetDescription id="event-filters-description" className="text-right">
پس از انتخاب فیلترهای موردنظر روی دکمه‌ی «اعمال» کلید کنید.
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<EventFilters
filters={filters}
priceCeiling={priceCeiling}
disabled={disabled}
onChange={onChange}
onReset={onReset}
showHeader={false}
compactCalendar
/>
</div>
<div dir="ltr" className="flex gap-3 border-t border-border bg-background px-5 py-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<Button
type="button"
dir="rtl"
variant="ghost"
className="shrink-0 gap-2 text-foreground hover:bg-secondary hover:text-foreground"
onClick={onReset}
disabled={disabled}
>
<RotateCcw aria-hidden="true" className="size-4 text-primary" />
پاک‌کردن فیلترها
</Button>
<Button type="button" dir="rtl" className="flex-1" onClick={onApply} disabled={disabled}>
اعمال
</Button>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,60 @@
import Image from "next/image";
import { Award } from "lucide-react";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
import { getGamificationBadgeImagePath } from "@/lib/gamification-badges";
const categoryLabels: Record<PublicGamificationBadgeSchema["category"], string> = {
level: "سطح کلی",
attendance: "رویداد",
posts: "نویسندگی",
comments: "گفت‌وگو",
likes: "پسندها",
};
export function GamificationBadges({
badges,
className = "",
excludeIds = [],
compact = false,
}: {
badges: PublicGamificationBadgeSchema[];
className?: string;
excludeIds?: string[];
compact?: boolean;
}) {
const visibleBadges = badges.filter((badge) => !excludeIds.includes(badge.id));
if (!visibleBadges.length) {
return <p className="text-sm text-muted-foreground">هنوز نشانی دریافت نشده است.</p>;
}
return (
<ul className={`flex flex-wrap gap-3 ${className}`} aria-label="نشان‌های کسب‌شده">
{visibleBadges.map((badge) => {
const imagePath = getGamificationBadgeImagePath(badge.id);
return (
<li key={badge.id} className={`min-w-0 basis-[calc(50%-0.375rem)] ${compact ? "sm:basis-28" : "sm:basis-36"}`}>
<div className={`flex h-full flex-col items-center justify-center rounded-xl border border-border/70 bg-muted/15 px-2 text-center ${compact ? "min-h-24 gap-1 py-2" : "min-h-32 gap-1.5 py-3"}`}>
{imagePath ? (
<Image
src={imagePath}
alt=""
width={compact ? 56 : 72}
height={compact ? 56 : 72}
sizes={compact ? "56px" : "72px"}
className={compact ? "h-14 w-14 object-contain" : "h-[4.5rem] w-[4.5rem] object-contain"}
/>
) : (
<span className={`flex items-center justify-center text-primary ${compact ? "h-14 w-14" : "h-[4.5rem] w-[4.5rem]"}`}>
<Award className="h-9 w-9" aria-hidden="true" />
</span>
)}
<span className={`font-semibold leading-5 ${compact ? "text-xs" : "text-sm"}`}>{badge.label}</span>
<span className="text-xs text-muted-foreground">{categoryLabels[badge.category]}</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,145 @@
import {
CalendarCheck2,
CircleHelp,
Heart,
MessageCircle,
Trophy,
} from "lucide-react";
import Image from "next/image";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { getGamificationBadgeImagePath, getRankBadgeId } from "@/lib/gamification-badges";
import { Link } from "@/lib/router";
import type { GamificationSummarySchema } from "@/lib/types";
import { formatNumberPersian } from "@/lib/utils";
function ScoreBreakdown({
icon: Icon,
label,
points,
}: {
icon: typeof CalendarCheck2;
label: string;
points: number;
}) {
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<span>{label}</span>
</div>
<span className="shrink-0 font-semibold text-foreground">{formatNumberPersian(points)}</span>
</div>
);
}
export function GamificationCard({
summary,
isLoading,
isError,
onRetry,
}: {
summary?: GamificationSummarySchema;
isLoading: boolean;
isError: boolean;
onRetry: () => void;
}) {
if (isLoading) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="space-y-4 p-6" aria-busy="true" aria-label="در حال دریافت رتبه و نشان‌ها">
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="grid gap-3 sm:grid-cols-2"><div className="h-12 animate-pulse rounded bg-muted" /><div className="h-12 animate-pulse rounded bg-muted" /></div>
</CardContent>
</Card>
);
}
if (isError || !summary) {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="flex flex-col items-start gap-3 p-6 text-right sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">رتبه و نشان‌ها بارگذاری نشد</p>
<p className="mt-1 text-sm text-muted-foreground">برای دریافت دوباره اطلاعات تلاش کنید.</p>
</div>
<Button type="button" variant="outline" onClick={onRetry}>تلاش دوباره</Button>
</CardContent>
</Card>
);
}
const currentRankBadgeId = getRankBadgeId(summary.current_level.id);
const currentRankImage = getGamificationBadgeImagePath(currentRankBadgeId);
return (
<Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between gap-3 text-right">
<div className="space-y-1">
<CardTitle className="text-xl">رتبه و دستاوردهای من</CardTitle>
<CardDescription>سطح مادام‌العمر بر اساس مشارکت‌های معتبر در انجمن</CardDescription>
</div>
<Button asChild type="button" variant="outline" size="icon" className="h-10 w-10 shrink-0" aria-label="راهنمای رتبه‌بندی و نشان‌ها">
<Link to="/rank-guide" target="_blank" rel="noopener noreferrer" title="بازکردن راهنما در زبانه جدید">
<CircleHelp className="h-5 w-5" aria-hidden="true" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{currentRankImage ? (
<Image src={currentRankImage} alt="" width={112} height={112} sizes="112px" className="h-24 w-24 shrink-0 object-contain sm:h-28 sm:w-28" />
) : null}
<div>
<p className="text-sm text-muted-foreground">سطح فعلی</p>
<p className="mt-1 text-2xl font-bold">{summary.current_level.label}</p>
</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-2 text-sm font-semibold text-primary">
<Trophy className="h-4 w-4" aria-hidden="true" />
{formatNumberPersian(summary.points_total)} امتیاز
</div>
</div>
<div className="space-y-2">
{summary.next_level ? (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">پیشرفت تا سطح {summary.next_level.label}</span>
<span className="font-medium">{formatNumberPersian(summary.points_to_next)} امتیاز تا سطح بعدی</span>
</div>
) : (
<p className="text-sm text-muted-foreground">بالاترین سطح را دریافت کرده‌اید.</p>
)}
<Progress
value={summary.progress_percent}
aria-label="پیشرفت تا سطح بعدی"
aria-valuetext={`${formatNumberPersian(summary.progress_percent)} درصد`}
className="h-2.5 bg-muted"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ScoreBreakdown icon={CalendarCheck2} label="رویدادها" points={summary.points_event} />
<ScoreBreakdown icon={MessageCircle} label="بلاگ" points={summary.points_blog} />
</div>
<section className="space-y-3 border-t border-border/70 pt-4">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Heart className="h-4 w-4 text-primary" aria-hidden="true" />نشان‌های کسب‌شده
</h3>
<GamificationBadges badges={summary.badges} excludeIds={[currentRankBadgeId]} />
</section>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import Image from "next/image";
import { Trophy } from "lucide-react";
import { GamificationBadges } from "@/components/profile/GamificationBadges";
import {
getGamificationBadgeImagePath,
getLatestBadgePerCategory,
} from "@/lib/gamification-badges";
import type { PublicGamificationBadgeSchema } from "@/lib/types";
const activityCategories = ["attendance", "posts", "comments", "likes"] as const;
export function PublicProfileBadges({ badges }: { badges: PublicGamificationBadgeSchema[] }) {
const latestBadges = getLatestBadgePerCategory(badges);
const overallRank = latestBadges.level;
const activityBadges = activityCategories.flatMap((category) => {
const badge = latestBadges[category];
return badge ? [badge] : [];
});
const rankImage = overallRank ? getGamificationBadgeImagePath(overallRank.id) : undefined;
return (
<div className="space-y-5">
{overallRank ? (
<section
aria-label="رتبه کلی کسب‌شده"
className="relative isolate overflow-hidden rounded-2xl border border-amber-500/40 bg-gradient-to-bl from-amber-500/15 via-primary/10 to-cyan-500/10 p-4 shadow-sm"
>
<div aria-hidden="true" className="pointer-events-none absolute -left-8 -top-10 h-36 w-36 rounded-full bg-amber-300/15 blur-2xl" />
<div className="relative flex flex-col items-center gap-3 text-center min-[480px]:flex-row min-[480px]:text-right">
{rankImage ? (
<Image src={rankImage} alt="" width={128} height={128} sizes="128px" loading="eager" className="h-24 w-24 shrink-0 object-contain lg:h-28 lg:w-28" />
) : null}
<div className="min-w-0 min-[480px]:flex-1">
<p className="inline-flex max-w-full shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-semibold text-amber-700 dark:text-amber-200">
<Trophy className="h-3.5 w-3.5" aria-hidden="true" />
رتبه کلی
</p>
<h3 className="mt-2 text-xl font-extrabold leading-7 sm:text-2xl">{overallRank.label}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">بالاترین سطح کلی کسب‌شده</p>
</div>
</div>
</section>
) : null}
<section aria-label="نشان‌ها" className="space-y-3">
<h3 className="text-sm font-semibold">نشان‌ها</h3>
<GamificationBadges badges={activityBadges} compact />
</section>
</div>
);
}

View File

@@ -16,6 +16,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -125,7 +125,6 @@ All colors MUST be HSL.
body {
@apply bg-background font-sans text-foreground;
min-width: 320px;
}
:focus-visible {
@@ -155,3 +154,447 @@ html.dark a.home-hero-secondary:focus-visible {
border-color: hsl(var(--ring)) !important;
color: hsl(var(--secondary-foreground)) !important;
}
@media (max-width: 639px) {
main.px-4 {
padding-inline: 1rem;
}
.container.px-4 {
padding-inline: 1rem;
}
/* Let the page own the mobile gutter when it already has horizontal padding. */
main.px-4 > .container {
padding-inline: 0;
}
main.py-8,
.container.py-8 {
padding-block: 1.5rem;
}
}
.event-range-modal-overlay {
position: fixed;
z-index: 60;
inset: 0;
background: hsl(var(--foreground) / 0.16);
animation: event-range-fade-in 140ms ease-out;
}
html.dark .event-range-modal-overlay {
background: hsl(0 0% 0% / 0.24);
}
.event-date-range-modal {
position: fixed;
z-index: 61;
top: 50%;
left: 50%;
width: min(36rem, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
padding: 0.5rem;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
color: hsl(var(--popover-foreground));
background: hsl(var(--popover));
box-shadow: 0 1rem 3rem hsl(var(--foreground) / 0.18);
text-align: center;
transform: translate(-50%, -50%);
animation: event-range-modal-in 160ms ease-out;
}
html.dark .event-date-range-modal {
box-shadow: 0 1rem 3rem hsl(0 0% 0% / 0.34);
}
.event-search-input::-webkit-search-cancel-button {
display: none;
-webkit-appearance: none;
appearance: none;
}
@keyframes event-range-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes event-range-modal-in {
from { opacity: 0; transform: translate(-50%, -48%) scale(0.98); }
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
.event-date-range-modal.is-compact {
width: min(36rem, calc(100vw - 1rem));
}
.event-range-calendars {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
direction: rtl;
}
.event-range-calendars.is-single-month {
grid-template-columns: minmax(0, 1fr);
}
.event-range-month {
min-width: 0;
padding: 0 0.5rem;
direction: rtl;
}
.event-range-month + .event-range-month {
border-inline-start: 1px solid hsl(var(--border));
}
.event-range-month-header {
position: relative;
display: flex;
height: 2.5rem;
align-items: center;
justify-content: center;
margin-inline: 0.625rem;
margin-bottom: 0.375rem;
}
.event-range-month-title {
margin: 0;
color: hsl(var(--foreground));
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25rem;
}
.event-range-month-title-picker {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.event-range-month-title-picker button {
padding: 0.125rem 0.25rem;
border: 0;
border-radius: 0.25rem;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
}
.event-range-month-title-picker button:hover,
.event-range-month-title-picker button.is-active {
color: hsl(var(--primary));
}
.event-range-month-title-picker button:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
.event-range-calendar-body.is-months,
.event-range-calendar-body.is-years {
min-height: 17rem;
padding-top: 0.5rem;
}
.event-range-month-choices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 2.75rem;
align-content: start;
gap: 0.5rem 0.375rem;
}
.event-range-year-choices {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 2.75rem;
align-content: start;
gap: 0.5rem 0.375rem;
}
.event-range-year-choices button,
.event-range-month-choices button {
min-height: 2.5rem;
padding-inline: 0.25rem;
border: 0;
border-radius: 0.375rem;
color: hsl(var(--foreground));
background: transparent;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.event-range-year-choices button:hover,
.event-range-month-choices button:hover {
background: hsl(var(--secondary));
}
.event-range-year-choices button[aria-pressed="true"],
.event-range-month-choices button[aria-pressed="true"] {
color: hsl(var(--primary-foreground));
background: hsl(var(--primary));
}
.event-range-nav {
position: absolute;
top: 50%;
display: inline-flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border: 0;
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
background: transparent;
transform: translateY(-50%);
cursor: pointer;
}
.event-range-nav:hover {
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.event-range-nav:focus-visible,
.event-range-day:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 1px;
}
.event-range-nav svg {
width: 1rem;
height: 1rem;
}
.event-range-nav-left {
left: 0;
}
.event-range-nav-right {
right: 0;
}
.event-range-weekdays,
.event-range-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
direction: rtl;
}
.event-range-weekdays {
height: 1.5rem;
align-items: center;
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
}
.event-range-days {
display: flex;
flex-direction: column;
margin-top: 0.25rem;
}
.event-range-week {
grid-auto-rows: 2.25rem;
}
.event-range-day,
.event-range-empty-day {
position: relative;
display: inline-flex;
width: 100%;
max-width: 2.25rem;
height: 2.25rem;
align-items: center;
justify-content: center;
justify-self: center;
border: 0;
border-radius: 0;
color: hsl(var(--foreground));
background: transparent;
font: inherit;
font-size: 0.8125rem;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.event-range-day {
cursor: pointer;
transition: color 100ms ease, background-color 100ms ease;
}
.event-range-day:hover,
.event-range-day.is-hover-preview {
border-radius: 0.5rem;
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.event-range-day.is-in-range {
background: hsl(var(--secondary));
}
.event-range-day.is-range-start {
border-radius: 0 0.5rem 0.5rem 0;
}
.event-range-day.is-range-end {
border-radius: 0.5rem 0 0 0.5rem;
}
.event-range-day.is-range-start.is-range-end,
.event-range-day.is-range-start:not(.is-in-range) {
border-radius: 0.5rem;
}
.event-range-day.is-range-start,
.event-range-day.is-range-end {
z-index: 1;
color: hsl(var(--primary-foreground));
background: hsl(var(--primary));
}
.event-range-day.is-today:not(.is-range-start):not(.is-range-end)::after {
position: absolute;
bottom: 0.1875rem;
left: 50%;
width: 0.1875rem;
height: 0.1875rem;
border-radius: 50%;
background: hsl(var(--primary));
content: "";
transform: translateX(-50%);
}
.event-range-actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--border));
}
.event-range-save,
.event-range-clear {
display: inline-flex;
min-width: 5.25rem;
height: 2.25rem;
align-items: center;
justify-content: center;
padding-inline: 0.75rem;
border: 1px solid transparent;
border-radius: 0.375rem;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
transition: color 100ms ease, background-color 100ms ease, border-color 100ms ease;
cursor: pointer;
}
.event-range-save {
color: hsl(var(--primary-foreground));
background: hsl(var(--primary));
}
.event-range-save:hover:not(:disabled) {
filter: brightness(0.96);
}
.event-range-clear {
border-color: hsl(var(--border));
color: hsl(var(--foreground));
background: hsl(var(--background));
}
.event-range-clear:hover:not(:disabled) {
background: hsl(var(--secondary));
}
.event-range-save:focus-visible,
.event-range-clear:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 1px;
}
.event-range-save:disabled,
.event-range-clear:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.event-date-range-modal.is-compact .event-range-month {
padding-inline: 0.375rem;
}
.event-date-range-modal.is-compact .event-range-week {
grid-auto-rows: auto;
}
.event-date-range-modal.is-compact .event-range-day,
.event-date-range-modal.is-compact .event-range-empty-day {
max-width: none;
height: auto;
aspect-ratio: 1;
font-size: clamp(0.75rem, 3vw, 0.875rem);
}
@media (max-width: 640px) {
.event-date-range-modal,
.event-date-range-modal.is-compact {
width: min(22rem, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
}
}
@media (max-width: 450px) {
.event-date-range-modal {
padding: 0.375rem;
}
.event-date-range-modal .event-range-month {
padding-inline: 0.25rem;
}
.event-range-month-header {
height: 2.25rem;
}
.event-range-month-title {
font-size: 0.75rem;
}
.event-range-weekdays {
font-size: 0.75rem;
}
.event-range-week {
grid-auto-rows: 2.5rem;
}
.event-range-day,
.event-range-empty-day {
max-width: 2.5rem;
height: 2.5rem;
font-size: 0.8125rem;
}
}
@media (prefers-reduced-motion: reduce) {
.event-range-modal-overlay,
.event-date-range-modal {
animation: none;
}
}

View File

@@ -271,6 +271,12 @@ class ApiClient {
);
}
async getMyGamification() {
return this.request<Types.GamificationSummarySchema>("/api/gamification/me", {
method: "GET",
});
}
async updateProfile(data: Types.UserUpdateSchema) {
return this.request<Types.UserProfileSchema>('/api/auth/profile', {
method: 'PUT',
@@ -840,10 +846,41 @@ class ApiClient {
return this.request<Types.EventListItemSchema[]>(url, { method: 'GET' });
}
async browseEvents(params: {
state?: 'all' | 'completed' | 'upcoming' | 'open';
event_type?: 'online' | 'on_site' | 'hybrid';
search?: string;
date_from?: string;
date_to?: string;
price_min?: number;
price_max?: number;
free_only?: boolean;
page?: number;
} = {}) {
const query = new URLSearchParams();
if (params.state) query.set('state', params.state);
if (params.event_type) query.set('event_type', params.event_type);
if (params.search?.trim()) query.set('search', params.search.trim());
if (params.date_from) query.set('date_from', params.date_from);
if (params.date_to) query.set('date_to', params.date_to);
if (params.price_min != null) query.set('price_min', String(params.price_min));
if (params.price_max != null) query.set('price_max', String(params.price_max));
if (params.free_only) query.set('free_only', 'true');
if (params.page != null) query.set('page', String(params.page));
return this.request<Types.EventBrowsePageSchema>(
`/api/events/browse/${query.size ? `?${query.toString()}` : ''}`,
{ method: 'GET' },
);
}
async getEventBySlug(slug: string) {
return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' });
}
async getEventById(eventId: number) {
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, { method: 'GET' });
}
async getEventAdminDetail(eventId: number) {
return this.request<Types.EventAdminDetailSchema>(`/api/events/${eventId}/admin-detail`);
}
@@ -981,6 +1018,12 @@ class ApiClient {
});
}
async cancelRegistrationCheckout(eventId: number) {
return this.request<Types.MessageSchema>(`/api/events/${eventId}/checkout/cancel`, {
method: "POST",
});
}
async verifyMyRegistration(ticket_id: string) {
return this.request<{
event_image: string;
@@ -1036,6 +1079,20 @@ class ApiClient {
);
}
async getRegistrationHubStatus(
eventSlug: string,
identifiers: { registration_id?: string | null; payment_id?: number | null } = {},
) {
const query = new URLSearchParams();
if (identifiers.registration_id) query.set("registration_id", identifiers.registration_id);
if (identifiers.payment_id) query.set("payment_id", String(identifiers.payment_id));
const suffix = query.size ? `?${query.toString()}` : "";
return this.request<Types.RegistrationHubSchema>(
`/api/events/${encodeURIComponent(eventSlug)}/registration/status${suffix}`,
{ method: "GET" },
);
}
// ============= Payment Endpoints =============
async createPayment(input: {
event_id: number;
@@ -1043,6 +1100,7 @@ class ApiClient {
discount_code?: string | null;
mobile?: string | null;
email?: string | null;
idempotency_key: string;
}) {
return this.request<Types.CreatePaymentOut>(
'/api/payments/create',
@@ -1054,23 +1112,8 @@ class ApiClient {
);
}
async getPaymentByRef(refId: string) {
return this.request<{
ref_id: string;
authority: string;
base_amount: number;
discount_amount: number;
amount: number;
status: 'INIT' | 'PENDING' | 'PAID' | 'FAILED' | 'CANCELED';
verified_at?: string | null;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
success_markdown?: string | null;
};
}>(`/api/payments/by-ref/${encodeURIComponent(refId)}`, { method: 'GET' });
async getPaymentStatus(paymentId: number) {
return this.request<Types.PaymentDetailSchema>(`/api/payments/status/${paymentId}`, { method: 'GET' });
}
async checkDiscountCode(event_id: number, code: string) {

View File

@@ -254,7 +254,7 @@ const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
members_intro: "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.",
gallery_heading: "خاطرات انجمن",
gallery_intro: "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.",
past_events_heading: "رویدادهای برگزارشده",
past_events_heading: "رویدادهای برگزار شده",
history_heading: "مسیر انجمن",
audience_heading: "برای چه کسانی؟",
year_events_heading: "لحظه‌های هر سال",

106
src/lib/event-catalog.ts Normal file
View File

@@ -0,0 +1,106 @@
export const EVENT_PAGE_SIZE = 12;
export type EventCatalogState = "all" | "completed" | "upcoming" | "open";
export type EventCatalogType = "all" | "online" | "on_site" | "hybrid";
export type EventCatalogFilters = {
search: string;
state: EventCatalogState;
eventType: EventCatalogType;
dateFrom: string;
dateTo: string;
priceMin: number;
priceMax: number;
freeOnly: boolean;
page: number;
};
export type EventCatalogItem = {
status?: string | null;
event_type?: string | null;
address?: string | null;
start_time?: string | null;
end_time?: string | null;
registration_start_date?: string | null;
registration_end_date?: string | null;
capacity?: number | null;
registration_count?: number | null;
};
const states = new Set<EventCatalogState>(["all", "completed", "upcoming", "open"]);
const eventTypes = new Set<EventCatalogType>(["all", "online", "on_site", "hybrid"]);
export function getPriceCeiling(maximumPrice: number | null | undefined) {
const price = Number(maximumPrice ?? 0);
if (!Number.isFinite(price) || price <= 0) return 0;
return Math.ceil(price / 1_000_000) * 1_000_000;
}
function isIsoDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
function nonnegativeInteger(value: string | null, fallback: number) {
if (value === null || !/^\d+$/.test(value)) return fallback;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : fallback;
}
export function parseEventCatalogParams(
params: URLSearchParams,
priceCeiling: number,
): EventCatalogFilters {
const stateValue = params.get("state") as EventCatalogState | null;
const typeValue = params.get("event_type") as EventCatalogType | null;
const dateFromValue = params.get("date_from") ?? "";
const dateToValue = params.get("date_to") ?? "";
const hasValidRange = isIsoDate(dateFromValue)
&& isIsoDate(dateToValue)
&& dateFromValue <= dateToValue;
const priceMin = nonnegativeInteger(params.get("price_min"), 0);
const priceMax = nonnegativeInteger(params.get("price_max"), priceCeiling);
const hasValidPriceRange = priceMin <= priceMax;
const page = nonnegativeInteger(params.get("page"), 1);
return {
search: (params.get("search") ?? "").trim(),
state: stateValue && states.has(stateValue) ? stateValue : "all",
eventType: typeValue && eventTypes.has(typeValue) ? typeValue : "all",
dateFrom: hasValidRange ? dateFromValue : "",
dateTo: hasValidRange ? dateToValue : "",
priceMin: hasValidPriceRange ? priceMin : 0,
priceMax: hasValidPriceRange ? Math.min(priceMax, priceCeiling) : priceCeiling,
freeOnly: ["true", "1"].includes((params.get("free_only") ?? "").toLowerCase()),
page: page > 0 ? page : 1,
};
}
export function serializeEventCatalogParams(
filters: EventCatalogFilters,
priceCeiling = Number.POSITIVE_INFINITY,
) {
const params = new URLSearchParams();
if (filters.search.trim()) params.set("search", filters.search.trim());
if (filters.state !== "all") params.set("state", filters.state);
if (filters.eventType !== "all") params.set("event_type", filters.eventType);
if (filters.dateFrom && filters.dateTo) {
params.set("date_from", filters.dateFrom);
params.set("date_to", filters.dateTo);
}
if (!filters.freeOnly && (filters.priceMin > 0 || filters.priceMax < priceCeiling)) {
params.set("price_min", String(filters.priceMin));
params.set("price_max", String(filters.priceMax));
}
if (filters.freeOnly) params.set("free_only", "true");
if (filters.page > 1) params.set("page", String(filters.page));
return params;
}
export function getEventLocationLabel(event: Pick<EventCatalogItem, "event_type" | "address">) {
if (event.event_type === "online") return "برگزاری آنلاین";
const address = event.address?.trim();
if (address) return address;
return "محل برگزاری اعلام نشده";
}

167
src/lib/event-ui.ts Normal file
View File

@@ -0,0 +1,167 @@
export type EventAvailabilityInput = {
status?: string | null;
registration_start_date?: string | null;
registration_end_date?: string | null;
start_time?: string | null;
end_time?: string | null;
capacity?: number | null;
registration_count?: number | null;
};
export function getEventPath(event: { id: number; slug?: string | null }) {
const segment = event.slug?.trim() || String(event.id);
return `/events/${encodeURIComponent(segment)}`;
}
export type EventRegistrationState =
| "open"
| "not_started"
| "closed"
| "full"
| "unavailable";
function timestamp(value?: string | null) {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
export function getEventRegistrationState(
event: EventAvailabilityInput,
now = Date.now(),
): { state: EventRegistrationState; remaining: number | null } {
if (event.status !== "published") {
return { state: "unavailable", remaining: null };
}
const endsAt = timestamp(event.registration_end_date);
if (endsAt !== null && now > endsAt) {
return { state: "closed", remaining: getRemainingCapacity(event) };
}
const startsAt = timestamp(event.registration_start_date);
if (startsAt !== null && now < startsAt) {
return { state: "not_started", remaining: getRemainingCapacity(event) };
}
const remaining = getRemainingCapacity(event);
if (remaining !== null && remaining <= 0) {
return { state: "full", remaining: 0 };
}
return { state: "open", remaining };
}
function getRemainingCapacity(event: EventAvailabilityInput) {
if (event.capacity == null || !Number.isFinite(Number(event.capacity))) {
return null;
}
const used = Number.isFinite(Number(event.registration_count))
? Number(event.registration_count)
: 0;
return Math.max(0, Number(event.capacity) - used);
}
export function getEventCardState(
event: EventAvailabilityInput,
now = Date.now(),
): "past" | "full" | "upcoming" {
const endsAt = timestamp(event.end_time);
if (event.status === "completed" || (endsAt !== null && endsAt < now)) {
return "past";
}
if (getEventRegistrationState(event, now).state === "full") {
return "full";
}
return "upcoming";
}
export function getEventBrowseLabel(event: EventAvailabilityInput, now = Date.now()) {
if (getEventCardState(event, now) === "past") return "برگزار شده";
if (getEventRegistrationState(event, now).state === "open") return "درحال برگزاری";
const startsAt = timestamp(event.start_time);
if (startsAt !== null && startsAt <= now) return "درحال برگزاری";
return "به‌زودی";
}
export function getEventRegistrationMessage(event: EventAvailabilityInput, now = Date.now()) {
if (getEventCardState(event, now) === "past") return "این رویداد برگزار شده است";
const registration = getEventRegistrationState(event, now);
if (registration.state === "not_started") return "ثبت‌نام هنوز آغاز نشده است";
if (registration.state === "closed") return "زمان ثبت‌نام به پایان رسیده است";
if (registration.state === "full") return "ظرفیت ثبت‌نام تکمیل شده است";
if (registration.state === "unavailable") return "امکان ثبت‌نام وجود ندارد";
if (registration.remaining === null) return "ثبت‌نام در حال انجام است";
return `${registration.remaining.toLocaleString("fa-IR")} نفر ظرفیت باقی‌مانده`;
}
export type RegistrationErrorKind =
| "already_registered"
| "not_started"
| "closed"
| "full"
| "unknown";
function normalizeApiMessage(message: unknown) {
return String(message ?? "")
.replace(/[يى]/g, "ی")
.replace(/\u200c/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLocaleLowerCase();
}
export function classifyRegistrationError(message: unknown): RegistrationErrorKind {
const normalized = normalizeApiMessage(message);
if (normalized.includes("already registered") || normalized.includes("قبلا در این ایونت ثبت نام کرده اید")) {
return "already_registered";
}
if (normalized.includes("زمان ثبت نام هنوز آغاز نشده") || normalized.includes("registration has not started")) {
return "not_started";
}
if (normalized.includes("مهلت ثبت نام به پایان رسیده") || normalized.includes("registration period has ended")) {
return "closed";
}
if (normalized.includes("ظرفیت شرکت کنندگان تکمیل است") || normalized.includes("event is full")) {
return "full";
}
return "unknown";
}
export type PaymentOutcome = "paid" | "pending" | "failed" | "canceled" | "unknown";
export function getPaymentOutcome(status: unknown): PaymentOutcome {
const normalized = normalizeApiMessage(status);
if (["success", "paid", "ok", "پرداخت شده", "تایید شده"].includes(normalized)) {
return "paid";
}
if (["pending", "initiated", "init", "در انتظار", "در حال پردازش"].includes(normalized)) {
return "pending";
}
if (["canceled", "cancelled", "لغو شده"].includes(normalized)) {
return "canceled";
}
if (["failed", "failure", "ناموفق"].includes(normalized)) {
return "failed";
}
return "unknown";
}
export type RegistrationOutcome = "confirmed" | "pending" | "canceled" | "unknown";
export function getRegistrationOutcome(status: unknown): RegistrationOutcome {
const normalized = normalizeApiMessage(status);
if (["confirmed", "attended", "approved", "تایید شده"].includes(normalized)) {
return "confirmed";
}
if (["pending", "در انتظار"].includes(normalized)) {
return "pending";
}
if (["canceled", "cancelled", "لغو شده"].includes(normalized)) {
return "canceled";
}
return "unknown";
}

View File

@@ -0,0 +1,135 @@
import type { PublicGamificationBadgeSchema } from "@/lib/types";
export type BadgeCategory = PublicGamificationBadgeSchema["category"];
export interface RankLevelDefinition {
id: string;
label: string;
minimumPoints: number;
badgeId: string;
}
export interface ActivityBadgeDefinition {
id: string;
label: string;
threshold: number;
description: string;
}
export interface ActivityLadderDefinition {
category: Exclude<BadgeCategory, "level">;
title: string;
description: string;
unitLabel: string;
pointsPerAction: number;
badges: ActivityBadgeDefinition[];
}
export const rankLevels: RankLevelDefinition[] = [
{ id: "companion", label: "همراه", minimumPoints: 0, badgeId: "level-companion" },
{ id: "contributor", label: "مشارکت‌گر", minimumPoints: 30, badgeId: "level-contributor" },
{ id: "ally", label: "همیار", minimumPoints: 150, badgeId: "level-ally" },
{ id: "influencer", label: "اثرگذار", minimumPoints: 750, badgeId: "level-influencer" },
{ id: "pioneer", label: "پیشرو", minimumPoints: 1500, badgeId: "level-pioneer" },
];
export const activityLadders: ActivityLadderDefinition[] = [
{
category: "attendance",
title: "حضور در رویدادها",
description: "پس از آن‌که برگزارکننده حضور شما را تأیید کند، امتیاز و نشان این مسیر ثبت می‌شود.",
unitLabel: "حضور تأییدشده",
pointsPerAction: 30,
badges: [
{ id: "attendance-1", label: "حضور اول", threshold: 1, description: "حضور تأییدشده در یک رویداد" },
{ id: "attendance-5", label: "همراه رویدادها", threshold: 5, description: "حضور تأییدشده در پنج رویداد" },
{ id: "attendance-10", label: "همیار رویدادها", threshold: 10, description: "حضور تأییدشده در ده رویداد" },
{ id: "attendance-25", label: "چهره آشنای رویدادها", threshold: 25, description: "حضور تأییدشده در بیست‌وپنج رویداد" },
],
},
{
category: "posts",
title: "نوشته‌های منتشرشده",
description: "نوشته‌های منتشرشده و هم‌نویسندگی‌هایی که در انجمن ثبت شده‌اند حساب می‌شوند.",
unitLabel: "نوشته منتشرشده یا هم‌نویسندگی",
pointsPerAction: 20,
badges: [
{ id: "post-1", label: "نویسنده انجمن", threshold: 1, description: "انتشار یا هم‌نویسندگی یک نوشته" },
{ id: "post-5", label: "نویسنده فعال", threshold: 5, description: "انتشار یا هم‌نویسندگی پنج نوشته" },
{ id: "post-10", label: "نویسنده برجسته", threshold: 10, description: "انتشار یا هم‌نویسندگی ده نوشته" },
],
},
{
category: "comments",
title: "گفت‌وگوهای سازنده",
description: "دیدگاه‌ها و پاسخ‌های تأییدشده در گفت‌وگوهای بلاگ به این مسیر کمک می‌کنند.",
unitLabel: "دیدگاه یا پاسخ تأییدشده",
pointsPerAction: 5,
badges: [
{ id: "comment-5", label: "اهل گفت‌وگو", threshold: 5, description: "پنج دیدگاه یا پاسخ تأییدشده" },
{ id: "comment-20", label: "همراه گفت‌وگو", threshold: 20, description: "بیست دیدگاه یا پاسخ تأییدشده" },
{ id: "comment-50", label: "صدای اثرگذار", threshold: 50, description: "پنجاه دیدگاه یا پاسخ تأییدشده" },
],
},
{
category: "likes",
title: "پسندهای انجمن",
description: "پسندهایی که داده‌اید و پسندهایی که نوشته‌های شما گرفته‌اند با هم شمرده می‌شوند.",
unitLabel: "پسند داده‌شده یا دریافت‌شده",
pointsPerAction: 1,
badges: [
{ id: "likes-10", label: "دست‌گرم", threshold: 10, description: "ده پسند داده‌شده یا دریافت‌شده" },
{ id: "likes-50", label: "محبوب انجمن", threshold: 50, description: "پنجاه پسند داده‌شده یا دریافت‌شده" },
{ id: "likes-100", label: "الهام‌بخش", threshold: 100, description: "صد پسند داده‌شده یا دریافت‌شده" },
],
},
];
export const activityPoints = {
save: 1,
saveLabel: "ذخیره نوشته امتیاز دارد، اما نشان جداگانه ندارد.",
selfLikeLabel: "پسند نوشته خودتان هم برای شما حساب می‌شود.",
reversalLabel: "اگر فعالیتی حذف یا نامعتبر شود، امتیاز و نشان وابسته به آن هم بازبینی می‌شود.",
};
const badgeImagePaths: Record<string, string> = Object.fromEntries(
[
...rankLevels.map(({ badgeId }) => badgeId),
...activityLadders.flatMap(({ badges }) => badges.map(({ id }) => id)),
].map((id) => {
// The generated art uses a plural filename for the singular API comment IDs.
const filenameId = id.startsWith("comment-") ? id.replace("comment-", "comments-") : id;
return [id, `/images/gamification/badges/${filenameId}.png`];
}),
);
export function getGamificationBadgeImagePath(id: string): string | undefined {
return badgeImagePaths[id];
}
export function getRankBadgeId(levelId: string): string {
return `level-${levelId}`;
}
export function getLatestBadgePerCategory(
badges: PublicGamificationBadgeSchema[],
): Partial<Record<BadgeCategory, PublicGamificationBadgeSchema>> {
const latest: Partial<Record<BadgeCategory, PublicGamificationBadgeSchema>> = {};
const progressionFor = (badge: PublicGamificationBadgeSchema): number => {
if (badge.category === "level") {
return rankLevels.findIndex((level) => level.badgeId === badge.id);
}
const ladder = activityLadders.find(({ category }) => category === badge.category);
return ladder?.badges.find(({ id }) => id === badge.id)?.threshold ?? -1;
};
for (const badge of badges) {
const current = latest[badge.category];
if (!current || progressionFor(badge) > progressionFor(current)) {
latest[badge.category] = badge;
}
}
return latest;
}

View File

@@ -2,6 +2,7 @@ export type MarkdownHeading = {
id: string;
level: 1 | 2 | 3;
text: string;
line: number;
};
function plainHeadingText(value: string) {
@@ -32,7 +33,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
return (content || "")
.split(/\r?\n/)
.map((line) => {
.map((line, index) => {
const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim());
if (!match) return null;
@@ -46,6 +47,7 @@ export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
id: nextCount === 1 ? base : `${base}-${nextCount}`,
level,
text,
line: index + 1,
};
})
.filter(Boolean) as MarkdownHeading[];

View File

@@ -136,8 +136,39 @@ export async function getPublicEvents(options?: { search?: string; limit?: numbe
});
}
export async function getPublicEventBrowsePage(options: {
state: 'all' | 'completed' | 'upcoming' | 'open';
eventType: 'all' | 'online' | 'on_site' | 'hybrid';
search: string;
dateFrom: string;
dateTo: string;
priceMin?: number;
priceMax?: number;
freeOnly: boolean;
page: number;
}) {
return requestJson<Types.EventBrowsePageSchema>('/api/events/browse/', {
params: {
state: options.state,
...(options.eventType !== 'all' ? { event_type: options.eventType } : {}),
...(options.search.trim() ? { search: options.search.trim() } : {}),
...(options.dateFrom ? { date_from: options.dateFrom } : {}),
...(options.dateTo ? { date_to: options.dateTo } : {}),
...(options.priceMin != null ? { price_min: options.priceMin } : {}),
...(options.priceMax != null ? { price_max: options.priceMax } : {}),
...(options.freeOnly ? { free_only: true } : {}),
page: options.page,
},
revalidate: 0,
});
}
export async function getPublicEventBySlug(slug: string) {
return requestJson<Types.EventDetailSchema>(
`/api/events/slug/${encodeURIComponent(slug)}`,
);
}
export async function getPublicEventById(eventId: number) {
return requestJson<Types.EventDetailSchema>(`/api/events/${eventId}`);
}

View 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);
}

View 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);
}

View File

@@ -93,13 +93,63 @@ export interface PublicMemberProfileSchema {
bio: string;
details_visible: boolean;
mobile?: string | null;
email?: string | null;
student_id?: string | null;
year_of_study?: number | null;
university?: string | null;
major?: string | null;
badges: PublicGamificationBadgeSchema[];
events: PublicProfileEventSchema[];
}
export interface PublicGamificationBadgeSchema {
id: string;
category: "level" | "attendance" | "posts" | "comments" | "likes";
label: string;
}
export interface GamificationBadgeSchema extends PublicGamificationBadgeSchema {
description?: string | null;
threshold?: number | null;
}
export interface GamificationLevelSchema {
id: string;
label: string;
minimum_points: number;
}
export interface GamificationSummarySchema {
points_total: number;
points_event: number;
points_blog: number;
activity_counts: {
attendance: number;
posts: number;
comments: number;
likes: number;
};
levels: GamificationLevelSchema[];
points_per_activity: {
attendance: number;
post: number;
comment: number;
like: number;
save: number;
};
badge_thresholds: {
attendance: number[];
posts: number[];
comments: number[];
likes: number[];
};
current_level: GamificationLevelSchema;
next_level: GamificationLevelSchema | null;
points_to_next: number;
progress_percent: number;
badges: GamificationBadgeSchema[];
}
export interface UserListSchema {
id: number;
username: string;
@@ -184,7 +234,9 @@ export type UserUpdateSchema = {
};
export interface UserPaymentSchema {
payment_id: number;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
@@ -193,6 +245,7 @@ export interface UserPaymentSchema {
discount_amount: number;
amount: number;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
refund_status?: "none" | "pending" | "succeeded" | "manual_review";
created_at: string;
paid_at?: string | null;
}
@@ -572,7 +625,7 @@ export interface BlogFiltersSchema {
}
// Events Types
export interface EventListItemSchema {
export interface EventListItemSchema {
id: number;
title: string;
slug: string;
@@ -595,8 +648,17 @@ export interface EventListItemSchema {
status_label?: string;
event_type_label?: string;
registration_count: number;
created_at: string;
}
created_at: string;
}
export interface EventBrowsePageSchema {
count: number;
page: number;
page_size: number;
total_pages: number;
price_ceiling: number;
results: EventListItemSchema[];
}
export interface EventGalleryItem {
id: number;
@@ -746,6 +808,8 @@ export interface EventRegistrationSchema {
id: number;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
ticket_id: string;
final_price?: number | null;
discount_amount?: number;
registered_at: string;
created_at: string;
updated_at: string;
@@ -760,12 +824,92 @@ export interface EventRegistrationSchema {
export interface RegistrationStatusSchema {
is_registered: boolean;
checkout_state?: "none" | "confirmed" | "active" | "legacy_pending" | "expired" | "unresolved" | "cancelled";
registration_id?: number | null;
registration_status?: "pending" | "confirmed" | "cancelled" | "attended" | null;
ticket_id?: string | null;
checkout_expires_at?: string | null;
payment_id?: number | null;
payment_status?: "initiated" | "pending" | "paid" | "failed" | "canceled" | null;
refund_status?: "none" | "pending" | "succeeded" | "manual_review" | null;
can_resume?: boolean;
can_cancel?: boolean;
}
export type RegistrationHubState =
| "ready"
| "active_checkout"
| "legacy_pending"
| "verification_pending"
| "manual_review"
| "paid_success"
| "free_success"
| "failed"
| "cancelled"
| "expired"
| "refund_pending"
| "refund_manual_review"
| "refund_succeeded";
export interface RegistrationHubSchema {
state: RegistrationHubState;
event: {
id: number;
title: string;
slug: string;
description: string;
event_type: "online" | "on_site" | "hybrid";
address?: string | null;
location?: string | null;
start_time: string;
end_time: string;
registration_start_date?: string | null;
registration_end_date?: string | null;
capacity?: number | null;
price: number;
status: "draft" | "published" | "cancelled" | "completed";
registration_count: number;
image_url?: string | null;
registration_success_markdown?: string | null;
};
registration?: {
ticket_id: string;
status: "pending" | "confirmed" | "cancelled" | "attended";
registered_at: string;
checkout_started_at?: string | null;
checkout_expires_at?: string | null;
cancellation_reason: string;
final_price?: number | null;
discount_amount: number;
} | null;
payment?: {
id: number;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
verification_state: "awaiting_verification" | "checking" | "retry_scheduled" | "manual_review" | "verified" | "failed" | "canceled";
refund_status: "none" | "pending" | "succeeded" | "manual_review";
base_amount: number;
discount_amount: number;
amount: number;
ref_id?: string | null;
created_at: string;
gateway_paid_at?: string | null;
} | null;
can_register: boolean;
can_resume: boolean;
can_cancel: boolean;
}
export interface MyEventRegistrationSchema {
id: number;
ticket_id: string;
created_at: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
checkout_state?: "none" | "active" | "legacy_pending" | "expired" | "unresolved" | "cancelled";
checkout_expires_at?: string | null;
payment_id?: number | null;
payment_status?: "initiated" | "pending" | "paid" | "failed" | "canceled" | null;
refund_status?: "none" | "pending" | "succeeded" | "manual_review" | null;
can_resume?: boolean;
event: {
id: number;
title: string;
@@ -1004,9 +1148,43 @@ export interface BlogAnalyticsSchema {
// payment
export interface CreatePaymentOut {
start_pay_url: string;
authority: string;
start_pay_url: string | null;
authority: string | null;
payment_id: number | null;
idempotency_key: string | null;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
base_amount: number;
discount_amount: number;
amount: number;
}
export interface PaymentDetailSchema {
payment_id: number;
event_id: number;
ref_id: string | null;
authority: string | null;
base_amount: number;
discount_amount: number;
amount: number;
status: "initiated" | "pending" | "paid" | "failed" | "canceled";
verified_at: string | null;
gateway_paid_at?: string | null;
refund_status?: "none" | "pending" | "succeeded" | "manual_review";
refund_attempts?: number;
refund_last_attempt_at?: string | null;
refund_next_attempt_at?: string | null;
refund_reference?: string | null;
refund_last_error?: string | null;
verification_state: "awaiting_verification" | "checking" | "retry_scheduled" | "manual_review" | "verified" | "failed" | "canceled";
verification_attempts: number;
last_verification_at: string | null;
next_verification_at: string | null;
manual_review_at: string | null;
event: {
id: number;
title: string;
slug: string;
image_url?: string | null;
success_markdown?: string | null;
};
}

View File

@@ -1,7 +1,8 @@
"use client";
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 type * as Types from "@/lib/types";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -11,12 +12,10 @@ import GalleryLightbox, { type GalleryLightboxItem } from "@/components/GalleryL
import ProgressiveImage from "@/components/ProgressiveImage";
import { useToast } from "@/hooks/use-toast";
import Markdown from "@/components/Markdown";
import CouponDialogFa from "@/components/CouponDialogFa";
import {
formatJalali,
formatNumberPersian,
formatToman,
getEventCardImageUrl,
getEventHeroImageUrl,
getEventSeoImageUrl,
getGalleryImageBlurUrl,
@@ -27,6 +26,7 @@ import {
} from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import { buildAuthPath } from "@/lib/auth-return";
import { getEventPath, getEventRegistrationState } from "@/lib/event-ui";
const typeLabel: Record<string, string> = {
online: "آنلاین",
@@ -34,32 +34,27 @@ const typeLabel: Record<string, string> = {
hybrid: "آنلاین و حضوری",
};
function formatDateTimeRange(start?: string | null, end?: string | null) {
if (!start) return end ? formatJalali(end) : "—";
if (!end) return formatJalali(start);
const startDate = formatJalali(start, false);
if (startDate !== "—" && startDate === formatJalali(end, false)) {
const formatTime = (value: string) => new Intl.DateTimeFormat("fa-IR", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
return `${startDate}، ساعت ${formatTime(start)} تا ${formatTime(end)}`;
}
return `${formatJalali(start)} تا ${formatJalali(end)}`;
}
type EventDetailProps = {
initialEvent?: Types.EventDetailSchema | null;
};
function buildPaymentSnapshot(
event: Types.EventDetailSchema,
eventThumb: string | null,
payload: {
baseAmount: number;
discountAmount: number;
amount: number;
},
) {
return JSON.stringify({
event_id: event.id,
slug: event.slug,
title: event.title,
thumb: eventThumb,
base_amount: payload.baseAmount,
discount_amount: payload.discountAmount,
amount: payload.amount,
started_at: new Date().toISOString(),
success_markdown: event.registration_success_markdown,
});
}
export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const { slug } = useParams<{ slug: string }>();
const { isAuthenticated } = useAuth();
@@ -67,164 +62,21 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const { toast } = useToast();
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
const [eventThumb, setEventThumb] = useState<string | null>(
initialEvent ? getEventCardImageUrl(initialEvent) : null,
);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(!initialEvent);
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
const [eventLoadError, setEventLoadError] = useState(false);
const [eventLoadAttempt, setEventLoadAttempt] = useState(0);
const [nowTs, setNowTs] = useState(() => Date.now());
const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
useEffect(() => {
let cancelled = false;
async function checkRegistration() {
if (!isAuthenticated || !event?.id) {
return;
}
try {
const res = await api.getRegistrationStatus(event.id);
if (!cancelled) {
setAlreadyRegistered(res.is_registered);
}
} catch {
// Ignore registration status failures on the detail page.
}
}
checkRegistration();
return () => {
cancelled = true;
};
}, [event?.id, isAuthenticated]);
const goSuccess = (registrationId?: string) => {
const handleMainCTA = () => {
if (!event) return;
const query = registrationId ? `?registration_id=${registrationId}` : "";
setAlreadyRegistered(true);
toast({ title: "ثبت‌نام با موفقیت انجام شد!", variant: "success" });
navigate(`/events/${event.slug}/success${query}`);
};
const handleMainCTA = async () => {
if (!event) return;
if (!isAuthenticated) {
toast({
title: "ابتدا وارد شوید",
description: "برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.",
variant: "destructive",
});
navigate(buildAuthPath(`/events/${event.slug}`));
navigate(buildAuthPath(`${getEventPath(event)}/registration`));
return;
}
if (!isFree) {
setOpen(true);
return;
}
try {
setSubmitting(true);
const res = await api.registerForEvent(event.id);
goSuccess(res.ticket_id);
} catch (error: unknown) {
const msg = resolveErrorMessage(error, "");
if (msg.includes("already registered")) {
setAlreadyRegistered(true);
toast({ title: "شما قبلاً ثبت‌نام کرده‌اید", variant: "destructive" });
return;
}
toast({
title: "خطا در ثبت‌نام",
description: msg || "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => {
if (!event) return;
if (!isAuthenticated) {
toast({
title: "ابتدا وارد شوید",
description: "برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.",
variant: "destructive",
});
navigate(buildAuthPath(`/events/${event.slug}`));
return;
}
try {
setSubmitting(true);
const reg = await api.registerForEvent(event.id, coupon);
if (finalAmount === 0) {
window.sessionStorage.setItem(
"payment:last",
buildPaymentSnapshot(event, eventThumb, {
baseAmount: Number(event.price ?? 0),
discountAmount: Number(event.price ?? 0),
amount: 0,
}),
);
await api.ChangeRegistrationStatus(reg.id, "confirmed");
goSuccess(reg.ticket_id);
return;
}
const result = await api.createPayment({
event_id: event.id,
description: `پرداخت رویداد: ${event.title}`,
discount_code: (coupon ?? "").trim() || null,
});
if (!result?.start_pay_url || Number(result.amount) === 0) {
window.sessionStorage.setItem(
"payment:last",
buildPaymentSnapshot(event, eventThumb, {
baseAmount: result.base_amount,
discountAmount: result.discount_amount ?? result.base_amount,
amount: 0,
}),
);
goSuccess(reg.ticket_id);
return;
}
window.sessionStorage.setItem(
"payment:last",
buildPaymentSnapshot(event, eventThumb, {
baseAmount: result.base_amount,
discountAmount: result.discount_amount,
amount: result.amount,
}),
);
window.location.href = result.start_pay_url;
} catch (error: unknown) {
const msg = resolveErrorMessage(error, "");
if (msg.includes("already registered")) {
setAlreadyRegistered(true);
toast({ title: "شما قبلاً ثبت‌نام کرده‌اید", variant: "destructive" });
return;
}
toast({
title: "خطا در پردازش پرداخت",
description: msg || "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally {
setSubmitting(false);
setOpen(false);
}
navigate(`${getEventPath(event)}/registration`);
};
useEffect(() => {
@@ -232,20 +84,25 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
async function loadEvent() {
try {
setEventLoadError(false);
setLoading(true);
if (!slug) return;
if (initialEvent && initialEvent.slug === slug) {
if (initialEvent && (initialEvent.slug === slug || String(initialEvent.id) === slug)) {
setEvent(initialEvent);
setEventThumb(getEventCardImageUrl(initialEvent));
return;
}
const data = await api.getEventBySlug(slug);
setEvent(null);
const data = /^\d+$/.test(slug)
? await api.getEventById(Number(slug))
: await api.getEventBySlug(slug);
if (cancelled) return;
setEvent(data);
setEventThumb(getEventCardImageUrl(data));
} catch (error: unknown) {
if (cancelled) return;
setEventLoadError(true);
toast({
title: "خطا در بارگذاری رویداد",
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
@@ -262,18 +119,13 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return () => {
cancelled = true;
};
}, [initialEvent, slug, toast]);
}, [eventLoadAttempt, initialEvent, slug, toast]);
useEffect(() => {
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const rsTs = useMemo<number | null>(
() => (event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null),
[event?.registration_start_date],
);
const deadlineTs = useMemo<number | null>(
() => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
[event?.registration_end_date],
@@ -302,17 +154,7 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
};
const meta = useMemo(() => {
if (!event) return null;
const registrationOpen =
(rsTs == null || nowTs >= rsTs) && (deadlineTs == null || nowTs <= deadlineTs);
const unlimited = event.capacity == null;
const remaining = unlimited
? Number.POSITIVE_INFINITY
: Math.max(0, (event.capacity || 0) - (event.registration_count || 0));
const full = !unlimited && remaining <= 0;
return { registrationOpen, remaining, full };
}, [deadlineTs, event, nowTs, rsTs]);
const registration = event ? getEventRegistrationState(event, nowTs) : null;
const galleryItems = useMemo<GalleryLightboxItem[]>(
() =>
@@ -332,12 +174,29 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
}
if (!event) {
return <div className="min-h-[60vh] flex items-center justify-center">رویداد مورد نظر یافت نشد.</div>;
return (
<div className="container mx-auto flex min-h-[60vh] items-center justify-center px-4 py-10" dir="rtl">
<Card className="w-full max-w-xl">
<CardContent className="py-10 text-center">
<h1 className="text-lg font-semibold">{eventLoadError ? "بارگذاری رویداد ناموفق بود" : "رویداد مورد نظر یافت نشد"}</h1>
<p className="mt-2 text-sm text-muted-foreground">
{eventLoadError ? "اتصال را بررسی کنید و دوباره تلاش کنید." : "ممکن است نشانی رویداد تغییر کرده باشد یا رویداد در دسترس نباشد."}
</p>
<div className="mt-5 flex justify-center gap-2">
{eventLoadError ? (
<Button type="button" onClick={() => setEventLoadAttempt((attempt) => attempt + 1)}>تلاش دوباره</Button>
) : null}
<Button asChild variant="outline"><Link to="/events">بازگشت به رویدادها</Link></Button>
</div>
</CardContent>
</Card>
</div>
);
}
const beforeStart = rsTs != null && nowTs < rsTs;
const ended = deadlineTs !== null && remainingMs === 0;
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
const beforeStart = registration?.state === "not_started";
const ended = registration?.state === "closed";
const showCountdown = !beforeStart && !ended && deadlineTs !== null && (remainingMs ?? 0) > 0;
return (
<div className="container mx-auto px-4 py-8" dir="rtl">
@@ -370,10 +229,10 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-8">
<div className="lg:col-span-2">
<Card className="overflow-hidden">
<div className="w-full aspect-video overflow-hidden rounded-lg">
<div className="aspect-video w-full overflow-hidden bg-muted">
<ProgressiveImage
src={getEventHeroImageUrl(event)}
blurSrc={event.absolute_featured_image_thumbnail_url}
@@ -384,17 +243,14 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
</div>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-2xl">{event.title}</CardTitle>
<CardDescription className="mt-1">
{formatJalali(event.start_time)}
{event.end_time ? ` تا ${formatJalali(event.end_time)}` : null}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="text-xl leading-8 md:text-2xl">{event.title}</CardTitle>
<CardDescription className="mt-2">
{formatDateTimeRange(event.start_time, event.end_time)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-2">
<Badge variant="default">{typeLabel[event.event_type] || event.event_type}</Badge>
</div>
<Badge variant="outline" className="w-fit shrink-0">{typeLabel[event.event_type] || event.event_type}</Badge>
</div>
</CardHeader>
<CardContent>
@@ -435,55 +291,58 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
<CardDescription>جزئیات دسترسی به رویداد</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{event.address && <div>آدرس: {event.address}</div>}
<div>
ظرفیت کل: {event.capacity == null ? "نامحدود" : formatNumberPersian(event.capacity)}
</div>
{meta && event.capacity != null && (
<div>
ظرفیت باقی‌مانده:{" "}
{meta.remaining === Number.POSITIVE_INFINITY
? "نامحدود"
: formatNumberPersian(meta.remaining)}
<div className="space-y-3 border-b border-border pb-4">
<div className="flex items-start gap-3">
<CalendarDays aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">زمان برگزاری</p>
<p className="mt-1 font-medium">{formatDateTimeRange(event.start_time, event.end_time)}</p>
</div>
</div>
)}
<div>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</div>
<div className="flex items-start gap-3">
<MapPin aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">محل برگزاری</p>
<p className="mt-1 font-medium">{event.address || (event.event_type === "online" ? "برگزاری آنلاین" : event.event_type === "hybrid" ? "برگزاری آنلاین و حضوری" : "جزئیات محل برگزاری در توضیحات رویداد")}</p>
</div>
</div>
<div className="flex items-start gap-3">
<UsersRound aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">ظرفیت</p>
<p className="mt-1 font-medium">
{event.capacity == null
? "بدون محدودیت ظرفیت"
: `${formatNumberPersian(registration?.remaining ?? Math.max(0, event.capacity - (event.registration_count ?? 0)))} نفر از ${formatNumberPersian(event.capacity)} نفر باقی مانده`}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Ticket aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">هزینه ثبت‌نام</p>
<p className="mt-1 font-medium">{isFree ? "رایگان" : formatToman(basePrice)}</p>
</div>
</div>
{event.registration_end_date ? (
<div className="flex items-start gap-3">
<Clock3 aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<p className="text-xs text-muted-foreground">مهلت ثبت‌نام</p>
<p className="mt-1 font-medium">{formatJalali(event.registration_end_date)}</p>
</div>
</div>
) : null}
</div>
<Button
onClick={handleMainCTA}
className="w-full mt-2"
disabled={
submitting ||
alreadyRegistered ||
event.status !== "published" ||
meta?.full === true ||
!meta?.registrationOpen
}
>
{event.status !== "published"
? "ثبت‌نام این رویداد فعال نیست"
: alreadyRegistered
? "شما قبلاً ثبت‌نام کرده‌اید"
: !meta?.registrationOpen
? "ثبت‌نام هنوز آغاز نشده است"
: meta?.full
? "ظرفیت ثبت‌نام تکمیل شده است"
: submitting
? "در حال ثبت‌نام..."
: event.price === 0
? "ثبت‌نام (رایگان)"
: "ثبت‌نام و ادامه پرداخت"}
{registration?.state === "open"
? isFree ? "ثبت‌نام رایگان" : "ثبت‌نام و پرداخت"
: "مشاهدهٔ وضعیت ثبت‌نام"}
</Button>
{!isFree && (
<CouponDialogFa
open={open}
onOpenChange={setOpen}
basePrice={basePrice}
onVerifyCouponRaw={(code) => api.checkDiscountCode(event.id, code)}
onContinue={handleContinueFromModal}
/>
)}
</CardContent>
</Card>
</div>

View File

@@ -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 || "—";
}
}

View 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>
</>
);
}

View File

@@ -1,176 +1,403 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { api } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import ProgressiveImage from "@/components/ProgressiveImage";
import type * as Types from "@/lib/types";
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
import { Link } from "@/lib/router";
import { buildAuthPath } from "@/lib/auth-return";
import { Input } from "@/components/ui/input";
import { EventCard } from "@/components/events/EventCard";
import { EventFiltersDrawer } from "@/components/events/EventFiltersDrawer";
import type { EventBrowsePageSchema } from "@/lib/types";
import { api } from "@/lib/api";
import { formatNumberPersian } from "@/lib/utils";
import type { EventsContentPage } from "@/lib/content-cms";
import {
EVENT_PAGE_SIZE,
parseEventCatalogParams,
serializeEventCatalogParams,
type EventCatalogFilters,
} from "@/lib/event-catalog";
type EventsProps = {
content: EventsContentPage;
initialEvents?: Types.EventListItemSchema[];
initialSearch?: string;
};
function labelPrice(event: Types.EventListItemSchema) {
const price = Number(event?.price ?? 0);
return price <= 0 ? "رایگان" : formatToman(price);
function defaultFilters(search: string, priceCeiling: number): EventCatalogFilters {
return {
search,
state: "all",
eventType: "all",
dateFrom: "",
dateTo: "",
priceMin: 0,
priceMax: priceCeiling,
freeOnly: false,
page: 1,
};
}
function modeFa(eventType: Types.EventListItemSchema["event_type"]) {
return eventType === "online" ? "آنلاین" : "حضوری";
function EventCardSkeleton() {
return (
<article aria-hidden="true" className="flex h-full min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="aspect-video w-full shrink-0 animate-pulse bg-muted motion-reduce:animate-none" />
<div className="flex flex-1 flex-col p-3 md:p-4">
<div className="ms-auto h-5 w-4/5 animate-pulse rounded bg-muted motion-reduce:animate-none" />
<div className="mt-3 grid gap-3 md:mt-4">
{["w-32", "w-40", "w-24", "w-36"].map((width) => (
<div key={width} className="flex items-center justify-end gap-2">
<div className={`h-3.5 ${width} animate-pulse rounded bg-muted motion-reduce:animate-none`} />
<div className="size-4 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
</div>
))}
</div>
<div className="mt-auto min-h-10 w-full animate-pulse rounded-md bg-muted pt-2 md:mt-5 md:min-h-11 motion-reduce:animate-none" />
</div>
</article>
);
}
function spotsLeft(event: Types.EventListItemSchema) {
const cap = Number(event.capacity);
const used = Number(event.registration_count);
return cap - used;
function EventResultsSkeleton() {
return (
<div role="status" aria-live="polite" className="space-y-3">
<span className="sr-only">در حال اعمال فیلترها و دریافت رویدادها…</span>
<div aria-hidden="true" className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => <EventCardSkeleton key={index} />)}
</div>
</div>
);
}
function isAvailable(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date ?? event.start_time);
const timeOk = end.getTime() > now.getTime();
return timeOk && spotsLeft(event) > 0;
function getPageNumbers(total: number, current: number): Array<number | "ellipsis-left" | "ellipsis-right"> {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
const pages: Array<number | "ellipsis-left" | "ellipsis-right"> = [1];
if (start > 2) pages.push("ellipsis-left");
for (let page = start; page <= end; page += 1) pages.push(page);
if (end < total - 1) pages.push("ellipsis-right");
pages.push(total);
return pages;
}
function notAvailableReasonFa(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date ?? event.start_time);
if (end.getTime() <= now.getTime()) return "ثبت‌نام پایان‌یافته";
if (spotsLeft(event) <= 0) return "ظرفیت تکمیل";
return "غیرقابل ثبت‌نام";
function toBrowseRequest(filters: EventCatalogFilters) {
return {
state: filters.state,
event_type: filters.eventType === "all" ? undefined : filters.eventType,
search: filters.search,
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
price_min: filters.freeOnly ? undefined : filters.priceMin,
price_max: filters.freeOnly ? undefined : filters.priceMax,
free_only: filters.freeOnly,
page: filters.page,
};
}
export default function Events({
content,
initialEvents = [],
initialSearch = "",
}: EventsProps) {
const navigate = useNavigate();
const location = useLocation();
const [events, setEvents] = useState<Types.EventListItemSchema[]>(initialEvents);
const [search, setSearch] = useState(initialSearch);
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
initialPage,
initialFilters,
unmatchedPaymentCallback = false,
}: {
content: EventsContentPage;
initialPage: EventBrowsePageSchema;
initialFilters: EventCatalogFilters;
unmatchedPaymentCallback?: boolean;
}) {
const [pageData, setPageData] = useState(initialPage);
const [filters, setFilters] = useState(initialFilters);
const [draftFilters, setDraftFilters] = useState(initialFilters);
const [search, setSearch] = useState(initialFilters.search);
const [drawerOpen, setDrawerOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [retryToken, setRetryToken] = useState(0);
const [now] = useState(() => Date.now());
const searchInputRef = useRef<HTMLInputElement>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const skipFirstFetch = useRef(true);
useEffect(() => {
setEvents(initialEvents);
}, [initialEvents]);
const query = new URLSearchParams(window.location.search);
const nextUrl = query.toString()
? `${window.location.pathname}?${query.toString()}`
: window.location.pathname;
const handlePopState = () => {
const restored = parseEventCatalogParams(
new URLSearchParams(window.location.search),
pageData.price_ceiling,
);
setFilters(restored);
setDraftFilters(restored);
setSearch(restored.search);
setDrawerOpen(false);
};
window.addEventListener("popstate", handlePopState);
useEffect(() => {
setSearch(initialSearch);
}, [initialSearch]);
const loadEvents = useCallback(async () => {
try {
setLoading(true);
const data = await api.getEvents({
search: search || undefined,
statuses: ["published", "completed"],
limit: 30,
});
setEvents(data);
} catch (error) {
console.error("Error loading events:", error);
} finally {
setLoading(false);
// Keep the route query canonical while preserving Next.js' router state.
if (nextUrl !== `${window.location.pathname}${window.location.search}`) {
window.history.replaceState(null, "", nextUrl);
}
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [pageData.price_ceiling]);
useEffect(() => {
const timer = window.setTimeout(() => {
setFilters((current) => {
const normalizedSearch = search.trim();
if (current.search === normalizedSearch) return current;
return { ...current, search: normalizedSearch, page: 1 };
});
}, 300);
return () => window.clearTimeout(timer);
}, [search]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
const query = serializeEventCatalogParams(filters, pageData.price_ceiling);
const nextUrl = query.size
? `${window.location.pathname}?${query.toString()}`
: window.location.pathname;
if (nextUrl !== `${window.location.pathname}${window.location.search}`) {
window.history.replaceState(null, "", nextUrl);
}
}, [filters, pageData.price_ceiling]);
useEffect(() => {
const params = new URLSearchParams();
if (search.trim()) {
params.set("search", search.trim());
if (skipFirstFetch.current) {
skipFirstFetch.current = false;
return;
}
const basePath = location.pathname || "/events";
const nextPath = params.size
? `${basePath}?${params.toString()}`
: basePath;
navigate(nextPath, { replace: true });
}, [location.pathname, navigate, search]);
let active = true;
setLoading(true);
setLoadError(false);
const timer = window.setTimeout(() => {
api.browseEvents(toBrowseRequest(filters))
.then((response) => {
if (!active) return;
setPageData(response);
setFilters((current) => {
const nextPage = response.page;
const nextPriceMax = current.priceMax >= pageData.price_ceiling
? response.price_ceiling
: current.priceMax;
if (current.page === nextPage && current.priceMax === nextPriceMax) return current;
return { ...current, page: nextPage, priceMax: nextPriceMax };
});
setDraftFilters((current) => {
const nextPriceMax = current.priceMax >= pageData.price_ceiling
? response.price_ceiling
: current.priceMax;
return current.priceMax === nextPriceMax ? current : { ...current, priceMax: nextPriceMax };
});
})
.catch(() => {
if (active) setLoadError(true);
})
.finally(() => {
if (active) setLoading(false);
});
}, 180);
return () => {
active = false;
window.clearTimeout(timer);
};
}, [filters, pageData.price_ceiling, retryToken]);
const updateFilters = useCallback((patch: Partial<EventCatalogFilters>) => {
const mergePatch = (current: EventCatalogFilters): EventCatalogFilters => ({
...current,
...patch,
search: search.trim(),
page: 1,
});
setDraftFilters(mergePatch);
}, [search]);
const resetFilters = useCallback(() => {
const reset = defaultFilters(search.trim(), pageData.price_ceiling);
setDraftFilters(reset);
setFilters(reset);
setDrawerOpen(false);
}, [pageData.price_ceiling, search]);
const handleDrawerChange = (open: boolean) => {
if (open) setDraftFilters(filters);
setDrawerOpen(open);
};
const applyFilters = () => {
setFilters({ ...draftFilters, search: search.trim(), page: 1 });
setDrawerOpen(false);
};
const activeFilterCount = [
filters.state !== "all",
filters.eventType !== "all",
Boolean(filters.dateFrom && filters.dateTo),
filters.freeOnly,
!filters.freeOnly && (filters.priceMin > 0 || filters.priceMax < pageData.price_ceiling),
].filter(Boolean).length;
const pageNumbers = useMemo(
() => getPageNumbers(pageData.total_pages, pageData.page),
[pageData.page, pageData.total_pages],
);
const changePage = (page: number) => {
if (page < 1 || page > pageData.total_pages || page === filters.page) return;
setFilters((current) => ({ ...current, page }));
window.setTimeout(() => {
resultsRef.current?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "start",
});
}, 0);
};
return (
<main className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<h1 className="mb-3 text-4xl font-bold">{content.title}</h1>
{content.intro ? <p className="mb-8 max-w-3xl leading-8 text-muted-foreground">{content.intro}</p> : null}
<div className="mb-8">
<Input
type="text"
placeholder="جستجو در رویدادها..."
aria-label="جستجو در رویدادها"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
<main className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto max-w-screen-2xl px-3 py-5 sm:px-4 sm:py-8 lg:px-6">
{unmatchedPaymentCallback ? (
<div role="status" className="mb-5 flex flex-col gap-3 rounded-xl border border-primary/20 bg-primary/[0.04] p-4 text-sm leading-6 sm:flex-row sm:items-center sm:justify-between">
<p>نتیجهٔ بازگشت از درگاه به ثبت‌نام یا پرداختی در حساب شما متصل نشد. وارد حساب شوید و بخش‌های ثبت‌نام‌ها و پرداخت‌ها را بررسی کنید.</p>
<Button asChild variant="outline" className="shrink-0">
<Link to={buildAuthPath("/profile?tab=payments")}>بررسی پرداخت‌ها</Link>
</Button>
</div>
) : null}
<header className="mb-6 border-b border-border pb-5 sm:mb-8 sm:pb-6">
<h1 className="text-2xl font-bold leading-tight tracking-tight sm:text-3xl">{content.title}</h1>
{content.intro ? <p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base">{content.intro}</p> : null}
</header>
{loading ? (
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
) : events.length === 0 ? (
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
<div className="w-full aspect-video overflow-hidden rounded-lg">
<ProgressiveImage
src={getEventCardImageUrl(event)}
alt={event.title}
wrapperClassName="h-full w-full"
className="h-full w-full object-cover"
/>
</div>
<div className="flex-1 flex flex-col justify-between">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
<Badge variant="default">{modeFa(event.event_type)}</Badge>
</div>
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-1 text-sm" dir="rtl">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">ظرفیت رویداد</span>
<span className="font-medium">
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}
/
{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">هزینه‌ی ثبت‌نام</span>
<span className="font-medium">{labelPrice(event)}</span>
</div>
{isAvailable(event) ? (
<Button>جزئیات رویداد</Button>
) : (
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
)}
</div>
</CardContent>
</div>
</Card>
</Link>
))}
<section aria-label="نتایج رویدادها" className="min-w-0 lg:flex lg:flex-col">
<div dir="rtl" className="mb-4 flex items-center gap-2 sm:mb-5">
<div className="relative min-w-0 flex-1">
{search ? (
<button
type="button"
aria-label="پاک‌کردن جستجو"
className="absolute left-2 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("");
searchInputRef.current?.focus();
}}
>
<X aria-hidden="true" className="size-4" />
</button>
) : (
<Search aria-hidden="true" className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
)}
<Input
ref={searchInputRef}
type="search"
placeholder="جستجو در رویدادها…"
aria-label="جستجو در رویدادها"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="event-search-input h-11 pl-10 pr-3 focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<EventFiltersDrawer
open={drawerOpen}
filters={draftFilters}
priceCeiling={pageData.price_ceiling}
activeFilterCount={activeFilterCount}
disabled={loading}
onOpenChange={handleDrawerChange}
onChange={updateFilters}
onReset={resetFilters}
onApply={applyFilters}
/>
</div>
)}
</div>
</main>
{loadError ? (
<div role="alert" className="mb-4 flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm">دریافت رویدادها با خطا روبه‌رو شد. اتصال را بررسی کنید و دوباره تلاش کنید.</p>
<Button type="button" variant="outline" onClick={() => setRetryToken((value) => value + 1)} disabled={loading}>
تلاش دوباره
</Button>
</div>
) : null}
<div ref={resultsRef} className="scroll-mt-24 lg:flex-1" aria-busy={loading}>
{loading ? (
<EventResultsSkeleton />
) : pageData.results.length ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{pageData.results.map((event) => <EventCard key={event.id} event={event} now={now} />)}
</div>
) : loadError ? (
<div className="rounded-xl border border-border bg-card px-5 py-12 text-center">
<p className="font-semibold">رویدادها بارگذاری نشدند</p>
<p className="mt-2 text-sm text-muted-foreground">بعد از برقراری اتصال، دوباره تلاش کنید.</p>
<Button type="button" className="mt-5" onClick={() => setRetryToken((value) => value + 1)} disabled={loading}>تلاش دوباره</Button>
</div>
) : (
<div role="status" className="flex min-h-[22rem] flex-col items-center justify-center rounded-xl border border-border bg-card px-5 py-10 text-center sm:py-12 lg:h-full">
<Image
src="/images/events-empty-state-quiera.png"
alt=""
aria-hidden="true"
width={1489}
height={1056}
className="mb-5 h-auto w-full max-w-[300px] object-contain sm:max-w-[420px]"
/>
<p className="font-semibold">رویدادی با این مشخصات پیدا نشد.</p>
</div>
)}
</div>
{pageData.total_pages > 1 ? (
<nav aria-label="صفحه‌بندی رویدادها" className="mt-7 flex flex-wrap items-center justify-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
disabled={pageData.page <= 1 || loading}
onClick={() => changePage(pageData.page - 1)}
aria-label="صفحهٔ قبلی"
>
قبلی
</Button>
{pageNumbers.map((page) => typeof page === "number" ? (
<Button
key={page}
type="button"
variant={page === pageData.page ? "default" : "outline"}
size="icon"
className="size-9"
aria-current={page === pageData.page ? "page" : undefined}
aria-label={`صفحهٔ ${formatNumberPersian(page)}`}
onClick={() => changePage(page)}
disabled={loading}
>
{formatNumberPersian(page)}
</Button>
) : (
<span key={page} aria-hidden="true" className="px-1 text-muted-foreground">…</span>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={pageData.page >= pageData.total_pages || loading}
onClick={() => changePage(pageData.page + 1)}
aria-label="صفحهٔ بعدی"
>
بعدی
</Button>
</nav>
) : null}
<span className="sr-only">{formatNumberPersian(EVENT_PAGE_SIZE)} رویداد در هر صفحه</span>
</section>
</div>
</main>
);
}

View File

@@ -1,277 +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';
type SavedPayment = {
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;
};
export default function PaymentResult() {
const [params] = useSearchParams();
const status = params.get('status'); // success | failed
const refId = params.get('ref_id') || '';
const eventId = Number(params.get('event_id') || '0');
const humanEventId = eventId ? formatNumberPersian(eventId) : '—';
const refIdDisplay = refId ? toPersianDigits(refId) : '';
const [fallback, setFallback] = useState<SavedPayment | null>(null);
// 2) اگر saved نبود و refId هست، از بک‌اند اطلاعات را می‌گیریم (اندپوینت اختیاری by-ref)
useEffect(() => {
(async () => {
if (!refId) return;
try {
const p = await api.getPaymentByRef(refId);
setFallback({
event_id: p.event.id,
slug: p.event.slug,
title: p.event.title,
thumb: p.event.image_url || null,
base_amount: p.base_amount,
discount_amount: p.discount_amount,
amount: p.amount,
started_at: p.verified_at || undefined,
success_markdown: p.event?.success_markdown
});
} catch {
// بی‌صدا؛ حداقل status/ref_id نمایش داده می‌شود
}
})();
}, [refId]);
const data = fallback;
const ok = status === 'success';
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 pageState =
status === 'success'
? 'Payment successful'
: status === 'failed'
? 'Payment failed'
: 'Payment status';
const pageTitle = `${pageState} | ${siteName}`;
const pageDescription = `${ok ? 'Payment confirmed' : 'Review your payment status'} for ${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', ok ? 'success' : 'failed');
return url.toString();
}, [eventId, ok, refId]);
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={[
"rounded-md p-3 text-sm mb-4 border",
ok
? "bg-emerald-100 text-emerald-800 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-200 dark:border-emerald-800/50"
: "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-200 dark:border-red-800/50",
].join(" ")}
>
{ok ? "پرداخت با موفقیت انجام شد." : "پرداخت ناموفق بود."}
</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>
<div className="mx-auto mt-6 flex max-w-xl items-center justify-end gap-2">
<Markdown content={successMarkdown} justify size="base" />
</div>
{/* 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 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?.slug ? (
<Link to={`/events/${data.slug}`}>
<Button variant="outline">بازگشت به رویداد</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>
);
}

View File

@@ -6,7 +6,6 @@ import Image from "next/image";
import {
Activity,
ArrowDownWideNarrow,
ArrowUpLeft,
ChevronLeft,
ChevronRight,
Bookmark,
@@ -26,6 +25,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { GamificationCard } from "@/components/profile/GamificationCard";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Helmet } from "@/lib/helmet";
@@ -34,6 +34,7 @@ import { buildAuthPath } from "@/lib/auth-return";
import { api } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import { blogPostPath } from "@/lib/blog-routes";
import { getEventPath } from "@/lib/event-ui";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
import type * as Types from "@/lib/types";
import { formatJalali, formatNumberPersian, getBlogCardImageUrl, toPersianDigits } from "@/lib/utils";
@@ -86,6 +87,13 @@ const paymentStatusLabels: Record<Types.UserPaymentSchema["status"], string> = {
canceled: "لغو شده",
};
const refundStatusLabels: Record<NonNullable<Types.UserPaymentSchema["refund_status"]>, string> = {
none: "بدون بازگشت وجه",
pending: "بازگشت وجه در حال پیگیری",
succeeded: "وجه بازگردانده شد",
manual_review: "بازگشت وجه در انتظار پشتیبانی",
};
const sectionItems: Array<{ id: ProfileSection; label: string; icon: typeof Activity }> = [
{ id: "overview", label: "نمای کلی", icon: UserRound },
{ id: "events", label: "رویدادها", icon: CalendarDays },
@@ -154,24 +162,35 @@ function PagePagination({
function ProfileEventCard({ registration }: { registration: Types.MyEventRegistrationSchema }) {
const event = registration.event;
const imageUrl = toAbsoluteUrl(event.absolute_image_url, apiBaseUrl) || "/placeholder.svg";
const eventPath = getEventPath(event);
const registrationHubUrl = `${eventPath}/registration?registration_id=${encodeURIComponent(registration.ticket_id)}`;
return (
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right sm:flex-row sm:gap-4 sm:p-4">
<Link
to={`/events/${event.slug}`}
to={eventPath}
className="w-full shrink-0 overflow-hidden rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-32"
aria-label={`مشاهده رویداد ${event.title}`}
>
<Image src={imageUrl || "/placeholder.svg"} 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>
<div className="flex min-w-0 flex-1 flex-col">
<Link to={`/events/${event.slug}`} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Link to={eventPath} className="break-words font-semibold text-foreground hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.title}
</Link>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<Badge variant="outline" className={eventStatusBadgeClasses[registration.status]}>{eventStatusLabels[registration.status]}</Badge>
<span className="text-xs text-muted-foreground">{formatJalali(event.start_date, false)}</span>
</div>
{registration.checkout_state === "active" && registration.can_resume ? (
<Button asChild variant="default" size="sm" className="mt-3 min-h-10 w-fit">
<Link to={registrationHubUrl}>ادامه پرداخت</Link>
</Button>
) : registration.checkout_state === "unresolved" ? (
<Button asChild variant="outline" size="sm" className="mt-3 min-h-10 w-fit">
<Link to={registrationHubUrl}>پیگیری پرداخت قبلی</Link>
</Button>
) : null}
</div>
</article>
);
@@ -248,14 +267,9 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
enabled: isAuthenticated,
initialData: user ?? undefined,
});
const registrationsQuery = useQuery({
queryKey: ["my-registrations"],
queryFn: () => api.getMyRegistrations(),
enabled: isAuthenticated && section === "overview",
});
const blogQuery = useQuery({
queryKey: ["my-blog-activity"],
queryFn: () => api.getMyBlogActivity(),
const gamificationQuery = useQuery({
queryKey: ["my-gamification"],
queryFn: () => api.getMyGamification(),
enabled: isAuthenticated && section === "overview",
});
const eventSortOption = eventSortItems.find(({ id }) => id === eventSort) ?? eventSortItems[0];
@@ -286,12 +300,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
() => [profile?.first_name, profile?.last_name].filter(Boolean).join(" ").trim() || profile?.username || "کاربر",
[profile?.first_name, profile?.last_name, profile?.username],
);
const eventRegistrations = registrationsQuery.data ?? [];
const confirmedEvents = eventRegistrations.filter((item) => item.status === "confirmed" || item.status === "attended");
const blogActivity = blogQuery.data;
const comments = [...(blogActivity?.comments ?? []), ...(blogActivity?.replies ?? [])].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const gamification = gamificationQuery.data;
const activeBlogPageData = blogSection === "comments" ? blogCommentsPageQuery.data : blogPostsPageQuery.data;
const activeBlogIsLoading = blogSection === "comments" ? blogCommentsPageQuery.isLoading : blogPostsPageQuery.isLoading;
const activeBlogIsError = blogSection === "comments" ? blogCommentsPageQuery.isError : blogPostsPageQuery.isError;
@@ -319,7 +328,6 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
const profilePicture = profile.profile_picture_preview_url || profile.profile_picture_thumbnail_url || profile.profile_picture;
const initials = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username.slice(0, 1);
const blogCount = (blogActivity?.liked_posts.length ?? 0) + (blogActivity?.saved_posts.length ?? 0) + comments.length;
const publicPath = `/users/profiles/${encodeURIComponent(profile.username)}`;
const renderSection = () => {
@@ -478,7 +486,7 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
{paymentsQuery.data?.results.map((payment, index) => (
<article key={`${payment.event.slug}-${payment.created_at}-${index}`} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right sm:flex-row sm:items-center sm:justify-between sm:p-4">
<div className="min-w-0">
<Link to={`/events/${payment.event.slug}`} className="font-semibold hover:text-primary">{payment.event.title}</Link>
<Link to={getEventPath(payment.event)} className="font-semibold hover:text-primary">{payment.event.title}</Link>
<p className="mt-1 text-sm text-muted-foreground">{formatJalali(payment.paid_at || payment.created_at)}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
@@ -491,6 +499,14 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<Badge variant={payment.status === "paid" ? "secondary" : payment.status === "failed" || payment.status === "canceled" ? "destructive" : "outline"}>
{paymentStatusLabels[payment.status]}
</Badge>
{payment.refund_status && payment.refund_status !== "none" ? (
<Badge variant="outline">{refundStatusLabels[payment.refund_status]}</Badge>
) : null}
<Button asChild variant="outline" size="sm" className="min-h-10">
<Link to={`${getEventPath(payment.event)}/registration?payment_id=${payment.payment_id}`}>
مشاهدهٔ وضعیت
</Link>
</Button>
</div>
</article>
))}
@@ -507,41 +523,33 @@ export default function Profile({ initialTab }: { initialTab?: string }) {
<div className="space-y-5">
<Card className="border-border/70 shadow-sm">
<CardHeader className="text-right">
<CardTitle>فعالیت‌های اخیر</CardTitle>
<CardDescription>مروری سریع بر حضور و مشارکت شما در انجمن</CardDescription>
<CardTitle>مشارکت‌های ثبت‌شده</CardTitle>
<CardDescription>فعالیت‌های معتبر برای امتیاز و نشان‌های شما</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<CalendarDays className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(confirmedEvents.length)}</p>
<p className="mt-1 text-sm text-muted-foreground">رویداد تأییدشده یا حضور یافته</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.attendance ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">حضور تأییدشده در رویداد</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<Heart className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogActivity?.liked_posts.length ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">نوشته پسندیده‌شده</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(gamification?.activity_counts.likes ?? 0)}</p>
<p className="mt-1 text-sm text-muted-foreground">پسند داده‌شده یا دریافت‌شده</p>
</div>
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-right shadow-sm dark:bg-background/70">
<Activity className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="mt-3 text-2xl font-bold">{formatNumberPersian(blogCount)}</p>
<p className="mt-1 text-sm text-muted-foreground">تعامل بلاگ</p>
<p className="mt-3 text-2xl font-bold">{formatNumberPersian((gamification?.activity_counts.posts ?? 0) + (gamification?.activity_counts.comments ?? 0))}</p>
<p className="mt-1 text-sm text-muted-foreground">نوشته‌ها و دیدگاه‌های تأییدشده</p>
</div>
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardHeader className="flex flex-row-reverse items-center justify-between gap-3 text-right">
<div>
<CardTitle>رویدادهای اخیر</CardTitle>
<CardDescription>ثبت‌نام‌ها و حضورهای شما</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => navigateToSection("events")}>همه رویدادها <ArrowUpLeft className="mr-1 h-4 w-4" /></Button>
</CardHeader>
<CardContent className="space-y-3">
{registrationsQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال دریافت رویدادها...</p> : null}
{!registrationsQuery.isLoading && !confirmedEvents.length ? <EmptyState title="هنوز رویداد تأییدشده‌ای ندارید" description="ثبت‌نام‌های تأییدشده اینجا دیده می‌شوند." /> : null}
{confirmedEvents.slice(0, 3).map((registration) => <ProfileEventCard key={registration.id} registration={registration} />)}
</CardContent>
</Card>
<GamificationCard
summary={gamification}
isLoading={gamificationQuery.isLoading}
isError={gamificationQuery.isError}
onRetry={() => { void gamificationQuery.refetch(); }}
/>
</div>
);
};

View File

@@ -5,17 +5,18 @@ import Image from "next/image";
import { CalendarDays, EyeOff, UserRound } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { PublicProfileBadges } from "@/components/profile/PublicProfileBadges";
import { Helmet } from "@/lib/helmet";
import { Link, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import { formatJalali, formatNumberPersian, toPersianDigits } from "@/lib/utils";
import { formatJalali, toPersianDigits } from "@/lib/utils";
import type { PublicProfileEventSchema } from "@/lib/types";
function EventCard({ event }: { event: PublicProfileEventSchema }) {
return (
<Link to={`/events/${event.slug}`} className="group flex min-h-28 gap-4 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.image_url ? <Image src={event.image_url} alt="" width={160} height={120} unoptimized className="aspect-[4/3] w-24 shrink-0 rounded-xl object-cover sm:w-32" /> : (
<span className="flex aspect-[4/3] w-24 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
<Link to={`/events/${event.slug}`} className="group flex min-h-28 flex-col gap-3 rounded-2xl border border-border/70 bg-background p-3 text-right transition-colors hover:bg-muted/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:flex-row sm:gap-4">
{event.image_url ? <Image src={event.image_url} alt="" width={640} height={360} unoptimized className="aspect-video w-full shrink-0 rounded-xl object-cover sm:w-32" /> : (
<span className="flex aspect-video w-full shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:w-32"><CalendarDays className="h-6 w-6" aria-hidden="true" /></span>
)}
<span className="flex min-w-0 flex-1 flex-col gap-2 py-1">
<span className="font-semibold text-foreground group-hover:text-primary">{event.title}</span>
@@ -53,11 +54,12 @@ export default function PublicProfile() {
const fallback = `${profile.first_name?.[0] ?? ""}${profile.last_name?.[0] ?? ""}` || profile.username[0];
const maskedDetails = [
{ label: "شماره موبایل", value: profile.mobile },
{ label: "شماره دانشجویی", value: profile.student_id },
{ label: "دانشگاه", value: profile.university },
{ label: "رشته", value: profile.major },
{ label: "سال ورود", value: profile.year_of_study || null },
{ label: "شماره موبایل", value: profile.mobile, isEmail: false },
...(profile.email ? [{ label: "ایمیل", value: profile.email, isEmail: true }] : []),
{ label: "دانشگاه", value: profile.university, isEmail: false },
{ label: "رشته", value: profile.major, isEmail: false },
{ label: "شماره دانشجویی", value: profile.student_id, isEmail: false },
{ label: "سال ورود", value: profile.year_of_study || null, isEmail: false },
];
const pageDescription = profile.bio || `پروفایل عمومی ${profile.full_name} و رویدادهای او در انجمن علمی مهندسی کامپیوتر.`;
@@ -85,25 +87,11 @@ export default function PublicProfile() {
</div>
</div>
{profile.bio ? <p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{profile.bio}</p> : null}
<div className="mt-5 grid grid-cols-1 gap-3">
<div className="rounded-2xl border border-border/70 bg-muted/45 p-4 text-center shadow-sm dark:bg-background/70"><p className="text-xl font-bold">{formatNumberPersian(profile.events.length)}</p><p className="mt-1 text-xs text-muted-foreground">رویداد</p></div>
</div>
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardHeader className="pb-2 text-right"><CardTitle className="text-base">{profile.details_visible ? "اطلاعات بیشتر" : "اطلاعات خصوصی"}</CardTitle></CardHeader>
<CardContent className="space-y-1">
{profile.details_visible ? maskedDetails.map(({ label, value }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-left font-medium">{value ? toPersianDigits(String(value)) : "ثبت نشده"}</span>
</div>
)) : (
<div className="relative isolate overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-5 sm:p-6">
<div aria-hidden="true" className="pointer-events-none select-none space-y-3 opacity-55 blur-[4px]">
{!profile.details_visible ? (
<div className="relative isolate mt-5 overflow-hidden rounded-xl border border-border/70 bg-muted/20 p-4 sm:p-5">
<div aria-hidden="true" className="pointer-events-none select-none space-y-2.5 opacity-55 blur-[4px]">
{maskedDetails.map(({ label }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-2.5 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="font-medium">••••••••</span>
</div>
@@ -111,17 +99,34 @@ export default function PublicProfile() {
</div>
<div className="absolute inset-0 flex items-center justify-center bg-background/55 p-4 text-center">
<div className="max-w-xs">
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<EyeOff className="h-6 w-6" aria-hidden="true" />
<span className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<EyeOff className="h-5 w-5" aria-hidden="true" />
</span>
<p className="font-semibold">جزئیات این پروفایل خصوصی است</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">نمایش اطلاعات تکمیلی به درخواست صاحب حساب غیرفعال شده است.</p>
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-1">
{maskedDetails.map(({ label, value, isEmail }) => (
<div key={label} className="flex items-center justify-between gap-4 border-b border-border/60 py-3 last:border-0 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span dir={isEmail ? "ltr" : undefined} className={`min-w-0 text-left font-medium ${isEmail ? "break-all" : ""}`}>
{value ? (isEmail ? String(value) : toPersianDigits(String(value))) : "ثبت نشده"}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="border-border/70 shadow-sm">
<CardContent className="pt-6">
<PublicProfileBadges badges={profile.badges} />
</CardContent>
</Card>
</aside>
<div className="space-y-5 lg:col-span-8">

222
src/views/RankGuide.tsx Normal file
View File

@@ -0,0 +1,222 @@
import Image from "next/image";
import Link from "next/link";
import {
ArrowLeft,
Award,
CalendarCheck2,
Heart,
MessageCircle,
PenLine,
Sparkles,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import {
activityLadders,
activityPoints,
getGamificationBadgeImagePath,
rankLevels,
} from "@/lib/gamification-badges";
import { formatNumberPersian } from "@/lib/utils";
const activityIcons = {
attendance: CalendarCheck2,
posts: PenLine,
comments: MessageCircle,
likes: Heart,
} as const;
function ProgressionBadge({
id,
label,
requirement,
description,
size,
eyebrow,
className = "",
}: {
id: string;
label: string;
requirement: string;
description: string;
size: "level" | "activity";
eyebrow?: string;
className?: string;
}) {
const imagePath = getGamificationBadgeImagePath(id);
return (
<li className={`flex min-w-0 flex-col items-center text-center ${size === "level" ? "gap-2" : "gap-1.5"} ${className}`}>
{eyebrow ? <span className="mb-1 text-[0.65rem] font-semibold text-muted-foreground">{eyebrow}</span> : null}
{imagePath ? (
<Image
src={imagePath}
alt=""
width={size === "level" ? 144 : 96}
height={size === "level" ? 144 : 96}
sizes={size === "level" ? "(max-width: 640px) 112px, 144px" : "96px"}
className={size === "level" ? "h-28 w-28 object-contain sm:h-36 sm:w-36" : "h-20 w-20 object-contain sm:h-24 sm:w-24"}
/>
) : null}
<span className={`font-semibold leading-5 ${size === "level" ? "text-sm sm:text-base" : "text-xs sm:text-sm"}`}>{label}</span>
<span className="text-xs leading-5 text-muted-foreground">{requirement}</span>
<span className="sr-only">{description}</span>
</li>
);
}
export default function RankGuide() {
return (
<main className="min-h-screen bg-muted/20 px-4 py-6 sm:py-10" dir="rtl">
<div className="container mx-auto max-w-6xl space-y-8">
<Link
href="/profile"
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
بازگشت به پروفایل
</Link>
<header className="relative isolate overflow-hidden rounded-3xl bg-primary text-primary-foreground">
<div aria-hidden="true" className="pointer-events-none absolute -left-16 -top-24 h-72 w-72 rounded-full bg-cyan-300/20 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute -bottom-40 right-1/3 h-72 w-72 rounded-full bg-amber-300/15 blur-3xl" />
<div className="relative flex flex-col gap-5 p-6 sm:flex-row sm:items-end sm:justify-between sm:p-9">
<div className="max-w-2xl space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-medium">
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
راهنمای دستاوردهای انجمن
</div>
<h1 className="text-3xl font-bold leading-tight sm:text-4xl">مشارکت شما، نشان‌دار می‌شود</h1>
<p className="max-w-xl text-sm leading-7 text-primary-foreground/80 sm:text-base">
با حضور در رویدادها و مشارکت در گفت‌وگوهای انجمن امتیاز بگیرید، سطح کلی‌تان را بالا ببرید و نشان‌های هر مسیر را جمع کنید.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-sm font-semibold">
<Award className="h-5 w-5 text-amber-200" aria-hidden="true" />
۵ سطح کلی · ۴ مسیر نشان
</div>
</div>
</header>
<section aria-labelledby="overall-levels-title" className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold tracking-wide text-primary">پیشرفت ترکیبی</p>
<h2 id="overall-levels-title" className="mt-1 text-2xl font-bold">سطح کلی شما</h2>
</div>
<p className="max-w-lg text-sm leading-6 text-muted-foreground">امتیاز رویدادها و فعالیت‌های بلاگ با هم جمع می‌شوند. سطح‌ها مادام‌العمرند و برای همه دست‌یافتنی هستند.</p>
</div>
<div className="relative overflow-hidden rounded-2xl border border-border/70 bg-card p-4 shadow-sm sm:p-6">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[9%] top-[5.7rem] hidden h-px bg-gradient-to-l from-primary/10 via-primary/50 to-primary/10 lg:block" />
<ol className="relative grid grid-cols-2 gap-x-3 gap-y-7 sm:grid-cols-3 lg:grid-cols-5 lg:gap-2">
{rankLevels.map((level, index) => (
<ProgressionBadge
key={level.id}
id={level.badgeId}
label={level.label}
requirement={level.minimumPoints === 0 ? "شروع مسیر" : `از ${formatNumberPersian(level.minimumPoints)} امتیاز`}
description={`نشان سطح ${level.label}`}
size="level"
eyebrow={`سطح ${formatNumberPersian(index + 1)}`}
className="relative"
/>
))}
</ol>
</div>
</section>
<section aria-labelledby="activity-ladders-title" className="space-y-4">
<div className="max-w-2xl">
<p className="text-xs font-semibold tracking-wide text-primary">دستاوردهای مستقل</p>
<h2 id="activity-ladders-title" className="mt-1 text-2xl font-bold">چهار مسیر برای نشان گرفتن</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">هر مسیر جداگانه پیش می‌رود. با رسیدن به هر حدنصاب، نشان همان مرحله به مجموعه دستاوردهای شما اضافه می‌شود.</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{activityLadders.map((ladder) => {
const Icon = activityIcons[ladder.category];
return (
<Card key={ladder.category} className="overflow-hidden border-border/70 shadow-sm">
<CardContent className="p-0">
<div className="flex items-start justify-between gap-3 border-b border-border/70 bg-muted/20 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h3 className="font-bold">{ladder.title}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{ladder.description}</p>
</div>
</div>
<span className="shrink-0 rounded-full border border-primary/20 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary">
+{formatNumberPersian(ladder.pointsPerAction)} امتیاز
</span>
</div>
<div className="p-4 sm:p-5">
<p className="mb-3 text-xs font-medium text-muted-foreground">نشان‌ها با این تعداد {ladder.unitLabel} باز می‌شوند</p>
<ol className={`grid gap-3 ${ladder.badges.length === 4 ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
{ladder.badges.map((badge) => (
<ProgressionBadge
key={badge.id}
id={badge.id}
label={badge.label}
requirement={`${formatNumberPersian(badge.threshold)} ${ladder.unitLabel}`}
description={badge.description}
size="activity"
/>
))}
</ol>
</div>
</CardContent>
</Card>
);
})}
</div>
</section>
<section aria-labelledby="scoring-title" className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<div className="rounded-2xl border border-border/70 bg-card p-5 sm:p-6">
<p className="text-xs font-semibold tracking-wide text-primary">امتیازها چطور حساب می‌شوند؟</p>
<h2 id="scoring-title" className="mt-1 text-xl font-bold">مشارکت معتبر، امتیاز می‌گیرد</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">این امتیازها سطح کلی را می‌سازند؛ حدنصاب نشان‌های هر مسیر بر اساس تعداد فعالیت همان مسیر است.</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{activityLadders.map((ladder) => {
const Icon = activityIcons[ladder.category];
return (
<div key={ladder.category} className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{ladder.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">هر مورد معتبر: {formatNumberPersian(ladder.pointsPerAction)} امتیاز</p>
</div>
</div>
);
})}
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card px-4 py-3 sm:col-span-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Heart className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">تعامل با نوشته‌ها</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{activityPoints.saveLabel} {activityPoints.selfLikeLabel}
</p>
</div>
</div>
</div>
</section>
<footer className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-muted/20 p-5 text-sm leading-6 text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>{activityPoints.reversalLabel} پرداخت‌ها و مبالغ تراکنش در رتبه‌بندی نقشی ندارند.</p>
<Link href="/profile" className="inline-flex shrink-0 items-center gap-2 font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
مشاهده دستاوردهای من
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</footer>
</div>
</main>
);
}

24
tasks/plan.md Normal file
View File

@@ -0,0 +1,24 @@
# Rank guide and badge art
## Outcome
Replace the profile rank help dialog with a standalone, responsive visual guide at `/rank-guide`. The profile help control opens the guide in a new tab. Show every current overall level and activity badge milestone with its transparent PNG, label, and earning requirement. Profile badge collections continue to show earned badges only.
## Design direction
- Keep the association's teal surface and border tokens, with cobalt, cyan, and gold drawn from the approved glossy medal artwork.
- Lead with a visual five-level progression strip, then show four activity ladders as image-led rows.
- Keep scoring and reversal rules in short supporting blocks; no text is embedded in badge images.
- Make larger overall-level art distinct from activity badges, and keep the layout RTL and responsive.
## Implementation tasks
1. Add an explicit catalog mapping API badge IDs to the 18 generated PNG assets, labels, milestones, and concise explanations.
2. Replace the profile help dialog with an accessible new-tab link to `/rank-guide` and show the user's current overall badge larger in the rank card.
3. Render earned badge artwork in signed-in and public profiles without exposing unearned badges.
4. Build `/rank-guide` with all five overall levels, all activity milestones, scoring rules, and short guidance on reversals.
5. Verify assets have transparency, lint affected components, and run a production build.
## Backend contract
The backend already returns current levels, points per activity, activity badge thresholds, and earned badge IDs. This page uses the existing contract and current rules; it does not require a migration or a new scoring API. Keep frontend asset IDs aligned with the backend's current IDs (`post-*`, singular `comment-*`, `attendance-*`, `likes-*`, and `level-*`).

7
tasks/todo.md Normal file
View File

@@ -0,0 +1,7 @@
# Task checklist
- [x] Create the badge catalog for all 18 generated assets.
- [x] Replace the rank dialog with a new-tab guide link.
- [x] Show generated artwork for earned badges and the current overall level.
- [x] Implement the visual `/rank-guide` page with all current progression rules.
- [x] Verify transparency, lint, and production build.

View File

@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
EVENT_PAGE_SIZE,
getEventLocationLabel,
getPriceCeiling,
parseEventCatalogParams,
serializeEventCatalogParams,
} from "../src/lib/event-catalog.ts";
import {
getEventBrowseLabel,
getEventRegistrationMessage,
} from "../src/lib/event-ui.ts";
const now = Date.parse("2026-09-30T12:00:00.000Z");
function event(overrides = {}) {
return {
status: "published",
event_type: "on_site",
address: "",
location: null,
start_time: "2026-10-01T12:00:00.000Z",
end_time: "2026-10-01T14:00:00.000Z",
registration_start_date: "2026-09-29T12:00:00.000Z",
registration_end_date: "2026-10-01T12:00:00.000Z",
capacity: 20,
registration_count: 8,
...overrides,
};
}
test("uses the agreed Persian status labels for completed, open, and upcoming events", () => {
assert.equal(getEventBrowseLabel(event({ status: "completed" }), now), "برگزار شده");
assert.equal(getEventBrowseLabel(event(), now), "درحال برگزاری");
assert.equal(
getEventBrowseLabel(event({ registration_start_date: "2026-10-01T12:00:00.000Z" }), now),
"به‌زودی",
);
assert.equal(
getEventBrowseLabel(event({ capacity: 8 }), now),
"به‌زودی",
);
});
test("uses a distinct registration message for not started, closed, full, and completed events", () => {
assert.equal(
getEventRegistrationMessage(event({ registration_start_date: "2026-10-01T12:00:00.000Z" }), now),
"ثبت‌نام هنوز آغاز نشده است",
);
assert.equal(
getEventRegistrationMessage(event({ registration_end_date: "2026-09-29T11:59:00.000Z" }), now),
"زمان ثبت‌نام به پایان رسیده است",
);
assert.equal(
getEventRegistrationMessage(event({ capacity: 8 }), now),
"ظرفیت ثبت‌نام تکمیل شده است",
);
assert.equal(
getEventRegistrationMessage(event({ status: "completed" }), now),
"این رویداد برگزار شده است",
);
});
test("rounds the global price maximum up to the next complete million", () => {
assert.equal(getPriceCeiling(650000), 1000000);
assert.equal(getPriceCeiling(4300000), 5000000);
assert.equal(getPriceCeiling(5000000), 5000000);
assert.equal(getPriceCeiling(0), 0);
});
test("always provides a location label and applies the requested fallbacks", () => {
assert.equal(getEventLocationLabel(event({ event_type: "online" })), "برگزاری آنلاین");
assert.equal(getEventLocationLabel(event({ event_type: "online", address: "گوگلمیت" })), "برگزاری آنلاین");
assert.equal(getEventLocationLabel(event()), "محل برگزاری اعلام نشده");
assert.equal(getEventLocationLabel(event({ address: "ساختمان دانشکده" })), "ساختمان دانشکده");
});
test("round-trips shareable catalog filters and defaults to 12 items per page", () => {
const filters = parseEventCatalogParams(new URLSearchParams(
"search=برنامه%20نویسی&state=upcoming&event_type=hybrid&date_from=2026-09-20&date_to=2026-09-30&price_min=100000&price_max=500000&page=3",
), 1000000);
assert.deepEqual(filters, {
search: "برنامه نویسی",
state: "upcoming",
eventType: "hybrid",
dateFrom: "2026-09-20",
dateTo: "2026-09-30",
priceMin: 100000,
priceMax: 500000,
freeOnly: false,
page: 3,
});
assert.deepEqual(
parseEventCatalogParams(serializeEventCatalogParams(filters, 1000000), 1000000),
filters,
);
assert.equal(
serializeEventCatalogParams({
search: "",
state: "all",
eventType: "all",
dateFrom: "",
dateTo: "",
priceMin: 0,
priceMax: 1000000,
freeOnly: false,
page: 1,
}, 1000000).toString(),
"",
);
assert.equal(EVENT_PAGE_SIZE, 12);
});
test("omits price bounds from the URL when free-only is enabled", () => {
const params = serializeEventCatalogParams({
search: "",
state: "upcoming",
eventType: "all",
dateFrom: "",
dateTo: "",
priceMin: 350000,
priceMax: 1000000,
freeOnly: true,
page: 1,
}, 1000000);
assert.equal(params.get("free_only"), "true");
assert.equal(params.has("price_min"), false);
assert.equal(params.has("price_max"), false);
});
test("ignores invalid or reversed query values and uses safe catalog defaults", () => {
assert.deepEqual(
parseEventCatalogParams(new URLSearchParams(
"state=unknown&event_type=other&date_from=2026-10-02&date_to=2026-10-01&price_min=-1&price_max=nope&page=0",
), 500000),
{
search: "",
state: "all",
eventType: "all",
dateFrom: "",
dateTo: "",
priceMin: 0,
priceMax: 500000,
freeOnly: false,
page: 1,
},
);
});

108
tests/event-ui.test.mjs Normal file
View File

@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getEventCardState,
getEventRegistrationState,
classifyRegistrationError,
getPaymentOutcome,
getRegistrationOutcome,
getEventPath,
} from "../src/lib/event-ui.ts";
const now = Date.parse("2026-09-29T12:00:00.000Z");
function event(overrides = {}) {
return {
status: "published",
start_time: "2026-10-01T12:00:00.000Z",
end_time: "2026-10-01T14:00:00.000Z",
registration_start_date: null,
registration_end_date: "2026-09-30T12:00:00.000Z",
capacity: 20,
registration_count: 8,
...overrides,
};
}
test("treats a missing capacity as unlimited", () => {
assert.deepEqual(
getEventRegistrationState(event({ capacity: null }), now),
{ state: "open", remaining: null },
);
});
test("marks an event full when confirmed registrations reach capacity", () => {
assert.deepEqual(
getEventRegistrationState(event({ capacity: 8 }), now),
{ state: "full", remaining: 0 },
);
});
test("uses inclusive registration window boundaries like the backend", () => {
assert.equal(
getEventRegistrationState(event({ registration_start_date: new Date(now).toISOString() }), now).state,
"open",
);
assert.equal(
getEventRegistrationState(event({ registration_end_date: new Date(now).toISOString() }), now).state,
"open",
);
});
test("distinguishes unpublished, not-yet-open, and ended registration", () => {
assert.equal(getEventRegistrationState(event({ status: "completed" }), now).state, "unavailable");
assert.equal(
getEventRegistrationState(event({ registration_start_date: "2026-09-30T12:00:00.000Z" }), now).state,
"not_started",
);
assert.equal(
getEventRegistrationState(event({ registration_end_date: "2026-09-29T11:59:59.000Z" }), now).state,
"closed",
);
});
test("prioritizes an elapsed registration deadline over a future opening date", () => {
assert.equal(
getEventRegistrationState(event({
registration_start_date: "2026-09-30T12:00:00.000Z",
registration_end_date: "2026-09-28T12:00:00.000Z",
}), now).state,
"closed",
);
});
test("labels completed events separately from registration availability", () => {
assert.equal(getEventCardState(event({ status: "completed" }), now), "past");
assert.equal(getEventCardState(event(), now), "upcoming");
assert.equal(getEventCardState(event({ capacity: 8 }), now), "full");
});
test("classifies backend registration errors including Persian duplicate responses", () => {
assert.equal(classifyRegistrationError("شما قبلا در این ایونت ثبت‌نام کرده‌اید."), "already_registered");
assert.equal(classifyRegistrationError("زمان ثبت‌نام هنوز آغاز نشده است"), "not_started");
assert.equal(classifyRegistrationError("مهلت ثبت‌نام به پایان رسیده‌است"), "closed");
assert.equal(classifyRegistrationError("ظرفیت شرکت‌کنندگان تکمیل است"), "full");
assert.equal(classifyRegistrationError("network error"), "unknown");
});
test("keeps callback outcomes distinct and never treats unknown as paid", () => {
assert.equal(getPaymentOutcome("success"), "paid");
assert.equal(getPaymentOutcome("canceled"), "canceled");
assert.equal(getPaymentOutcome("failed"), "failed");
assert.equal(getPaymentOutcome("pending"), "pending");
assert.equal(getPaymentOutcome("unexpected"), "unknown");
});
test("distinguishes confirmed, pending, canceled, and unknown registrations", () => {
assert.equal(getRegistrationOutcome("confirmed"), "confirmed");
assert.equal(getRegistrationOutcome("attended"), "confirmed");
assert.equal(getRegistrationOutcome("pending"), "pending");
assert.equal(getRegistrationOutcome("cancelled"), "canceled");
assert.equal(getRegistrationOutcome("other"), "unknown");
});
test("uses the event id as a detail route when the event has no slug", () => {
assert.equal(getEventPath({ id: 42, slug: "" }), "/events/42");
assert.equal(getEventPath({ id: 42, slug: " " }), "/events/42");
assert.equal(getEventPath({ id: 42, slug: "event-title" }), "/events/event-title");
});

View File

@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { extractMarkdownHeadings } from "../src/lib/markdown-headings.ts";
test("keeps markdown heading IDs tied to their source lines", () => {
assert.deepEqual(
extractMarkdownHeadings("# مقدمه\n\n## موضوع\n## موضوع"),
[
{ id: "مقدمه", level: 1, text: "مقدمه", line: 1 },
{ id: "موضوع", level: 2, text: "موضوع", line: 3 },
{ id: "موضوع-2", level: 2, text: "موضوع", line: 4 },
],
);
});

View 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);
});

View File

@@ -28,7 +28,8 @@
"name": "next"
}
],
"strictNullChecks": true
"strictNullChecks": true,
"allowImportingTsExtensions": true
},
"include": [
"next-env.d.ts",