# 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 value - `unit?: string` — unit suffix (e.g., "tr/m²") - `delta?: number` — percentage change - `deltaDirection?: PriceDeltaDirection` — forces up/down/neutral - `sublabel?: 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 2 - `hideIcon?: boolean` — hide arrow icon if true - `direction?: 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 value - `changePercent: number` — percentage change - `change?: 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-3xl` mono, 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[]` — column definitions - `data: T[]` — row data - `getRowId?: (row, index) => string | number` — row key fn - `onRowClick?: (row) => void` — row click handler - `stickyHeader?: boolean` — sticky headers (default true) - `loading?: boolean` — loading state - `emptyText?: React.ReactNode` — empty state text - `dense?: boolean` — compact rows (default true), 36px (h-row) vs 40px - `defaultSortId?: string` — initial sort column - `defaultSortDir?: 'asc' | 'desc'` — initial sort direction - **Column Definition:** ```ts interface DataTableColumn { 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-mono` with `tabular-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.ReactNode` - `breadcrumb?: React.ReactNode` - `search?: React.ReactNode` — hidden on mobile - `actions?: 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.ReactNode` - `sidebar?: React.ReactNode` - `ticker?: React.ReactNode` - `statusBar?: React.ReactNode` - `sidebarWidth?: number` — expanded width (default 200px), collapsed 56px - `sidebarCollapsed?: boolean` — collapse state - `children: 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-screen` flex, 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 ```css /* 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 ```css --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 ```css --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 text - `font-mono`: JetBrains Mono (var(--font-jetbrains-mono)) — numbers, code **Data-Specific Font Sizes (Tailwind):** - `text-ticker`: 0.8125rem, line-height 1, letter-spacing 0.01em - `text-data-sm`: 0.75rem, line-height 1.2 - `text-data-md`: 0.875rem, line-height 1.3 - `text-data-lg`: 1.25rem, line-height 1.2 ### Spacing (Tailwind) - `cell`: 0.5rem — compact table cells - `row`: 2.25rem (36px) — table row height (h-row) - `ticker-bar`: 2rem (32px) — ticker strip height - `header-compact`: 3rem (48px) — dashboard header ### Shadows - `elevation-1`: 0 1px 2px rgba(0, 0, 0, 0.3) — subtle - `elevation-2`: 0 4px 12px rgba(0, 0, 0, 0.4) — prominent ### Animations - `animate-ticker`: 60s linear infinite, translateX(-50%), pauses on hover - `flash-up`: 1s ease-out, signal-up-bg background flash - `flash-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 gets `tabular-nums` for 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 ```ts 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 ``` #### Heatmap Data ```ts 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 ``` #### Price Trend Analysis ```ts 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 ``` #### District Statistics ```ts 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 ``` #### POI & Nearby Search ```ts 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 ``` #### AI Valuations & Advice ```ts 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 // Project AI advice (same advice block, no valuation) interface ProjectAiAdvice { advice: ListingAiAdviceBody; model: string; cacheHit: boolean; cacheUsage?: { /* cache tokens */ }; } getProjectAiAdvice(projectId: string) → Promise ``` --- ## 4. React Query Hooks **Location:** `apps/web/lib/hooks/use-analytics.ts` ### Query Keys Factory ```ts 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 ```ts useMarketReport(city: string, period: string) → UseQueryResult useHeatmap(city: string, period: string) → UseQueryResult useDistrictStats(city: string, period: string) → UseQueryResult usePriceTrend(district: string, city: string, propertyType: string, periods: string[]) → UseQueryResult // 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:** ```ts 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 - **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:** ```ts 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:** 1. **KPI Cards** — 4 stat cards (deals, revenue, response time, conversion %) 2. **Monthly Deals** — Dual-axis bar chart (deals + revenue) 3. **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:** ```ts 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:** ```ts 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:** ```ts 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.css` includes `.mapboxgl-*` selectors for button colors, popup styling --- ## 8. Key Patterns & Best Practices ### Design System Conventions 1. **No prop spreading:** Props explicitly defined for clarity 2. **Semantic HTML:** Tables use ``, headers use `
` 3. **Accessibility:** `aria-hidden` on decorative icons, proper heading hierarchy 4. **Numeric alignment:** All numeric cells use `font-mono` + `tabular-nums` via `[data-numeric]` selector 5. **Responsive:** Mobile-first, `md:` breakpoint for expanded views (search in header) ### Color & Theming 1. **HSL-based tokens** with dark-first architecture 2. **Signal colors** for market data: up (green), down (red), neutral (yellow) 3. **Semantic naming:** `foreground-muted`, `background-elevated`, `signal-up` 4. **Consistent shadows:** `elevation-1` (subtle), `elevation-2` (prominent) ### Data Display 1. **Tables:** Client-side sort, sticky header, alternating row bg 2. **Charts:** Recharts with HSL variables, custom formatters for localization 3. **Maps:** Mapbox with fallback centroids, theme sync, clickable markers --- ## 9. Files to Integrate with Homepage Refactor ### Export Paths ```ts // 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 1. **Consistent spacing:** Use `gap-4`, `gap-6` classes; define custom `row` and `cell` spacings 2. **Monospace numbers:** Always apply `[data-numeric]` or `font-mono` to numeric content for alignment 3. **Signal colors:** Green (up), red (down), yellow (neutral) — use `signal-*` tokens 4. **Sticky headers:** Use `sticky top-0 z-10` on table/list headers 5. **Dark-first theme:** Light mode is override in `:root`; dark is in `.dark` selector 6. **Mapbox token:** Required for heatmaps; graceful fallback shown if missing 7. **Recharts styling:** Use `hsl(var(--primary))` pattern for dynamic theming 8. **Responsive:** Mobile-first; use `md:`, `lg:` breakpoints 9. **Query keys factory:** Use `analyticsKeys` for consistency in useQuery calls 10. **No external design tokens file:** Tokens are in Tailwind config + globals.css CSS vars