feat: improve events browsing filters and calendar
This commit is contained in:
@@ -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