feat: improve events browsing filters and calendar
This commit is contained in:
BIN
public/images/events-empty-state-quiera.png
Normal file
BIN
public/images/events-empty-state-quiera.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
99
src/components/events/EventCard.tsx
Normal file
99
src/components/events/EventCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
src/components/events/EventDateRangePicker.tsx
Normal file
524
src/components/events/EventDateRangePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/components/events/EventFilters.tsx
Normal file
222
src/components/events/EventFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/events/EventFiltersDrawer.tsx
Normal file
117
src/components/events/EventFiltersDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
411
src/index.css
411
src/index.css
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
106
src/lib/event-catalog.ts
Normal 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
167
src/lib/event-ui.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -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)}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -646,7 +646,16 @@ export interface EventListItemSchema {
|
|||||||
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;
|
||||||
|
|||||||
@@ -1,175 +1,373 @@
|
|||||||
"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">
|
||||||
|
<div dir="rtl" className="mb-4 flex items-center gap-2 sm:mb-5">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search aria-hidden="true" className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="search"
|
||||||
placeholder="جستجو در رویدادها..."
|
placeholder="جستجو در رویدادها…"
|
||||||
aria-label="جستجو در رویدادها"
|
aria-label="جستجو در رویدادها"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="max-w-md"
|
className="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>
|
||||||
|
|
||||||
|
{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 ? (
|
{loading ? (
|
||||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
<EventResultsSkeleton />
|
||||||
) : events.length === 0 ? (
|
) : pageData.results.length ? (
|
||||||
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
|
<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 className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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">
|
||||||
{events.map((event) => (
|
<Image
|
||||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
src="/images/events-empty-state-quiera.png"
|
||||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
alt=""
|
||||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
aria-hidden="true"
|
||||||
<ProgressiveImage
|
width={1489}
|
||||||
src={getEventCardImageUrl(event)}
|
height={1056}
|
||||||
alt={event.title}
|
className="mb-5 h-auto w-full max-w-[300px] object-contain sm:max-w-[420px]"
|
||||||
wrapperClassName="h-full w-full"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="font-semibold">رویدادی با این مشخصات پیدا نشد.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col justify-between">
|
{pageData.total_pages > 1 ? (
|
||||||
<CardHeader>
|
<nav aria-label="صفحهبندی رویدادها" className="mt-7 flex flex-wrap items-center justify-center gap-1.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<Button
|
||||||
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
|
type="button"
|
||||||
<Badge variant="default">{modeFa(event.event_type)}</Badge>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
|
disabled={pageData.page <= 1 || loading}
|
||||||
</CardHeader>
|
onClick={() => changePage(pageData.page - 1)}
|
||||||
|
aria-label="صفحهٔ قبلی"
|
||||||
<CardContent>
|
>
|
||||||
<div className="grid gap-1 text-sm" dir="rtl">
|
قبلی
|
||||||
<div className="flex items-center justify-between">
|
</Button>
|
||||||
<span className="text-muted-foreground">ظرفیت رویداد</span>
|
{pageNumbers.map((page) => typeof page === "number" ? (
|
||||||
<span className="font-medium">
|
<Button
|
||||||
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}
|
key={page}
|
||||||
/
|
type="button"
|
||||||
{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
|
variant={page === pageData.page ? "default" : "outline"}
|
||||||
</span>
|
size="icon"
|
||||||
</div>
|
className="size-9"
|
||||||
<div className="flex items-center justify-between">
|
aria-current={page === pageData.page ? "page" : undefined}
|
||||||
<span className="text-muted-foreground">هزینهی ثبتنام</span>
|
aria-label={`صفحهٔ ${formatNumberPersian(page)}`}
|
||||||
<span className="font-medium">{labelPrice(event)}</span>
|
onClick={() => changePage(page)}
|
||||||
</div>
|
disabled={loading}
|
||||||
{isAvailable(event) ? (
|
>
|
||||||
<Button>جزئیات رویداد</Button>
|
{formatNumberPersian(page)}
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
|
<span key={page} aria-hidden="true" className="px-1 text-muted-foreground">…</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
151
tests/event-catalog.test.mjs
Normal file
151
tests/event-catalog.test.mjs
Normal 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
108
tests/event-ui.test.mjs
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user