151 lines
4.3 KiB
TypeScript
151 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { FILTERS } from "@/lib/ui-config";
|
|
import type { AppState, Dispatch } from "@/lib/app-state";
|
|
import { Header, IconBtn, OfflineBanner, EmptyState } from "@/components/ui-primitives";
|
|
import { PlaceCard } from "@/components/place-card";
|
|
import { Icons } from "@/components/icons";
|
|
|
|
export function PlacesListScreen({
|
|
state,
|
|
dispatch,
|
|
}: {
|
|
state: AppState;
|
|
dispatch: Dispatch;
|
|
}) {
|
|
const { filter, search, places, offline } = state;
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
|
|
const filtered = useMemo(() => {
|
|
let out = places;
|
|
if (filter !== "all") {
|
|
if (filter === "visited") out = out.filter((p) => p.visited);
|
|
else if (filter === "unvisited") out = out.filter((p) => !p.visited);
|
|
else out = out.filter((p) => p.category === filter);
|
|
}
|
|
if (search.trim()) {
|
|
const s = search.toLowerCase();
|
|
out = out.filter(
|
|
(p) =>
|
|
p.name.toLowerCase().includes(s) ||
|
|
p.address.toLowerCase().includes(s) ||
|
|
p.tags.some((t) => t.toLowerCase().includes(s)),
|
|
);
|
|
}
|
|
return out;
|
|
}, [filter, search, places]);
|
|
|
|
return (
|
|
<div className="app-surface">
|
|
<Header
|
|
big
|
|
title="Địa điểm"
|
|
subtitle={`${places.length} chỗ đã lưu · ${places.filter((p) => p.visited).length} đã đến`}
|
|
right={
|
|
<IconBtn
|
|
icon="Search"
|
|
label="Tìm"
|
|
onClick={() => setSearchOpen((v) => !v)}
|
|
variant="muted"
|
|
/>
|
|
}
|
|
/>
|
|
{offline && <OfflineBanner />}
|
|
{searchOpen && (
|
|
<div style={{ padding: "0 16px 12px" }}>
|
|
<div className="input" style={{ height: 44 }}>
|
|
<Icons.Search
|
|
size={18}
|
|
stroke={1.75}
|
|
style={{ color: "var(--muted-foreground)" }}
|
|
/>
|
|
<input
|
|
autoFocus
|
|
placeholder="Tên quán, địa chỉ, tag..."
|
|
value={search}
|
|
onChange={(e) =>
|
|
dispatch({ type: "SET_SEARCH", value: e.target.value })
|
|
}
|
|
/>
|
|
{search && (
|
|
<button
|
|
type="button"
|
|
onClick={() => dispatch({ type: "SET_SEARCH", value: "" })}
|
|
style={{
|
|
background: "transparent",
|
|
border: 0,
|
|
color: "var(--muted-foreground)",
|
|
}}
|
|
>
|
|
<Icons.X size={16} stroke={2} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="no-scrollbar"
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
padding: "0 16px 12px",
|
|
overflowX: "auto",
|
|
overflowY: "hidden",
|
|
scrollSnapType: "x proximity",
|
|
}}
|
|
>
|
|
{FILTERS.map((f) => (
|
|
<button
|
|
key={f.id}
|
|
className="pill"
|
|
data-active={filter === f.id}
|
|
onClick={() => dispatch({ type: "SET_FILTER", value: f.id })}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="app-scroll">
|
|
{filtered.length === 0 ? (
|
|
<EmptyState
|
|
icon={search ? "Search" : "MapPin"}
|
|
title={search ? "Không tìm thấy gì" : "Chưa có địa điểm nào"}
|
|
body={
|
|
search
|
|
? `Không có địa điểm nào khớp với "${search}".`
|
|
: "Bấm nút + để lưu địa điểm đầu tiên — quán cà phê quen, chỗ ăn ngon, hay nơi muốn đến."
|
|
}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 10,
|
|
padding: "4px 16px 24px",
|
|
}}
|
|
>
|
|
{filtered.map((p, i) => (
|
|
<div
|
|
key={p.id}
|
|
className="page-enter"
|
|
style={{ animationDelay: `${i * 18}ms` }}
|
|
>
|
|
<PlaceCard
|
|
place={p}
|
|
onTap={() =>
|
|
dispatch({ type: "NAV", screen: "place", placeId: p.id })
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|