From 72aa7aab5710254ef7ff4b09c6d8d8779a0de385 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 09:17:45 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20high-density=20listings=20board=20?= =?UTF-8?q?with=20filters,=20sort,=20preview=20=E2=80=94=20TEC-3059?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/[locale]/(public)/listings/page.tsx | 813 +++++++++++------- .../components/design-system/data-table.tsx | 5 + .../listings/listing-preview-panel.tsx | 93 ++ apps/web/components/listings/sparkline.tsx | 72 ++ apps/web/lib/listings-api.ts | 7 + 5 files changed, 697 insertions(+), 293 deletions(-) create mode 100644 apps/web/components/listings/listing-preview-panel.tsx create mode 100644 apps/web/components/listings/sparkline.tsx diff --git a/apps/web/app/[locale]/(public)/listings/page.tsx b/apps/web/app/[locale]/(public)/listings/page.tsx index fe30f1a..38f733d 100644 --- a/apps/web/app/[locale]/(public)/listings/page.tsx +++ b/apps/web/app/[locale]/(public)/listings/page.tsx @@ -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[] { +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[] { return [ { id: 'index', @@ -65,7 +143,7 @@ function buildColumns( cell: (_row, index) => ( {index + 1} ), - width: '40px', + width: '36px', }, { id: 'code', @@ -73,59 +151,34 @@ function buildColumns( cell: (row) => ( {shortId(row.id)} ), - width: '80px', + width: '76px', + }, + { + id: 'title', + header: 'Tiêu đề', + cell: (row) => ( + + {row.property.title} + + ), + sortable: true, + sortValue: (row) => row.property.title, + width: '200px', }, { id: 'district', - header: 'Quận', + header: 'Quận/Phường', cell: (row) => ( - {row.property.district} +
+ {row.property.district} + {row.property.ward && ( + · {row.property.ward} + )} +
), 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 {label}; - }, - width: '90px', - }, - { - id: 'price', - header: 'Giá', - cell: (row) => ( - - {formatPrice(row.priceVND)} tỷ - - ), - 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 ; - } - return ; - }, - 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) => ( - - {row.viewCount} + + {formatPrice(row.priceVND)} ), align: 'right', numeric: true, sortable: true, - sortValue: (row) => row.viewCount, + sortValue: (row) => Number(row.priceVND), + width: '100px', + }, + { + id: 'pricePerM2', + header: 'Giá/m²', + cell: (row) => ( + + {row.pricePerM2 ? formatPricePerM2(row.pricePerM2) : '—'} + + ), + align: 'right', + numeric: true, + sortable: true, + sortValue: (row) => row.pricePerM2 ?? 0, + width: '90px', + }, + { + id: 'bedrooms', + header: 'PN', + cell: (row) => ( + + {row.property.bedrooms ?? '—'} + + ), + align: 'right', + numeric: true, + sortable: true, + sortValue: (row) => row.property.bedrooms ?? 0, + width: '50px', + }, + { + id: 'publishedAt', + header: 'Ngày đăng', + cell: (row) => ( + + {formatPublishedAt(row.publishedAt)} + + ), + sortable: true, + sortValue: (row) => row.publishedAt ?? '', + width: '80px', + }, + { + id: 'sparkline', + header: '30d', + cell: (row) => , + align: 'center', + width: '72px', + }, + { + id: 'delta30d', + header: 'Δ30d', + cell: (row) => { + const delta = getDelta30d(row); + if (delta === null) { + return ; + } + return ; + }, + align: 'right', + numeric: true, + sortable: true, + sortValue: (row) => getDelta30d(row) ?? -Infinity, + width: '70px', + }, + { + id: 'agent', + header: 'Môi giới', + cell: (row) => ( + + {row.agent?.agency ?? '—'} + + ), width: '80px', }, ]; } +// --------------------------------------------------------------------------- +// Filter sidebar +// --------------------------------------------------------------------------- + +function FilterSelect({ + label, + value, + onChange, + children, +}: { + label: string; + value: string; + onChange: (v: string) => void; + children: React.ReactNode; +}) { + return ( +
+ + +
+ ); +} + // --------------------------------------------------------------------------- // 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(() => + 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('table'); - const [filters, setFilters] = React.useState(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(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 = { + // Build API params from filters + const apiParams = React.useMemo(() => { + const params: Record = { 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 ( -
- {/* Tiêu đề trang */} +
+ {/* Header */}

Thị Trường BĐS

- {data && !loading && ( + {!isLoading && (

- {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

)}
- {/* Toggle view */} -
- - +
+ {/* Density toggle */} + + + {/* View toggle */} +
+ + +
- {/* Filter bar */} -
- + {/* Layout: sidebar + table + preview */} +
+ {/* ── Left sidebar filters ── */} + + + {/* ── Main content ── */} +
+ {isError ? ( +
+

Không thể tải danh sách bất động sản

+ +
+ ) : viewMode === 'table' ? ( + + 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 ? ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {listings.map((listing) => ( + + ))} +
+ ) + )} + + {/* Pagination */} + {totalPages > 1 && !isLoading && ( +
+

+ Trang {page} / {totalPages} · {total.toLocaleString('vi-VN')} kết quả +

+
+ + {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 ( + + ); + })} + +
+
+ )} +
+ + {/* ── Right preview panel (table mode only) ── */} + {viewMode === 'table' && ( + )}
- - {/* Nội dung */} - {error ? ( -
-

Không thể tải danh sách bất động sản

- -
- ) : viewMode === 'table' ? ( - /* ── Chế độ bảng ticker ── */ - - 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 ? ( -
- {Array.from({ length: 12 }).map((_, i) => ( -
- ))} -
- ) : ( -
- {(data?.listings ?? []).map((listing) => ( - - ))} -
- ) - )} - - {/* Phân trang */} - {data && data.totalPages > 1 && !loading && ( -
-

- Trang {page} / {data.totalPages} · {data.total.toLocaleString('vi-VN')} kết quả -

-
- - {/* 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 ( - - ); - })} - -
-
- )}
); } diff --git a/apps/web/components/design-system/data-table.tsx b/apps/web/components/design-system/data-table.tsx index d380c9c..1939dc1 100644 --- a/apps/web/components/design-system/data-table.tsx +++ b/apps/web/components/design-system/data-table.tsx @@ -32,6 +32,8 @@ export interface DataTableProps { 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({ data, getRowId, onRowClick, + onRowHover, stickyHeader = true, loading = false, emptyText = 'Không có dữ liệu', @@ -181,6 +184,8 @@ export function DataTable({ 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', diff --git a/apps/web/components/listings/listing-preview-panel.tsx b/apps/web/components/listings/listing-preview-panel.tsx new file mode 100644 index 0000000..6899ae7 --- /dev/null +++ b/apps/web/components/listings/listing-preview-panel.tsx @@ -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 ( +
+ Di chuột qua dòng để xem trước +
+ ); + } + + const thumbnail = + listing.property.thumbnail ?? + listing.property.media?.[0]?.url ?? + null; + + return ( +
+ {/* Image */} + {thumbnail ? ( +
+ {listing.property.title} +
+ ) : ( +
+ Không có ảnh +
+ )} + + {/* Title */} +

+ {listing.property.title} +

+ + {/* Location */} +

+ {listing.property.ward}, {listing.property.district} +

+ + {/* KPI grid */} +
+ + + + + + +
+ + {/* Agent */} + {listing.agent && ( +
+

Môi giới

+

+ {listing.agent.agency ?? 'Cá nhân'} +

+
+ )} +
+ ); +} diff --git a/apps/web/components/listings/sparkline.tsx b/apps/web/components/listings/sparkline.tsx new file mode 100644 index 0000000..3ab8b5f --- /dev/null +++ b/apps/web/components/listings/sparkline.tsx @@ -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 ( + + ); + } + + if (!data || data.length < 2) { + return ; + } + + 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 ( + + + + ); +} diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index 29e8007..6f47fe6 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -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; }