diff --git a/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx
new file mode 100644
index 0000000..6c2da50
--- /dev/null
+++ b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx
@@ -0,0 +1,361 @@
+/* eslint-disable import-x/order */
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ListingDetail } from '@/lib/listings-api';
+
+// ─── Mock next/navigation ────────────────────────────────────────────────────
+
+const mockPush = vi.fn();
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+// ─── Mock next-intl (sử dụng bởi PropertyCard → AddToCompareButton) ──────────
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+ useLocale: () => 'vi',
+ NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// ─── Mock next/link & next/image ─────────────────────────────────────────────
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
+ {children}
+ ),
+}));
+
+vi.mock('next/image', () => ({
+ default: (props: Record) =>
,
+}));
+
+// ─── Mock comparison button (dùng next-intl bên trong) ───────────────────────
+
+vi.mock('@/components/comparison/add-to-compare-button', () => ({
+ AddToCompareButton: () => null,
+}));
+
+// ─── Mock listings API ───────────────────────────────────────────────────────
+
+vi.mock('@/lib/listings-api', () => ({
+ listingsApi: {
+ search: vi.fn(),
+ },
+}));
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function makeProperty(overrides: Partial = {}): ListingDetail['property'] {
+ return {
+ id: 'prop-default',
+ propertyType: 'APARTMENT',
+ title: 'Căn hộ test',
+ description: 'Mô tả',
+ address: '123 Test',
+ ward: 'Phường Test',
+ district: 'Quận 7',
+ city: 'Hồ Chí Minh',
+ areaM2: 75,
+ usableAreaM2: null,
+ bedrooms: 2,
+ bathrooms: 2,
+ floors: null,
+ floor: null,
+ totalFloors: null,
+ direction: null,
+ yearBuilt: null,
+ legalStatus: null,
+ amenities: null,
+ nearbyPOIs: null,
+ metroDistanceM: null,
+ projectName: null,
+ latitude: 10.73,
+ longitude: 106.73,
+ furnishing: null,
+ propertyCondition: null,
+ balconyDirection: null,
+ maintenanceFeeVND: null,
+ parkingSlots: null,
+ viewType: [],
+ petFriendly: null,
+ suitableFor: [],
+ whyThisLocation: null,
+ media: [],
+ thumbnail: null,
+ ...overrides,
+ };
+}
+
+function makeListing(id: string, priceVND: string, district: string): ListingDetail {
+ return {
+ id,
+ status: 'ACTIVE',
+ transactionType: 'SALE',
+ priceVND,
+ pricePerM2: null,
+ rentPriceMonthly: null,
+ commissionPct: null,
+ viewCount: 42,
+ saveCount: 3,
+ inquiryCount: 1,
+ publishedAt: '2025-01-01T00:00:00.000Z',
+ createdAt: '2025-01-01T00:00:00.000Z',
+ property: makeProperty({ id: `prop-${id}`, district }),
+ seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0912345678' },
+ agent: null,
+ };
+}
+
+// id rõ ràng để shortId dễ distinguish:
+// shortId lấy slice(0,5): 'aaaaa' → 'AAAAA', 'bbbbb' → 'BBBBB', 'ccccc' → 'CCCCC'
+const LISTING_A = makeListing('aaaaa-cheap', '1500000000', 'Quận 1');
+const LISTING_B = makeListing('bbbbb-mid', '5000000000', 'Quận 7');
+const LISTING_C = makeListing('ccccc-dear', '8000000000', 'Quận 3');
+
+const mockListings = {
+ data: [LISTING_A, LISTING_B, LISTING_C],
+ total: 3,
+ page: 1,
+ limit: 50,
+ totalPages: 1,
+};
+
+// ─── Imports phụ thuộc mock ───────────────────────────────────────────────────
+
+import { listingsApi } from '@/lib/listings-api';
+import ListingsPage from '../page';
+
+const mockedApi = vi.mocked(listingsApi);
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe('ListingsPage — ticker table', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedApi.search.mockResolvedValue(mockListings as never);
+ });
+
+ // ── Render cơ bản ──────────────────────────────────────────────────────────
+
+ it('hiển thị tiêu đề trang', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('Thị Trường BĐS')).toBeInTheDocument();
+ });
+ });
+
+ it('gọi API với status=ACTIVE khi mount', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockedApi.search).toHaveBeenCalledWith(
+ expect.objectContaining({ status: 'ACTIVE' }),
+ );
+ });
+ });
+
+ it('hiển thị header cột bảng đúng', async () => {
+ render();
+ await waitFor(() => {
+ const table = screen.getByRole('table');
+ const headers = table.querySelectorAll('thead th');
+ const headerTexts = Array.from(headers).map((h) => h.textContent?.trim());
+ expect(headerTexts).toContain('#');
+ expect(headerTexts).toContain('Mã');
+ expect(headerTexts).toContain('Quận');
+ expect(headerTexts).toContain('Loại');
+ expect(headerTexts).toContain('Giá');
+ expect(headerTexts).toContain('Δ30d');
+ expect(headerTexts).toContain('DT m²');
+ expect(headerTexts).toContain('KL/Views');
+ });
+ });
+
+ it('hiển thị dấu — cho cột Δ30d (chưa có dữ liệu API)', async () => {
+ render();
+ await waitFor(() => {
+ // Tất cả 3 rows phải hiển thị "—" vì API chưa có field priceDelta30d.
+ const dashes = screen.getAllByText('—');
+ expect(dashes.length).toBeGreaterThanOrEqual(3);
+ });
+ });
+
+ it('hiển thị mã tin dạng GG-XXXXX', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText('GG-AAAAA')).toBeInTheDocument();
+ expect(screen.getByText('GG-BBBBB')).toBeInTheDocument();
+ expect(screen.getByText('GG-CCCCC')).toBeInTheDocument();
+ });
+ });
+
+ it('hiển thị số lượng kết quả khi load xong', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByText(/3 bất động sản đang niêm yết/)).toBeInTheDocument();
+ });
+ });
+
+ it('hiển thị thông báo lỗi khi API thất bại', async () => {
+ mockedApi.search.mockRejectedValue(new Error('Network error'));
+ render();
+ await waitFor(() => {
+ expect(screen.getByText(/Không thể tải danh sách/)).toBeInTheDocument();
+ });
+ });
+
+ // ── Sort ───────────────────────────────────────────────────────────────────
+
+ it('bảng hiển thị đúng 3 rows dữ liệu', async () => {
+ render();
+ await waitFor(() => {
+ const rows = screen.getAllByRole('row');
+ // 1 header row + 3 data rows
+ expect(rows.length).toBe(4);
+ });
+ });
+
+ it('sort desc theo Giá mặc định — listing đắt nhất (ccccc-dear) đứng đầu', async () => {
+ render();
+ await waitFor(() => {
+ const rows = screen.getAllByRole('row');
+ // row[0] = header, row[1] = first data row
+ expect(rows[1]?.textContent).toContain('GG-CCCCC');
+ });
+ });
+
+ it('toggle sort Giá: click header Giá để đổi chiều sort', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByRole('table');
+ const giaHeader = Array.from(table.querySelectorAll('thead th')).find(
+ (th) => th.textContent?.trim().includes('Giá'),
+ ) as HTMLElement;
+
+ expect(giaHeader).toBeTruthy();
+
+ // Click một lần (asc) — listing rẻ nhất phải lên đầu
+ await user.click(giaHeader);
+ let rows = screen.getAllByRole('row').slice(1);
+ expect(rows.length).toBe(3);
+ expect(rows[0]?.textContent).toContain('GG-AAAAA');
+
+ // Click lần hai (desc trở lại) — listing đắt nhất lên đầu
+ await user.click(giaHeader);
+ rows = screen.getAllByRole('row').slice(1);
+ expect(rows[0]?.textContent).toContain('GG-CCCCC');
+ });
+
+ it('sort theo DT m² khi click header đó', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+
+ const table = screen.getByRole('table');
+ const dtHeader = Array.from(table.querySelectorAll('thead th')).find(
+ (th) => th.textContent?.trim().includes('DT m²'),
+ ) as HTMLElement;
+
+ await user.click(dtHeader);
+ // Sau sort không crash — rows vẫn hiển thị
+ const rows = screen.getAllByRole('row').slice(1);
+ expect(rows.length).toBe(3);
+ });
+
+ // ── Toggle view ────────────────────────────────────────────────────────────
+
+ it('hiển thị bảng mặc định (table mode)', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+ });
+
+ it('chuyển sang card mode khi click nút Chế độ thẻ', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /chế độ thẻ/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /chế độ thẻ/i }));
+
+ // Bảng biến mất
+ expect(screen.queryByRole('table')).not.toBeInTheDocument();
+ });
+
+ it('quay lại table mode khi click nút Chế độ bảng', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /chế độ thẻ/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /chế độ thẻ/i }));
+ expect(screen.queryByRole('table')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /chế độ bảng/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+ });
+
+ it('nút toggle giữ aria-pressed đúng trạng thái', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ const tableBtn = screen.getByRole('button', { name: /chế độ bảng/i });
+ const cardBtn = screen.getByRole('button', { name: /chế độ thẻ/i });
+ expect(tableBtn).toHaveAttribute('aria-pressed', 'true');
+ expect(cardBtn).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ await user.click(screen.getByRole('button', { name: /chế độ thẻ/i }));
+
+ const tableBtn = screen.getByRole('button', { name: /chế độ bảng/i });
+ const cardBtn = screen.getByRole('button', { name: /chế độ thẻ/i });
+ expect(tableBtn).toHaveAttribute('aria-pressed', 'false');
+ expect(cardBtn).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ // ── Filter ─────────────────────────────────────────────────────────────────
+
+ it('hiển thị filter bar với 4 select', async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByRole('combobox', { name: /loại giao dịch/i })).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /loại bất động sản/i })).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /quận/i })).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /khoảng giá/i })).toBeInTheDocument();
+ });
+ });
+
+ // ── Navigation ─────────────────────────────────────────────────────────────
+
+ it('điều hướng đến trang chi tiết khi click row', async () => {
+ render();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getAllByRole('row').length).toBeGreaterThan(1);
+ });
+
+ const dataRows = screen.getAllByRole('row').slice(1) as HTMLElement[];
+ await user.click(dataRows[0]!);
+
+ expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/listings/'));
+ });
+});
diff --git a/apps/web/app/[locale]/(public)/listings/page.tsx b/apps/web/app/[locale]/(public)/listings/page.tsx
index b9483fe..fe30f1a 100644
--- a/apps/web/app/[locale]/(public)/listings/page.tsx
+++ b/apps/web/app/[locale]/(public)/listings/page.tsx
@@ -41,12 +41,14 @@ 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
+/**
+ * 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;
}
// ---------------------------------------------------------------------------
@@ -111,11 +113,18 @@ function buildColumns(
{
id: 'delta30d',
header: 'Δ30d',
- cell: (row) => ,
+ 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) => mockDelta(row.id),
+ sortValue: (row) => getDelta30d(row) ?? -Infinity,
width: '90px',
},
{
diff --git a/apps/web/components/design-system/data-table.tsx b/apps/web/components/design-system/data-table.tsx
index f2e62bf..d380c9c 100644
--- a/apps/web/components/design-system/data-table.tsx
+++ b/apps/web/components/design-system/data-table.tsx
@@ -182,10 +182,15 @@ export function DataTable({
key={key}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn(
- 'border-b border-border/60 transition-colors',
+ 'border-b border-border/60 transition-colors duration-100',
dense ? 'h-row' : 'h-10',
index % 2 === 1 && 'bg-background-surface/40',
- onRowClick && 'cursor-pointer hover:bg-background-surface',
+ onRowClick && [
+ 'cursor-pointer',
+ 'hover:bg-background-surface',
+ 'active:bg-accent/10',
+ 'focus-within:bg-background-surface',
+ ],
)}
>
{columns.map((col) => (