feat: improve events browsing filters and calendar

This commit is contained in:
2026-09-30 17:55:45 +03:30
parent 22cbc0b3fc
commit 61bf510a9c
16 changed files with 2337 additions and 154 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

View File

@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Events from "@/views/Events"; import Events from "@/views/Events";
import { getPublicEvents } from "@/lib/public-api"; import { getPublicEventBrowsePage } from "@/lib/public-api";
import { siteUrl } from "@/lib/site"; import { siteUrl } from "@/lib/site";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms"; import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
import { cmsPageMetadata, collectionPageStructuredData, safeJsonLd } from "@/lib/seo"; 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>>; type SearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -41,8 +43,18 @@ export default async function EventsPage({
}) { }) {
const resolved = await searchParams; const resolved = await searchParams;
const search = firstString(resolved.search).trim(); const search = firstString(resolved.search).trim();
const [events, content, siteContent] = await Promise.all([ const query = new URLSearchParams();
getPublicEvents({ search: search || undefined }), 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"), getCmsPageContent("events"),
getCmsSiteContent(), getCmsSiteContent(),
]); ]);
@@ -51,9 +63,9 @@ export default async function EventsPage({
description: content.seo.description || content.intro, description: content.seo.description || content.intro,
path: "/events", path: "/events",
siteContent, siteContent,
items: events.filter((event) => event.slug).map((event) => ({ items: eventPage.results.map((event) => ({
name: event.title, name: event.title,
url: new URL(`/events/${encodeURIComponent(event.slug)}`, siteUrl).toString(), url: new URL(getEventPath(event), siteUrl).toString(),
description: event.description, description: event.description,
})), })),
}); });
@@ -61,7 +73,11 @@ export default async function EventsPage({
return ( return (
<> <>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }} /> <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)}
/>
</> </>
); );
} }

View File

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

View File

@@ -0,0 +1,99 @@
"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 {
getEventCardState,
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 isPast = getEventCardState(event, now) === "past";
const isComingSoon = statusLabel === "به‌زودی";
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 ${isPast || isComingSoon ? "bg-muted text-muted-foreground group-hover:bg-muted/80 dark:bg-primary/15 dark:text-primary dark:group-hover:bg-primary/20" : "bg-primary text-primary-foreground group-hover:bg-primary/90"}`}>
{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" />
فیلترها
{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

@@ -125,7 +125,6 @@ All colors MUST be HSL.
body { body {
@apply bg-background font-sans text-foreground; @apply bg-background font-sans text-foreground;
min-width: 320px;
} }
:focus-visible { :focus-visible {
@@ -175,3 +174,413 @@ html.dark a.home-hero-secondary:focus-visible {
padding-block: 1.5rem; 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;
}
.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;
}
@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

@@ -846,6 +846,33 @@ class ApiClient {
return this.request<Types.EventListItemSchema[]>(url, { method: 'GET' }); 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) { async getEventBySlug(slug: string) {
return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' }); return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' });
} }

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

@@ -136,6 +136,33 @@ 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) { export async function getPublicEventBySlug(slug: string) {
return requestJson<Types.EventDetailSchema>( return requestJson<Types.EventDetailSchema>(
`/api/events/slug/${encodeURIComponent(slug)}`, `/api/events/slug/${encodeURIComponent(slug)}`,

View File

@@ -622,7 +622,7 @@ export interface BlogFiltersSchema {
} }
// Events Types // Events Types
export interface EventListItemSchema { export interface EventListItemSchema {
id: number; id: number;
title: string; title: string;
slug: string; slug: string;
@@ -645,8 +645,17 @@ export interface EventListItemSchema {
status_label?: string; status_label?: string;
event_type_label?: string; event_type_label?: string;
registration_count: number; 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 { export interface EventGalleryItem {
id: number; id: number;

View File

@@ -1,176 +1,374 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router"; import Image from "next/image";
import { api } from "@/lib/api"; import { Search } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ProgressiveImage from "@/components/ProgressiveImage"; import { Input } from "@/components/ui/input";
import type * as Types from "@/lib/types"; import { EventCard } from "@/components/events/EventCard";
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils"; 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 type { EventsContentPage } from "@/lib/content-cms";
import {
EVENT_PAGE_SIZE,
parseEventCatalogParams,
serializeEventCatalogParams,
type EventCatalogFilters,
} from "@/lib/event-catalog";
type EventsProps = { function defaultFilters(search: string, priceCeiling: number): EventCatalogFilters {
content: EventsContentPage; return {
initialEvents?: Types.EventListItemSchema[]; search,
initialSearch?: string; state: "all",
}; eventType: "all",
dateFrom: "",
function labelPrice(event: Types.EventListItemSchema) { dateTo: "",
const price = Number(event?.price ?? 0); priceMin: 0,
return price <= 0 ? "رایگان" : formatToman(price); priceMax: priceCeiling,
freeOnly: false,
page: 1,
};
} }
function modeFa(eventType: Types.EventListItemSchema["event_type"]) { function EventCardSkeleton() {
return eventType === "online" ? "آنلاین" : "حضوری"; 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) { function EventResultsSkeleton() {
const cap = Number(event.capacity); return (
const used = Number(event.registration_count); <div role="status" aria-live="polite" className="space-y-3">
return cap - used; <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) { function getPageNumbers(total: number, current: number): Array<number | "ellipsis-left" | "ellipsis-right"> {
const now = new Date(); if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
const end = new Date(event.registration_end_date ?? event.start_time);
const timeOk = end.getTime() > now.getTime(); const start = Math.max(2, current - 1);
return timeOk && spotsLeft(event) > 0; 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) { function toBrowseRequest(filters: EventCatalogFilters) {
const now = new Date(); return {
const end = new Date(event.registration_end_date ?? event.start_time); state: filters.state,
if (end.getTime() <= now.getTime()) return "ثبت‌نام پایان‌یافته"; event_type: filters.eventType === "all" ? undefined : filters.eventType,
if (spotsLeft(event) <= 0) return "ظرفیت تکمیل"; search: filters.search,
return "غیرقابل ثبت‌نام"; 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({ export default function Events({
content, content,
initialEvents = [], initialPage,
initialSearch = "", initialFilters,
}: EventsProps) { }: {
const navigate = useNavigate(); content: EventsContentPage;
const location = useLocation(); initialPage: EventBrowsePageSchema;
const [events, setEvents] = useState<Types.EventListItemSchema[]>(initialEvents); initialFilters: EventCatalogFilters;
const [search, setSearch] = useState(initialSearch); }) {
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch); 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 resultsRef = useRef<HTMLDivElement>(null);
const skipFirstFetch = useRef(true);
useEffect(() => { useEffect(() => {
setEvents(initialEvents); const query = new URLSearchParams(window.location.search);
}, [initialEvents]); 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(() => { // Keep the route query canonical while preserving Next.js' router state.
setSearch(initialSearch); if (nextUrl !== `${window.location.pathname}${window.location.search}`) {
}, [initialSearch]); window.history.replaceState(null, "", nextUrl);
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);
} }
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]); }, [search]);
useEffect(() => { useEffect(() => {
loadEvents(); const query = serializeEventCatalogParams(filters, pageData.price_ceiling);
}, [loadEvents]); 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(() => { useEffect(() => {
const params = new URLSearchParams(); if (skipFirstFetch.current) {
if (search.trim()) { skipFirstFetch.current = false;
params.set("search", search.trim()); return;
} }
const basePath = location.pathname || "/events";
const nextPath = params.size let active = true;
? `${basePath}?${params.toString()}` setLoading(true);
: basePath; setLoadError(false);
navigate(nextPath, { replace: true }); const timer = window.setTimeout(() => {
}, [location.pathname, navigate, search]); 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 ( return (
<main className="min-h-screen bg-background" dir="rtl"> <main className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto max-w-screen-2xl px-3 py-5 sm:px-4 sm:py-8 lg:px-6">
<h1 className="mb-3 text-4xl font-bold">{content.title}</h1> <header className="mb-6 border-b border-border pb-5 sm:mb-8 sm:pb-6">
{content.intro ? <p className="mb-8 max-w-3xl leading-8 text-muted-foreground">{content.intro}</p> : null} <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>
<div className="mb-8"> <section aria-label="نتایج رویدادها" className="min-w-0 lg:flex lg:flex-col">
<Input <div dir="rtl" className="mb-4 flex items-center gap-2 sm:mb-5">
type="text" <div className="relative min-w-0 flex-1">
placeholder="جستجو در رویدادها..." <Search aria-hidden="true" className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
aria-label="جستجو در رویدادها" <Input
value={search} type="search"
onChange={(e) => setSearch(e.target.value)} placeholder="جستجو در رویدادها…"
className="max-w-md" aria-label="جستجو در رویدادها"
/> value={search}
</div> onChange={(event) => setSearch(event.target.value)}
className="h-11 pl-10 pr-3 focus-visible:ring-0 focus-visible:ring-offset-0"
{loading ? ( />
<p className="text-center text-muted-foreground">در حال بارگذاری...</p> </div>
) : events.length === 0 ? ( <EventFiltersDrawer
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p> open={drawerOpen}
) : ( filters={draftFilters}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> priceCeiling={pageData.price_ceiling}
{events.map((event) => ( activeFilterCount={activeFilterCount}
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full"> disabled={loading}
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow"> onOpenChange={handleDrawerChange}
<div className="w-full aspect-video overflow-hidden rounded-lg"> onChange={updateFilters}
<ProgressiveImage onReset={resetFilters}
src={getEventCardImageUrl(event)} onApply={applyFilters}
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>
))}
</div> </div>
)}
</div> {loadError ? (
</main> <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

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