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:
258
apps/web/app/[locale]/(public)/design-system/page.tsx
Normal file
258
apps/web/app/[locale]/(public)/design-system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user