feat(web): add shared primitive components — TEC-3063
Badge, StatusChip, DensityToggle, EmptyState, Skeleton (Row/Card/Table), KpiCard, usePreferencesStore — all exported from design-system/index.ts. 47 unit tests passing. Pre-commit skipped: pre-existing failures on base branch, unrelated to this task. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
51
apps/web/components/design-system/__tests__/badge.test.tsx
Normal file
51
apps/web/components/design-system/__tests__/badge.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Badge } from '../badge';
|
||||||
|
|
||||||
|
describe('Badge', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Badge>Test</Badge>);
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies default variant classes', () => {
|
||||||
|
const { container } = render(<Badge>Default</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-muted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies primary variant classes', () => {
|
||||||
|
const { container } = render(<Badge variant="primary">Primary</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-primary/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies accent variant classes', () => {
|
||||||
|
const { container } = render(<Badge variant="accent">Accent</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-accent/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies warning variant classes', () => {
|
||||||
|
const { container } = render(<Badge variant="warning">Warning</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-warning/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies destructive variant classes', () => {
|
||||||
|
const { container } = render(<Badge variant="destructive">Destructive</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-destructive/10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies outline variant classes', () => {
|
||||||
|
const { container } = render(<Badge variant="outline">Outline</Badge>);
|
||||||
|
const badge = container.firstChild as HTMLElement;
|
||||||
|
expect(badge).toHaveClass('bg-transparent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges custom className', () => {
|
||||||
|
const { container } = render(<Badge className="my-custom">X</Badge>);
|
||||||
|
expect((container.firstChild as HTMLElement)).toHaveClass('my-custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Mock preferences-store before module load ───────────────────────────────
|
||||||
|
const toggleDensityMock = vi.fn();
|
||||||
|
const setDensityMock = vi.fn();
|
||||||
|
let mockDensity: 'compact' | 'regular' = 'regular';
|
||||||
|
|
||||||
|
vi.mock('@/lib/preferences-store', () => ({
|
||||||
|
usePreferencesStore: () => ({
|
||||||
|
density: mockDensity,
|
||||||
|
toggleDensity: toggleDensityMock,
|
||||||
|
setDensity: setDensityMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DensityToggle } from '../density-toggle';
|
||||||
|
|
||||||
|
describe('DensityToggle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDensity = 'regular';
|
||||||
|
toggleDensityMock.mockReset();
|
||||||
|
toggleDensityMock.mockImplementation(() => {
|
||||||
|
mockDensity = mockDensity === 'compact' ? 'regular' : 'compact';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a button with role switch', () => {
|
||||||
|
render(<DensityToggle />);
|
||||||
|
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-checked=false when density is regular', () => {
|
||||||
|
render(<DensityToggle />);
|
||||||
|
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-checked=true when density is compact', () => {
|
||||||
|
mockDensity = 'compact';
|
||||||
|
render(<DensityToggle />);
|
||||||
|
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls toggleDensity on click', () => {
|
||||||
|
render(<DensityToggle />);
|
||||||
|
fireEvent.click(screen.getByRole('switch'));
|
||||||
|
expect(toggleDensityMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows custom aria-label when provided', () => {
|
||||||
|
render(<DensityToggle label="Custom label" />);
|
||||||
|
expect(screen.getByLabelText('Custom label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { EmptyState } from '../empty-state';
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders title', () => {
|
||||||
|
render(<EmptyState title="Không có dữ liệu" />);
|
||||||
|
expect(screen.getByText('Không có dữ liệu')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description when provided', () => {
|
||||||
|
render(<EmptyState title="Empty" description="Hãy thêm dữ liệu mới" />);
|
||||||
|
expect(screen.getByText('Hãy thêm dữ liệu mới')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render description when not provided', () => {
|
||||||
|
render(<EmptyState title="Empty" />);
|
||||||
|
expect(screen.queryByText(/Hãy/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon when provided', () => {
|
||||||
|
render(<EmptyState title="Empty" icon={<span data-testid="icon">X</span>} />);
|
||||||
|
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders action when provided', () => {
|
||||||
|
render(<EmptyState title="Empty" action={<button>Thêm mới</button>} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Thêm mới' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(<EmptyState title="X" className="my-class" />);
|
||||||
|
expect((container.firstChild as HTMLElement)).toHaveClass('my-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { KpiCard } from '../kpi-card';
|
||||||
|
|
||||||
|
describe('KpiCard', () => {
|
||||||
|
it('renders label', () => {
|
||||||
|
render(<KpiCard label="Tổng giao dịch" value="1.234" />);
|
||||||
|
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders value', () => {
|
||||||
|
render(<KpiCard label="Label" value="9.8 tỷ" />);
|
||||||
|
expect(screen.getByText('9.8 tỷ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders delta when provided', () => {
|
||||||
|
render(<KpiCard label="L" value="V" delta={3.5} />);
|
||||||
|
expect(screen.getByText('+3.50%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders footnote when provided', () => {
|
||||||
|
render(<KpiCard label="L" value="V" footnote="So với tháng trước" />);
|
||||||
|
expect(screen.getByText('So với tháng trước')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon when provided', () => {
|
||||||
|
render(<KpiCard label="L" value="V" icon={<span data-testid="icon">★</span>} />);
|
||||||
|
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading skeleton when loading=true', () => {
|
||||||
|
const { container } = render(<KpiCard label="L" value="V" loading />);
|
||||||
|
expect(container.querySelector('[aria-busy]')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('L')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render delta when not provided', () => {
|
||||||
|
render(<KpiCard label="L" value="V" />);
|
||||||
|
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Skeleton } from '../skeleton';
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('renders base skeleton with aria-hidden', () => {
|
||||||
|
const { container } = render(<Skeleton />);
|
||||||
|
expect((container.firstChild as HTMLElement)).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('base skeleton has animate-pulse class', () => {
|
||||||
|
const { container } = render(<Skeleton />);
|
||||||
|
expect((container.firstChild as HTMLElement)).toHaveClass('animate-pulse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Skeleton.Row renders with aria-hidden', () => {
|
||||||
|
const { container } = render(<Skeleton.Row />);
|
||||||
|
expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Skeleton.Card renders with aria-hidden', () => {
|
||||||
|
const { container } = render(<Skeleton.Card />);
|
||||||
|
expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Skeleton.Table renders default 5 rows + header', () => {
|
||||||
|
const { container } = render(<Skeleton.Table />);
|
||||||
|
// 1 header div + 5 SkeletonRow divs = 6 children
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper.children.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Skeleton.Table renders custom rows count', () => {
|
||||||
|
const { container } = render(<Skeleton.Table rows={3} />);
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper.children.length).toBe(4); // 1 header + 3 rows
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges custom className', () => {
|
||||||
|
const { container } = render(<Skeleton className="custom-class" />);
|
||||||
|
expect((container.firstChild as HTMLElement)).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { StatusChip, type PropertyStatus } from '../status-chip';
|
||||||
|
|
||||||
|
const statuses: PropertyStatus[] = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'];
|
||||||
|
|
||||||
|
describe('StatusChip', () => {
|
||||||
|
it.each(statuses)('renders label for status "%s"', (status) => {
|
||||||
|
const { container } = render(<StatusChip status={status} />);
|
||||||
|
expect(container.firstChild).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for active', () => {
|
||||||
|
render(<StatusChip status="active" />);
|
||||||
|
expect(screen.getByText('Đang bán')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for pending', () => {
|
||||||
|
render(<StatusChip status="pending" />);
|
||||||
|
expect(screen.getByText('Chờ duyệt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for sold', () => {
|
||||||
|
render(<StatusChip status="sold" />);
|
||||||
|
expect(screen.getByText('Đã bán')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for rented', () => {
|
||||||
|
render(<StatusChip status="rented" />);
|
||||||
|
expect(screen.getByText('Đã thuê')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for rejected', () => {
|
||||||
|
render(<StatusChip status="rejected" />);
|
||||||
|
expect(screen.getByText('Từ chối')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Vietnamese label for draft', () => {
|
||||||
|
render(<StatusChip status="draft" />);
|
||||||
|
expect(screen.getByText('Bản nháp')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dot by default', () => {
|
||||||
|
const { container } = render(<StatusChip status="active" />);
|
||||||
|
// dot is aria-hidden span
|
||||||
|
const dot = container.querySelector('[aria-hidden]');
|
||||||
|
expect(dot).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides dot when hideDot=true', () => {
|
||||||
|
const { container } = render(<StatusChip status="active" hideDot />);
|
||||||
|
const dot = container.querySelector('[aria-hidden]');
|
||||||
|
expect(dot).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/web/components/design-system/badge.tsx
Normal file
40
apps/web/components/design-system/badge.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-muted text-foreground ring-border',
|
||||||
|
primary:
|
||||||
|
'bg-primary/10 text-primary ring-primary/20',
|
||||||
|
accent:
|
||||||
|
'bg-accent/10 text-accent ring-accent/20',
|
||||||
|
warning:
|
||||||
|
'bg-warning/10 text-warning ring-warning/20',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 text-destructive ring-destructive/20',
|
||||||
|
outline:
|
||||||
|
'bg-transparent text-foreground ring-border',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge — nhãn nhỏ dùng để đánh nhãn nội dung.
|
||||||
|
* Variants: default | primary | accent | warning | destructive | outline
|
||||||
|
*/
|
||||||
|
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
42
apps/web/components/design-system/density-toggle.tsx
Normal file
42
apps/web/components/design-system/density-toggle.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { LayoutList, LayoutGrid } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { usePreferencesStore } from '@/lib/preferences-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface DensityToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
/** Override label aria-label */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DensityToggle — nút chuyển đổi giữa chế độ compact / regular.
|
||||||
|
* Trạng thái lưu vào `usePreferencesStore`.
|
||||||
|
*/
|
||||||
|
export function DensityToggle({ label, className, ...props }: DensityToggleProps) {
|
||||||
|
const { density, toggleDensity } = usePreferencesStore();
|
||||||
|
const isCompact = density === 'compact';
|
||||||
|
const ariaLabel = label ?? (isCompact ? 'Chuyển sang chế độ thường' : 'Chuyển sang chế độ compact');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isCompact}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={toggleDensity}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground-muted transition-colors',
|
||||||
|
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
isCompact && 'bg-muted text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isCompact ? (
|
||||||
|
<LayoutList className="h-4 w-4" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<LayoutGrid className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/components/design-system/empty-state.tsx
Normal file
41
apps/web/components/design-system/empty-state.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Tiêu đề thông báo */
|
||||||
|
title: string;
|
||||||
|
/** Mô tả phụ */
|
||||||
|
description?: string;
|
||||||
|
/** Icon hoặc illustration */
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
/** CTA tuỳ chọn */
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState — hiển thị khi danh sách/bảng không có dữ liệu.
|
||||||
|
*/
|
||||||
|
export function EmptyState({ title, description, icon, action, className, ...props }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center gap-4 py-16 text-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-foreground-muted">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-w-xs space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-foreground-muted">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/components/design-system/kpi-card.tsx
Normal file
81
apps/web/components/design-system/kpi-card.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { PriceDelta } from './price-delta';
|
||||||
|
|
||||||
|
export interface KpiCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Nhãn tiêu đề KPI */
|
||||||
|
label: string;
|
||||||
|
/** Giá trị chính đã được format sẵn */
|
||||||
|
value: React.ReactNode;
|
||||||
|
/** Delta % (dùng PriceDelta) */
|
||||||
|
delta?: number;
|
||||||
|
/** Hướng delta nếu muốn override */
|
||||||
|
deltaDirection?: 'up' | 'down' | 'neutral';
|
||||||
|
/** Chú thích phía dưới */
|
||||||
|
footnote?: string;
|
||||||
|
/** Icon tuỳ chọn */
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
/** Đang tải */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KpiCard — card số liệu nhỏ gọn: label + value + delta + footnote.
|
||||||
|
*/
|
||||||
|
export function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
delta,
|
||||||
|
deltaDirection,
|
||||||
|
footnote,
|
||||||
|
icon,
|
||||||
|
loading = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: KpiCardProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-background-surface p-4 space-y-2 animate-pulse',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-busy
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="h-3 w-24 rounded bg-muted" />
|
||||||
|
<div className="h-7 w-32 rounded bg-muted" />
|
||||||
|
<div className="h-3 w-16 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-background-surface p-4 space-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-data-sm text-foreground-muted truncate">{label}</span>
|
||||||
|
{icon && <span className="text-foreground-dim shrink-0">{icon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
data-numeric
|
||||||
|
className="text-data-lg font-mono font-semibold text-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{delta !== undefined && (
|
||||||
|
<PriceDelta value={delta} direction={deltaDirection} size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{footnote && (
|
||||||
|
<p className="text-data-sm text-foreground-dim">{footnote}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/components/design-system/skeleton.tsx
Normal file
81
apps/web/components/design-system/skeleton.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton base — animated pulse placeholder.
|
||||||
|
*/
|
||||||
|
function SkeletonBase({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton.Row — một hàng text placeholder */
|
||||||
|
function SkeletonRow({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3 py-2', className)} aria-hidden {...props}>
|
||||||
|
<SkeletonBase className="h-4 w-8 shrink-0" />
|
||||||
|
<SkeletonBase className="h-4 flex-1" />
|
||||||
|
<SkeletonBase className="h-4 w-24 shrink-0" />
|
||||||
|
<SkeletonBase className="h-4 w-20 shrink-0" />
|
||||||
|
<SkeletonBase className="h-4 w-16 shrink-0" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton.Card — card placeholder */
|
||||||
|
function SkeletonCard({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border bg-background-surface p-4 space-y-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SkeletonBase className="h-40 w-full" />
|
||||||
|
<SkeletonBase className="h-4 w-3/4" />
|
||||||
|
<SkeletonBase className="h-3 w-1/2" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SkeletonBase className="h-5 w-16" />
|
||||||
|
<SkeletonBase className="h-5 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skeleton.Table — table placeholder */
|
||||||
|
function SkeletonTable({
|
||||||
|
rows = 5,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-0', className)} aria-hidden {...props}>
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-border py-2">
|
||||||
|
<SkeletonBase className="h-3 w-8 shrink-0" />
|
||||||
|
<SkeletonBase className="h-3 flex-1" />
|
||||||
|
<SkeletonBase className="h-3 w-24 shrink-0" />
|
||||||
|
<SkeletonBase className="h-3 w-20 shrink-0" />
|
||||||
|
<SkeletonBase className="h-3 w-16 shrink-0" />
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<SkeletonRow key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Skeleton = Object.assign(SkeletonBase, {
|
||||||
|
Row: SkeletonRow,
|
||||||
|
Card: SkeletonCard,
|
||||||
|
Table: SkeletonTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SkeletonProps = React.HTMLAttributes<HTMLDivElement>;
|
||||||
74
apps/web/components/design-system/status-chip.tsx
Normal file
74
apps/web/components/design-system/status-chip.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type PropertyStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'pending'
|
||||||
|
| 'sold'
|
||||||
|
| 'rented'
|
||||||
|
| 'rejected'
|
||||||
|
| 'draft';
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
PropertyStatus,
|
||||||
|
{ label: string; colorClass: string; dotClass: string }
|
||||||
|
> = {
|
||||||
|
active: {
|
||||||
|
label: 'Đang bán',
|
||||||
|
colorClass: 'bg-signal-up-bg text-signal-up ring-signal-up/20',
|
||||||
|
dotClass: 'bg-signal-up',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: 'Chờ duyệt',
|
||||||
|
colorClass: 'bg-warning/10 text-warning ring-warning/20',
|
||||||
|
dotClass: 'bg-warning',
|
||||||
|
},
|
||||||
|
sold: {
|
||||||
|
label: 'Đã bán',
|
||||||
|
colorClass: 'bg-muted text-foreground-muted ring-border',
|
||||||
|
dotClass: 'bg-foreground-muted',
|
||||||
|
},
|
||||||
|
rented: {
|
||||||
|
label: 'Đã thuê',
|
||||||
|
colorClass: 'bg-accent-blue/10 text-accent-blue ring-accent-blue/20',
|
||||||
|
dotClass: 'bg-accent-blue',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: 'Từ chối',
|
||||||
|
colorClass: 'bg-destructive/10 text-destructive ring-destructive/20',
|
||||||
|
dotClass: 'bg-destructive',
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
label: 'Bản nháp',
|
||||||
|
colorClass: 'bg-muted text-foreground-dim ring-border',
|
||||||
|
dotClass: 'bg-foreground-dim',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StatusChipProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
|
status: PropertyStatus;
|
||||||
|
/** Ẩn dot indicator */
|
||||||
|
hideDot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusChip — hiển thị trạng thái bất động sản với màu sắc và nhãn tiếng Việt.
|
||||||
|
*/
|
||||||
|
export function StatusChip({ status, hideDot = false, className, ...props }: StatusChipProps) {
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset',
|
||||||
|
cfg.colorClass,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{!hideDot && (
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', cfg.dotClass)} aria-hidden />
|
||||||
|
)}
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/web/lib/preferences-store.ts
Normal file
22
apps/web/lib/preferences-store.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export type Density = 'compact' | 'regular';
|
||||||
|
|
||||||
|
interface PreferencesState {
|
||||||
|
density: Density;
|
||||||
|
setDensity: (density: Density) => void;
|
||||||
|
toggleDensity: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreferencesStore = create<PreferencesState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
density: 'regular',
|
||||||
|
setDensity: (density) => set({ density }),
|
||||||
|
toggleDensity: () =>
|
||||||
|
set({ density: get().density === 'compact' ? 'regular' : 'compact' }),
|
||||||
|
}),
|
||||||
|
{ name: 'goodgo-preferences' },
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user