feat: CulturalSpaceExplorer lọc theo cấp xếp hạng + loại di tích

This commit is contained in:
tuyenio
2026-06-23 11:11:50 +07:00
parent 735e44b158
commit fbf058c45f
@@ -0,0 +1,180 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, Search, SlidersHorizontal } from "lucide-react";
import { useMemo, useState } from "react";
import type { HeritageSite } from "@/app/content";
const statusFilters = ["Tất cả", "Đang mở tour", "Đang bổ sung tư liệu"] as const;
const levelFilters = ["Tất cả", "Cấp quốc gia", "Cấp tỉnh"] as const;
const typeFilters = ["Tất cả", "Đình", "Chùa", "Đền", "Văn miếu", "Di tích khác"] as const;
type StatusFilter = (typeof statusFilters)[number];
type LevelFilter = (typeof levelFilters)[number];
type TypeFilter = (typeof typeFilters)[number];
export function CulturalSpaceExplorer({ sites }: { sites: HeritageSite[] }) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("Tất cả");
const [level, setLevel] = useState<LevelFilter>("Tất cả");
const [type, setType] = useState<TypeFilter>("Tất cả");
const filteredSites = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase("vi-VN");
return sites.filter((site) => {
const matchesQuery =
normalizedQuery.length === 0 ||
site.name.toLocaleLowerCase("vi-VN").includes(normalizedQuery) ||
site.type.toLocaleLowerCase("vi-VN").includes(normalizedQuery) ||
site.shortDescription.toLocaleLowerCase("vi-VN").includes(normalizedQuery);
const matchesStatus = status === "Tất cả" || site.status === status;
const matchesLevel = level === "Tất cả" || site.level === level;
const matchesType = type === "Tất cả" || site.type === type;
return matchesQuery && matchesStatus && matchesLevel && matchesType;
});
}, [query, sites, status, level, type]);
return (
<section className="mx-auto max-w-7xl px-4 py-14 sm:px-6 lg:py-20">
<div className="public-panel rounded-[8px] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto_auto] lg:items-end">
<label className="grid gap-2">
<span className="text-sm font-bold text-[var(--tour-ink)]">Tìm kiếm di tích</span>
<span className="relative">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--primary)]" strokeWidth={1.8} />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Nhập tên di tích, loại địa điểm hoặc từ khóa"
className="h-12 w-full rounded-[8px] border border-[var(--surface-border)] bg-white/82 pl-11 pr-4 text-sm font-semibold text-[var(--tour-ink)] outline-none transition focus:border-[var(--primary)] focus:ring-4 focus:ring-[rgb(142_95_11_/_0.12)]"
/>
</span>
</label>
<FilterGroup
label="Trạng thái"
options={statusFilters}
value={status}
onChange={setStatus}
/>
<FilterGroup label="Cấp xếp hạng" options={levelFilters} value={level} onChange={setLevel} />
<FilterGroup
label="Loại địa điểm"
options={typeFilters}
value={type}
onChange={setType}
/>
</div>
</div>
<div className="mt-8 flex items-center justify-between gap-4">
<p className="text-sm font-bold text-[var(--muted-foreground)]">
Hiển thị {filteredSites.length} / {sites.length} đa điểm
</p>
<button
type="button"
onClick={() => {
setQuery("");
setStatus("Tất cả");
setLevel("Tất cả");
setType("Tất cả");
}}
className="text-sm font-bold text-[var(--tour-ink)] transition hover:text-[var(--primary)]"
>
Xóa bộ lọc
</button>
</div>
{filteredSites.length > 0 ? (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{filteredSites.map((site) => (
<article
id={site.id}
key={site.id}
className="public-card public-stagger-item group rounded-[8px]"
>
<div className="relative aspect-[16/11] overflow-hidden">
<Image
src={site.image}
alt={site.name}
fill
sizes="(max-width: 768px) 100vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.025]"
/>
</div>
<div className="flex min-h-[19rem] flex-col p-5">
<span
className={`w-fit rounded-full border px-3 py-1 text-xs font-extrabold ${
site.isAvailable
? "border-[rgb(14_52_39_/_0.18)] bg-[rgb(14_92_62_/_0.12)] text-[var(--tour-ink)]"
: "border-[rgb(142_95_11_/_0.18)] bg-[rgb(142_95_11_/_0.1)] text-[var(--primary)]"
}`}
>
{site.status}
</span>
<p className="mt-4 text-sm font-bold text-[var(--primary)]">{site.type}</p>
<h2 className="mt-2 text-xl font-extrabold leading-tight text-[var(--tour-ink)]">
{site.name}
</h2>
<p className="mt-3 text-sm leading-6 text-[var(--muted-foreground)]">
{site.shortDescription}
</p>
<Link href={site.isAvailable ? site.vrUrl ?? site.detailUrl : site.detailUrl} className="mt-auto inline-flex items-center gap-2 pt-5 text-sm font-extrabold text-[var(--tour-ink)] transition hover:text-[var(--primary)]">
{site.isAvailable ? "Khám phá VR360" : "Xem thông tin"}
<ArrowRight className="h-4 w-4" strokeWidth={1.8} />
</Link>
</div>
</article>
))}
</div>
) : (
<div className="public-panel mt-6 rounded-[8px] p-8 text-center">
<SlidersHorizontal className="mx-auto h-8 w-8 text-[var(--primary)]" strokeWidth={1.8} />
<p className="mt-4 text-lg font-bold text-[var(--tour-ink)]">
Không tìm thấy di tích phù hợp. Vui lòng thử lại với từ khóa khác.
</p>
</div>
)}
</section>
);
}
function FilterGroup<T extends readonly string[]>({
label,
options,
value,
onChange,
}: {
label: string;
options: T;
value: T[number];
onChange: (value: T[number]) => void;
}) {
return (
<div className="grid gap-2">
<span className="text-sm font-bold text-[var(--tour-ink)]">{label}</span>
<div className="flex max-w-full gap-2 overflow-x-auto pb-1 [scrollbar-width:none]">
{options.map((option) => (
<button
key={option}
type="button"
onClick={() => onChange(option)}
className={`h-12 shrink-0 rounded-full border px-4 text-sm font-extrabold transition active:scale-[0.98] ${
value === option
? "border-[var(--primary)] bg-[var(--tour-ink)] text-[var(--primary-foreground)]"
: "border-[var(--surface-border)] bg-white/76 text-[var(--tour-ink)] hover:border-[var(--primary)]/40"
}`}
>
{option}
</button>
))}
</div>
</div>
);
}