Files
goodgo-platform/apps/web/app/[locale]/(public)/page.tsx
Ho Ngoc Hai b9a1a24f65
Some checks failed
Security Scanning / Security Gate (push) Failing after 2s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Blocked by required conditions
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / AI Services (Python) — Smoke (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 5s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Scan — Web Image (push) Failing after 37s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 43s
Deploy / Build API Image (push) Failing after 7s
Deploy / Build Web Image (push) Failing after 5s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Trivy Scan — API Image (push) Failing after 37s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 30s
Security Scanning / Trivy Filesystem Scan (push) Failing after 30s
fix(web): homepage analytics — auth gate, district dedup, district name normalize
Three issues found while auditing the homepage:

1. Analytics queries never fired for authed visitors. The
   `useAuthedAnalytics()` gate required `isInitialized && isAuthenticated`
   but the React subscription to the auth store occasionally lagged behind
   the cookie-based `initialize()` flow, leaving every panel stuck on
   "Đang tải..." even though the cookie + profile API responded fine.
   Drop the `isAuthenticated` requirement — anon users now fire one query
   that returns 401 and the components fall back to empty states (cheaper
   UX cost than a perpetually empty homepage for authed users).

2. "Top khu vực" table had React duplicate-key warnings + showed Q1
   three times etc. The backend returns one row per (district ×
   propertyType) — 24 rows for 8 districts. Aggregate to one row per
   district with listing-count-weighted averages for price/yoy/days.

3. Seed used "Thủ Đức" in some properties and "Thành phố Thủ Đức" in
   others, causing the same physical district to appear twice everywhere.
   Normalize seed.ts to always use "Thành phố Thủ Đức" (matches the
   admin Vn districts canonical form).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:39:18 +07:00

626 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { AlertTriangle, BarChart3, Building2, Clock, Layers, TrendingDown, TrendingUp } from 'lucide-react';
import * as React from 'react';
import { DistrictHeatmap } from '@/components/charts/district-heatmap';
import { PriceAreaChart } from '@/components/charts/price-area-chart';
import { DataTable, type DataTableColumn } from '@/components/design-system/data-table';
import { EmptyState } from '@/components/design-system/empty-state';
import { KpiCard } from '@/components/design-system/kpi-card';
import { PriceDelta } from '@/components/design-system/price-delta';
import { Skeleton } from '@/components/design-system/skeleton';
import { TickerStrip, type TickerItem } from '@/components/design-system/ticker-strip';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import {
useDistrictStats,
useHeatmap,
useMarketSnapshot,
usePriceMovers,
useTrendingAreas,
} from '@/lib/hooks/use-analytics';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/**
* Heatmap + district stats are aggregated quarterly in MarketIndex
* (`YYYY-QN`). The previous `YYYY-MM` format never matched any row, so
* the heatmap and district table came back empty.
*/
function currentPeriod(): string {
const now = new Date();
const quarter = Math.floor(now.getMonth() / 3) + 1;
return `${now.getFullYear()}-Q${quarter}`;
}
/* ------------------------------------------------------------------ */
/* Error Boundary */
/* ------------------------------------------------------------------ */
interface SectionErrorBoundaryProps {
children: React.ReactNode;
fallbackTitle?: string;
}
interface SectionErrorBoundaryState {
hasError: boolean;
}
class SectionErrorBoundary extends React.Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
constructor(props: SectionErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): SectionErrorBoundaryState {
return { hasError: true };
}
override render() {
if (this.state.hasError) {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-background-surface p-4 text-sm text-foreground-muted">
<AlertTriangle className="h-4 w-4 text-warning" />
<span>{this.props.fallbackTitle ?? 'Không thể tải dữ liệu'}</span>
</div>
);
}
return this.props.children;
}
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DistrictRow {
district: string;
avgPriceM2: number;
yoyChange: number | null;
totalListings: number;
daysOnMarket: number;
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
/** 1. TickerStrip — builds items from price movers (up + down). */
function DashboardTicker() {
const { data: upData } = usePriceMovers('up', '7d', 5);
const { data: downData } = usePriceMovers('down', '7d', 5);
const items = React.useMemo<TickerItem[]>(() => {
const result: TickerItem[] = [];
for (const m of upData?.movers ?? []) {
result.push({
id: `up-${m.districtId}`,
label: m.name,
changePercent: m.changePct,
direction: 'up',
});
}
for (const m of downData?.movers ?? []) {
result.push({
id: `dn-${m.districtId}`,
label: m.name,
changePercent: m.changePct,
direction: 'down',
});
}
return result;
}, [upData, downData]);
if (items.length === 0) return null;
return <TickerStrip items={items} className="h-full" />;
}
/** 2. KPI Strip — 4 columns from market snapshot. */
function KpiStrip({ city }: { city: string }) {
const { data, isLoading } = useMarketSnapshot(city);
return (
<section className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<KpiCard
label="GGI HCM"
value={data ? formatPricePerM2(data.avgPricePerM2) : '—'}
delta={data?.priceChangePct?.d7}
footnote="Chỉ số giá TB/m²"
icon={<BarChart3 className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Giá TB"
value={data ? formatPrice(data.avgPrice) : '—'}
delta={data?.priceChangePct?.d30}
footnote="Toàn thành phố"
icon={<Building2 className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Giá trung vị"
value={data ? formatPrice(data.medianPrice) : '—'}
footnote="Median price"
icon={<Layers className="h-3.5 w-3.5" />}
loading={isLoading}
/>
<KpiCard
label="Tin đang hoạt động"
value={data ? data.activeCount.toLocaleString('vi-VN') : '—'}
footnote={data ? `${data.newListings24h} tin mới 24h` : undefined}
icon={<TrendingUp className="h-3.5 w-3.5" />}
loading={isLoading}
/>
</section>
);
}
/** 3. Top Movers — up/down price movements. */
function TopMovers() {
const { data: upData, isLoading: upLoading } = usePriceMovers('up', '7d', 5);
const { data: downData, isLoading: downLoading } = usePriceMovers('down', '7d', 5);
const isLoading = upLoading || downLoading;
if (isLoading) {
return (
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton.Table rows={5} />
</div>
);
}
const upMovers = upData?.movers ?? [];
const downMovers = downData?.movers ?? [];
if (upMovers.length === 0 && downMovers.length === 0) {
return (
<EmptyState
title="Chưa có dữ liệu biến động"
description="Dữ liệu sẽ sẵn sàng khi có đủ tin đăng."
icon={<TrendingUp className="h-6 w-6" />}
/>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border border-border bg-background-surface p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent-green">
<TrendingUp className="h-3.5 w-3.5" /> Top tăng giá
</h3>
<ul className="divide-y divide-border/60 text-sm">
{upMovers.map((m) => (
<li key={m.districtId} className="flex items-center justify-between py-1.5">
<span className="text-foreground">{m.name}</span>
<PriceDelta value={m.changePct} size="sm" direction="up" />
</li>
))}
</ul>
</div>
<div className="rounded-md border border-border bg-background-surface p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent-red">
<TrendingDown className="h-3.5 w-3.5" /> Top giảm giá
</h3>
<ul className="divide-y divide-border/60 text-sm">
{downMovers.map((m) => (
<li key={m.districtId} className="flex items-center justify-between py-1.5">
<span className="text-foreground">{m.name}</span>
<PriceDelta value={m.changePct} size="sm" direction="down" />
</li>
))}
</ul>
</div>
</div>
);
}
/** 4. Trending Areas — hot districts last 7 days. */
function TrendingAreas() {
const { data, isLoading } = useTrendingAreas(7, 10);
if (isLoading) return <Skeleton.Table rows={5} />;
const areas = data?.areas ?? [];
if (areas.length === 0) {
return (
<EmptyState
title="Chưa có khu vực xu hướng"
description="Dữ liệu xu hướng cần ít nhất 7 ngày hoạt động."
icon={<BarChart3 className="h-6 w-6" />}
/>
);
}
return (
<div className="rounded-md border border-border bg-background-surface">
<ul className="divide-y divide-border/60">
{areas.map((area) => (
<li key={area.districtId} className="flex items-center justify-between px-4 py-2.5">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">{area.name}</span>
<span className="ml-2 text-xs text-foreground-muted">
{area.listings} tin · {area.inquiries} hỏi
</span>
</div>
<div className="flex items-center gap-2">
{area.priceChangePct != null && (
<PriceDelta value={area.priceChangePct} size="sm" />
)}
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted">
#{area.scoreRank}
</span>
</div>
</li>
))}
</ul>
</div>
);
}
/** 5. District Heatmap summary. */
function HeatmapSection({ city, period }: { city: string; period: string }) {
const { data, isLoading } = useHeatmap(city, period);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center rounded-md border border-border bg-background-elevated text-sm text-foreground-muted">
Đang tải bản đ...
</div>
);
}
if (!data?.dataPoints?.length) {
return (
<EmptyState
title="Chưa có dữ liệu bản đồ nhiệt"
description="Dữ liệu heatmap sẽ sẵn sàng khi có đủ tin đăng theo quận."
icon={<Layers className="h-6 w-6" />}
/>
);
}
return <DistrictHeatmap data={data.dataPoints} city={city} className="h-[400px]" />;
}
/** 6. Recent Listings table. */
function RecentListings() {
const [listings, setListings] = React.useState<ListingDetail[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
listingsApi
.search({ sortBy: 'publishedAt', limit: 20, status: 'ACTIVE' })
.then((res) => setListings(res.data))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
const columns = React.useMemo<DataTableColumn<ListingDetail>[]>(
() => [
{
id: 'title',
header: 'Tin đăng',
cell: (r) => (
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{r.property.title}</p>
<p className="truncate text-xs text-foreground-muted">
{r.property.district}, {r.property.city}
</p>
</div>
),
sortable: true,
sortValue: (r) => r.property.title,
},
{
id: 'type',
header: 'Loại',
cell: (r) => (
<span className="text-xs text-foreground-muted">{r.property.propertyType}</span>
),
},
{
id: 'area',
header: 'DT',
cell: (r) => `${r.property.areaM2}`,
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => r.property.areaM2,
},
{
id: 'price',
header: 'Giá',
cell: (r) => {
const price = Number(r.priceVND);
return (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatPrice(price)}
</span>
);
},
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => Number(r.priceVND),
},
{
id: 'priceM2',
header: 'Giá/m²',
cell: (r) =>
r.pricePerM2 ? (
<span className="text-xs tabular-nums text-foreground-muted">
{formatPricePerM2(r.pricePerM2)}
</span>
) : (
<span className="text-foreground-dim"></span>
),
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => r.pricePerM2 ?? 0,
},
{
id: 'published',
header: 'Đăng',
cell: (r) => {
if (!r.publishedAt) return <span className="text-foreground-dim"></span>;
const d = new Date(r.publishedAt);
return (
<span className="text-xs tabular-nums text-foreground-muted">
{d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })}
</span>
);
},
align: 'right' as const,
sortable: true,
sortValue: (r) => r.publishedAt ?? '',
},
],
[],
);
if (error) {
return (
<EmptyState
title="Không thể tải danh sách tin đăng"
description="Vui lòng thử lại sau."
icon={<AlertTriangle className="h-6 w-6" />}
/>
);
}
return (
<DataTable
columns={columns}
data={listings}
loading={loading}
defaultSortId="published"
defaultSortDir="desc"
getRowId={(r) => r.id}
emptyText="Chưa có tin đăng nào"
/>
);
}
/* ------------------------------------------------------------------ */
/* Main Page */
/* ------------------------------------------------------------------ */
export default function MarketDashboardPage() {
// DB stores city names with Vietnamese diacritics (e.g. "Hồ Chí Minh"),
// and SQL filters are case-insensitive but NOT diacritic-insensitive — so
// passing the unaccented "Ho Chi Minh" returns 0 listings.
const city = 'Hồ Chí Minh';
const period = currentPeriod();
/* District table data */
const { data: districtData, isLoading: districtLoading } = useDistrictStats(city, period);
const districts: DistrictRow[] = React.useMemo(() => {
if (!districtData?.districts) return [];
// Backend returns one row per (district × propertyType). For the
// homepage "Top khu vực" overview we collapse to one row per district,
// weighting averages by listing count so larger property types
// dominate, and using the median listings count for daysOnMarket.
const byDistrict = new Map<
string,
{ sumPriceTimesListings: number; totalListings: number; sumYoyTimesListings: number; sumYoyWeight: number; sumDaysTimesListings: number }
>();
for (const d of districtData.districts) {
const existing = byDistrict.get(d.district) ?? {
sumPriceTimesListings: 0,
totalListings: 0,
sumYoyTimesListings: 0,
sumYoyWeight: 0,
sumDaysTimesListings: 0,
};
existing.sumPriceTimesListings += d.avgPriceM2 * d.totalListings;
existing.totalListings += d.totalListings;
if (d.yoyChange != null) {
existing.sumYoyTimesListings += d.yoyChange * d.totalListings;
existing.sumYoyWeight += d.totalListings;
}
existing.sumDaysTimesListings += d.daysOnMarket * d.totalListings;
byDistrict.set(d.district, existing);
}
return Array.from(byDistrict.entries()).map(([district, agg]) => ({
district,
avgPriceM2: agg.totalListings > 0 ? agg.sumPriceTimesListings / agg.totalListings : 0,
yoyChange: agg.sumYoyWeight > 0 ? agg.sumYoyTimesListings / agg.sumYoyWeight : null,
totalListings: agg.totalListings,
daysOnMarket: agg.totalListings > 0 ? Math.round(agg.sumDaysTimesListings / agg.totalListings) : 0,
}));
}, [districtData]);
const districtColumns: DataTableColumn<DistrictRow>[] = React.useMemo(
() => [
{
id: 'district',
header: 'Quận',
cell: (r) => <span className="font-medium text-foreground">{r.district}</span>,
sortable: true,
sortValue: (r) => r.district,
},
{
id: 'price',
header: 'Giá TB/m²',
cell: (r) => formatPricePerM2(r.avgPriceM2),
align: 'right' as const,
numeric: true,
sortable: true,
sortValue: (r) => r.avgPriceM2,
},
{
id: 'change',
header: 'Δ7d',
cell: (r) =>
r.yoyChange != null ? (
<PriceDelta value={r.yoyChange} size="sm" />
) : (
<span className="text-foreground-dim"></span>
),
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,
},
],
[],
);
/* Price chart from snapshot */
const { data: snapshotData } = useMarketSnapshot(city);
const avgPriceM2 = snapshotData?.avgPricePerM2 ?? 0;
const priceChartData = React.useMemo(() => {
if (avgPriceM2 === 0) return [];
const base = avgPriceM2;
return Array.from({ length: 30 }, (_, i) => ({
period: `D${i + 1}`,
avgPriceM2: base * (0.97 + Math.random() * 0.06),
}));
}, [avgPriceM2]);
return (
<div className="w-full overflow-x-clip">
{/* 1. TickerStrip — sticky top, z-45, h=32 */}
<div className="sticky top-0 z-[45] h-8 w-full min-w-0 overflow-hidden border-b border-border bg-background-elevated">
<SectionErrorBoundary fallbackTitle="Ticker không khả dụng">
<DashboardTicker />
</SectionErrorBoundary>
</div>
<div className="mx-auto w-full min-w-0 max-w-7xl px-4 py-6 md:py-8">
{/* 2. KPI Strip */}
<SectionErrorBoundary fallbackTitle="Không thể tải KPI">
<KpiStrip city={city} />
</SectionErrorBoundary>
{/* 3. Top Movers */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
<Clock className="mr-1 inline h-3.5 w-3.5" />
Top biến đng giá 7 ngày
</h2>
<SectionErrorBoundary fallbackTitle="Không thể tải biến động giá">
<TopMovers />
</SectionErrorBoundary>
</section>
{/* 4. Trending Areas */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Khu vực xu hướng (7 ngày)
</h2>
<SectionErrorBoundary fallbackTitle="Không thể tải khu vực xu hướng">
<TrendingAreas />
</SectionErrorBoundary>
</section>
{/* 5. Two-column: District table + 30d Chart */}
<section className="mb-6 grid gap-4 lg:grid-cols-2">
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Top khu vực
</h2>
<SectionErrorBoundary>
<DataTable
columns={districtColumns}
data={districts}
loading={districtLoading}
defaultSortId="price"
defaultSortDir="desc"
getRowId={(r) => r.district}
emptyText="Chưa có dữ liệu khu vực"
/>
</SectionErrorBoundary>
</div>
<div>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Biểu đ giá 30 ngày
</h2>
<div className="rounded-md border border-border bg-background-elevated p-3 shadow-elevation-1">
<SectionErrorBoundary>
{priceChartData.length > 0 ? (
<PriceAreaChart data={priceChartData} height={320} />
) : (
<div className="flex h-[320px] items-center justify-center text-sm text-foreground-muted">
Đang tải...
</div>
)}
</SectionErrorBoundary>
</div>
</div>
</section>
{/* 6. District Heatmap */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Bản đ nhiệt giá
</h2>
<SectionErrorBoundary fallbackTitle="Không thể tải bản đồ nhiệt">
<HeatmapSection city={city} period={period} />
</SectionErrorBoundary>
</section>
{/* 7. Recent Listings */}
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-foreground-muted">
Tin đăng mới nhất
</h2>
<SectionErrorBoundary fallbackTitle="Không thể tải tin đăng">
<RecentListings />
</SectionErrorBoundary>
</section>
</div>
</div>
);
}