feat(web): listings page — ticker-style DataTable với toggle card view
Tạo mới trang /listings dạng bảng ticker-style theo spec TEC-3034. - DataTable compact (row 36px, sticky header, alternating rows) - Cột: #, Mã (GG-xxx), Quận, Loại, Giá, Δ30d, DT m², KL/Views - Sortable theo Giá, Δ30d, DT m², KL/Views - Filter inline: Loại giao dịch, Loại BĐS, Quận, Khoảng giá - Toggle view: Table (default) ↔ Card grid (legacy component cũ) - Pagination restyle compact, giữ nguyên API params - Click row → navigate to detail page - Dùng DataTable + PriceDelta từ @/components/design-system Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { LogOut, Menu, Moon, Sun, User as UserIcon, X } from 'lucide-react';
|
||||
import { TickerStrip } from '@/components/design-system/ticker-strip';
|
||||
import type { TickerItem } from '@/components/design-system/ticker-strip';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
@@ -80,8 +82,24 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
},
|
||||
];
|
||||
|
||||
/** Mock top-8 district price movement data (7-day delta). */
|
||||
const tickerItems: TickerItem[] = [
|
||||
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
|
||||
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
|
||||
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
|
||||
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
|
||||
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
|
||||
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
|
||||
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
|
||||
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Ticker strip — biến động 7d top 8 quận */}
|
||||
<div className="h-ticker-bar border-b border-border bg-background-elevated">
|
||||
<TickerStrip items={tickerItems} />
|
||||
</div>
|
||||
<header
|
||||
role="banner"
|
||||
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
|
||||
434
apps/web/app/[locale]/(public)/listings/page.tsx
Normal file
434
apps/web/app/[locale]/(public)/listings/page.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutGrid, List, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { DataTable, PriceDelta } from '@/components/design-system';
|
||||
import type { DataTableColumn } from '@/components/design-system';
|
||||
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 { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hằng số
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DISTRICTS = [
|
||||
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
|
||||
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12',
|
||||
'Bình Thạnh', 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
|
||||
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
|
||||
];
|
||||
|
||||
const PRICE_RANGES = [
|
||||
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' },
|
||||
{ label: '1 – 3 tỷ', min: '1000000000', max: '3000000000' },
|
||||
{ label: '3 – 7 tỷ', min: '3000000000', max: '7000000000' },
|
||||
{ label: '7 – 15 tỷ', min: '7000000000', max: '15000000000' },
|
||||
{ label: 'Trên 15 tỷ', min: '15000000000', max: '' },
|
||||
];
|
||||
|
||||
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()}`;
|
||||
}
|
||||
|
||||
/** Giả lập delta 30d từ pricePerM2 (chưa có API lịch sử giá). */
|
||||
function mockDelta(id: string): number {
|
||||
// Dùng hash đơn giản để ra delta nhất quán theo id, không random mỗi render.
|
||||
const seed = id.charCodeAt(0) + id.charCodeAt(id.length - 1);
|
||||
const raw = ((seed * 17) % 100) - 50; // -50 … +49
|
||||
return parseFloat((raw / 25).toFixed(2)); // -2.0 … +1.96
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cột DataTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildColumns(
|
||||
onRowClick: (listing: ListingDetail) => void,
|
||||
): DataTableColumn<ListingDetail>[] {
|
||||
return [
|
||||
{
|
||||
id: 'index',
|
||||
header: '#',
|
||||
cell: (_row, index) => (
|
||||
<span className="text-foreground-dim text-[11px] tabular-nums">{index + 1}</span>
|
||||
),
|
||||
width: '40px',
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
header: 'Mã',
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[12px] text-primary">{shortId(row.id)}</span>
|
||||
),
|
||||
width: '80px',
|
||||
},
|
||||
{
|
||||
id: 'district',
|
||||
header: 'Quận',
|
||||
cell: (row) => (
|
||||
<span className="text-foreground text-[13px]">{row.property.district}</span>
|
||||
),
|
||||
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) => <PriceDelta value={mockDelta(row.id)} size="sm" />,
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => mockDelta(row.id),
|
||||
width: '90px',
|
||||
},
|
||||
{
|
||||
id: 'area',
|
||||
header: 'DT m²',
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[12px] tabular-nums text-foreground-muted">
|
||||
{row.property.areaM2}
|
||||
</span>
|
||||
),
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.property.areaM2,
|
||||
width: '80px',
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
header: 'KL/Views',
|
||||
cell: (row) => (
|
||||
<span className="font-mono text-[11px] tabular-nums text-foreground-dim">
|
||||
{row.viewCount}
|
||||
</span>
|
||||
),
|
||||
align: 'right',
|
||||
numeric: true,
|
||||
sortable: true,
|
||||
sortValue: (row) => row.viewCount,
|
||||
width: '80px',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 [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);
|
||||
|
||||
// Fetch listings khi filter / page thay đổi
|
||||
const fetchListings = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
const params: Record<string, string | number> = {
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
if (filters.transactionType) params['transactionType'] = filters.transactionType;
|
||||
if (filters.propertyType) params['propertyType'] = filters.propertyType;
|
||||
if (filters.district) params['district'] = filters.district;
|
||||
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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
|
||||
// Điều hướng khi click row
|
||||
const handleRowClick = React.useCallback(
|
||||
(listing: ListingDetail) => {
|
||||
router.push(`/listings/${listing.id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const columns = React.useMemo(() => buildColumns(handleRowClick), [handleRowClick]);
|
||||
|
||||
const hasFilters =
|
||||
filters.transactionType || filters.propertyType || filters.district || filters.priceRange;
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setPage(1);
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setPage(1);
|
||||
setFilters(defaultFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-5">
|
||||
{/* Tiêu đề trang */}
|
||||
<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 && (
|
||||
<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
|
||||
</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>
|
||||
</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" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user