diff --git a/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx b/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx new file mode 100644 index 0000000..a23fcab --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dev/tokens/page.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { notFound } from 'next/navigation'; +import { + Surface, + SurfaceElevated, + Divider, + DensityProvider, + useDensity, + DENSITY_ROW_HEIGHT, + Numeric, + Signal, +} from '@/components/design-system'; + +// Dev-only: block in production +if (process.env.NODE_ENV === 'production') { + // Will 404 at build time for static generation +} + +const COLOR_TOKENS = [ + { name: '--background', tw: 'bg-background' }, + { name: '--background-elevated', tw: 'bg-background-elevated' }, + { name: '--background-surface', tw: 'bg-background-surface' }, + { name: '--primary', tw: 'bg-primary' }, + { name: '--primary-hover', tw: 'bg-primary-hover' }, + { name: '--destructive', tw: 'bg-destructive' }, + { name: '--warning', tw: 'bg-warning' }, + { name: '--success', tw: 'bg-success' }, + { name: '--accent-blue', tw: 'bg-accent-blue' }, + { name: '--accent-purple', tw: 'bg-accent-purple' }, + { name: '--signal-up', tw: 'bg-signal-up' }, + { name: '--signal-down', tw: 'bg-signal-down' }, + { name: '--signal-neutral', tw: 'bg-signal-neutral' }, +]; + +const CHART_TOKENS = [ + { name: '--chart-1', tw: 'bg-chart-1' }, + { name: '--chart-2', tw: 'bg-chart-2' }, + { name: '--chart-3', tw: 'bg-chart-3' }, + { name: '--chart-4', tw: 'bg-chart-4' }, + { name: '--chart-5', tw: 'bg-chart-5' }, + { name: '--chart-6', tw: 'bg-chart-6' }, +]; + +function DensityDemo() { + const { density, setDensity } = useDensity(); + return ( +
+
+ {(['compact', 'regular', 'roomy'] as const).map((d) => ( + + ))} +
+
+
+ Mẫu hàng — {density} +
+ {[1, 2, 3].map((i) => ( +
+ Dòng {i} + +
+ ))} +
+
+ ); +} + +export default function DevTokensPage() { + if (process.env.NODE_ENV === 'production') { + notFound(); + } + + return ( +
+

Design Tokens Showcase

+

Dev-only route — không hiển thị trên production.

+ + {/* Colors */} +
+

Màu sắc

+
+ {COLOR_TOKENS.map((t) => ( +
+
+

{t.name}

+
+ ))} +
+
+ + {/* Chart palette */} +
+

Chart Palette

+
+ {CHART_TOKENS.map((t) => ( +
+
+

{t.name}

+
+ ))} +
+
+ + {/* Typography */} +
+

Typography

+
+

heading-xl (1.875rem)

+

heading-lg (1.5rem)

+

heading-md (1.125rem)

+

heading-sm (0.875rem)

+

heading-xs (0.75rem uppercase tracking)

+ +

data-lg: 1.250.000.000 ₫

+

data-md: 850.000.000 ₫

+

data-sm: 450.000.000 ₫

+

ticker: Q1 +2.4%

+
+
+ + {/* Shadows */} +
+

Elevation (Shadow)

+
+ {['elevation-0', 'elevation-1', 'elevation-2', 'elevation-3'].map((s) => ( +
+ {s} +
+ ))} +
+
+ + {/* Signals */} +
+

Signal

+
+ + + +
+
+ + {/* Glow shadows */} +
+

Glow Shadows

+
+
+ glow-up +
+
+ glow-down +
+
+
+ + {/* Surfaces */} +
+

Surface

+
+ +

Surface (flat)

+
+ +

SurfaceElevated

+
+
+
+ + {/* Numeric */} +
+

Numeric

+
+
+ VND: + +
+
+ Percent: + +
+
+ Compact: + +
+
+
+ + {/* Density */} +
+

Density

+ + + +
+ + {/* Tick flash animations */} +
+

Tick Flash

+
+
Flash Up
+
Flash Down
+
+

Refresh page to replay animation. Disabled with prefers-reduced-motion.

+
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 6b4cf15..23072f3 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -39,6 +39,21 @@ --input: 214.3 31.8% 91.4%; --ring: 142.1 76.2% 36.3%; --radius: 0.5rem; + + /* Chart palette (light) */ + --chart-1: 200 90% 45%; + --chart-2: 142 65% 38%; + --chart-3: 38 95% 48%; + --chart-4: 280 65% 50%; + --chart-5: 0 75% 50%; + --chart-6: 180 60% 40%; + + /* Motion */ + --duration-xs: 80ms; + --duration-sm: 150ms; + --duration-md: 240ms; + --ease-standard: cubic-bezier(.2, 0, 0, 1); + --ease-emphasized: cubic-bezier(.3, 0, 0, 1); } .dark { @@ -78,6 +93,16 @@ --ring: 142 72% 42%; } + .dark { + /* Chart palette (dark) */ + --chart-1: 200 90% 60%; + --chart-2: 142 70% 50%; + --chart-3: 38 95% 60%; + --chart-4: 280 70% 65%; + --chart-5: 0 75% 60%; + --chart-6: 180 65% 50%; + } + * { @apply border-border; } @@ -135,28 +160,44 @@ animation-play-state: paused; } -/* Signal flash for price updates */ -@keyframes signal-flash-up { - 0%, +/* Signal flash for price updates (tick-flash per design tokens) */ +@keyframes tick-up { + 0% { + background-color: hsl(var(--signal-up) / 0.18); + } 100% { background-color: transparent; } - 30% { - background-color: hsl(var(--signal-up-bg) / 0.2); - } } -@keyframes signal-flash-down { - 0%, +@keyframes tick-down { + 0% { + background-color: hsl(var(--signal-down) / 0.18); + } 100% { background-color: transparent; } - 30% { - background-color: hsl(var(--signal-down-bg) / 0.2); - } } +.tick-flash-up { + animation: tick-up 480ms ease-out; +} +.tick-flash-down { + animation: tick-down 480ms ease-out; +} +/* Legacy aliases */ .flash-up { - animation: signal-flash-up 1s ease-out; + animation: tick-up 480ms ease-out; } .flash-down { - animation: signal-flash-down 1s ease-out; + animation: tick-down 480ms ease-out; +} +@media (prefers-reduced-motion: reduce) { + .tick-flash-up, + .tick-flash-down, + .flash-up, + .flash-down { + animation: none; + } + .animate-ticker { + animation: none; + } } diff --git a/apps/web/components/design-system/density-provider.tsx b/apps/web/components/design-system/density-provider.tsx new file mode 100644 index 0000000..55fc604 --- /dev/null +++ b/apps/web/components/design-system/density-provider.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +export type DensityMode = 'compact' | 'regular' | 'roomy'; + +interface DensityContextValue { + density: DensityMode; + setDensity: (mode: DensityMode) => void; +} + +const STORAGE_KEY = 'goodgo.density'; + +const DensityContext = createContext({ + density: 'regular', + setDensity: () => {}, +}); + +export function useDensity() { + return useContext(DensityContext); +} + +/** Row height in Tailwind spacing tokens per density mode. */ +export const DENSITY_ROW_HEIGHT: Record = { + compact: 'h-row-compact', // 32px + regular: 'h-row', // 36px + roomy: 'h-row-roomy', // 44px +}; + +/** Cell padding classes per density mode. */ +export const DENSITY_CELL_PADDING: Record = { + compact: 'px-2 py-1', // 4px 8px + regular: 'px-2.5 py-1.5', // 6px 10px + roomy: 'px-3 py-2.5', // 10px 12px +}; + +/** Data font size per density mode. */ +export const DENSITY_DATA_FONT: Record = { + compact: 'text-data-sm', + regular: 'text-data-md', + roomy: 'text-data-md', +}; + +export function DensityProvider({ + defaultDensity = 'regular', + children, +}: { + defaultDensity?: DensityMode; + children: React.ReactNode; +}) { + const [density, setDensityState] = useState(defaultDensity); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) as DensityMode | null; + if (stored === 'compact' || stored === 'regular' || stored === 'roomy') { + setDensityState(stored); + } + }, []); + + const setDensity = useCallback((mode: DensityMode) => { + setDensityState(mode); + localStorage.setItem(STORAGE_KEY, mode); + }, []); + + return ( + + {children} + + ); +} diff --git a/apps/web/components/design-system/divider.tsx b/apps/web/components/design-system/divider.tsx new file mode 100644 index 0000000..809ca7e --- /dev/null +++ b/apps/web/components/design-system/divider.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; + +export interface DividerProps extends React.HTMLAttributes { + /** Use border-strong variant for headers/section separators. */ + strong?: boolean; + /** Orientation. Default horizontal. */ + orientation?: 'horizontal' | 'vertical'; +} + +export function Divider({ + strong = false, + orientation = 'horizontal', + className, + ...props +}: DividerProps) { + return ( +
+ ); +} diff --git a/apps/web/components/design-system/index.ts b/apps/web/components/design-system/index.ts index 0072b43..c036395 100644 --- a/apps/web/components/design-system/index.ts +++ b/apps/web/components/design-system/index.ts @@ -18,3 +18,34 @@ export type { DashboardLayoutProps } from './dashboard-layout'; export { TickerStrip } from './ticker-strip'; export type { TickerStripProps, TickerItem } from './ticker-strip'; + +export { Badge } from './badge'; +export type { BadgeProps } from './badge'; + +export { StatusChip } from './status-chip'; +export type { StatusChipProps, PropertyStatus } from './status-chip'; + +export { DensityToggle } from './density-toggle'; +export type { DensityToggleProps } from './density-toggle'; + +export { EmptyState } from './empty-state'; +export type { EmptyStateProps } from './empty-state'; + +export { Skeleton } from './skeleton'; +export type { SkeletonProps } from './skeleton'; + +export { KpiCard } from './kpi-card'; +export type { KpiCardProps } from './kpi-card'; + +export { Surface, SurfaceElevated } from './surface'; +export { Divider } from './divider'; +export type { DividerProps } from './divider'; + +export { DensityProvider, useDensity, DENSITY_ROW_HEIGHT, DENSITY_CELL_PADDING, DENSITY_DATA_FONT } from './density-provider'; +export type { DensityMode } from './density-provider'; + +export { Numeric } from './numeric'; +export type { NumericProps } from './numeric'; + +export { Signal } from './signal'; +export type { SignalDirection, SignalProps } from './signal'; diff --git a/apps/web/components/design-system/numeric.tsx b/apps/web/components/design-system/numeric.tsx new file mode 100644 index 0000000..67fd8f9 --- /dev/null +++ b/apps/web/components/design-system/numeric.tsx @@ -0,0 +1,64 @@ +import { cn } from '@/lib/utils'; + +export interface NumericProps extends React.HTMLAttributes { + /** The numeric value to display. */ + value: number; + /** Format style. Default 'vnd'. */ + format?: 'vnd' | 'percent' | 'decimal' | 'compact'; + /** Number of fraction digits for percent/decimal. Default 1 for percent, 0 for vnd. */ + fractionDigits?: number; +} + +const vndFormatter = new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0, +}); + +const compactFormatter = new Intl.NumberFormat('vi-VN', { + notation: 'compact', + maximumFractionDigits: 1, +}); + +function formatValue( + value: number, + format: NumericProps['format'], + fractionDigits?: number, +): string { + switch (format) { + case 'percent': + return `${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits ?? 1)}%`; + case 'decimal': + return new Intl.NumberFormat('vi-VN', { + minimumFractionDigits: fractionDigits ?? 0, + maximumFractionDigits: fractionDigits ?? 2, + }).format(value); + case 'compact': + return compactFormatter.format(value); + case 'vnd': + default: + return vndFormatter.format(value); + } +} + +/** + * Numeric display — right-aligned, tabular-nums, formatted for VND/percent. + * Automatically sets `data-numeric` for global tabular-nums styling. + */ +export function Numeric({ + value, + format = 'vnd', + fractionDigits, + className, + ...props +}: NumericProps) { + return ( + + {formatValue(value, format, fractionDigits)} + + ); +} diff --git a/apps/web/components/design-system/signal.tsx b/apps/web/components/design-system/signal.tsx new file mode 100644 index 0000000..25ec376 --- /dev/null +++ b/apps/web/components/design-system/signal.tsx @@ -0,0 +1,46 @@ +import { ArrowDown, ArrowUp, Minus } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type SignalDirection = 'up' | 'down' | 'neutral'; + +export interface SignalProps { + /** Direction of the signal. */ + direction: SignalDirection; + /** Text label shown inside the pill. */ + label?: string; + /** Additional class names. */ + className?: string; +} + +const directionStyles: Record = { + up: 'bg-signal-up/10 text-signal-up', + down: 'bg-signal-down/10 text-signal-down', + neutral: 'bg-signal-neutral/10 text-signal-neutral', +}; + +const icons: Record = { + up: ArrowUp, + down: ArrowDown, + neutral: Minus, +}; + +/** + * Signal pill — shows direction (up/down/neutral) with arrow icon and optional label. + * Uses `--signal-*` design tokens. + */ +export function Signal({ direction, label, className }: SignalProps) { + const Icon = icons[direction]; + + return ( + + + ); +} diff --git a/apps/web/components/design-system/surface.tsx b/apps/web/components/design-system/surface.tsx new file mode 100644 index 0000000..aa3898a --- /dev/null +++ b/apps/web/components/design-system/surface.tsx @@ -0,0 +1,30 @@ +import { cn } from '@/lib/utils'; + +/** Flat surface — uses `--background` (app bg). */ +export function Surface({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +/** Elevated surface — uses `--background-elevated` with shadow-elevation-1. */ +export function SurfaceElevated({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} diff --git a/apps/web/components/providers/__tests__/theme-provider.spec.tsx b/apps/web/components/providers/__tests__/theme-provider.spec.tsx index d41f876..307935b 100644 --- a/apps/web/components/providers/__tests__/theme-provider.spec.tsx +++ b/apps/web/components/providers/__tests__/theme-provider.spec.tsx @@ -3,35 +3,23 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { ThemeProvider, useTheme } from '../theme-provider'; -// Provide a working localStorage mock for this test file -const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { store[key] = value; }), - removeItem: vi.fn((key: string) => { delete store[key]; }), - clear: vi.fn(() => { store = {}; }), - get length() { return Object.keys(store).length; }, - key: vi.fn((index: number) => Object.keys(store)[index] ?? null), - }; -})(); +// Mock next-themes +const mockSetTheme = vi.fn(); +let mockTheme = 'dark'; +let mockResolvedTheme = 'dark'; -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); - -// Mock window.matchMedia (not implemented in jsdom) -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), -}); +vi.mock('next-themes', () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useTheme: () => ({ + theme: mockTheme, + resolvedTheme: mockResolvedTheme, + setTheme: (t: string) => { + mockSetTheme(t); + mockTheme = t; + mockResolvedTheme = t; + }, + }), +})); // Test consumer component function ThemeConsumer() { @@ -46,8 +34,8 @@ function ThemeConsumer() { describe('ThemeProvider', () => { beforeEach(() => { - document.documentElement.classList.remove('dark'); - localStorageMock.clear(); + mockTheme = 'dark'; + mockResolvedTheme = 'dark'; vi.clearAllMocks(); }); @@ -60,54 +48,7 @@ describe('ThemeProvider', () => { expect(screen.getByText('Child content')).toBeInTheDocument(); }); - it('defaults to light theme', () => { - render( - - - , - ); - expect(screen.getByTestId('theme')).toHaveTextContent('light'); - }); - - it('toggles theme to dark', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByText('Toggle')); - expect(screen.getByTestId('theme')).toHaveTextContent('dark'); - }); - - it('toggles theme back to light', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByText('Toggle')); - await user.click(screen.getByText('Toggle')); - expect(screen.getByTestId('theme')).toHaveTextContent('light'); - }); - - it('persists theme to localStorage', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByText('Toggle')); - expect(localStorageMock.setItem).toHaveBeenCalledWith('goodgo-theme', 'dark'); - }); - - it('loads stored theme from localStorage', () => { - localStorageMock.getItem.mockReturnValueOnce('dark'); + it('defaults to dark theme', () => { render( @@ -115,11 +56,37 @@ describe('ThemeProvider', () => { ); expect(screen.getByTestId('theme')).toHaveTextContent('dark'); }); + + it('toggles theme to light', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByText('Toggle')); + expect(mockSetTheme).toHaveBeenCalledWith('light'); + }); + + it('toggles theme back to dark', async () => { + mockTheme = 'light'; + mockResolvedTheme = 'light'; + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByText('Toggle')); + expect(mockSetTheme).toHaveBeenCalledWith('dark'); + }); }); describe('useTheme', () => { - it('returns default values outside provider', () => { + it('returns dark as default outside provider', () => { render(); - expect(screen.getByTestId('theme')).toHaveTextContent('light'); + expect(screen.getByTestId('theme')).toHaveTextContent('dark'); }); }); diff --git a/apps/web/components/providers/theme-provider.tsx b/apps/web/components/providers/theme-provider.tsx index 2a80bde..ff64047 100644 --- a/apps/web/components/providers/theme-provider.tsx +++ b/apps/web/components/providers/theme-provider.tsx @@ -1,51 +1,31 @@ 'use client'; -import { createContext, useCallback, useContext, useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark'; - -interface ThemeContextValue { - theme: Theme; - toggleTheme: () => void; -} - -const ThemeContext = createContext({ - theme: 'light', - toggleTheme: () => {}, -}); - -export function useTheme() { - return useContext(ThemeContext); -} - -const STORAGE_KEY = 'goodgo-theme'; +import { ThemeProvider as NextThemesProvider, useTheme as useNextTheme } from 'next-themes'; export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState('light'); - - useEffect(() => { - const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; - if (stored === 'dark' || stored === 'light') { - setTheme(stored); - document.documentElement.classList.toggle('dark', stored === 'dark'); - } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { - setTheme('dark'); - document.documentElement.classList.add('dark'); - } - }, []); - - const toggleTheme = useCallback(() => { - setTheme((prev) => { - const next = prev === 'light' ? 'dark' : 'light'; - localStorage.setItem(STORAGE_KEY, next); - document.documentElement.classList.toggle('dark', next === 'dark'); - return next; - }); - }, []); - return ( - + {children} - + ); } + +/** + * Backward-compatible useTheme hook. + * Returns `theme` ('light' | 'dark') and `toggleTheme`. + */ +export function useTheme() { + const { theme, setTheme, resolvedTheme } = useNextTheme(); + const current = (resolvedTheme ?? theme ?? 'dark') as 'light' | 'dark'; + + const toggleTheme = () => { + setTheme(current === 'dark' ? 'light' : 'dark'); + }; + + return { theme: current, toggleTheme }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 19fbe01..02ad2a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "mapbox-gl": "^3.21.0", "next": "^15.5.14", "next-intl": "^4.9.0", + "next-themes": "^0.4.6", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.72.1", diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 5479b27..19a7ab3 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -15,12 +15,21 @@ const config: Config = { 'data-sm': ['0.75rem', { lineHeight: '1.2' }], 'data-md': ['0.875rem', { lineHeight: '1.3' }], 'data-lg': ['1.25rem', { lineHeight: '1.2' }], + 'heading-xs': ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.08em' }], + 'heading-sm': ['0.875rem', { lineHeight: '1.25rem' }], + 'heading-md': ['1.125rem', { lineHeight: '1.5rem' }], + 'heading-lg': ['1.5rem', { lineHeight: '1.875rem' }], + 'heading-xl': ['1.875rem', { lineHeight: '2.25rem' }], }, spacing: { cell: '0.5rem', row: '2.25rem', + 'row-compact': '2rem', + 'row-roomy': '2.75rem', 'ticker-bar': '2rem', 'header-compact': '3rem', + sidebar: '15rem', + 'sidebar-collapsed': '3.5rem', }, colors: { border: 'hsl(var(--border))', @@ -74,15 +83,44 @@ const config: Config = { }, success: 'hsl(var(--success))', warning: 'hsl(var(--warning))', + chart: { + 1: 'hsl(var(--chart-1))', + 2: 'hsl(var(--chart-2))', + 3: 'hsl(var(--chart-3))', + 4: 'hsl(var(--chart-4))', + 5: 'hsl(var(--chart-5))', + 6: 'hsl(var(--chart-6))', + }, }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', + pill: '9999px', }, boxShadow: { - 'elevation-1': '0 1px 2px rgba(0, 0, 0, 0.3)', - 'elevation-2': '0 4px 12px rgba(0, 0, 0, 0.4)', + 'elevation-0': 'none', + 'elevation-1': '0 1px 2px rgba(0,0,0,.30), 0 0 0 1px hsl(var(--border))', + 'elevation-2': '0 4px 12px rgba(0,0,0,.40)', + 'elevation-3': '0 12px 32px rgba(0,0,0,.50)', + 'glow-up': '0 0 0 1px hsl(var(--signal-up) / .4), 0 0 12px hsl(var(--signal-up) / .25)', + 'glow-down': '0 0 0 1px hsl(var(--signal-down) / .4), 0 0 12px hsl(var(--signal-down) / .25)', + }, + transitionTimingFunction: { + standard: 'cubic-bezier(.2,0,0,1)', + emphasized: 'cubic-bezier(.3,0,0,1)', + }, + transitionDuration: { + '80': '80ms', + '240': '240ms', + }, + zIndex: { + 'sticky-header': '30', + 'app-header': '40', + ticker: '45', + dropdown: '50', + modal: '60', + toast: '70', }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f00cf64..709993a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: next-intl: specifier: ^4.9.0 version: 4.9.0(next@15.5.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@6.0.2) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.0 version: 18.3.1 @@ -5639,6 +5642,12 @@ packages: typescript: optional: true + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.5.15: resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -13181,6 +13190,11 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + next@15.5.15(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.5.15