Files
goodgo-platform/docs/design-system/DESIGN_SYSTEM_AUDIT_2026_04_21.md
Ho Ngoc Hai 08b96f9c2d docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- 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>
2026-04-21 16:29:24 +07:00

21 KiB
Raw Blame History

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<T>[] — 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:
    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-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

/* 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 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

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>
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
  • 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:
    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:
    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 3664px, 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 (greenred 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.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 <table>, headers use <th scope="col">
  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

// 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