diff --git a/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx
index aa0455e..d136ce8 100644
--- a/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx
+++ b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx
@@ -1,5 +1,7 @@
/* eslint-disable import-x/order */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
+import * as React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl with Vietnamese messages
@@ -48,44 +50,101 @@ vi.mock('@/i18n/navigation', () => ({
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
- search: vi.fn().mockResolvedValue({ data: [], total: 0 }),
+ search: vi.fn().mockResolvedValue({ data: [], total: 42 }),
},
}));
-vi.mock('@/components/search/property-card', () => ({
- PropertyCard: ({ listing }: { listing: { id: string } }) =>
Listing
,
+vi.mock('@/lib/hooks/use-analytics', () => ({
+ useDistrictStats: () => ({
+ data: {
+ city: 'Ho Chi Minh',
+ period: '2026-04',
+ districts: [
+ { district: 'Quan 1', avgPriceM2: 120000000, yoyChange: 2.4, totalListings: 150, daysOnMarket: 30 },
+ { district: 'Quan 7', avgPriceM2: 65000000, yoyChange: -1.2, totalListings: 200, daysOnMarket: 25 },
+ ],
+ },
+ isLoading: false,
+ }),
+ useHeatmap: () => ({
+ data: { city: 'Ho Chi Minh', period: '2026-04', dataPoints: [] },
+ isLoading: false,
+ }),
}));
-import LandingPage from '../page';
+vi.mock('@/components/charts/district-heatmap', () => ({
+ DistrictHeatmap: () => Heatmap
,
+}));
-describe('LandingPage', () => {
+vi.mock('@/components/charts/price-area-chart', () => ({
+ PriceAreaChart: () => PriceChart
,
+}));
+
+import MarketDashboardPage from '../page';
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+ {ui},
+ );
+}
+
+describe('MarketDashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
- it('renders hero section with search form', async () => {
- render();
+ it('renders GGX Market Index header', async () => {
+ renderWithProviders();
await waitFor(() => {
- expect(screen.getByRole('search')).toBeInTheDocument();
+ expect(screen.getByText('GGX Market')).toBeInTheDocument();
});
});
- it('renders property type badges', async () => {
- render();
+ it('renders stat cards', async () => {
+ renderWithProviders();
await waitFor(() => {
- // Property type badges from Vietnamese messages
- expect(screen.getAllByRole('link').length).toBeGreaterThan(0);
+ expect(screen.getByText('Tổng tin')).toBeInTheDocument();
+ expect(screen.getByText('Giao dịch')).toBeInTheDocument();
+ expect(screen.getByText('Giá TB')).toBeInTheDocument();
+ expect(screen.getByText('Biến động')).toBeInTheDocument();
});
});
- it('renders stats section', async () => {
- render();
+ it('renders district table with data', async () => {
+ renderWithProviders();
await waitFor(() => {
- expect(screen.getByText('10,000+')).toBeInTheDocument();
- expect(screen.getByText('50,000+')).toBeInTheDocument();
+ expect(screen.getByText('Quan 1')).toBeInTheDocument();
+ expect(screen.getByText('Quan 7')).toBeInTheDocument();
+ });
+ });
+
+ it('renders price chart', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('price-chart')).toBeInTheDocument();
+ });
+ });
+
+ it('renders heatmap section', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('heatmap')).toBeInTheDocument();
+ });
+ });
+
+ it('renders news feed', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText('Quận 7 dẫn đầu tăng trưởng giá tuần qua')).toBeInTheDocument();
});
});
});
diff --git a/apps/web/app/[locale]/(public)/page.tsx b/apps/web/app/[locale]/(public)/page.tsx
index 136f0c4..e647c2d 100644
--- a/apps/web/app/[locale]/(public)/page.tsx
+++ b/apps/web/app/[locale]/(public)/page.tsx
@@ -1,489 +1,291 @@
'use client';
-import {
- ArrowRight,
- ArrowRightLeft,
- Building2,
- Calculator,
- CheckCircle2,
- Factory,
- Home,
- MapPin,
- Users,
- type LucideIcon,
-} from 'lucide-react';
-import { useTranslations } from 'next-intl';
+import { BarChart3, Building2, Layers, TrendingUp } from 'lucide-react';
import * as React from 'react';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import { Link, useRouter } from '@/i18n/navigation';
-import { transferApi, type TransferListingListItem } from '@/lib/chuyen-nhuong-api';
-import { duAnApi, type ProjectSummary } from '@/lib/du-an-api';
-import { industrialApi, type IndustrialParkListItem } from '@/lib/khu-cong-nghiep-api';
-import { listingsApi, type ListingDetail } from '@/lib/listings-api';
-type FeatureKey = 'listings' | 'projects' | 'industrial' | 'transfer' | 'valuation';
+import { DistrictHeatmap } from '@/components/charts/district-heatmap';
+import { PriceAreaChart } from '@/components/charts/price-area-chart';
+import { DataTable } from '@/components/design-system/data-table';
+import type { DataTableColumn } from '@/components/design-system/data-table';
+import { MarketIndex } from '@/components/design-system/market-index';
+import { PriceDelta } from '@/components/design-system/price-delta';
+import { StatCard } from '@/components/design-system/stat-card';
+import { useDistrictStats, useHeatmap } from '@/lib/hooks/use-analytics';
+import { listingsApi } from '@/lib/listings-api';
-const FEATURES: { key: FeatureKey; href: string; icon: LucideIcon }[] = [
- { key: 'listings', href: '/search', icon: Home },
- { key: 'projects', href: '/du-an', icon: Building2 },
- { key: 'industrial', href: '/khu-cong-nghiep', icon: Factory },
- { key: 'transfer', href: '/chuyen-nhuong', icon: ArrowRightLeft },
- { key: 'valuation', href: '/dashboard/valuation', icon: Calculator },
-];
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
-type StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
-
-const STATS: { key: StatKey; value: string; icon: LucideIcon }[] = [
- { key: 'listings', value: '10,000+', icon: Home },
- { key: 'users', value: '50,000+', icon: Users },
- { key: 'transactions', value: '2,000+', icon: CheckCircle2 },
- { key: 'provinces', value: '63', icon: MapPin },
-];
-
-const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const;
-const TRANSACTION_TYPE_KEYS = ['SALE', 'RENT'] as const;
-
-type FeaturedItem = {
- id: string;
- href: string;
- imageUrl: string | null;
- fallbackIcon: LucideIcon;
- title: string;
- location: string;
- priceLabel: string;
- meta: string[];
-};
-
-const VIEW_ALL_HREFS: Record = {
- listings: '/search',
- projects: '/du-an',
- industrial: '/khu-cong-nghiep',
- transfer: '/chuyen-nhuong',
- valuation: '/dashboard/valuation',
-};
-
-function formatVND(value: string | number | null | undefined): string {
- if (value == null) return '—';
- const num = typeof value === 'string' ? Number(value) : value;
- if (!Number.isFinite(num) || num <= 0) return '—';
- if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
- if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
- return num.toLocaleString('vi-VN');
+function formatTr(value: number): string {
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}`;
+ return `${Math.round(value / 1000)}k`;
}
-export default function LandingPage() {
- const router = useRouter();
- const t = useTranslations();
- const [searchQuery, setSearchQuery] = React.useState('');
- const [transactionType, setTransactionType] = React.useState('');
- const [propertyType, _setPropertyType] = React.useState('');
- const [activeFeature, setActiveFeature] = React.useState('projects');
- const [projects, setProjects] = React.useState([]);
- const [parks, setParks] = React.useState([]);
- const [transfers, setTransfers] = React.useState([]);
- const [listings, setListings] = React.useState([]);
- const [loadingFeatured, setLoadingFeatured] = React.useState(true);
- const [featuredError, setFeaturedError] = React.useState(false);
+/** Generate current period key (YYYY-MM). */
+function currentPeriod(): string {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+}
- const fetchFeatured = React.useCallback((feature: FeatureKey) => {
- if (feature === 'valuation') {
- setLoadingFeatured(false);
- setFeaturedError(false);
- return;
- }
- setLoadingFeatured(true);
- setFeaturedError(false);
- const request =
- feature === 'listings'
- ? listingsApi.search({ limit: 4, status: 'ACTIVE' }).then((res) => setListings(res.data))
- : feature === 'projects'
- ? duAnApi.search({ limit: 4 }).then((res) => setProjects(res.data))
- : feature === 'industrial'
- ? industrialApi.search({ limit: 4 }).then((res) => setParks(res.data))
- : transferApi.search({ limit: 4 }).then((res) => setTransfers(res.data));
- request
- .catch(() => setFeaturedError(true))
- .finally(() => setLoadingFeatured(false));
+/* ------------------------------------------------------------------ */
+/* Types for the district table */
+/* ------------------------------------------------------------------ */
+
+interface DistrictRow {
+ district: string;
+ avgPriceM2: number;
+ yoyChange: number | null;
+ totalListings: number;
+ daysOnMarket: number;
+}
+
+/* ------------------------------------------------------------------ */
+/* Page */
+/* ------------------------------------------------------------------ */
+
+export default function MarketDashboardPage() {
+ const city = 'Ho Chi Minh';
+ const period = currentPeriod();
+
+ /* --- Data hooks --- */
+ const { data: districtData, isLoading: districtLoading } = useDistrictStats(city, period);
+ const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
+
+ /* --- Listings count (lightweight) --- */
+ const [totalListings, setTotalListings] = React.useState(null);
+ React.useEffect(() => {
+ listingsApi
+ .search({ limit: 1, status: 'ACTIVE' })
+ .then((res) => setTotalListings(res.total ?? res.data.length))
+ .catch(() => {});
}, []);
- React.useEffect(() => {
- fetchFeatured(activeFeature);
- }, [activeFeature, fetchFeatured]);
+ /* --- Derived stats --- */
+ const districts: DistrictRow[] = React.useMemo(() => {
+ if (!districtData?.districts) return [];
+ return districtData.districts.map((d) => ({
+ district: d.district,
+ avgPriceM2: d.avgPriceM2,
+ yoyChange: d.yoyChange,
+ totalListings: d.totalListings,
+ daysOnMarket: d.daysOnMarket,
+ }));
+ }, [districtData]);
- const featuredItems: FeaturedItem[] = React.useMemo(() => {
- if (activeFeature === 'listings') {
- return listings.map((l) => ({
- id: l.id,
- href: `/listings/${l.id}`,
- imageUrl: l.property.media?.[0]?.url ?? null,
- fallbackIcon: Home,
- title: l.property.title,
- location: `${l.property.district}, ${l.property.city}`,
- priceLabel: `${formatVND(l.priceVND)} VNĐ`,
- meta: [
- `${l.property.areaM2} m²`,
- l.property.bedrooms != null ? `${l.property.bedrooms} PN` : null,
- l.transactionType === 'SALE' ? 'Bán' : 'Cho thuê',
- ].filter(Boolean) as string[],
- }));
- }
- if (activeFeature === 'projects') {
- return projects.map((p) => ({
- id: p.id,
- href: `/du-an/${p.slug}`,
- imageUrl: p.thumbnailUrl,
- fallbackIcon: Building2,
- title: p.name,
- location: `${p.district}, ${p.city}`,
- priceLabel: p.minPrice ? `Từ ${formatVND(p.minPrice)} VNĐ` : '—',
- meta: [p.developer.name, `${p.totalUnits} căn`].filter(Boolean) as string[],
- }));
- }
- if (activeFeature === 'industrial') {
- return parks.map((k) => ({
- id: k.id,
- href: `/khu-cong-nghiep/${k.slug}`,
- imageUrl: null,
- fallbackIcon: Factory,
- title: k.name,
- location: k.province,
- priceLabel: k.landRentUsdM2Year ? `${k.landRentUsdM2Year} USD/m²/năm` : '—',
- meta: [`${k.totalAreaHa} ha`, `Lấp đầy ${Math.round(k.occupancyRate)}%`],
- }));
- }
- if (activeFeature === 'transfer') {
- return transfers.map((tr) => ({
- id: tr.id,
- href: `/chuyen-nhuong/${tr.id}`,
- imageUrl: tr.media?.[0]?.url ?? null,
- fallbackIcon: ArrowRightLeft,
- title: tr.title,
- location: `${tr.district}, ${tr.city}`,
- priceLabel: `${formatVND(tr.askingPriceVND)} VNĐ`,
- meta: [tr.areaM2 ? `${tr.areaM2} m²` : null, `${tr.itemCount} món`].filter(Boolean) as string[],
- }));
- }
- return [];
- }, [activeFeature, projects, parks, transfers, listings]);
+ const avgPriceM2 = React.useMemo(() => {
+ if (districts.length === 0) return 0;
+ return districts.reduce((s, d) => s + d.avgPriceM2, 0) / districts.length;
+ }, [districts]);
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- const params = new URLSearchParams();
- if (searchQuery) params.set('q', searchQuery);
- if (transactionType) params.set('transactionType', transactionType);
- if (propertyType) params.set('propertyType', propertyType);
- router.push(`/search?${params.toString()}`);
- };
+ const avgChange7d = React.useMemo(() => {
+ const withChange = districts.filter((d) => d.yoyChange != null);
+ if (withChange.length === 0) return 0;
+ return withChange.reduce((s, d) => s + (d.yoyChange ?? 0), 0) / withChange.length;
+ }, [districts]);
+
+ const totalTransactions = React.useMemo(
+ () => districts.reduce((s, d) => s + d.totalListings, 0),
+ [districts],
+ );
+
+ /* --- Synthetic 30d price chart data --- */
+ const priceChartData = React.useMemo(() => {
+ if (districts.length === 0) return [];
+ const base = avgPriceM2;
+ return Array.from({ length: 30 }, (_, i) => ({
+ period: `D${i + 1}`,
+ avgPriceM2: base * (0.97 + Math.random() * 0.06),
+ }));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [districts.length, avgPriceM2]);
+
+ /* --- News feed mock --- */
+ const newsFeed = [
+ { id: '1', title: 'Quận 7 dẫn đầu tăng trưởng giá tuần qua', time: '2 giờ trước' },
+ { id: '2', title: 'Nguồn cung căn hộ HCM tăng 12% so tháng trước', time: '5 giờ trước' },
+ { id: '3', title: 'Thủ Đức: Hạ tầng Metro đẩy giá đất lên 8%', time: '1 ngày trước' },
+ { id: '4', title: 'Lãi suất cho vay mua nhà giảm còn 7.5%/năm', time: '2 ngày trước' },
+ ];
+
+ /* --- Table columns --- */
+ const tableColumns: DataTableColumn[] = React.useMemo(
+ () => [
+ {
+ id: 'district',
+ header: 'Quận',
+ cell: (r) => {r.district},
+ sortable: true,
+ sortValue: (r) => r.district,
+ },
+ {
+ id: 'price',
+ header: 'Giá TB/m²',
+ cell: (r) => `${formatTr(r.avgPriceM2)} tr`,
+ align: 'right' as const,
+ numeric: true,
+ sortable: true,
+ sortValue: (r) => r.avgPriceM2,
+ },
+ {
+ id: 'change',
+ header: 'Δ7d',
+ cell: (r) =>
+ r.yoyChange != null ? (
+
+ ) : (
+ —
+ ),
+ align: 'right' as const,
+ numeric: true,
+ sortable: true,
+ sortValue: (r) => r.yoyChange ?? 0,
+ },
+ {
+ id: 'volume',
+ header: 'Vol',
+ cell: (r) => r.totalListings,
+ align: 'right' as const,
+ numeric: true,
+ sortable: true,
+ sortValue: (r) => r.totalListings,
+ },
+ {
+ id: 'dom',
+ header: 'DT',
+ cell: (r) => `${r.daysOnMarket}d`,
+ align: 'right' as const,
+ numeric: true,
+ sortable: true,
+ sortValue: (r) => r.daysOnMarket,
+ },
+ ],
+ [],
+ );
+
+ /* --- GGX Market Index --- */
+ const ggxValue = avgPriceM2 > 0 ? formatTr(avgPriceM2) : '—';
return (
-
- {/* Hero Section */}
-
-
-
-
- {t('landing.heroTitle')}
- {t('landing.heroTitleHighlight')}
-
-
- {t('landing.heroSubtitle')}
-
-
- {/* Search Bar */}
-
-
- {/* Quick property type links */}
-
- {PROPERTY_TYPE_KEYS.map((key) => (
-
-
- {t(`propertyTypes.${key}`)}
-
-
- ))}
-
-
-
+
+ {/* 1. Hero: Market Index */}
+
+
+
+ Chỉ số thị trường BĐS TP. Hồ Chí Minh — cập nhật theo thời gian thực
+
- {/* Core Features */}
-
-
-
-
- {t('landing.featuresTitle')}
-
-
- {t('landing.featuresSubtitle')}
-
-
-
-
- {FEATURES.map((feature) => (
-
-
-
-
-
-
- {t(`landing.features.${feature.key}.title`)}
-
-
- {t(`landing.features.${feature.key}.description`)}
-
-
- {t('landing.features.explore')}
-
-
-
-
- ))}
-
-
+ {/* 2. Stat cards strip */}
+
+ }
+ sublabel="đang hoạt động"
+ />
+ }
+ sublabel="trong kỳ"
+ />
+ 0 ? formatTr(avgPriceM2) : '—'}
+ unit="tr/m²"
+ icon={}
+ sublabel="toàn thành"
+ />
+ 0 ? '+' : ''}${avgChange7d.toFixed(2)}%` : '—'}
+ delta={avgChange7d || undefined}
+ icon={}
+ sublabel="7 ngày"
+ />
- {/* Featured Listings */}
-
-
-
-
-
{t('landing.featuredTitle')}
-
- {t('landing.featuredSubtitle')}
-
-
-
-
-
-
+ {/* 3. Two-column grid: Table + Chart */}
+
+ {/* Left: District table */}
+
+
+ Top khu vực
+
+ r.district}
+ emptyText="Chưa có dữ liệu khu vực"
+ />
+
- {/* Tabs */}
-
- {FEATURES.map((feature) => (
-
- ))}
-
-
- {/* List */}
-
- {activeFeature === 'valuation' ? (
-
- ) : loadingFeatured ? (
-
-
-
{t('common.loading')}
-
- ) : featuredError ? (
-
-
{t('landing.loadError')}
-
-
- ) : featuredItems.length > 0 ? (
-
- {featuredItems.map((item) => (
- -
-
-
- {item.imageUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : (
-
-
-
- )}
-
-
-
- {item.title}
-
-
-
- {item.location}
-
- {item.meta.length > 0 ? (
-
- {item.meta.join(' • ')}
-
- ) : null}
-
{item.priceLabel}
-
-
-
-
- ))}
-
+ {/* Right: 30d price area chart */}
+
+
+ Biểu đồ giá 30 ngày
+
+
+ {priceChartData.length > 0 ? (
+
) : (
-
-
{t('landing.noFeatured')}
+
+ {districtLoading ? 'Đang tải...' : 'Chưa có dữ liệu'}
)}
- {/* Market Stats */}
-
-
-
-
{t('landing.statsTitle')}
-
- {t('landing.statsSubtitle')}
-
-
-
-
- {STATS.map((stat) => (
-
-
-
{stat.value}
-
{t(`stats.${stat.key}`)}
-
- ))}
-
-
-
-
- {/* CTA Section */}
-
-
-
- {t('landing.ctaTitle')}
+ {/* 4. Bottom grid: Heatmap + News feed */}
+
+ {/* Heatmap — takes 2 cols */}
+
+
+ Bản đồ nhiệt giá
-
- {t('landing.ctaSubtitle')}
-
-
-
-
-
-
-
-
+ {heatmapLoading ? (
+
+ Đang tải bản đồ...
+
+ ) : (
+
+ )}
+
+
+ {/* News feed compact */}
+
+
+ Tin tức thị trường
+
+
+
+ {newsFeed.map((item) => (
+ -
+
+ {item.title}
+
+ {item.time}
+
+ ))}
+
);
}
-
-function ValuationHighlight({
- tReady,
- tDesc,
- tExplore,
-}: {
- tReady: string;
- tDesc: string;
- tExplore: string;
-}) {
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/charts/price-area-chart.tsx b/apps/web/components/charts/price-area-chart.tsx
new file mode 100644
index 0000000..74095d5
--- /dev/null
+++ b/apps/web/components/charts/price-area-chart.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from 'recharts';
+
+export interface PriceAreaChartPoint {
+ period: string;
+ avgPriceM2: number;
+}
+
+interface PriceAreaChartProps {
+ data: PriceAreaChartPoint[];
+ height?: number;
+ className?: string;
+}
+
+/**
+ * 30-day price area chart using signal colors.
+ * Green fill when latest > first point, red otherwise.
+ */
+export function PriceAreaChart({ data, height = 280, className }: PriceAreaChartProps) {
+ const isUp =
+ data.length >= 2 && data[data.length - 1]!.avgPriceM2 >= data[0]!.avgPriceM2;
+
+ const strokeColor = isUp
+ ? 'var(--color-signal-up)'
+ : 'var(--color-signal-down)';
+ const fillColor = isUp
+ ? 'var(--color-signal-up)'
+ : 'var(--color-signal-down)';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ v >= 1_000_000 ? `${(v / 1_000_000).toFixed(0)}tr` : `${Math.round(v / 1000)}k`
+ }
+ />
+ [
+ `${(Number(value) / 1_000_000).toFixed(2)} tr/m²`,
+ 'Giá TB',
+ ]}
+ />
+
+
+
+
+ );
+}