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:
@@ -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',
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user