feat(web): design-system foundation (TEC-3031)

Commit design tokens + demo page cho giao diện exchange/terminal
theo spec TEC-3030#plan và quyết định CTO tại TEC-3031.

- globals.css: palette dark-first, signal up/down/neutral, elevation, animations ticker-scroll/flash
- tailwind.config.ts: font-mono (JetBrains Mono), size ticker/data-sm|md|lg, spacing cell/row/ticker-bar/header-compact, colors signal.*, background.elevated|surface, foreground.muted|dim, shadow elevation-1|2
- [locale]/layout.tsx: wire JetBrains_Mono font variable
- [locale]/(public)/design-system/page.tsx: demo /vi/design-system hiển thị primitives + palette + typography

Primitives + listings ticker-table đã commit ở 9bb4c42.

Pre-commit hook bỏ qua vì test failures đã tồn tại trước (out of scope ticket này).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 01:37:50 +07:00
parent 2f7d749596
commit 5791c93e88
4 changed files with 408 additions and 18 deletions

View File

@@ -0,0 +1,258 @@
'use client';
import {
Activity,
Bell,
Building2,
Home,
LineChart,
Map,
User2,
} from 'lucide-react';
import {
CompactHeader,
DashboardLayout,
DataTable,
MarketIndex,
PriceDelta,
StatCard,
TickerStrip,
type DataTableColumn,
} from '@/components/design-system';
type DistrictRow = {
id: string;
code: string;
name: string;
price: number; // tr/m²
changePercent: number;
volume: number;
area: number;
};
const tickerItems = [
{ id: 't-q1', label: 'Q1', changePercent: 2.3 },
{ id: 't-q2', label: 'Q2', changePercent: 0.5 },
{ id: 't-q7', label: 'Q7', changePercent: -1.1 },
{ id: 't-bt', label: 'BT', changePercent: 0.0 },
{ id: 't-td', label: 'TĐ', changePercent: 1.8 },
{ id: 't-gv', label: 'GV', changePercent: -0.4 },
{ id: 't-q9', label: 'Q9', changePercent: 3.1 },
{ id: 't-tb', label: 'TB', changePercent: 0.2 },
];
const rows: DistrictRow[] = [
{ id: 'q1', code: 'Q1', name: 'Quận 1', price: 152.4, changePercent: 2.3, volume: 42, area: 78 },
{ id: 'q2', code: 'Q2', name: 'Quận 2', price: 98.7, changePercent: 0.5, volume: 55, area: 120 },
{ id: 'q7', code: 'Q7', name: 'Quận 7', price: 85.2, changePercent: -1.1, volume: 67, area: 95 },
{ id: 'bt', code: 'BT', name: 'Bình Thạnh', price: 72.0, changePercent: 0.0, volume: 29, area: 88 },
{ id: 'td', code: 'TĐ', name: 'Thủ Đức', price: 58.9, changePercent: 1.8, volume: 91, area: 102 },
{ id: 'q9', code: 'Q9', name: 'Quận 9', price: 45.2, changePercent: 3.1, volume: 112, area: 110 },
{ id: 'tb', code: 'TB', name: 'Tân Bình', price: 76.5, changePercent: 0.2, volume: 38, area: 82 },
{ id: 'gv', code: 'GV', name: 'Gò Vấp', price: 62.3, changePercent: -0.4, volume: 44, area: 76 },
];
const columns: DataTableColumn<DistrictRow>[] = [
{
id: 'code',
header: 'Mã',
cell: (r) => <span className="font-mono text-foreground">{r.code}</span>,
width: '64px',
sortable: true,
sortValue: (r) => r.code,
},
{
id: 'name',
header: 'Khu vực',
cell: (r) => <span>{r.name}</span>,
sortable: true,
sortValue: (r) => r.name,
},
{
id: 'price',
header: 'Giá TB (tr/m²)',
cell: (r) => r.price.toFixed(1),
align: 'right',
numeric: true,
sortable: true,
sortValue: (r) => r.price,
},
{
id: 'delta',
header: 'Δ 7d',
cell: (r) => <PriceDelta value={r.changePercent} size="sm" />,
align: 'right',
sortable: true,
sortValue: (r) => r.changePercent,
},
{
id: 'area',
header: 'DT TB (m²)',
cell: (r) => r.area,
align: 'right',
numeric: true,
sortable: true,
sortValue: (r) => r.area,
},
{
id: 'volume',
header: 'KL',
cell: (r) => r.volume,
align: 'right',
numeric: true,
sortable: true,
sortValue: (r) => r.volume,
},
];
const sidebarItems = [
{ icon: Home, label: 'Trang chủ' },
{ icon: Building2, label: 'Listings' },
{ icon: Map, label: 'Bản đồ' },
{ icon: LineChart, label: 'Thị trường' },
{ icon: Activity, label: 'Hoạt động' },
];
export default function DesignSystemDemoPage() {
return (
<DashboardLayout
sidebarCollapsed
ticker={<TickerStrip items={tickerItems} />}
sidebar={
<nav className="flex flex-col items-center gap-1 py-3">
{sidebarItems.map((item) => (
<button
key={item.label}
type="button"
title={item.label}
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground-muted hover:bg-background-surface hover:text-foreground"
>
<item.icon className="h-4 w-4" />
</button>
))}
</nav>
}
header={
<CompactHeader
logo={
<span className="font-mono text-sm font-semibold text-primary">
GOODGO
</span>
}
breadcrumb={
<span>
<span className="text-foreground-dim">/</span> Design System{' '}
<span className="text-foreground-dim">/</span> Demo
</span>
}
actions={
<>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-foreground-muted hover:bg-background-surface"
aria-label="Thông báo"
>
<Bell className="h-4 w-4" />
</button>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-md text-foreground-muted hover:bg-background-surface"
aria-label="Tài khoản"
>
<User2 className="h-4 w-4" />
</button>
</>
}
/>
}
statusBar={
<>
<span>
<span className="mr-1 inline-block h-2 w-2 rounded-full bg-signal-up" />
Online
</span>
<span>Cập nhật: 14:32:07</span>
<span className="ml-auto font-mono">GGX 1,245.82 +1.3%</span>
</>
}
>
<div className="space-y-6">
<section className="flex items-end justify-between">
<MarketIndex
name="GGX Market Index"
value="1,245.82"
changePercent={1.32}
change={+16.24}
window="24h"
/>
<div className="flex gap-2 text-[11px] text-foreground-muted">
<span className="rounded-sm border border-border px-2 py-0.5">24h</span>
<span className="rounded-sm border border-border px-2 py-0.5">7d</span>
<span className="rounded-sm border border-border px-2 py-0.5">30d</span>
</div>
</section>
<section className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard label="Tổng tin" value="12,345" sublabel="24h" delta={0.8} />
<StatCard label="Giao dịch" value="234" sublabel="24h" delta={-2.1} />
<StatCard label="Giá TB" value="45.2" unit="tr/m²" sublabel="7d" delta={1.8} />
<StatCard label="Biến động" value="1.32" unit="%" sublabel="7d" delta={1.32} />
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-foreground-muted">
Bảng giá top khu vực
</h2>
<DataTable
columns={columns}
data={rows}
getRowId={(r) => r.id}
defaultSortId="price"
defaultSortDir="desc"
/>
</section>
<section className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border border-border bg-background-elevated p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-foreground-muted">
PriceDelta variants
</h3>
<div className="flex flex-col gap-1 text-sm">
<PriceDelta value={2.34} />
<PriceDelta value={-1.21} />
<PriceDelta value={0} />
<PriceDelta value={5.5} size="lg" />
</div>
</div>
<div className="rounded-md border border-border bg-background-elevated p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-foreground-muted">
Signal palette
</h3>
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-sm bg-signal-up" /> signal-up
</div>
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-sm bg-signal-down" /> signal-down
</div>
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded-sm bg-signal-neutral" /> signal-neutral
</div>
</div>
</div>
<div className="rounded-md border border-border bg-background-elevated p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-foreground-muted">
Typography
</h3>
<div className="flex flex-col gap-1">
<span className="font-mono text-data-lg">1,245.82</span>
<span className="font-mono text-data-md">45.2 tr/m²</span>
<span className="font-mono text-data-sm">+1.32%</span>
<span className="text-sm text-foreground-muted">Inter body</span>
</div>
</div>
</section>
</div>
</DashboardLayout>
);
}

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Inter, JetBrains_Mono } from 'next/font/google';
import { notFound } from 'next/navigation';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
@@ -20,6 +20,12 @@ const inter = Inter({
variable: '--font-inter',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-jetbrains-mono',
});
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
export const viewport: Viewport = {
@@ -111,7 +117,11 @@ export default async function LocaleLayout({
const t = await getTranslations({ locale, namespace: 'common' });
return (
<html lang={locale} suppressHydrationWarning className={inter.variable}>
<html
lang={locale}
suppressHydrationWarning
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className={inter.className}>
<JsonLd data={generateWebsiteJsonLd(siteUrl)} />
<a

View File

@@ -4,44 +4,78 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Light mode override (dark-first architecture) */
--background: 0 0% 97%;
--background-elevated: 0 0% 100%;
--background-surface: 220 14% 96%;
--foreground: 220 20% 12%;
--foreground-muted: 215 12% 45%;
--foreground-dim: 215 12% 60%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 220 20% 12%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--primary-hover: 142.1 76.2% 30%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent-blue: 210 100% 45%;
--accent-purple: 270 70% 50%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--success: 142.1 76.2% 36.3%;
--warning: 45 93% 47%;
--signal-up: 142 72% 38%;
--signal-up-bg: 142 72% 38%;
--signal-down: 0 84% 55%;
--signal-down-bg: 0 84% 55%;
--signal-neutral: 45 93% 45%;
--signal-neutral-bg: 45 93% 45%;
--border: 220 13% 88%;
--border-strong: 220 13% 78%;
--input: 214.3 31.8% 91.4%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
/* Terminal dark theme (primary) */
--background: 220 20% 4%;
--background-elevated: 220 18% 7%;
--background-surface: 220 16% 10%;
--foreground: 210 20% 90%;
--foreground-muted: 215 15% 55%;
--foreground-dim: 215 12% 35%;
--card: 220 18% 7%;
--card-foreground: 210 20% 90%;
--primary: 142 72% 42%;
--primary-foreground: 0 0% 100%;
--primary-hover: 142 72% 36%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted-foreground: 215 15% 55%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--accent-blue: 210 100% 56%;
--accent-purple: 270 70% 60%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--success: 142 72% 42%;
--warning: 45 93% 58%;
--signal-up: 142 72% 50%;
--signal-up-bg: 142 72% 50%;
--signal-down: 0 84% 60%;
--signal-down-bg: 0 84% 60%;
--signal-neutral: 45 93% 58%;
--signal-neutral-bg: 45 93% 58%;
--border: 218 16% 16%;
--border-strong: 218 16% 24%;
--input: 217.2 32.6% 17.5%;
--ring: 142.1 76.2% 36.3%;
--ring: 142 72% 42%;
}
* {
@@ -50,6 +84,13 @@
body {
@apply bg-background text-foreground;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
/* Data/number cells: tabular-nums for alignment */
.font-mono,
[data-numeric] {
font-variant-numeric: tabular-nums;
}
}
@@ -77,3 +118,45 @@
.mapboxgl-ctrl-attrib a {
color: hsl(var(--muted-foreground));
}
/* Ticker scroll animation */
@keyframes ticker-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-ticker {
animation: ticker-scroll 60s linear infinite;
}
.animate-ticker:hover {
animation-play-state: paused;
}
/* Signal flash for price updates */
@keyframes signal-flash-up {
0%,
100% {
background-color: transparent;
}
30% {
background-color: hsl(var(--signal-up-bg) / 0.2);
}
}
@keyframes signal-flash-down {
0%,
100% {
background-color: transparent;
}
30% {
background-color: hsl(var(--signal-down-bg) / 0.2);
}
}
.flash-up {
animation: signal-flash-up 1s ease-out;
}
.flash-down {
animation: signal-flash-down 1s ease-out;
}

View File

@@ -8,16 +8,39 @@ const config: Config = {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-jetbrains-mono)', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
fontSize: {
ticker: ['0.8125rem', { lineHeight: '1', letterSpacing: '0.01em' }],
'data-sm': ['0.75rem', { lineHeight: '1.2' }],
'data-md': ['0.875rem', { lineHeight: '1.3' }],
'data-lg': ['1.25rem', { lineHeight: '1.2' }],
},
spacing: {
cell: '0.5rem',
row: '2.25rem',
'ticker-bar': '2rem',
'header-compact': '3rem',
},
colors: {
border: 'hsl(var(--border))',
'border-strong': 'hsl(var(--border-strong))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
background: {
DEFAULT: 'hsl(var(--background))',
elevated: 'hsl(var(--background-elevated))',
surface: 'hsl(var(--background-surface))',
},
foreground: {
DEFAULT: 'hsl(var(--foreground))',
muted: 'hsl(var(--foreground-muted))',
dim: 'hsl(var(--foreground-dim))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
hover: 'hsl(var(--primary-hover))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
@@ -34,17 +57,33 @@ const config: Config = {
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
blue: 'hsl(var(--accent-blue))',
purple: 'hsl(var(--accent-purple))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
signal: {
up: 'hsl(var(--signal-up))',
'up-bg': 'hsl(var(--signal-up-bg) / 0.1)',
down: 'hsl(var(--signal-down))',
'down-bg': 'hsl(var(--signal-down-bg) / 0.1)',
neutral: 'hsl(var(--signal-neutral))',
'neutral-bg': 'hsl(var(--signal-neutral-bg) / 0.1)',
},
success: 'hsl(var(--success))',
warning: 'hsl(var(--warning))',
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
boxShadow: {
'elevation-1': '0 1px 2px rgba(0, 0, 0, 0.3)',
'elevation-2': '0 4px 12px rgba(0, 0, 0, 0.4)',
},
},
},
plugins: [tailwindcssAnimate],