feat: improve events browsing filters and calendar

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

View File

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