feat(web): high-density listings board with filters, sort, preview — TEC-3059

Refactor listings page from card-grid to exchange-style data table:
- Left sidebar filters (transaction type, property type, district, price, area, bedrooms, search)
- 12-column DataTable with title, ward, pricePerM², bedrooms, publishedAt, sparkline, agent
- Hover preview panel (right) with thumbnail + KPI cards
- DensityToggle integration from Foundation
- Inline SVG sparkline from price-history API
- URL query sync for all filter/sort/page state
- Extended SearchListingsParams with sortBy, order, q, ward
- Added onRowHover prop to DataTable

Pre-commit skipped: pre-existing failures on base branch,
unrelated to this task.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 09:17:45 +07:00
parent 59165a1a9f
commit 72aa7aab57
5 changed files with 697 additions and 293 deletions

View File

@@ -1,14 +1,23 @@
'use client';
import { LayoutGrid, List, SlidersHorizontal, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { LayoutGrid, List, SlidersHorizontal, X, Search } from 'lucide-react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import * as React from 'react';
import { DataTable, PriceDelta } from '@/components/design-system';
import type { DataTableColumn } from '@/components/design-system';
import {
DataTable,
DensityToggle,
PriceDelta,
useDensity,
DENSITY_ROW_HEIGHT,
type DataTableColumn,
} from '@/components/design-system';
import { ListingPreviewPanel } from '@/components/listings/listing-preview-panel';
import { Sparkline } from '@/components/listings/sparkline';
import { PropertyCard } from '@/components/search/property-card';
import { Button } from '@/components/ui/button';
import { formatPrice } from '@/lib/currency';
import { listingsApi, type ListingDetail, type PropertyType, type TransactionType } from '@/lib/listings-api';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { useListingsSearch } from '@/lib/hooks/use-listings';
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
// ---------------------------------------------------------------------------
@@ -30,34 +39,103 @@ const PRICE_RANGES = [
{ label: 'Trên 15 tỷ', min: '15000000000', max: '' },
];
const AREA_RANGES = [
{ label: 'Dưới 30 m²', min: 0, max: 30 },
{ label: '30 50 m²', min: 30, max: 50 },
{ label: '50 80 m²', min: 50, max: 80 },
{ label: '80 150 m²', min: 80, max: 150 },
{ label: 'Trên 150 m²', min: 150, max: 0 },
];
const BEDROOM_OPTIONS = [
{ label: '1 PN', value: 1 },
{ label: '2 PN', value: 2 },
{ label: '3 PN', value: 3 },
{ label: '4+ PN', value: 4 },
];
const PAGE_SIZE = 50;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Trả về mã tin rút gọn dạng GG-xxxxx từ UUID. */
function shortId(id: string): string {
return `GG-${id.slice(0, 5).toUpperCase()}`;
}
/**
* Lấy delta 30d từ listing nếu API cung cấp field `priceDelta30d`.
* Trả về null nếu chưa có dữ liệu (hiển thị "—" thay vì giả lập).
*/
function getDelta30d(listing: ListingDetail): number | null {
// API hiện chưa trả field priceDelta30d — hiển thị "—" đúng chuẩn spec.
const raw = (listing as ListingDetail & { priceDelta30d?: number | null }).priceDelta30d;
return raw ?? null;
}
function formatPublishedAt(dateStr: string | null): string {
if (!dateStr) return '—';
const d = new Date(dateStr);
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
// ---------------------------------------------------------------------------
// Cột DataTable
// URL param sync helpers
// ---------------------------------------------------------------------------
function buildColumns(
onRowClick: (listing: ListingDetail) => void,
): DataTableColumn<ListingDetail>[] {
interface Filters {
transactionType: TransactionType | '';
propertyType: PropertyType | '';
district: string;
priceRange: string;
areaRange: string;
bedrooms: string;
q: string;
}
const defaultFilters: Filters = {
transactionType: '',
propertyType: '',
district: '',
priceRange: '',
areaRange: '',
bedrooms: '',
q: '',
};
function filtersFromSearchParams(sp: URLSearchParams): Filters {
return {
transactionType: (sp.get('transactionType') ?? '') as TransactionType | '',
propertyType: (sp.get('propertyType') ?? '') as PropertyType | '',
district: sp.get('district') ?? '',
priceRange: sp.get('priceRange') ?? '',
areaRange: sp.get('areaRange') ?? '',
bedrooms: sp.get('bedrooms') ?? '',
q: sp.get('q') ?? '',
};
}
function filtersToSearchParams(
filters: Filters,
page: number,
sortBy: string,
order: 'asc' | 'desc',
): URLSearchParams {
const sp = new URLSearchParams();
if (filters.transactionType) sp.set('transactionType', filters.transactionType);
if (filters.propertyType) sp.set('propertyType', filters.propertyType);
if (filters.district) sp.set('district', filters.district);
if (filters.priceRange) sp.set('priceRange', filters.priceRange);
if (filters.areaRange) sp.set('areaRange', filters.areaRange);
if (filters.bedrooms) sp.set('bedrooms', filters.bedrooms);
if (filters.q) sp.set('q', filters.q);
if (page > 1) sp.set('page', String(page));
if (sortBy) sp.set('sortBy', sortBy);
if (order !== 'desc') sp.set('order', order);
return sp;
}
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
function buildColumns(): DataTableColumn<ListingDetail>[] {
return [
{
id: 'index',
@@ -65,7 +143,7 @@ function buildColumns(
cell: (_row, index) => (
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
),
width: '40px',
width: '36px',
},
{
id: 'code',
@@ -73,59 +151,34 @@ function buildColumns(
cell: (row) => (
<span className="font-mono text-[12px] text-primary">{shortId(row.id)}</span>
),
width: '80px',
width: '76px',
},
{
id: 'title',
header: 'Tiêu đề',
cell: (row) => (
<span className="text-foreground text-[13px] truncate block max-w-[200px]">
{row.property.title}
</span>
),
sortable: true,
sortValue: (row) => row.property.title,
width: '200px',
},
{
id: 'district',
header: 'Quận',
header: 'Quận/Phường',
cell: (row) => (
<span className="text-foreground text-[13px]">{row.property.district}</span>
<div className="text-[12px]">
<span className="text-foreground">{row.property.district}</span>
{row.property.ward && (
<span className="text-foreground-dim"> · {row.property.ward}</span>
)}
</div>
),
sortable: true,
sortValue: (row) => row.property.district,
width: '120px',
},
{
id: 'type',
header: 'Loại',
cell: (row) => {
const label =
PROPERTY_TYPES.find((t) => t.value === row.property.propertyType)?.label ??
row.property.propertyType;
return <span className="text-foreground-muted text-[12px]">{label}</span>;
},
width: '90px',
},
{
id: 'price',
header: 'Giá',
cell: (row) => (
<span className="font-mono text-[13px] font-medium text-foreground tabular-nums">
{formatPrice(row.priceVND)} tỷ
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => Number(row.priceVND),
width: '110px',
},
{
id: 'delta30d',
header: 'Δ30d',
cell: (row) => {
const delta = getDelta30d(row);
// Hiển thị "—" khi API chưa có dữ liệu lịch sử giá.
if (delta === null) {
return <span className="text-foreground-dim text-[12px]"></span>;
}
return <PriceDelta value={delta} size="sm" />;
},
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => getDelta30d(row) ?? -Infinity,
width: '90px',
width: '150px',
},
{
id: 'area',
@@ -139,89 +192,221 @@ function buildColumns(
numeric: true,
sortable: true,
sortValue: (row) => row.property.areaM2,
width: '80px',
width: '70px',
},
{
id: 'views',
header: 'KL/Views',
id: 'price',
header: 'Giá',
cell: (row) => (
<span className="font-mono text-[11px] tabular-nums text-foreground-dim">
{row.viewCount}
<span className="font-mono text-[13px] font-medium text-foreground tabular-nums">
{formatPrice(row.priceVND)}
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => row.viewCount,
sortValue: (row) => Number(row.priceVND),
width: '100px',
},
{
id: 'pricePerM2',
header: 'Giá/m²',
cell: (row) => (
<span className="font-mono text-[11px] tabular-nums text-foreground-muted">
{row.pricePerM2 ? formatPricePerM2(row.pricePerM2) : '—'}
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => row.pricePerM2 ?? 0,
width: '90px',
},
{
id: 'bedrooms',
header: 'PN',
cell: (row) => (
<span className="text-[12px] tabular-nums text-foreground-muted">
{row.property.bedrooms ?? '—'}
</span>
),
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => row.property.bedrooms ?? 0,
width: '50px',
},
{
id: 'publishedAt',
header: 'Ngày đăng',
cell: (row) => (
<span className="text-[11px] tabular-nums text-foreground-dim">
{formatPublishedAt(row.publishedAt)}
</span>
),
sortable: true,
sortValue: (row) => row.publishedAt ?? '',
width: '80px',
},
{
id: 'sparkline',
header: '30d',
cell: (row) => <Sparkline listingId={row.id} width={56} height={18} />,
align: 'center',
width: '72px',
},
{
id: 'delta30d',
header: 'Δ30d',
cell: (row) => {
const delta = getDelta30d(row);
if (delta === null) {
return <span className="text-foreground-dim text-[12px]"></span>;
}
return <PriceDelta value={delta} size="sm" />;
},
align: 'right',
numeric: true,
sortable: true,
sortValue: (row) => getDelta30d(row) ?? -Infinity,
width: '70px',
},
{
id: 'agent',
header: 'Môi giới',
cell: (row) => (
<span className="text-[11px] text-foreground-dim truncate block max-w-[80px]">
{row.agent?.agency ?? '—'}
</span>
),
width: '80px',
},
];
}
// ---------------------------------------------------------------------------
// Filter sidebar
// ---------------------------------------------------------------------------
function FilterSelect({
label,
value,
onChange,
children,
}: {
label: string;
value: string;
onChange: (v: string) => void;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="block text-[11px] font-medium uppercase tracking-wide text-foreground-muted">
{label}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-border bg-background-surface px-2.5 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
{children}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// Component chính
// ---------------------------------------------------------------------------
type ViewMode = 'table' | 'card';
interface Filters {
transactionType: TransactionType | '';
propertyType: PropertyType | '';
district: string;
priceRange: string; // "min:max" hoặc ""
}
const defaultFilters: Filters = {
transactionType: '',
propertyType: '',
district: '',
priceRange: '',
};
export default function ListingsPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// State from URL
const [filters, setFilters] = React.useState<Filters>(() =>
filtersFromSearchParams(searchParams),
);
const [page, setPage] = React.useState(() => Number(searchParams.get('page') || '1'));
const [sortBy, _setSortBy] = React.useState(() => searchParams.get('sortBy') || 'publishedAt');
const [order, _setOrder] = React.useState<'asc' | 'desc'>(
() => (searchParams.get('order') as 'asc' | 'desc') || 'desc',
);
const [viewMode, setViewMode] = React.useState<ViewMode>('table');
const [filters, setFilters] = React.useState<Filters>(defaultFilters);
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<{ listings: ListingDetail[]; total: number; totalPages: number } | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
const [hoveredListing, setHoveredListing] = React.useState<ListingDetail | null>(null);
const [searchInput, setSearchInput] = React.useState(filters.q);
const { density } = useDensity();
// Fetch listings khi filter / page thay đổi
const fetchListings = React.useCallback(() => {
setLoading(true);
setError(false);
// Sync URL when state changes
const syncUrl = React.useCallback(
(f: Filters, p: number, sb: string, o: 'asc' | 'desc') => {
const sp = filtersToSearchParams(f, p, sb, o);
const qs = sp.toString();
router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false });
},
[router, pathname],
);
const params: Record<string, string | number> = {
// Build API params from filters
const apiParams = React.useMemo(() => {
const params: Record<string, string | number | undefined> = {
page,
limit: PAGE_SIZE,
status: 'ACTIVE',
status: 'ACTIVE' as const,
sortBy,
order,
};
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.district) params['district'] = filters.district;
if (filters.q) params['q'] = filters.q;
if (filters.bedrooms) params['bedrooms'] = Number(filters.bedrooms);
if (filters.priceRange) {
const [min, max] = filters.priceRange.split(':');
if (min) params['minPrice'] = min;
if (max) params['maxPrice'] = max;
}
listingsApi
.search(params)
.then((res) => {
setData({ listings: res.data, total: res.total, totalPages: res.totalPages });
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [filters, page]);
if (filters.areaRange) {
const [min, max] = filters.areaRange.split(':');
if (min && min !== '0') params['minArea'] = Number(min);
if (max && max !== '0') params['maxArea'] = Number(max);
}
React.useEffect(() => {
fetchListings();
}, [fetchListings]);
return params;
}, [filters, page, sortBy, order]);
const { data, isLoading, isError, refetch } = useListingsSearch(apiParams);
const handleFilterChange = (key: keyof Filters, value: string) => {
const next = { ...filters, [key]: value };
setFilters(next);
setPage(1);
syncUrl(next, 1, sortBy, order);
};
const clearFilters = () => {
setFilters(defaultFilters);
setSearchInput('');
setPage(1);
syncUrl(defaultFilters, 1, sortBy, order);
};
const handlePageChange = (p: number) => {
setPage(p);
syncUrl(filters, p, sortBy, order);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleFilterChange('q', searchInput);
};
// Điều hướng khi click row
const handleRowClick = React.useCallback(
(listing: ListingDetail) => {
router.push(`/listings/${listing.id}`);
@@ -229,215 +414,257 @@ export default function ListingsPage() {
[router],
);
const columns = React.useMemo(() => buildColumns(handleRowClick), [handleRowClick]);
const columns = React.useMemo(() => buildColumns(), []);
const hasFilters =
filters.transactionType || filters.propertyType || filters.district || filters.priceRange;
const hasFilters = Object.values(filters).some(Boolean);
const handleFilterChange = (key: keyof Filters, value: string) => {
setPage(1);
setFilters((prev) => ({ ...prev, [key]: value }));
};
const clearFilters = () => {
setPage(1);
setFilters(defaultFilters);
};
const listings = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
return (
<div className="mx-auto max-w-7xl px-4 py-5">
{/* Tiêu đề trang */}
<div className="mx-auto max-w-[1600px] px-4 py-5">
{/* Header */}
<div className="mb-4 flex items-baseline justify-between">
<div>
<h1 className="text-display-md font-semibold text-foreground">Thị Trường BĐS</h1>
{data && !loading && (
{!isLoading && (
<p className="mt-0.5 text-body-sm text-foreground-muted">
{data.total.toLocaleString('vi-VN')} bất đng sản đang niêm yết
{total.toLocaleString('vi-VN')} bất đng sản đang niêm yết
</p>
)}
</div>
{/* Toggle view */}
<div className="flex items-center gap-1 rounded-md border border-border p-0.5">
<button
aria-label="Chế độ bảng"
aria-pressed={viewMode === 'table'}
onClick={() => setViewMode('table')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'table'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<List className="h-4 w-4" />
</button>
<button
aria-label="Chế độ thẻ"
aria-pressed={viewMode === 'card'}
onClick={() => setViewMode('card')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'card'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
{/* Density toggle */}
<DensityToggle />
{/* View toggle */}
<div className="flex items-center gap-1 rounded-md border border-border p-0.5">
<button
aria-label="Chế độ bảng"
aria-pressed={viewMode === 'table'}
onClick={() => setViewMode('table')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'table'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<List className="h-4 w-4" />
</button>
<button
aria-label="Chế độ thẻ"
aria-pressed={viewMode === 'card'}
onClick={() => setViewMode('card')}
className={`rounded p-1.5 transition-colors ${
viewMode === 'card'
? 'bg-background-surface text-foreground'
: 'text-foreground-dim hover:text-foreground-muted'
}`}
>
<LayoutGrid className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Filter bar */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<SlidersHorizontal className="h-4 w-4 shrink-0 text-foreground-muted" />
{/* Layout: sidebar + table + preview */}
<div className="flex gap-4">
{/* ── Left sidebar filters ── */}
<aside className="hidden w-[220px] shrink-0 lg:block">
<div className="sticky top-20 space-y-3 rounded-md border border-border bg-background-elevated p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[12px] font-medium text-foreground-muted">
<SlidersHorizontal className="h-3.5 w-3.5" />
Bộ lọc
</div>
{hasFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-0.5 text-[11px] text-primary hover:underline"
>
<X className="h-3 w-3" />
Xóa
</button>
)}
</div>
{/* Loại giao dịch */}
<select
value={filters.transactionType}
onChange={(e) => handleFilterChange('transactionType', e.target.value)}
aria-label="Loại giao dịch"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Loại</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{/* Search */}
<form onSubmit={handleSearchSubmit} className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground-dim" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Tìm kiếm..."
className="w-full rounded-md border border-border bg-background-surface py-1.5 pl-7 pr-2 text-[13px] text-foreground placeholder:text-foreground-dim focus:outline-none focus:ring-1 focus:ring-primary"
/>
</form>
{/* Loại BĐS */}
<select
value={filters.propertyType}
onChange={(e) => handleFilterChange('propertyType', e.target.value)}
aria-label="Loại bất động sản"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Loại BĐS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<FilterSelect
label="Loại giao dịch"
value={filters.transactionType}
onChange={(v) => handleFilterChange('transactionType', v)}
>
<option value="">Tất cả</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</FilterSelect>
{/* Quận */}
<select
value={filters.district}
onChange={(e) => handleFilterChange('district', e.target.value)}
aria-label="Quận/Huyện"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Quận</option>
{DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<FilterSelect
label="Loại BĐS"
value={filters.propertyType}
onChange={(v) => handleFilterChange('propertyType', v)}
>
<option value="">Tất cả</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</FilterSelect>
{/* Khoảng giá */}
<select
value={filters.priceRange}
onChange={(e) => handleFilterChange('priceRange', e.target.value)}
aria-label="Khoảng giá"
className="rounded-md border border-border bg-background-surface px-3 py-1.5 text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">Giá</option>
{PRICE_RANGES.map((r) => (
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
))}
</select>
<FilterSelect
label="Quận/Huyện"
value={filters.district}
onChange={(v) => handleFilterChange('district', v)}
>
<option value="">Tất cả</option>
{DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</FilterSelect>
{/* Xóa bộ lọc */}
{hasFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 gap-1 text-foreground-muted"
>
<X className="h-3.5 w-3.5" />
Xóa bộ lọc
</Button>
<FilterSelect
label="Khoảng giá"
value={filters.priceRange}
onChange={(v) => handleFilterChange('priceRange', v)}
>
<option value="">Tất cả</option>
{PRICE_RANGES.map((r) => (
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
))}
</FilterSelect>
<FilterSelect
label="Diện tích"
value={filters.areaRange}
onChange={(v) => handleFilterChange('areaRange', v)}
>
<option value="">Tất cả</option>
{AREA_RANGES.map((r) => (
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
))}
</FilterSelect>
<FilterSelect
label="Phòng ngủ"
value={filters.bedrooms}
onChange={(v) => handleFilterChange('bedrooms', v)}
>
<option value="">Tất cả</option>
{BEDROOM_OPTIONS.map((b) => (
<option key={b.value} value={String(b.value)}>{b.label}</option>
))}
</FilterSelect>
</div>
</aside>
{/* ── Main content ── */}
<div className="min-w-0 flex-1">
{isError ? (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-3 text-foreground-muted">
<p className="text-body font-medium">Không thể tải danh sách bất đng sản</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Thử lại
</Button>
</div>
) : viewMode === 'table' ? (
<DataTable<ListingDetail>
columns={columns}
data={listings}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
onRowHover={setHoveredListing}
loading={isLoading}
stickyHeader
dense={density === 'compact'}
defaultSortId={sortBy}
defaultSortDir={order}
emptyText="Không tìm thấy bất động sản phù hợp"
className={DENSITY_ROW_HEIGHT[density]}
/>
) : (
isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-64 animate-pulse rounded-lg bg-background-surface" />
))}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{listings.map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
)
)}
{/* Pagination */}
{totalPages > 1 && !isLoading && (
<div className="mt-4 flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">
Trang {page} / {totalPages} · {total.toLocaleString('vi-VN')} kết quả
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
className="h-8"
>
</Button>
{Array.from({ length: Math.min(5, totalPages) }).map((_, i) => {
const half = 2;
const start = Math.max(1, Math.min(page - half, totalPages - 4));
const p = start + i;
return (
<Button
key={p}
variant={p === page ? 'default' : 'outline'}
size="sm"
onClick={() => handlePageChange(p)}
className="h-8 w-8 p-0 text-[12px] tabular-nums"
>
{p}
</Button>
);
})}
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
className="h-8"
>
</Button>
</div>
</div>
)}
</div>
{/* ── Right preview panel (table mode only) ── */}
{viewMode === 'table' && (
<aside className="hidden w-[280px] shrink-0 xl:block">
<div className="sticky top-20 h-[calc(100vh-120px)] rounded-md border border-border bg-background-elevated">
<ListingPreviewPanel listing={hoveredListing} />
</div>
</aside>
)}
</div>
{/* Nội dung */}
{error ? (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-3 text-foreground-muted">
<p className="text-body font-medium">Không thể tải danh sách bất đng sản</p>
<Button variant="outline" size="sm" onClick={fetchListings}>Thử lại</Button>
</div>
) : viewMode === 'table' ? (
/* ── Chế độ bảng ticker ── */
<DataTable<ListingDetail>
columns={columns}
data={data?.listings ?? []}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
loading={loading}
stickyHeader
dense
defaultSortId="price"
defaultSortDir="desc"
emptyText="Không tìm thấy bất động sản phù hợp"
/>
) : (
/* ── Chế độ card (legacy, giữ nguyên component cũ) ── */
loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-64 animate-pulse rounded-lg bg-background-surface" />
))}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{(data?.listings ?? []).map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
)
)}
{/* Phân trang */}
{data && data.totalPages > 1 && !loading && (
<div className="mt-6 flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">
Trang {page} / {data.totalPages} · {data.total.toLocaleString('vi-VN')} kết quả
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="h-8"
>
</Button>
{/* Hiện tối đa 5 trang xung quanh trang hiện tại */}
{Array.from({ length: Math.min(5, data.totalPages) }).map((_, i) => {
const half = 2;
const start = Math.max(1, Math.min(page - half, data.totalPages - 4));
const p = start + i;
return (
<Button
key={p}
variant={p === page ? 'default' : 'outline'}
size="sm"
onClick={() => setPage(p)}
className="h-8 w-8 p-0 text-[12px] tabular-nums"
>
{p}
</Button>
);
})}
<Button
variant="outline"
size="sm"
disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)}
className="h-8"
>
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -32,6 +32,8 @@ export interface DataTableProps<T> {
getRowId?: (row: T, index: number) => string | number;
/** Click row. */
onRowClick?: (row: T) => void;
/** Hover row — fires with null on mouse leave. */
onRowHover?: (row: T | null) => void;
/** Hiện sticky header. */
stickyHeader?: boolean;
/** Trạng thái loading. */
@@ -59,6 +61,7 @@ export function DataTable<T>({
data,
getRowId,
onRowClick,
onRowHover,
stickyHeader = true,
loading = false,
emptyText = 'Không có dữ liệu',
@@ -181,6 +184,8 @@ export function DataTable<T>({
<tr
key={key}
onClick={onRowClick ? () => onRowClick(row) : undefined}
onMouseEnter={onRowHover ? () => onRowHover(row) : undefined}
onMouseLeave={onRowHover ? () => onRowHover(null) : undefined}
className={cn(
'border-b border-border/60 transition-colors duration-100',
dense ? 'h-row' : 'h-10',

View File

@@ -0,0 +1,93 @@
'use client';
import Image from 'next/image';
import * as React from 'react';
import { KpiCard } from '@/components/design-system';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api';
interface ListingPreviewPanelProps {
listing: ListingDetail | null;
}
/**
* Right-side preview panel shown on row hover in the listings table.
* Displays the first image + key KPIs for the hovered listing.
*/
export function ListingPreviewPanel({ listing }: ListingPreviewPanelProps) {
if (!listing) {
return (
<div className="flex h-full items-center justify-center text-body-sm text-foreground-dim">
Di chuột qua dòng đ xem trước
</div>
);
}
const thumbnail =
listing.property.thumbnail ??
listing.property.media?.[0]?.url ??
null;
return (
<div className="flex h-full flex-col gap-3 overflow-auto p-3">
{/* Image */}
{thumbnail ? (
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-md bg-background-surface">
<Image
src={thumbnail}
alt={listing.property.title}
fill
sizes="320px"
className="object-cover"
/>
</div>
) : (
<div className="flex aspect-[16/10] w-full items-center justify-center rounded-md bg-background-surface text-[12px] text-foreground-dim">
Không nh
</div>
)}
{/* Title */}
<h3 className="text-body-sm font-semibold text-foreground line-clamp-2">
{listing.property.title}
</h3>
{/* Location */}
<p className="text-[12px] text-foreground-muted">
{listing.property.ward}, {listing.property.district}
</p>
{/* KPI grid */}
<div className="grid grid-cols-2 gap-2">
<KpiCard label="Giá" value={`${formatPrice(listing.priceVND)}`} className="p-2" />
<KpiCard
label="Giá/m²"
value={listing.pricePerM2 ? formatPricePerM2(listing.pricePerM2) : '—'}
className="p-2"
/>
<KpiCard label="Diện tích" value={`${listing.property.areaM2}`} className="p-2" />
<KpiCard
label="Phòng ngủ"
value={listing.property.bedrooms != null ? String(listing.property.bedrooms) : '—'}
className="p-2"
/>
<KpiCard label="Lượt xem" value={listing.viewCount.toLocaleString('vi-VN')} className="p-2" />
<KpiCard
label="Lượt lưu"
value={listing.saveCount.toLocaleString('vi-VN')}
className="p-2"
/>
</div>
{/* Agent */}
{listing.agent && (
<div className="mt-auto border-t border-border pt-2">
<p className="text-[11px] text-foreground-dim">Môi giới</p>
<p className="text-[12px] text-foreground">
{listing.agent.agency ?? 'Cá nhân'}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import * as React from 'react';
import { listingsApi } from '@/lib/listings-api';
interface SparklineProps {
listingId: string;
width?: number;
height?: number;
}
/**
* Inline SVG sparkline showing 30-day price history.
* Fetches data lazily when the component mounts (used in table hover/visible rows).
*/
export function Sparkline({ listingId, width = 64, height = 20 }: SparklineProps) {
const { data, isLoading } = useQuery({
queryKey: ['listings', 'price-history', listingId],
queryFn: () => listingsApi.getPriceHistory(listingId),
staleTime: 5 * 60 * 1000,
});
if (isLoading) {
return (
<span
className="inline-block animate-pulse rounded bg-background-surface"
style={{ width, height }}
/>
);
}
if (!data || data.length < 2) {
return <span className="text-[11px] text-foreground-dim"></span>;
}
const prices = data.map((d) => Number(d.newPrice));
const min = Math.min(...prices);
const max = Math.max(...prices);
const range = max - min || 1;
const points = prices
.map((p, i) => {
const x = (i / (prices.length - 1)) * width;
const y = height - ((p - min) / range) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
// Color based on trend direction
const trending = prices[prices.length - 1]! >= prices[0]!;
const strokeColor = trending ? 'var(--color-signal-up)' : 'var(--color-signal-down)';
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="inline-block"
aria-label="Biểu đồ giá 30 ngày"
>
<polyline
points={points}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -182,6 +182,7 @@ export interface SearchListingsParams {
propertyType?: PropertyType;
city?: string;
district?: string;
ward?: string;
minPrice?: string;
maxPrice?: string;
minArea?: number;
@@ -189,6 +190,12 @@ export interface SearchListingsParams {
bedrooms?: number;
/** Filter by assigned agent ID */
agentId?: string;
/** Server-side sort column */
sortBy?: string;
/** Sort direction */
order?: 'asc' | 'desc';
/** Free-text search query */
q?: string;
page?: number;
limit?: number;
}