feat: improve events browsing filters and calendar
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import Events from "@/views/Events";
|
||||
import { getPublicEvents } from "@/lib/public-api";
|
||||
import { getPublicEventBrowsePage } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { cmsPageMetadata, collectionPageStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
import { getEventPath } from "@/lib/event-ui";
|
||||
import { parseEventCatalogParams } from "@/lib/event-catalog";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -41,8 +43,18 @@ export default async function EventsPage({
|
||||
}) {
|
||||
const resolved = await searchParams;
|
||||
const search = firstString(resolved.search).trim();
|
||||
const [events, content, siteContent] = await Promise.all([
|
||||
getPublicEvents({ search: search || undefined }),
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(resolved).forEach(([key, value]) => {
|
||||
const firstValue = firstString(value);
|
||||
if (firstValue !== "") query.set(key, firstValue);
|
||||
});
|
||||
const requestedFilters = parseEventCatalogParams(query, Number.MAX_SAFE_INTEGER);
|
||||
const [eventPage, content, siteContent] = await Promise.all([
|
||||
getPublicEventBrowsePage({
|
||||
...requestedFilters,
|
||||
priceMin: query.has("price_min") ? requestedFilters.priceMin : undefined,
|
||||
priceMax: query.has("price_max") ? requestedFilters.priceMax : undefined,
|
||||
}),
|
||||
getCmsPageContent("events"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
@@ -51,9 +63,9 @@ export default async function EventsPage({
|
||||
description: content.seo.description || content.intro,
|
||||
path: "/events",
|
||||
siteContent,
|
||||
items: events.filter((event) => event.slug).map((event) => ({
|
||||
items: eventPage.results.map((event) => ({
|
||||
name: event.title,
|
||||
url: new URL(`/events/${encodeURIComponent(event.slug)}`, siteUrl).toString(),
|
||||
url: new URL(getEventPath(event), siteUrl).toString(),
|
||||
description: event.description,
|
||||
})),
|
||||
});
|
||||
@@ -61,7 +73,11 @@ export default async function EventsPage({
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }} />
|
||||
<Events content={content} initialEvents={events} initialSearch={search} />
|
||||
<Events
|
||||
content={content}
|
||||
initialPage={eventPage}
|
||||
initialFilters={parseEventCatalogParams(query, eventPage.price_ceiling)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ export default function ProgressiveImage({
|
||||
}: ProgressiveImageProps) {
|
||||
const resolvedSrc = src || blurSrc || fallbackSrc;
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const imageRef = React.useRef<HTMLImageElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoaded(false);
|
||||
const image = imageRef.current;
|
||||
setLoaded(Boolean(image?.complete && image.naturalWidth > 0));
|
||||
}, [resolvedSrc]);
|
||||
|
||||
return (
|
||||
@@ -54,6 +56,7 @@ export default function ProgressiveImage({
|
||||
)}
|
||||
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={resolvedSrc}
|
||||
alt={alt}
|
||||
sizes={sizes}
|
||||
|
||||
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 {
|
||||
@apply bg-background font-sans text-foreground;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -175,3 +174,413 @@ html.dark a.home-hero-secondary:focus-visible {
|
||||
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' });
|
||||
}
|
||||
|
||||
async browseEvents(params: {
|
||||
state?: 'all' | 'completed' | 'upcoming' | 'open';
|
||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||
search?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
price_min?: number;
|
||||
price_max?: number;
|
||||
free_only?: boolean;
|
||||
page?: number;
|
||||
} = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.state) query.set('state', params.state);
|
||||
if (params.event_type) query.set('event_type', params.event_type);
|
||||
if (params.search?.trim()) query.set('search', params.search.trim());
|
||||
if (params.date_from) query.set('date_from', params.date_from);
|
||||
if (params.date_to) query.set('date_to', params.date_to);
|
||||
if (params.price_min != null) query.set('price_min', String(params.price_min));
|
||||
if (params.price_max != null) query.set('price_max', String(params.price_max));
|
||||
if (params.free_only) query.set('free_only', 'true');
|
||||
if (params.page != null) query.set('page', String(params.page));
|
||||
return this.request<Types.EventBrowsePageSchema>(
|
||||
`/api/events/browse/${query.size ? `?${query.toString()}` : ''}`,
|
||||
{ method: 'GET' },
|
||||
);
|
||||
}
|
||||
|
||||
async getEventBySlug(slug: string) {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/slug/${encodeURIComponent(slug)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
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) {
|
||||
return requestJson<Types.EventDetailSchema>(
|
||||
`/api/events/slug/${encodeURIComponent(slug)}`,
|
||||
|
||||
@@ -622,7 +622,7 @@ export interface BlogFiltersSchema {
|
||||
}
|
||||
|
||||
// Events Types
|
||||
export interface EventListItemSchema {
|
||||
export interface EventListItemSchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
@@ -645,8 +645,17 @@ export interface EventListItemSchema {
|
||||
status_label?: string;
|
||||
event_type_label?: string;
|
||||
registration_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventBrowsePageSchema {
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
price_ceiling: number;
|
||||
results: EventListItemSchema[];
|
||||
}
|
||||
|
||||
export interface EventGalleryItem {
|
||||
id: number;
|
||||
|
||||
@@ -1,176 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { EventFiltersDrawer } from "@/components/events/EventFiltersDrawer";
|
||||
import type { EventBrowsePageSchema } from "@/lib/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatNumberPersian } from "@/lib/utils";
|
||||
import type { EventsContentPage } from "@/lib/content-cms";
|
||||
import {
|
||||
EVENT_PAGE_SIZE,
|
||||
parseEventCatalogParams,
|
||||
serializeEventCatalogParams,
|
||||
type EventCatalogFilters,
|
||||
} from "@/lib/event-catalog";
|
||||
|
||||
type EventsProps = {
|
||||
content: EventsContentPage;
|
||||
initialEvents?: Types.EventListItemSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
|
||||
function labelPrice(event: Types.EventListItemSchema) {
|
||||
const price = Number(event?.price ?? 0);
|
||||
return price <= 0 ? "رایگان" : formatToman(price);
|
||||
function defaultFilters(search: string, priceCeiling: number): EventCatalogFilters {
|
||||
return {
|
||||
search,
|
||||
state: "all",
|
||||
eventType: "all",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
priceMin: 0,
|
||||
priceMax: priceCeiling,
|
||||
freeOnly: false,
|
||||
page: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function modeFa(eventType: Types.EventListItemSchema["event_type"]) {
|
||||
return eventType === "online" ? "آنلاین" : "حضوری";
|
||||
function EventCardSkeleton() {
|
||||
return (
|
||||
<article aria-hidden="true" className="flex h-full min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="aspect-video w-full shrink-0 animate-pulse bg-muted motion-reduce:animate-none" />
|
||||
<div className="flex flex-1 flex-col p-3 md:p-4">
|
||||
<div className="ms-auto h-5 w-4/5 animate-pulse rounded bg-muted motion-reduce:animate-none" />
|
||||
<div className="mt-3 grid gap-3 md:mt-4">
|
||||
{["w-32", "w-40", "w-24", "w-36"].map((width) => (
|
||||
<div key={width} className="flex items-center justify-end gap-2">
|
||||
<div className={`h-3.5 ${width} animate-pulse rounded bg-muted motion-reduce:animate-none`} />
|
||||
<div className="size-4 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto min-h-10 w-full animate-pulse rounded-md bg-muted pt-2 md:mt-5 md:min-h-11 motion-reduce:animate-none" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function spotsLeft(event: Types.EventListItemSchema) {
|
||||
const cap = Number(event.capacity);
|
||||
const used = Number(event.registration_count);
|
||||
return cap - used;
|
||||
function EventResultsSkeleton() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="space-y-3">
|
||||
<span className="sr-only">در حال اعمال فیلترها و دریافت رویدادها…</span>
|
||||
<div aria-hidden="true" className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, index) => <EventCardSkeleton key={index} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isAvailable(event: Types.EventListItemSchema) {
|
||||
const now = new Date();
|
||||
const end = new Date(event.registration_end_date ?? event.start_time);
|
||||
const timeOk = end.getTime() > now.getTime();
|
||||
return timeOk && spotsLeft(event) > 0;
|
||||
function getPageNumbers(total: number, current: number): Array<number | "ellipsis-left" | "ellipsis-right"> {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
const pages: Array<number | "ellipsis-left" | "ellipsis-right"> = [1];
|
||||
if (start > 2) pages.push("ellipsis-left");
|
||||
for (let page = start; page <= end; page += 1) pages.push(page);
|
||||
if (end < total - 1) pages.push("ellipsis-right");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
function notAvailableReasonFa(event: Types.EventListItemSchema) {
|
||||
const now = new Date();
|
||||
const end = new Date(event.registration_end_date ?? event.start_time);
|
||||
if (end.getTime() <= now.getTime()) return "ثبتنام پایانیافته";
|
||||
if (spotsLeft(event) <= 0) return "ظرفیت تکمیل";
|
||||
return "غیرقابل ثبتنام";
|
||||
function toBrowseRequest(filters: EventCatalogFilters) {
|
||||
return {
|
||||
state: filters.state,
|
||||
event_type: filters.eventType === "all" ? undefined : filters.eventType,
|
||||
search: filters.search,
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
price_min: filters.freeOnly ? undefined : filters.priceMin,
|
||||
price_max: filters.freeOnly ? undefined : filters.priceMax,
|
||||
free_only: filters.freeOnly,
|
||||
page: filters.page,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Events({
|
||||
content,
|
||||
initialEvents = [],
|
||||
initialSearch = "",
|
||||
}: EventsProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [events, setEvents] = useState<Types.EventListItemSchema[]>(initialEvents);
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
|
||||
initialPage,
|
||||
initialFilters,
|
||||
}: {
|
||||
content: EventsContentPage;
|
||||
initialPage: EventBrowsePageSchema;
|
||||
initialFilters: EventCatalogFilters;
|
||||
}) {
|
||||
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(() => {
|
||||
setEvents(initialEvents);
|
||||
}, [initialEvents]);
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const nextUrl = query.toString()
|
||||
? `${window.location.pathname}?${query.toString()}`
|
||||
: window.location.pathname;
|
||||
const handlePopState = () => {
|
||||
const restored = parseEventCatalogParams(
|
||||
new URLSearchParams(window.location.search),
|
||||
pageData.price_ceiling,
|
||||
);
|
||||
setFilters(restored);
|
||||
setDraftFilters(restored);
|
||||
setSearch(restored.search);
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getEvents({
|
||||
search: search || undefined,
|
||||
statuses: ["published", "completed"],
|
||||
limit: 30,
|
||||
});
|
||||
setEvents(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading events:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Keep the route query canonical while preserving Next.js' router state.
|
||||
if (nextUrl !== `${window.location.pathname}${window.location.search}`) {
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}, [pageData.price_ceiling]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setFilters((current) => {
|
||||
const normalizedSearch = search.trim();
|
||||
if (current.search === normalizedSearch) return current;
|
||||
return { ...current, search: normalizedSearch, page: 1 };
|
||||
});
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
const query = serializeEventCatalogParams(filters, pageData.price_ceiling);
|
||||
const nextUrl = query.size
|
||||
? `${window.location.pathname}?${query.toString()}`
|
||||
: window.location.pathname;
|
||||
if (nextUrl !== `${window.location.pathname}${window.location.search}`) {
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
}, [filters, pageData.price_ceiling]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) {
|
||||
params.set("search", search.trim());
|
||||
if (skipFirstFetch.current) {
|
||||
skipFirstFetch.current = false;
|
||||
return;
|
||||
}
|
||||
const basePath = location.pathname || "/events";
|
||||
const nextPath = params.size
|
||||
? `${basePath}?${params.toString()}`
|
||||
: basePath;
|
||||
navigate(nextPath, { replace: true });
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
api.browseEvents(toBrowseRequest(filters))
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setPageData(response);
|
||||
setFilters((current) => {
|
||||
const nextPage = response.page;
|
||||
const nextPriceMax = current.priceMax >= pageData.price_ceiling
|
||||
? response.price_ceiling
|
||||
: current.priceMax;
|
||||
if (current.page === nextPage && current.priceMax === nextPriceMax) return current;
|
||||
return { ...current, page: nextPage, priceMax: nextPriceMax };
|
||||
});
|
||||
setDraftFilters((current) => {
|
||||
const nextPriceMax = current.priceMax >= pageData.price_ceiling
|
||||
? response.price_ceiling
|
||||
: current.priceMax;
|
||||
return current.priceMax === nextPriceMax ? current : { ...current, priceMax: nextPriceMax };
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLoadError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [filters, pageData.price_ceiling, retryToken]);
|
||||
|
||||
const updateFilters = useCallback((patch: Partial<EventCatalogFilters>) => {
|
||||
const mergePatch = (current: EventCatalogFilters): EventCatalogFilters => ({
|
||||
...current,
|
||||
...patch,
|
||||
search: search.trim(),
|
||||
page: 1,
|
||||
});
|
||||
setDraftFilters(mergePatch);
|
||||
}, [search]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
const reset = defaultFilters(search.trim(), pageData.price_ceiling);
|
||||
setDraftFilters(reset);
|
||||
setFilters(reset);
|
||||
setDrawerOpen(false);
|
||||
}, [pageData.price_ceiling, search]);
|
||||
|
||||
const handleDrawerChange = (open: boolean) => {
|
||||
if (open) setDraftFilters(filters);
|
||||
setDrawerOpen(open);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
setFilters({ ...draftFilters, search: search.trim(), page: 1 });
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.state !== "all",
|
||||
filters.eventType !== "all",
|
||||
Boolean(filters.dateFrom && filters.dateTo),
|
||||
filters.freeOnly,
|
||||
!filters.freeOnly && (filters.priceMin > 0 || filters.priceMax < pageData.price_ceiling),
|
||||
].filter(Boolean).length;
|
||||
|
||||
const pageNumbers = useMemo(
|
||||
() => getPageNumbers(pageData.total_pages, pageData.page),
|
||||
[pageData.page, pageData.total_pages],
|
||||
);
|
||||
|
||||
const changePage = (page: number) => {
|
||||
if (page < 1 || page > pageData.total_pages || page === filters.page) return;
|
||||
setFilters((current) => ({ ...current, page }));
|
||||
window.setTimeout(() => {
|
||||
resultsRef.current?.scrollIntoView({
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="mb-3 text-4xl font-bold">{content.title}</h1>
|
||||
{content.intro ? <p className="mb-8 max-w-3xl leading-8 text-muted-foreground">{content.intro}</p> : null}
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto max-w-screen-2xl px-3 py-5 sm:px-4 sm:py-8 lg:px-6">
|
||||
<header className="mb-6 border-b border-border pb-5 sm:mb-8 sm:pb-6">
|
||||
<h1 className="text-2xl font-bold leading-tight tracking-tight sm:text-3xl">{content.title}</h1>
|
||||
{content.intro ? <p className="mt-2 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base">{content.intro}</p> : null}
|
||||
</header>
|
||||
|
||||
<div className="mb-8">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در رویدادها..."
|
||||
aria-label="جستجو در رویدادها"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{events.map((event) => (
|
||||
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
|
||||
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
<ProgressiveImage
|
||||
src={getEventCardImageUrl(event)}
|
||||
alt={event.title}
|
||||
wrapperClassName="h-full w-full"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
|
||||
<Badge variant="default">{modeFa(event.event_type)}</Badge>
|
||||
</div>
|
||||
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="grid gap-1 text-sm" dir="rtl">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">ظرفیت رویداد</span>
|
||||
<span className="font-medium">
|
||||
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}
|
||||
/
|
||||
{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">هزینهی ثبتنام</span>
|
||||
<span className="font-medium">{labelPrice(event)}</span>
|
||||
</div>
|
||||
{isAvailable(event) ? (
|
||||
<Button>جزئیات رویداد</Button>
|
||||
) : (
|
||||
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<section aria-label="نتایج رویدادها" className="min-w-0 lg:flex lg:flex-col">
|
||||
<div dir="rtl" className="mb-4 flex items-center gap-2 sm:mb-5">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search aria-hidden="true" className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="جستجو در رویدادها…"
|
||||
aria-label="جستجو در رویدادها"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
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>
|
||||
</main>
|
||||
|
||||
{loadError ? (
|
||||
<div role="alert" className="mb-4 flex flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">دریافت رویدادها با خطا روبهرو شد. اتصال را بررسی کنید و دوباره تلاش کنید.</p>
|
||||
<Button type="button" variant="outline" onClick={() => setRetryToken((value) => value + 1)} disabled={loading}>
|
||||
تلاش دوباره
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={resultsRef} className="scroll-mt-24 lg:flex-1" aria-busy={loading}>
|
||||
{loading ? (
|
||||
<EventResultsSkeleton />
|
||||
) : pageData.results.length ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{pageData.results.map((event) => <EventCard key={event.id} event={event} now={now} />)}
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="rounded-xl border border-border bg-card px-5 py-12 text-center">
|
||||
<p className="font-semibold">رویدادها بارگذاری نشدند</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">بعد از برقراری اتصال، دوباره تلاش کنید.</p>
|
||||
<Button type="button" className="mt-5" onClick={() => setRetryToken((value) => value + 1)} disabled={loading}>تلاش دوباره</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div role="status" className="flex min-h-[22rem] flex-col items-center justify-center rounded-xl border border-border bg-card px-5 py-10 text-center sm:py-12 lg:h-full">
|
||||
<Image
|
||||
src="/images/events-empty-state-quiera.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={1489}
|
||||
height={1056}
|
||||
className="mb-5 h-auto w-full max-w-[300px] object-contain sm:max-w-[420px]"
|
||||
/>
|
||||
<p className="font-semibold">رویدادی با این مشخصات پیدا نشد.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageData.total_pages > 1 ? (
|
||||
<nav aria-label="صفحهبندی رویدادها" className="mt-7 flex flex-wrap items-center justify-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageData.page <= 1 || loading}
|
||||
onClick={() => changePage(pageData.page - 1)}
|
||||
aria-label="صفحهٔ قبلی"
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
{pageNumbers.map((page) => typeof page === "number" ? (
|
||||
<Button
|
||||
key={page}
|
||||
type="button"
|
||||
variant={page === pageData.page ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="size-9"
|
||||
aria-current={page === pageData.page ? "page" : undefined}
|
||||
aria-label={`صفحهٔ ${formatNumberPersian(page)}`}
|
||||
onClick={() => changePage(page)}
|
||||
disabled={loading}
|
||||
>
|
||||
{formatNumberPersian(page)}
|
||||
</Button>
|
||||
) : (
|
||||
<span key={page} aria-hidden="true" className="px-1 text-muted-foreground">…</span>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageData.page >= pageData.total_pages || loading}
|
||||
onClick={() => changePage(pageData.page + 1)}
|
||||
aria-label="صفحهٔ بعدی"
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<span className="sr-only">{formatNumberPersian(EVENT_PAGE_SIZE)} رویداد در هر صفحه</span>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user