- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/ - Reorganize 27 .md/.txt at workspace root: - audit reports -> docs/audits/ - exploration reports -> docs/explorations/ - design system -> docs/design-system/ - Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root - Refresh docs/README.md as canonical index with links to all groups - Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were overwritten by the newer root-level versions during the move Co-Authored-By: Paperclip <noreply@paperclip.ing>
21 KiB
21 KiB
Design System & Analytics API Audit Report
Date: April 21, 2026
Scope: Design system primitives, analytics API, charts, and map components for homepage refactor
1. Design System Components
Location: apps/web/components/design-system/
All components are exported from a central index and follow a consistent pattern with TypeScript props interfaces.
Component Inventory
StatCard (stat-card.tsx)
- Purpose: Compact KPI metric display for market dashboards
- Props:
label: string— metric name (e.g., "Giá TB/m²")value: string | number— formatted main valueunit?: string— unit suffix (e.g., "tr/m²")delta?: number— percentage changedeltaDirection?: PriceDeltaDirection— forces up/down/neutralsublabel?: string— secondary text (e.g., "24h", "7 ngày")icon?: React.ReactNode— optional prefix icon
- Layout: Vertical flex, small header (muted sans), large mono value, optional delta + sublabel
- Styling: Border, elevated background, consistent spacing (gap-1)
- Key Feature: Integrates PriceDelta component for visual signals
PriceDelta (price-delta.tsx)
- Purpose: Display percentage change with directional arrow icon
- Props:
value: number— percentage (positive = up, negative = down)unit?: string— default "%", can override (e.g., "₫")precision?: number— decimal places, default 2hideIcon?: boolean— hide arrow icon if truedirection?: PriceDeltaDirection— override direction ('up' | 'down' | 'neutral')size?: 'sm' | 'md' | 'lg'— text size (data-sm, data-md, data-lg)
- Icons: ArrowUp (green/up), ArrowDown (red/down), Minus (yellow/neutral)
- Styling:
font-mono,tabular-nums, signal colors from design tokens - Key Feature: Smart direction inference; always formats with +/- prefix
MarketIndex (market-index.tsx)
- Purpose: Display large market index value with change indicator
- Props:
name: string— index title (e.g., "GGX Market")value: string | number— current index valuechangePercent: number— percentage changechange?: string | number— absolute change (optional)window?: string— timeframe, default "24h"direction?: PriceDeltaDirection— override direction
- Layout: Horizontal; left: name + large value; right: delta + metadata
- Styling:
text-3xlmono, hero-style for dashboards - Key Feature: Companion text shows change amount + window frame
DataTable (data-table.tsx)
- Purpose: Sortable, sticky-header table for ticker/market data
- Props:
columns: DataTableColumn<T>[]— column definitionsdata: T[]— row datagetRowId?: (row, index) => string | number— row key fnonRowClick?: (row) => void— row click handlerstickyHeader?: boolean— sticky headers (default true)loading?: boolean— loading stateemptyText?: React.ReactNode— empty state textdense?: boolean— compact rows (default true), 36px (h-row) vs 40pxdefaultSortId?: string— initial sort columndefaultSortDir?: 'asc' | 'desc'— initial sort direction
- Column Definition:
interface DataTableColumn<T> { id: string; header: React.ReactNode; cell: (row: T, index: number) => React.ReactNode; align?: 'left' | 'right' | 'center'; sortable?: boolean; sortValue?: (row: T) => number | string; width?: string; numeric?: boolean; // renders with font-mono, tabular-nums } - Features:
- Client-side sort (no fetch)
- Alternating row backgrounds
- Hover highlight
- Sort icons (ChevronUp/Down)
- Numeric cells use
font-monowithtabular-nums
- Styling:
h-row(36px dense, 40px normal), border-b, alternating bg-surface/40
CompactHeader (compact-header.tsx)
- Purpose: Terminal-style compact header (h: 48px / h-header-compact)
- Props:
logo?: React.ReactNodebreadcrumb?: React.ReactNodesearch?: React.ReactNode— hidden on mobileactions?: React.ReactNode— right-aligned action buttons
- Layout: Flex row; logo | breadcrumb | [gap] | search (md:flex) | [auto-grow] | actions
- Styling: Sticky, border-b, elevated bg, px-4
TickerStrip (ticker-strip.tsx)
- Purpose: Horizontal scrolling ticker animation for top districts/indices
- Props:
items: TickerItem[]— array of {id, label, changePercent, direction?}paused?: boolean— disable animation (for tests/reduced motion)
- Animation: Duplicates items, translates -50% over 60s, pauses on hover
- Styling:
font-mono text-ticker, gap-6, monospace numbers - Key Feature: Self-contained animation loop without external dependencies
DashboardLayout (dashboard-layout.tsx)
- Purpose: Terminal-style dashboard frame with fixed header, sidebar, ticker, status bar
- Props:
header?: React.ReactNodesidebar?: React.ReactNodeticker?: React.ReactNodestatusBar?: React.ReactNodesidebarWidth?: number— expanded width (default 200px), collapsed 56pxsidebarCollapsed?: boolean— collapse statechildren: React.ReactNode— main content
- Layout Structure:
┌─────────────────────────────────┐ │ TICKER (h-ticker-bar: 32px) │ ├────────┬──────────────────────────┤ │ SIDEBAR│ HEADER (h-header-compact)│ │ 56px ├──────────────────────────┤ │ (or │ MAIN (overflow-y-auto) │ │ expand)│ (flex-1) │ ├────────┴──────────────────────────┤ │ STATUS BAR (h-6: 24px) │ └─────────────────────────────────┘ - Styling:
min-h-screenflex, smooth sidebar transition (duration-200)
2. Design Tokens & Theme System
Location: apps/web/app/globals.css + apps/web/tailwind.config.ts
CSS Custom Properties (Dark + Light Modes)
Core Color Palette
/* Light Mode */
--background: 0 0% 97% /* Off-white */
--background-elevated: 0 0% 100% /* Pure white */
--background-surface: 220 14% 96% /* Subtle gray */
--foreground: 220 20% 12% /* Dark navy */
--foreground-muted: 215 12% 45% /* Medium gray */
--foreground-dim: 215 12% 60% /* Light gray */
/* Dark Mode */
--background: 220 20% 4% /* Very dark */
--background-elevated: 220 18% 7% /* Elevated dark */
--background-surface: 220 16% 10% /* Surface dark */
--foreground: 210 20% 90% /* Off-white */
--foreground-muted: 215 15% 55% /* Muted gray */
--foreground-dim: 215 12% 35% /* Dim gray */
Semantic Colors
--primary: 142 72% 42% /* Green: market index */
--primary-foreground: 0 0% 100%
--primary-hover: 142 72% 36%
--signal-up: 142 72% 38% /* Green for up trend */
--signal-down: 0 84% 55% /* Red for down trend */
--signal-neutral: 45 93% 45% /* Yellow/orange for neutral */
--success: 142 72% 42%
--warning: 45 93% 47%
--destructive: 0 84% 60.2%
UI Surface Colors
--border: 220 13% 88% /* Light borders */
--border-strong: 220 13% 78%
--card: 0 0% 100% /* Card background */
--input: 214.3 31.8% 91.4% /* Form input bg */
--ring: 142 72% 42% /* Focus ring */
Typography
Fonts (Tailwind config):
font-sans: Inter (var(--font-inter)) — UI textfont-mono: JetBrains Mono (var(--font-jetbrains-mono)) — numbers, code
Data-Specific Font Sizes (Tailwind):
text-ticker: 0.8125rem, line-height 1, letter-spacing 0.01emtext-data-sm: 0.75rem, line-height 1.2text-data-md: 0.875rem, line-height 1.3text-data-lg: 1.25rem, line-height 1.2
Spacing (Tailwind)
cell: 0.5rem — compact table cellsrow: 2.25rem (36px) — table row height (h-row)ticker-bar: 2rem (32px) — ticker strip heightheader-compact: 3rem (48px) — dashboard header
Shadows
elevation-1: 0 1px 2px rgba(0, 0, 0, 0.3) — subtleelevation-2: 0 4px 12px rgba(0, 0, 0, 0.4) — prominent
Animations
animate-ticker: 60s linear infinite, translateX(-50%), pauses on hoverflash-up: 1s ease-out, signal-up-bg background flashflash-down: 1s ease-out, signal-down-bg background flash
CSS Utilities
[data-numeric]: Applied to numeric cells →font-variant-numeric: tabular-nums.font-mono: Also getstabular-numsfor alignment
3. Analytics API
Location: apps/web/lib/analytics-api.ts
Core API Client
Uses a shared apiClient (from api-client.ts) for HTTP requests.
Exported Interfaces & Functions
Market Data Endpoints
interface MarketReportDistrict {
district: string;
city: string;
propertyType: string;
period: string;
medianPrice: string;
avgPriceM2: number;
totalListings: number;
daysOnMarket: number;
inventoryLevel: number;
absorptionRate: number | null;
yoyChange: number | null;
}
interface MarketReportResponse {
city: string;
period: string;
districts: MarketReportDistrict[];
}
getMarketReport(city: string, period: string, propertyType?: string)
→ Promise<MarketReportResponse>
Heatmap Data
interface HeatmapDataPoint {
district: string;
city: string;
avgPriceM2: number;
totalListings: number;
medianPrice: string;
}
interface HeatmapResponse {
city: string;
period: string;
dataPoints: HeatmapDataPoint[];
}
getHeatmap(city: string, period: string)
→ Promise<HeatmapResponse>
Price Trend Analysis
interface PriceTrendPoint {
period: string;
medianPrice: string;
avgPriceM2: number;
totalListings: number;
}
interface PriceTrendResponse {
district: string;
city: string;
propertyType: string;
trend: PriceTrendPoint[];
}
getPriceTrend(district: string, city: string, propertyType: string, periods: string[])
→ Promise<PriceTrendResponse>
District Statistics
interface DistrictStats {
district: string;
city: string;
propertyType: string;
medianPrice: string;
avgPriceM2: number;
totalListings: number;
daysOnMarket: number;
inventoryLevel: number;
absorptionRate: number | null;
yoyChange: number | null;
}
interface DistrictStatsResponse {
city: string;
period: string;
districts: DistrictStats[];
}
getDistrictStats(city: string, period: string)
→ Promise<DistrictStatsResponse>
POI & Nearby Search
type NearbyPOICategory = 'school' | 'hospital' | 'transit' | 'shopping' | 'restaurant' | 'park';
interface NearbyPOI {
id: string;
name: string;
type: string;
category: NearbyPOICategory;
lat: number;
lng: number;
distance: number;
address: string | null;
}
interface NearbyPOIsResponse {
pois: NearbyPOI[];
center: { lat: number; lng: number };
}
getNearbyPOIs(lat: number, lng: number, radius = 2000, limit = 30)
→ Promise<NearbyPOIsResponse>
AI Valuations & Advice
type AiConfidence = 'low' | 'medium' | 'high';
interface ListingAiValuation {
estimateVND: number;
lowVND: number;
highVND: number;
confidence: AiConfidence;
rationale: string;
}
interface ListingAiAdviceBody {
summary: string;
pros: string[];
cons: string[];
suitableFor: string[];
}
interface ListingAiAdvice {
valuation: ListingAiValuation;
advice: ListingAiAdviceBody;
model: string;
cacheHit: boolean;
cacheUsage?: {
input: number;
cacheCreation: number;
cacheRead: number;
output: number;
};
}
getListingAiAdvice(listingId: string)
→ Promise<ListingAiAdvice>
// Project AI advice (same advice block, no valuation)
interface ProjectAiAdvice {
advice: ListingAiAdviceBody;
model: string;
cacheHit: boolean;
cacheUsage?: { /* cache tokens */ };
}
getProjectAiAdvice(projectId: string)
→ Promise<ProjectAiAdvice>
4. React Query Hooks
Location: apps/web/lib/hooks/use-analytics.ts
Query Keys Factory
const analyticsKeys = {
all: ['analytics'] as const,
marketReport: (city: string, period: string) =>
['analytics', 'market-report', city, period] as const,
heatmap: (city: string, period: string) =>
['analytics', 'heatmap', city, period] as const,
districtStats: (city: string, period: string) =>
['analytics', 'district-stats', city, period] as const,
priceTrend: (district: string, city: string, propertyType: string, periods: string[]) =>
['analytics', 'price-trend', district, city, propertyType, periods] as const,
};
Hook Functions
useMarketReport(city: string, period: string)
→ UseQueryResult<MarketReportResponse>
useHeatmap(city: string, period: string)
→ UseQueryResult<HeatmapResponse>
useDistrictStats(city: string, period: string)
→ UseQueryResult<DistrictStatsResponse>
usePriceTrend(district: string, city: string, propertyType: string, periods: string[])
→ UseQueryResult<PriceTrendResponse>
// Note: enabled only when all params truthy
5. Chart Components (Recharts)
Location: apps/web/components/charts/
Chart Dependencies
- recharts v2.x — all charts import from 'recharts'
- Uses HSL CSS variables for theme colors:
hsl(var(--primary)),hsl(var(--card)), etc.
PriceTrendChart (price-trend-chart.tsx)
- Type: Line chart (dual Y-axis)
- Props:
interface PriceTrendChartProps { data: { period: string; 'Gia/m2': number; 'Tin đăng': number }[]; height?: number; // default 350 } - Series:
- Left Y-axis:
Gia/m2(Price/m²) — solid line, green (primary) - Right Y-axis:
Tin đăng(Listings) — dashed line, muted
- Left Y-axis:
- Features: Grid, legend, tooltip with custom formatting
- Styling: CartesianGrid stroke-muted, axis text fill-muted-foreground
DistrictBarChart (district-bar-chart.tsx)
- Type: Bar chart
- Props:
interface DistrictBarChartProps { data: { district: string; price?: number; 'Gia/m2'?: number; listings: number }[]; height?: number; // default 300 dataKey?: string; // default 'price' tooltipFormatter?: (value, name) => [string, string]; } - Features: X-axis rotated -30°, custom tooltip formatter, flexible data key
- Styling: Bar radius [4, 4, 0, 0], fill primary
AgentPerformance (agent-performance.tsx)
- Type: Mixed (Bar + Pie + Custom Funnel)
- Sections:
- KPI Cards — 4 stat cards (deals, revenue, response time, conversion %)
- Monthly Deals — Dual-axis bar chart (deals + revenue)
- Lead Conversion Funnel — Horizontal bars + donut pie chart
- Mock Data: 6-month deals/revenue, 5-stage funnel (contact → close)
- Colors: Hardcoded palette:
['#94a3b8', '#60a5fa', '#a78bfa', '#fbbf24', '#34d399']
6. Map Components (Mapbox)
Location: apps/web/components/map/
Common Map Features
- Token:
process.env.NEXT_PUBLIC_MAPBOX_TOKEN - Style hook:
useMapboxStyle()→ applies theme-aware Mapbox styles - Attribution control included
- NavigationControl (zoom + rotate) in top-right
DistrictHeatmap (district-heatmap.tsx)
- Purpose: Display districts as color-coded circles on a map, sized by avg price
- Props:
interface HeatmapPoint { district: string; avgPriceM2: number; totalListings: number; medianPrice: string; } interface DistrictHeatmapProps { data: HeatmapPoint[]; city: string; className?: string; onDistrictClick?: (district: string) => void; } - Features:
- Markers: Sized 36–64px, color gradient green (cheap) → red (expensive)
- Hover: Scale 1 → 1.15, opacity 0.8 → 1
- Click: Emits district name
- Popup: Shows district name, price/m², listing count
- Legend: Color bar (green–red spectrum)
- Presets: Hard-coded district centroids for Ho Chi Minh, Ha Noi, Da Nang
- Fallback: Unknown districts spread in ring around city center
- Theme: Light-v11 (light mode), dark style (dark mode), better marker contrast
ListingMap (listing-map.tsx)
- Purpose: Display individual listings as clickable price markers
- Props:
interface ListingMapProps { listings: ListingDetail[]; onMarkerClick?: (listing: ListingDetail) => void; selectedListingId?: string; className?: string; } - Features:
- Markers: Price bubbles, clickable
- Selected State: Highlighted/scaled marker
- Popup: Shows listing title, price, address, link to detail
- Fit Bounds: Auto-zoom to all listings
- Fallback: If no lat/lng, pseudo-random position near city center
- City Presets: HCMC, Ha Noi, Da Nang, Nha Trang, Can Tho
LocationPicker (location-picker.tsx)
- Purpose: Interactive map-based location selection
- Props:
interface LocationPickerProps { lat?: number | null; lng?: number | null; onChange: (coords: { lat: number; lng: number }, resolved?: ResolvedAddress) => void; height?: string; // default '320px' className?: string; } interface ResolvedAddress { address?: string; ward?: string; district?: string; city?: string; } - Features:
- Map Click: Drag or click to set coordinates
- Search Box: Mapbox Places geocoder (Vietnam-scoped)
- Reverse Geocoding: Resolves click/search to address components
- Marker: Draggable, emits updated lat/lng
- Context Parsing: Extracts ward, district, city from Mapbox feature context
- Default: HCMC (10.7769, 106.7009)
7. Map Styling
Location: apps/web/lib/mapbox-style.ts (imported by map components)
- Dark Style:
MAPBOX_STYLE_DARK— dark theme style URL - Light Style:
mapbox://styles/mapbox/light-v11— light theme - Hook:
useMapboxStyle()— returns theme-aware style URL - CSS Overrides:
globals.cssincludes.mapboxgl-*selectors for button colors, popup styling
8. Key Patterns & Best Practices
Design System Conventions
- No prop spreading: Props explicitly defined for clarity
- Semantic HTML: Tables use
<table>, headers use<th scope="col"> - Accessibility:
aria-hiddenon decorative icons, proper heading hierarchy - Numeric alignment: All numeric cells use
font-mono+tabular-numsvia[data-numeric]selector - Responsive: Mobile-first,
md:breakpoint for expanded views (search in header)
Color & Theming
- HSL-based tokens with dark-first architecture
- Signal colors for market data: up (green), down (red), neutral (yellow)
- Semantic naming:
foreground-muted,background-elevated,signal-up - Consistent shadows:
elevation-1(subtle),elevation-2(prominent)
Data Display
- Tables: Client-side sort, sticky header, alternating row bg
- Charts: Recharts with HSL variables, custom formatters for localization
- Maps: Mapbox with fallback centroids, theme sync, clickable markers
9. Files to Integrate with Homepage Refactor
Export Paths
// Design system
import {
StatCard,
PriceDelta,
MarketIndex,
DataTable,
CompactHeader,
DashboardLayout,
TickerStrip,
} from '@/components/design-system';
// Analytics
import { analyticsApi } from '@/lib/analytics-api';
import {
useMarketReport,
useHeatmap,
useDistrictStats,
usePriceTrend,
analyticsKeys,
} from '@/lib/hooks/use-analytics';
// Charts
import { PriceTrendChart } from '@/components/charts/price-trend-chart';
import { DistrictBarChart } from '@/components/charts/district-bar-chart';
import { AgentPerformance } from '@/components/charts/agent-performance';
// Maps
import { DistrictHeatmap } from '@/components/charts/district-heatmap';
import { ListingMap } from '@/components/map/listing-map';
import { LocationPicker } from '@/components/map/location-picker';
// Theme tokens (CSS variables)
// See apps/web/app/globals.css for full token reference
Design Token Reference
- Colors: 30+ CSS variables (light + dark modes)
- Typography:
font-sans(Inter),font-mono(JetBrains Mono) - Data Sizes:
text-data-sm,text-data-md,text-data-lg - Spacing:
h-row(table),h-ticker-bar(ticker),h-header-compact(header) - Shadows:
elevation-1,elevation-2 - Animations:
animate-ticker,flash-up,flash-down
10. Notes for Homepage Refactor
- Consistent spacing: Use
gap-4,gap-6classes; define customrowandcellspacings - Monospace numbers: Always apply
[data-numeric]orfont-monoto numeric content for alignment - Signal colors: Green (up), red (down), yellow (neutral) — use
signal-*tokens - Sticky headers: Use
sticky top-0 z-10on table/list headers - Dark-first theme: Light mode is override in
:root; dark is in.darkselector - Mapbox token: Required for heatmaps; graceful fallback shown if missing
- Recharts styling: Use
hsl(var(--primary))pattern for dynamic theming - Responsive: Mobile-first; use
md:,lg:breakpoints - Query keys factory: Use
analyticsKeysfor consistency in useQuery calls - No external design tokens file: Tokens are in Tailwind config + globals.css CSS vars