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:
@@ -1,14 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { LayoutGrid, List, SlidersHorizontal, X } from 'lucide-react';
|
import { LayoutGrid, List, SlidersHorizontal, X, Search } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { DataTable, PriceDelta } from '@/components/design-system';
|
import {
|
||||||
import type { DataTableColumn } from '@/components/design-system';
|
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 { PropertyCard } from '@/components/search/property-card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
import { listingsApi, type ListingDetail, type PropertyType, type TransactionType } from '@/lib/listings-api';
|
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';
|
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -30,34 +39,103 @@ const PRICE_RANGES = [
|
|||||||
{ label: 'Trên 15 tỷ', min: '15000000000', max: '' },
|
{ 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;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Trả về mã tin rút gọn dạng GG-xxxxx từ UUID. */
|
|
||||||
function shortId(id: string): string {
|
function shortId(id: string): string {
|
||||||
return `GG-${id.slice(0, 5).toUpperCase()}`;
|
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 {
|
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;
|
const raw = (listing as ListingDetail & { priceDelta30d?: number | null }).priceDelta30d;
|
||||||
return raw ?? null;
|
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(
|
interface Filters {
|
||||||
onRowClick: (listing: ListingDetail) => void,
|
transactionType: TransactionType | '';
|
||||||
): DataTableColumn<ListingDetail>[] {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
id: 'index',
|
id: 'index',
|
||||||
@@ -65,7 +143,7 @@ function buildColumns(
|
|||||||
cell: (_row, index) => (
|
cell: (_row, index) => (
|
||||||
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
|
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
|
||||||
),
|
),
|
||||||
width: '40px',
|
width: '36px',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
@@ -73,59 +151,34 @@ function buildColumns(
|
|||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<span className="font-mono text-[12px] text-primary">{shortId(row.id)}</span>
|
<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',
|
id: 'district',
|
||||||
header: 'Quận',
|
header: 'Quận/Phường',
|
||||||
cell: (row) => (
|
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,
|
sortable: true,
|
||||||
sortValue: (row) => row.property.district,
|
sortValue: (row) => row.property.district,
|
||||||
width: '120px',
|
width: '150px',
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'area',
|
id: 'area',
|
||||||
@@ -139,89 +192,221 @@ function buildColumns(
|
|||||||
numeric: true,
|
numeric: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (row) => row.property.areaM2,
|
sortValue: (row) => row.property.areaM2,
|
||||||
width: '80px',
|
width: '70px',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'views',
|
id: 'price',
|
||||||
header: 'KL/Views',
|
header: 'Giá',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<span className="font-mono text-[11px] tabular-nums text-foreground-dim">
|
<span className="font-mono text-[13px] font-medium text-foreground tabular-nums">
|
||||||
{row.viewCount}
|
{formatPrice(row.priceVND)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
align: 'right',
|
align: 'right',
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sortable: 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',
|
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
|
// Component chính
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type ViewMode = 'table' | 'card';
|
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() {
|
export default function ListingsPage() {
|
||||||
const router = useRouter();
|
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 [viewMode, setViewMode] = React.useState<ViewMode>('table');
|
||||||
const [filters, setFilters] = React.useState<Filters>(defaultFilters);
|
const [hoveredListing, setHoveredListing] = React.useState<ListingDetail | null>(null);
|
||||||
const [page, setPage] = React.useState(1);
|
const [searchInput, setSearchInput] = React.useState(filters.q);
|
||||||
const [data, setData] = React.useState<{ listings: ListingDetail[]; total: number; totalPages: number } | null>(null);
|
const { density } = useDensity();
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState(false);
|
|
||||||
|
|
||||||
// Fetch listings khi filter / page thay đổi
|
// Sync URL when state changes
|
||||||
const fetchListings = React.useCallback(() => {
|
const syncUrl = React.useCallback(
|
||||||
setLoading(true);
|
(f: Filters, p: number, sb: string, o: 'asc' | 'desc') => {
|
||||||
setError(false);
|
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,
|
page,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE' as const,
|
||||||
|
sortBy,
|
||||||
|
order,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||||
if (filters.district) params['district'] = filters.district;
|
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) {
|
if (filters.priceRange) {
|
||||||
const [min, max] = filters.priceRange.split(':');
|
const [min, max] = filters.priceRange.split(':');
|
||||||
if (min) params['minPrice'] = min;
|
if (min) params['minPrice'] = min;
|
||||||
if (max) params['maxPrice'] = max;
|
if (max) params['maxPrice'] = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
listingsApi
|
if (filters.areaRange) {
|
||||||
.search(params)
|
const [min, max] = filters.areaRange.split(':');
|
||||||
.then((res) => {
|
if (min && min !== '0') params['minArea'] = Number(min);
|
||||||
setData({ listings: res.data, total: res.total, totalPages: res.totalPages });
|
if (max && max !== '0') params['maxArea'] = Number(max);
|
||||||
})
|
}
|
||||||
.catch(() => setError(true))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [filters, page]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
return params;
|
||||||
fetchListings();
|
}, [filters, page, sortBy, order]);
|
||||||
}, [fetchListings]);
|
|
||||||
|
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(
|
const handleRowClick = React.useCallback(
|
||||||
(listing: ListingDetail) => {
|
(listing: ListingDetail) => {
|
||||||
router.push(`/listings/${listing.id}`);
|
router.push(`/listings/${listing.id}`);
|
||||||
@@ -229,215 +414,257 @@ export default function ListingsPage() {
|
|||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = React.useMemo(() => buildColumns(handleRowClick), [handleRowClick]);
|
const columns = React.useMemo(() => buildColumns(), []);
|
||||||
|
|
||||||
const hasFilters =
|
const hasFilters = Object.values(filters).some(Boolean);
|
||||||
filters.transactionType || filters.propertyType || filters.district || filters.priceRange;
|
|
||||||
|
|
||||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
const listings = data?.data ?? [];
|
||||||
setPage(1);
|
const total = data?.total ?? 0;
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
const totalPages = data?.totalPages ?? 0;
|
||||||
};
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setPage(1);
|
|
||||||
setFilters(defaultFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-5">
|
<div className="mx-auto max-w-[1600px] px-4 py-5">
|
||||||
{/* Tiêu đề trang */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-baseline justify-between">
|
<div className="mb-4 flex items-baseline justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-display-md font-semibold text-foreground">Thị Trường BĐS</h1>
|
<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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle view */}
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1 rounded-md border border-border p-0.5">
|
{/* Density toggle */}
|
||||||
<button
|
<DensityToggle />
|
||||||
aria-label="Chế độ bảng"
|
|
||||||
aria-pressed={viewMode === 'table'}
|
{/* View toggle */}
|
||||||
onClick={() => setViewMode('table')}
|
<div className="flex items-center gap-1 rounded-md border border-border p-0.5">
|
||||||
className={`rounded p-1.5 transition-colors ${
|
<button
|
||||||
viewMode === 'table'
|
aria-label="Chế độ bảng"
|
||||||
? 'bg-background-surface text-foreground'
|
aria-pressed={viewMode === 'table'}
|
||||||
: 'text-foreground-dim hover:text-foreground-muted'
|
onClick={() => setViewMode('table')}
|
||||||
}`}
|
className={`rounded p-1.5 transition-colors ${
|
||||||
>
|
viewMode === 'table'
|
||||||
<List className="h-4 w-4" />
|
? 'bg-background-surface text-foreground'
|
||||||
</button>
|
: 'text-foreground-dim hover:text-foreground-muted'
|
||||||
<button
|
}`}
|
||||||
aria-label="Chế độ thẻ"
|
>
|
||||||
aria-pressed={viewMode === 'card'}
|
<List className="h-4 w-4" />
|
||||||
onClick={() => setViewMode('card')}
|
</button>
|
||||||
className={`rounded p-1.5 transition-colors ${
|
<button
|
||||||
viewMode === 'card'
|
aria-label="Chế độ thẻ"
|
||||||
? 'bg-background-surface text-foreground'
|
aria-pressed={viewMode === 'card'}
|
||||||
: 'text-foreground-dim hover:text-foreground-muted'
|
onClick={() => setViewMode('card')}
|
||||||
}`}
|
className={`rounded p-1.5 transition-colors ${
|
||||||
>
|
viewMode === 'card'
|
||||||
<LayoutGrid className="h-4 w-4" />
|
? 'bg-background-surface text-foreground'
|
||||||
</button>
|
: 'text-foreground-dim hover:text-foreground-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Layout: sidebar + table + preview */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="flex gap-4">
|
||||||
<SlidersHorizontal className="h-4 w-4 shrink-0 text-foreground-muted" />
|
{/* ── 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 */}
|
{/* Search */}
|
||||||
<select
|
<form onSubmit={handleSearchSubmit} className="relative">
|
||||||
value={filters.transactionType}
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground-dim" />
|
||||||
onChange={(e) => handleFilterChange('transactionType', e.target.value)}
|
<input
|
||||||
aria-label="Loại giao dịch"
|
type="text"
|
||||||
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"
|
value={searchInput}
|
||||||
>
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
<option value="">Loại</option>
|
placeholder="Tìm kiếm..."
|
||||||
{TRANSACTION_TYPES.map((t) => (
|
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"
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
/>
|
||||||
))}
|
</form>
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Loại BĐS */}
|
<FilterSelect
|
||||||
<select
|
label="Loại giao dịch"
|
||||||
value={filters.propertyType}
|
value={filters.transactionType}
|
||||||
onChange={(e) => handleFilterChange('propertyType', e.target.value)}
|
onChange={(v) => handleFilterChange('transactionType', v)}
|
||||||
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="">Tất cả</option>
|
||||||
>
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
<option value="">Loại BĐS</option>
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
{PROPERTY_TYPES.map((t) => (
|
))}
|
||||||
<option key={t.value} value={t.value}>{t.label}</option>
|
</FilterSelect>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Quận */}
|
<FilterSelect
|
||||||
<select
|
label="Loại BĐS"
|
||||||
value={filters.district}
|
value={filters.propertyType}
|
||||||
onChange={(e) => handleFilterChange('district', e.target.value)}
|
onChange={(v) => handleFilterChange('propertyType', v)}
|
||||||
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="">Tất cả</option>
|
||||||
>
|
{PROPERTY_TYPES.map((t) => (
|
||||||
<option value="">Quận</option>
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
{DISTRICTS.map((d) => (
|
))}
|
||||||
<option key={d} value={d}>{d}</option>
|
</FilterSelect>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Khoảng giá */}
|
<FilterSelect
|
||||||
<select
|
label="Quận/Huyện"
|
||||||
value={filters.priceRange}
|
value={filters.district}
|
||||||
onChange={(e) => handleFilterChange('priceRange', e.target.value)}
|
onChange={(v) => handleFilterChange('district', v)}
|
||||||
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="">Tất cả</option>
|
||||||
>
|
{DISTRICTS.map((d) => (
|
||||||
<option value="">Giá</option>
|
<option key={d} value={d}>{d}</option>
|
||||||
{PRICE_RANGES.map((r) => (
|
))}
|
||||||
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
|
</FilterSelect>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Xóa bộ lọc */}
|
<FilterSelect
|
||||||
{hasFilters && (
|
label="Khoảng giá"
|
||||||
<Button
|
value={filters.priceRange}
|
||||||
variant="ghost"
|
onChange={(v) => handleFilterChange('priceRange', v)}
|
||||||
size="sm"
|
>
|
||||||
onClick={clearFilters}
|
<option value="">Tất cả</option>
|
||||||
className="h-8 gap-1 text-foreground-muted"
|
{PRICE_RANGES.map((r) => (
|
||||||
>
|
<option key={r.label} value={`${r.min}:${r.max}`}>{r.label}</option>
|
||||||
<X className="h-3.5 w-3.5" />
|
))}
|
||||||
Xóa bộ lọc
|
</FilterSelect>
|
||||||
</Button>
|
|
||||||
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface DataTableProps<T> {
|
|||||||
getRowId?: (row: T, index: number) => string | number;
|
getRowId?: (row: T, index: number) => string | number;
|
||||||
/** Click row. */
|
/** Click row. */
|
||||||
onRowClick?: (row: T) => void;
|
onRowClick?: (row: T) => void;
|
||||||
|
/** Hover row — fires with null on mouse leave. */
|
||||||
|
onRowHover?: (row: T | null) => void;
|
||||||
/** Hiện sticky header. */
|
/** Hiện sticky header. */
|
||||||
stickyHeader?: boolean;
|
stickyHeader?: boolean;
|
||||||
/** Trạng thái loading. */
|
/** Trạng thái loading. */
|
||||||
@@ -59,6 +61,7 @@ export function DataTable<T>({
|
|||||||
data,
|
data,
|
||||||
getRowId,
|
getRowId,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
onRowHover,
|
||||||
stickyHeader = true,
|
stickyHeader = true,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyText = 'Không có dữ liệu',
|
emptyText = 'Không có dữ liệu',
|
||||||
@@ -181,6 +184,8 @@ export function DataTable<T>({
|
|||||||
<tr
|
<tr
|
||||||
key={key}
|
key={key}
|
||||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
onMouseEnter={onRowHover ? () => onRowHover(row) : undefined}
|
||||||
|
onMouseLeave={onRowHover ? () => onRowHover(null) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-border/60 transition-colors duration-100',
|
'border-b border-border/60 transition-colors duration-100',
|
||||||
dense ? 'h-row' : 'h-10',
|
dense ? 'h-row' : 'h-10',
|
||||||
|
|||||||
93
apps/web/components/listings/listing-preview-panel.tsx
Normal file
93
apps/web/components/listings/listing-preview-panel.tsx
Normal 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 có ả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} m²`} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/web/components/listings/sparkline.tsx
Normal file
72
apps/web/components/listings/sparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -182,6 +182,7 @@ export interface SearchListingsParams {
|
|||||||
propertyType?: PropertyType;
|
propertyType?: PropertyType;
|
||||||
city?: string;
|
city?: string;
|
||||||
district?: string;
|
district?: string;
|
||||||
|
ward?: string;
|
||||||
minPrice?: string;
|
minPrice?: string;
|
||||||
maxPrice?: string;
|
maxPrice?: string;
|
||||||
minArea?: number;
|
minArea?: number;
|
||||||
@@ -189,6 +190,12 @@ export interface SearchListingsParams {
|
|||||||
bedrooms?: number;
|
bedrooms?: number;
|
||||||
/** Filter by assigned agent ID */
|
/** Filter by assigned agent ID */
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
/** Server-side sort column */
|
||||||
|
sortBy?: string;
|
||||||
|
/** Sort direction */
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
/** Free-text search query */
|
||||||
|
q?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user