diff --git a/apps/web/components/design-system/__tests__/badge.test.tsx b/apps/web/components/design-system/__tests__/badge.test.tsx new file mode 100644 index 0000000..dde186e --- /dev/null +++ b/apps/web/components/design-system/__tests__/badge.test.tsx @@ -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(Test); + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + it('applies default variant classes', () => { + const { container } = render(Default); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-muted'); + }); + + it('applies primary variant classes', () => { + const { container } = render(Primary); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-primary/10'); + }); + + it('applies accent variant classes', () => { + const { container } = render(Accent); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-accent/10'); + }); + + it('applies warning variant classes', () => { + const { container } = render(Warning); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-warning/10'); + }); + + it('applies destructive variant classes', () => { + const { container } = render(Destructive); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-destructive/10'); + }); + + it('applies outline variant classes', () => { + const { container } = render(Outline); + const badge = container.firstChild as HTMLElement; + expect(badge).toHaveClass('bg-transparent'); + }); + + it('merges custom className', () => { + const { container } = render(X); + expect((container.firstChild as HTMLElement)).toHaveClass('my-custom'); + }); +}); diff --git a/apps/web/components/design-system/__tests__/density-toggle.test.tsx b/apps/web/components/design-system/__tests__/density-toggle.test.tsx new file mode 100644 index 0000000..46d169f --- /dev/null +++ b/apps/web/components/design-system/__tests__/density-toggle.test.tsx @@ -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(); + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('aria-checked=false when density is regular', () => { + render(); + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'); + }); + + it('aria-checked=true when density is compact', () => { + mockDensity = 'compact'; + render(); + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls toggleDensity on click', () => { + render(); + fireEvent.click(screen.getByRole('switch')); + expect(toggleDensityMock).toHaveBeenCalledOnce(); + }); + + it('shows custom aria-label when provided', () => { + render(); + expect(screen.getByLabelText('Custom label')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/design-system/__tests__/empty-state.test.tsx b/apps/web/components/design-system/__tests__/empty-state.test.tsx new file mode 100644 index 0000000..0f2d39c --- /dev/null +++ b/apps/web/components/design-system/__tests__/empty-state.test.tsx @@ -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(); + expect(screen.getByText('Không có dữ liệu')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render(); + expect(screen.getByText('Hãy thêm dữ liệu mới')).toBeInTheDocument(); + }); + + it('does not render description when not provided', () => { + render(); + expect(screen.queryByText(/Hãy/)).not.toBeInTheDocument(); + }); + + it('renders icon when provided', () => { + render(X} />); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('renders action when provided', () => { + render(Thêm mới} />); + expect(screen.getByRole('button', { name: 'Thêm mới' })).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect((container.firstChild as HTMLElement)).toHaveClass('my-class'); + }); +}); diff --git a/apps/web/components/design-system/__tests__/kpi-card.test.tsx b/apps/web/components/design-system/__tests__/kpi-card.test.tsx new file mode 100644 index 0000000..eb56b84 --- /dev/null +++ b/apps/web/components/design-system/__tests__/kpi-card.test.tsx @@ -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(); + expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument(); + }); + + it('renders value', () => { + render(); + expect(screen.getByText('9.8 tỷ')).toBeInTheDocument(); + }); + + it('renders delta when provided', () => { + render(); + expect(screen.getByText('+3.50%')).toBeInTheDocument(); + }); + + it('renders footnote when provided', () => { + render(); + expect(screen.getByText('So với tháng trước')).toBeInTheDocument(); + }); + + it('renders icon when provided', () => { + render(★} />); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + }); + + it('renders loading skeleton when loading=true', () => { + const { container } = render(); + expect(container.querySelector('[aria-busy]')).toBeInTheDocument(); + expect(screen.queryByText('L')).not.toBeInTheDocument(); + }); + + it('does not render delta when not provided', () => { + render(); + expect(screen.queryByText(/%/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/design-system/__tests__/skeleton.test.tsx b/apps/web/components/design-system/__tests__/skeleton.test.tsx new file mode 100644 index 0000000..f5ae6a5 --- /dev/null +++ b/apps/web/components/design-system/__tests__/skeleton.test.tsx @@ -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(); + expect((container.firstChild as HTMLElement)).toHaveAttribute('aria-hidden', 'true'); + }); + + it('base skeleton has animate-pulse class', () => { + const { container } = render(); + expect((container.firstChild as HTMLElement)).toHaveClass('animate-pulse'); + }); + + it('Skeleton.Row renders with aria-hidden', () => { + const { container } = render(); + expect(container.querySelector('[aria-hidden]')).toBeInTheDocument(); + }); + + it('Skeleton.Card renders with aria-hidden', () => { + const { container } = render(); + expect(container.querySelector('[aria-hidden]')).toBeInTheDocument(); + }); + + it('Skeleton.Table renders default 5 rows + header', () => { + const { container } = render(); + // 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(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.children.length).toBe(4); // 1 header + 3 rows + }); + + it('merges custom className', () => { + const { container } = render(); + expect((container.firstChild as HTMLElement)).toHaveClass('custom-class'); + }); +}); diff --git a/apps/web/components/design-system/__tests__/status-chip.test.tsx b/apps/web/components/design-system/__tests__/status-chip.test.tsx new file mode 100644 index 0000000..907c1ed --- /dev/null +++ b/apps/web/components/design-system/__tests__/status-chip.test.tsx @@ -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(); + expect(container.firstChild).toBeInTheDocument(); + }); + + it('renders Vietnamese label for active', () => { + render(); + expect(screen.getByText('Đang bán')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for pending', () => { + render(); + expect(screen.getByText('Chờ duyệt')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for sold', () => { + render(); + expect(screen.getByText('Đã bán')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for rented', () => { + render(); + expect(screen.getByText('Đã thuê')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for rejected', () => { + render(); + expect(screen.getByText('Từ chối')).toBeInTheDocument(); + }); + + it('renders Vietnamese label for draft', () => { + render(); + expect(screen.getByText('Bản nháp')).toBeInTheDocument(); + }); + + it('renders dot by default', () => { + const { container } = render(); + // dot is aria-hidden span + const dot = container.querySelector('[aria-hidden]'); + expect(dot).toBeInTheDocument(); + }); + + it('hides dot when hideDot=true', () => { + const { container } = render(); + const dot = container.querySelector('[aria-hidden]'); + expect(dot).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/design-system/badge.tsx b/apps/web/components/design-system/badge.tsx new file mode 100644 index 0000000..b4fef1a --- /dev/null +++ b/apps/web/components/design-system/badge.tsx @@ -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, + VariantProps {} + +/** + * 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 ; +} diff --git a/apps/web/components/design-system/density-toggle.tsx b/apps/web/components/design-system/density-toggle.tsx new file mode 100644 index 0000000..d891b59 --- /dev/null +++ b/apps/web/components/design-system/density-toggle.tsx @@ -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 { + /** 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 ( + + ); +} diff --git a/apps/web/components/design-system/empty-state.tsx b/apps/web/components/design-system/empty-state.tsx new file mode 100644 index 0000000..a13e4b7 --- /dev/null +++ b/apps/web/components/design-system/empty-state.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface EmptyStateProps extends React.HTMLAttributes { + /** 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 ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/apps/web/components/design-system/kpi-card.tsx b/apps/web/components/design-system/kpi-card.tsx new file mode 100644 index 0000000..09f632c --- /dev/null +++ b/apps/web/components/design-system/kpi-card.tsx @@ -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 { + /** 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 ( +
+
+
+
+
+ ); + } + + return ( +
+
+ {label} + {icon && {icon}} +
+
+ + {value} + + {delta !== undefined && ( + + )} +
+ {footnote && ( +

{footnote}

+ )} +
+ ); +} diff --git a/apps/web/components/design-system/skeleton.tsx b/apps/web/components/design-system/skeleton.tsx new file mode 100644 index 0000000..86487b8 --- /dev/null +++ b/apps/web/components/design-system/skeleton.tsx @@ -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) { + return ( +
+ ); +} + +/** Skeleton.Row — một hàng text placeholder */ +function SkeletonRow({ className, ...props }: React.HTMLAttributes) { + return ( +
+ + + + + +
+ ); +} + +/** Skeleton.Card — card placeholder */ +function SkeletonCard({ className, ...props }: React.HTMLAttributes) { + return ( +
+ + + +
+ + +
+
+ ); +} + +/** Skeleton.Table — table placeholder */ +function SkeletonTable({ + rows = 5, + className, + ...props +}: React.HTMLAttributes & { rows?: number }) { + return ( +
+ {/* header */} +
+ + + + + +
+ {Array.from({ length: rows }).map((_, i) => ( + + ))} +
+ ); +} + +export const Skeleton = Object.assign(SkeletonBase, { + Row: SkeletonRow, + Card: SkeletonCard, + Table: SkeletonTable, +}); + +export type SkeletonProps = React.HTMLAttributes; diff --git a/apps/web/components/design-system/status-chip.tsx b/apps/web/components/design-system/status-chip.tsx new file mode 100644 index 0000000..ef24e56 --- /dev/null +++ b/apps/web/components/design-system/status-chip.tsx @@ -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 { + 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 ( + + {!hideDot && ( + + )} + {cfg.label} + + ); +} diff --git a/apps/web/lib/preferences-store.ts b/apps/web/lib/preferences-store.ts new file mode 100644 index 0000000..e0a468a --- /dev/null +++ b/apps/web/lib/preferences-store.ts @@ -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()( + persist( + (set, get) => ({ + density: 'regular', + setDensity: (density) => set({ density }), + toggleDensity: () => + set({ density: get().density === 'compact' ? 'regular' : 'compact' }), + }), + { name: 'goodgo-preferences' }, + ), +);