Add i18 support

This commit is contained in:
Urtzi Alfaro
2025-09-22 11:04:03 +02:00
parent ecfc6a1997
commit ee36c45d25
28 changed files with 2307 additions and 565 deletions

View File

@@ -1,23 +1,20 @@
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
import { useTranslation } from 'react-i18next';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { useNotifications } from '../../../hooks/useNotifications';
import { Button } from '../../ui';
import { Avatar } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { ThemeToggle } from '../../ui/ThemeToggle';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
import {
Menu,
Search,
Bell,
Settings,
User,
LogOut,
X
} from 'lucide-react';
@@ -43,10 +40,6 @@ export interface HeaderProps {
* Show/hide theme toggle
*/
showThemeToggle?: boolean;
/**
* Show/hide user menu
*/
showUserMenu?: boolean;
/**
* Custom logo component
*/
@@ -67,17 +60,14 @@ export interface HeaderProps {
export interface HeaderRef {
focusSearch: () => void;
toggleUserMenu: () => void;
closeUserMenu: () => void;
}
/**
* Header - Top navigation header with logo, user menu, notifications, theme toggle
*
* Header - Top navigation header with logo, notifications, theme toggle
*
* Features:
* - Logo/brand area with responsive sizing
* - Global search functionality with keyboard shortcuts
* - User avatar with dropdown menu
* - Notifications bell with badge count
* - Theme toggle button (light/dark/system)
* - Mobile hamburger menu integration
@@ -90,16 +80,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
showSearch = true,
showNotifications = true,
showThemeToggle = true,
showUserMenu = true,
logo,
searchPlaceholder = 'Buscar...',
searchPlaceholder,
notificationCount = 0,
onNotificationClick,
}, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const { logout } = useAuthActions();
const { theme, resolvedTheme, setTheme } = useTheme();
const {
notifications,
@@ -111,34 +100,22 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
clearAll
} = useNotifications();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
// Focus search input
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
// Toggle user menu
const toggleUserMenu = useCallback(() => {
setIsUserMenuOpen(prev => !prev);
}, []);
// Close user menu
const closeUserMenu = useCallback(() => {
setIsUserMenuOpen(false);
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
focusSearch,
toggleUserMenu,
closeUserMenu,
}), [focusSearch, toggleUserMenu, closeUserMenu]);
}), [focusSearch]);
// Handle search
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -158,11 +135,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
searchInputRef.current?.focus();
}, []);
// Handle logout
const handleLogout = useCallback(async () => {
await logout();
setIsUserMenuOpen(false);
}, [logout]);
// Keyboard shortcuts
React.useEffect(() => {
@@ -175,7 +147,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
// Escape to close menus
if (e.key === 'Escape') {
setIsUserMenuOpen(false);
setIsNotificationPanelOpen(false);
if (isSearchFocused) {
searchInputRef.current?.blur();
@@ -191,9 +162,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-user-menu]')) {
setIsUserMenuOpen(false);
}
if (!target.closest('[data-notification-panel]')) {
setIsNotificationPanelOpen(false);
}
@@ -287,7 +255,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={searchPlaceholder}
placeholder={defaultSearchPlaceholder}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
@@ -297,14 +265,14 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label="Buscar en la aplicación"
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label="Limpiar búsqueda"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
@@ -330,12 +298,15 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
size="sm"
onClick={focusSearch}
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
aria-label="Buscar"
aria-label={t('common:actions.search', 'Search')}
>
<Search className="h-5 w-5" />
</Button>
)}
{/* Language selector */}
<CompactLanguageSelector className="w-auto min-w-[60px]" />
{/* Theme toggle */}
{showThemeToggle && (
<ThemeToggle variant="button" size="md" />
@@ -353,8 +324,8 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
!isConnected && "opacity-50",
isNotificationPanelOpen && "bg-[var(--bg-secondary)]"
)}
aria-label={`Notificaciones${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ' - Desconectado' : ''}`}
title={!isConnected ? 'Sin conexión en tiempo real' : undefined}
aria-label={`${t('common:navigation.notifications', 'Notifications')}${unreadCount > 0 ? ` (${unreadCount})` : ''}${!isConnected ? ` - ${t('common:status.disconnected', 'Disconnected')}` : ''}`}
title={!isConnected ? t('common:status.no_realtime_connection', 'No real-time connection') : undefined}
aria-expanded={isNotificationPanelOpen}
aria-haspopup="true"
>
@@ -385,93 +356,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
</div>
)}
{/* User menu */}
{showUserMenu && user && (
<div className="relative" data-user-menu>
<Button
variant="ghost"
size="sm"
onClick={toggleUserMenu}
className={clsx(
"flex items-center gap-2 pl-2 pr-3 py-1.5 h-9 min-w-0 rounded-lg",
"hover:bg-[var(--bg-secondary)] hover:ring-2 hover:ring-[var(--color-primary)]/20",
"transition-all duration-200 ease-in-out",
"active:scale-95",
isUserMenuOpen && "bg-[var(--bg-secondary)] ring-2 ring-[var(--color-primary)]/20"
)}
aria-label="Menú de usuario"
aria-expanded={isUserMenuOpen}
aria-haspopup="true"
>
<Avatar
src={user.avatar}
alt={user.name}
name={user.name}
size="xs"
className={clsx(
"flex-shrink-0 transition-all duration-200",
"hover:ring-2 hover:ring-[var(--color-primary)]/30",
isUserMenuOpen && "ring-2 ring-[var(--color-primary)]/30"
)}
/>
</Button>
{isUserMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-56 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-2 z-[var(--z-dropdown)]">
{/* User info */}
<div className="px-4 py-3 border-b border-[var(--border-primary)] flex items-center gap-3">
<Avatar
src={user.avatar || undefined}
alt={user.name}
name={user.name}
size="sm"
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="text-xs text-[var(--text-tertiary)] truncate">
{user.email}
</div>
</div>
</div>
{/* Menu items */}
<div className="py-1">
<button
onClick={() => {
navigate('/app/settings/profile');
setIsUserMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
>
<User className="h-4 w-4" />
Perfil
</button>
<button
onClick={() => {
navigate('/app/settings');
setIsUserMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors"
>
<Settings className="h-4 w-4" />
Configuración
</button>
</div>
{/* Logout */}
<div className="border-t border-[var(--border-primary)] pt-1">
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-[var(--bg-secondary)] transition-colors text-[var(--color-error)]"
>
<LogOut className="h-4 w-4" />
Cerrar Sesión
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</header>