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>
This commit is contained in:
680
docs/design-system/DESIGN_SYSTEM_AUDIT_2026_04_21.md
Normal file
680
docs/design-system/DESIGN_SYSTEM_AUDIT_2026_04_21.md
Normal file
@@ -0,0 +1,680 @@
|
||||
# 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:**
|
||||
```ts
|
||||
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
|
||||
```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<MarketReportResponse>
|
||||
```
|
||||
|
||||
#### 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<HeatmapResponse>
|
||||
```
|
||||
|
||||
#### 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<PriceTrendResponse>
|
||||
```
|
||||
|
||||
#### 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<DistrictStatsResponse>
|
||||
```
|
||||
|
||||
#### 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<NearbyPOIsResponse>
|
||||
```
|
||||
|
||||
#### 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<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
|
||||
|
||||
```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<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:**
|
||||
```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 `<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
|
||||
```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
|
||||
|
||||
Reference in New Issue
Block a user