feat: Thêm header điều hướng responsive, bộ chuyển đổi ngôn ngữ, thành phần UI chuyển đổi chủ đề và phím tắt chuyển đổi chủ đề.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 15:40:12 +07:00
parent 7cf4111531
commit b831058a8f
5 changed files with 521 additions and 4 deletions

View File

@@ -182,4 +182,66 @@
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* ============================================
EN: Responsive Layout Utilities
VI: Utilities cho responsive layout
============================================ */
/* Mobile-first container */
.container-responsive {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 640px) {
.container-responsive {
max-width: 640px;
}
}
@media (min-width: 768px) {
.container-responsive {
max-width: 768px;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.container-responsive {
max-width: 1024px;
padding-left: 2rem;
padding-right: 2rem;
}
}
@media (min-width: 1280px) {
.container-responsive {
max-width: 1280px;
}
}
/* Mobile-optimized text sizes */
.text-responsive-hero {
font-size: 2.5rem;
/* 40px mobile */
line-height: 1.1;
}
@media (min-width: 768px) {
.text-responsive-hero {
font-size: 3.75rem;
/* 60px desktop */
}
}
/* Touch-friendly buttons on mobile */
.btn-touch {
min-height: 44px;
min-width: 44px;
}
}

View File

@@ -0,0 +1,161 @@
'use client';
import React, { useState } from 'react';
import { Menu, X } from 'lucide-react';
import { BrandLogo, BrandLogoLink } from '../ui/brand-logo';
import { ThemeToggle } from '../ui/theme-toggle-enhanced';
import { LanguageSwitcher } from '../ui/language-switcher';
import { Button } from '../ui/button';
import { cn } from '@/lib/utils';
/**
* EN: Navigation Header props
* VI: Props cho Navigation Header
*/
export interface NavigationHeaderProps {
/** Show CTA button / Hiển thị button CTA */
showCTA?: boolean;
/** CTA button text / Text button CTA */
ctaText?: string;
/** CTA button onClick / onClick button CTA */
onCTAClick?: () => void;
/** Additional CSS classes / CSS classes bổ sung */
className?: string;
}
/**
* EN: Navigation Header - Responsive header with brand logo, theme toggle, and language switcher
* VI: Navigation Header - Header responsive với logo, theme toggle và language switcher
*
* Features:
* - Sticky header with glassmorphism
* - Responsive design (mobile menu)
* - Brand logo integration
* - Theme toggle
* - Language switcher
* - CTA button
*
* @example
* ```tsx
* <NavigationHeader
* showCTA
* ctaText="Get Started"
* onCTAClick={() => router.push('/auth/login')}
* />
* ```
*/
export function NavigationHeader({
showCTA = true,
ctaText = 'Get Started',
onCTAClick,
className,
}: NavigationHeaderProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<header
className={cn(
'sticky top-0 z-50 w-full border-b border-border-primary',
'bg-bg-primary/80 backdrop-blur-glass',
'transition-all duration-normal',
className
)}
>
<div className="container mx-auto px-4">
{/* EN: Main header content / VI: Nội dung header chính */}
<div className="flex h-16 items-center justify-between">
{/* EN: Logo / VI: Logo */}
<BrandLogoLink variant="wordmark" size="sm" href="/" />
{/* EN: Desktop Navigation / VI: Navigation desktop */}
<nav className="hidden md:flex items-center gap-4">
<ThemeToggle />
<LanguageSwitcher />
{showCTA && (
<Button
variant="brand"
size="md"
onClick={onCTAClick}
className="ml-2"
>
{ctaText}
</Button>
)}
</nav>
{/* EN: Mobile Menu Button / VI: Button menu mobile */}
<button
className="md:hidden p-2 hover:bg-bg-tertiary rounded-md transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle mobile menu"
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
</div>
{/* EN: Mobile Menu / VI: Menu mobile */}
{mobileMenuOpen && (
<div
className="md:hidden py-4 space-y-4 border-t border-border-primary animate-fadeIn"
role="navigation"
aria-label="Mobile navigation"
>
<div className="flex items-center justify-between gap-4">
<ThemeToggle />
<LanguageSwitcher />
</div>
{showCTA && (
<Button
variant="brand"
size="lg"
onClick={() => {
onCTAClick?.();
setMobileMenuOpen(false);
}}
className="w-full"
>
{ctaText}
</Button>
)}
</div>
)}
</div>
</header>
);
}
/**
* EN: Minimal Navigation Header - Simplified version without CTA
* VI: Navigation Header tối giản - Phiên bản đơn giản không có CTA
*
* @example
* ```tsx
* <MinimalNavigationHeader />
* ```
*/
export function MinimalNavigationHeader({ className }: { className?: string }) {
return (
<header
className={cn(
'sticky top-0 z-50 w-full border-b border-border-primary',
'bg-bg-primary/80 backdrop-blur-glass',
className
)}
>
<div className="container mx-auto px-4">
<div className="flex h-14 items-center justify-between">
<BrandLogo variant="icon" size="sm" />
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageSwitcher />
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import React from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { Button } from './button';
import { DropdownMenu } from './dropdown-menu';
import { cn } from '@/lib/utils';
/**
* EN: Language configuration
* VI: Cấu hình ngôn ngữ
*/
const languages = [
{ code: 'en', label: 'English', flag: '🇺🇸', nativeName: 'English' },
{ code: 'vi', label: 'Tiếng Việt', flag: '🇻🇳', nativeName: 'Tiếng Việt' },
] as const;
export type LanguageCode = typeof languages[number]['code'];
/**
* EN: Language Switcher - Multi-language support with flag icons
* VI: Bộ chuyển ngôn ngữ - Hỗ trợ đa ngôn ngữ với flag icons
*
* Features:
* - Flag emoji icons for visual identification
* - Dropdown menu for language selection
* - Persists locale to localStorage
* - Updates URL locale parameter
* - Smooth language switching
*
* @example
* ```tsx
* <LanguageSwitcher />
* ```
*/
export function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
// EN: Extract current locale from pathname
// VI: Trích xuất locale hiện tại từ pathname
const currentLocale = pathname.startsWith('/vi') ? 'vi' : 'en';
const currentLanguage = languages.find(lang => lang.code === currentLocale) || languages[0];
/**
* EN: Handle language change
* VI: Xử lý thay đổi ngôn ngữ
*/
const handleLanguageChange = (newLocale: LanguageCode) => {
// EN: Save to localStorage
// VI: Lưu vào localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('preferredLocale', newLocale);
}
// EN: Update URL
// VI: Cập nhật URL
let newPathname = pathname;
// Remove existing locale prefix
if (pathname.startsWith('/en') || pathname.startsWith('/vi')) {
newPathname = pathname.substring(3) || '/';
}
// Add new locale prefix
if (newLocale !== 'en') {
newPathname = `/${newLocale}${newPathname}`;
}
router.push(newPathname);
};
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button
variant="glass"
size="md"
className="gap-2 min-w-[100px]"
aria-label="Change language"
>
<span className="text-lg" role="img" aria-label={currentLanguage.label}>
{currentLanguage.flag}
</span>
<span className="hidden sm:inline">{currentLanguage.code.toUpperCase()}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
className="bg-glass-bg backdrop-blur-glass border border-glass-border min-w-[180px]"
>
{languages.map((language) => {
const isActive = currentLocale === language.code;
return (
<DropdownMenu.Item
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={cn(
'flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors',
'hover:bg-bg-tertiary focus:bg-bg-tertiary',
isActive && 'bg-brand-primary/10 text-brand-primary'
)}
>
<span className="text-lg" role="img" aria-label={language.label}>
{language.flag}
</span>
<div className="flex flex-col">
<span className="font-medium">{language.nativeName}</span>
<span className="text-xs text-text-tertiary">{language.label}</span>
</div>
{isActive && (
<span className="ml-auto text-brand-primary"></span>
)}
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu>
);
}
/**
* EN: Compact Language Switcher - Shows only flags
* VI: Language Switcher nhỏ gọn - Chỉ hiển thị cờ
*
* @example
* ```tsx
* <LanguageSwitcherCompact />
* ```
*/
export function LanguageSwitcherCompact() {
const router = useRouter();
const pathname = usePathname();
const currentLocale = pathname.startsWith('/vi') ? 'vi' : 'en';
const toggleLanguage = () => {
const newLocale = currentLocale === 'en' ? 'vi' : 'en';
if (typeof window !== 'undefined') {
localStorage.setItem('preferredLocale', newLocale);
}
let newPathname = pathname;
if (pathname.startsWith('/en') || pathname.startsWith('/vi')) {
newPathname = pathname.substring(3) || '/';
}
if (newLocale !== 'en') {
newPathname = `/${newLocale}${newPathname}`;
}
router.push(newPathname);
};
const currentLanguage = languages.find(lang => lang.code === currentLocale) || languages[0];
return (
<Button
variant="glass"
size="md"
className="w-10 h-10 p-0 text-lg"
onClick={toggleLanguage}
aria-label={`Switch to ${currentLocale === 'en' ? 'Vietnamese' : 'English'}`}
>
<span role="img" aria-label={currentLanguage.label}>
{currentLanguage.flag}
</span>
</Button>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React from 'react';
import { useTheme } from '@/contexts/theme-context';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Button } from './button';
import { DropdownMenu } from './dropdown-menu';
import { cn } from '@/lib/utils';
/**
* EN: Enhanced Theme Toggle - Professional theme switcher with brand styling
* VI: Theme Toggle nâng cao - Bộ chuyển theme chuyên nghiệp với brand styling
*
* Features:
* - Dropdown menu with 3 options: Light, Dark, System
* - Glassmorphism styling
* - Icons from lucide-react
* - Smooth transitions
* - Keyboard accessible
*
* @example
* ```tsx
* <ThemeToggle />
* ```
*/
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const themeOptions = [
{ value: 'light' as const, label: 'Light', icon: Sun },
{ value: 'dark' as const, label: 'Dark', icon: Moon },
{ value: 'system' as const, label: 'System', icon: Monitor },
];
const currentIcon = resolvedTheme === 'dark' ? Moon : Sun;
const CurrentIcon = currentIcon;
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<Button
variant="glass"
size="md"
className="w-10 h-10 p-0"
aria-label="Toggle theme"
>
<CurrentIcon className="h-5 w-5" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
className="bg-glass-bg backdrop-blur-glass border border-glass-border min-w-[150px]"
>
{themeOptions.map((option) => {
const Icon = option.icon;
const isActive = theme === option.value;
return (
<DropdownMenu.Item
key={option.value}
onClick={() => setTheme(option.value)}
className={cn(
'flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors',
'hover:bg-bg-tertiary focus:bg-bg-tertiary',
isActive && 'bg-brand-primary/10 text-brand-primary'
)}
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
{isActive && (
<span className="ml-auto text-brand-primary"></span>
)}
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu>
);
}
/**
* EN: Simple Theme Toggle Button - Toggles between light and dark only
* VI: Button Toggle Theme đơn giản - Chỉ chuyển đổi giữa light và dark
*
* @example
* ```tsx
* <ThemeToggleButton />
* ```
*/
export function ThemeToggleButton() {
const { toggleTheme, resolvedTheme } = useTheme();
const Icon = resolvedTheme === 'dark' ? Sun : Moon;
return (
<Button
variant="glass"
size="md"
className="w-10 h-10 p-0"
onClick={toggleTheme}
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
>
<Icon className="h-5 w-5 transition-transform duration-normal hover:rotate-12" />
</Button>
);
}

View File

@@ -44,7 +44,7 @@ const getSystemTheme = (): 'light' | 'dark' => {
*/
const applyTheme = (theme: 'light' | 'dark') => {
if (typeof window === 'undefined') return;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
@@ -61,7 +61,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (typeof window === 'undefined') return 'dark';
// EN: Load from localStorage or default to system
// VI: Load từ localStorage hoặc mặc định là system
const stored = localStorage.getItem('theme') as ThemeMode | null;
@@ -75,7 +75,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
// VI: Khởi tạo theme từ localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as ThemeMode | null;
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
setThemeState(stored);
@@ -119,7 +119,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
*/
const setTheme = useCallback((newTheme: ThemeMode) => {
setThemeState(newTheme);
if (typeof window !== 'undefined') {
localStorage.setItem('theme', newTheme);
}
@@ -139,6 +139,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
setTheme(newTheme);
}, [resolvedTheme, setTheme]);
// EN: Keyboard shortcut: Ctrl+Shift+T to toggle theme
// VI: Phím tắt: Ctrl+Shift+T để chuyển theme
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
e.preventDefault();
toggleTheme();
}
};
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}
}, [toggleTheme]);
const value: ThemeContextValue = {
theme,
resolvedTheme,