diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 05faf1941..d6235923b 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -99,7 +99,7 @@ export function App() { if (currentLang !== language) { window.i18n.changeLanguage(language); } else if (language !== currentLang) { - setLanguage(currentLang as 'en' | 'fr'); + setLanguage(currentLang as 'en' | 'fr' | 'es'); } } }, [setTheme, theme, language, setLanguage]); diff --git a/apps/web/src/features/settings/components/PreferenceSettings.tsx b/apps/web/src/features/settings/components/PreferenceSettings.tsx index 396fb66b2..8d173af28 100644 --- a/apps/web/src/features/settings/components/PreferenceSettings.tsx +++ b/apps/web/src/features/settings/components/PreferenceSettings.tsx @@ -1,6 +1,7 @@ import { Label } from '@/components/ui/label'; import { Select } from '@/components/ui/select'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useTranslation } from '@/hooks/useTranslation'; import { PreferenceSettings as PreferenceSettingsType } from '../types/settings'; interface PreferenceSettingsProps { @@ -12,13 +13,6 @@ const supportedLanguages = [ { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }, { value: 'es', label: 'Español' }, - { value: 'de', label: 'Deutsch' }, - { value: 'it', label: 'Italiano' }, - { value: 'pt', label: 'Português' }, - { value: 'ru', label: 'Русский' }, - { value: 'ja', label: '日本語' }, - { value: 'zh', label: '中文' }, - { value: 'ko', label: '한국어' }, ]; const commonTimezones = [ @@ -34,7 +28,14 @@ export function PreferenceSettings({ preferences, onChange, }: PreferenceSettingsProps) { + const { t, changeLanguage: changeI18nLanguage } = useTranslation(); + const handleLanguageChange = (value: string | string[]) => { + const lang = Array.isArray(value) ? (value[0] ?? '') : value; + // Sync i18n language immediately for no-reload switching + if (lang === 'en' || lang === 'fr' || lang === 'es') { + changeI18nLanguage(lang); + } onChange({ ...preferences, language: Array.isArray(value) ? (value[0] ?? '') : value, @@ -59,29 +60,29 @@ export function PreferenceSettings({
- +
- +
diff --git a/apps/web/src/hooks/types.ts b/apps/web/src/hooks/types.ts index 7fc8bc491..c1f2008ab 100644 --- a/apps/web/src/hooks/types.ts +++ b/apps/web/src/hooks/types.ts @@ -26,8 +26,8 @@ export interface UseTranslationReturn { isInitialized: boolean; [key: string]: unknown; }; - language: 'en' | 'fr'; - changeLanguage: (newLanguage: 'en' | 'fr') => void; + language: 'en' | 'fr' | 'es'; + changeLanguage: (newLanguage: 'en' | 'fr' | 'es') => void; isReady: boolean; } diff --git a/apps/web/src/hooks/useTranslation.test.ts b/apps/web/src/hooks/useTranslation.test.ts index 30461269f..1bd34c024 100644 --- a/apps/web/src/hooks/useTranslation.test.ts +++ b/apps/web/src/hooks/useTranslation.test.ts @@ -71,4 +71,15 @@ describe('useTranslation', () => { expect(result.current.isReady).toBe(true); }); + + it('should change language to Spanish', () => { + const { result } = renderHook(() => useTranslation()); + + act(() => { + result.current.changeLanguage('es'); + }); + + expect(mockChangeLanguage).toHaveBeenCalledWith('es'); + expect(mockSetLanguage).toHaveBeenCalledWith('es'); + }); }); diff --git a/apps/web/src/hooks/useTranslation.ts b/apps/web/src/hooks/useTranslation.ts index 5c34b6e9d..f2f55e260 100644 --- a/apps/web/src/hooks/useTranslation.ts +++ b/apps/web/src/hooks/useTranslation.ts @@ -10,7 +10,7 @@ export function useTranslation(): UseTranslationReturn { const { i18n, t } = useI18nTranslation(); const { language, setLanguage } = useUIStore(); - const changeLanguage = (newLanguage: 'en' | 'fr') => { + const changeLanguage = (newLanguage: 'en' | 'fr' | 'es') => { void i18n.changeLanguage(newLanguage); setLanguage(newLanguage); }; diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 3f58a9f77..057739599 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -5,6 +5,7 @@ import LanguageDetector from 'i18next-browser-languagedetector'; // Import des traductions import fr from '@/locales/fr.json'; import en from '@/locales/en.json'; +import es from '@/locales/es.json'; const resources = { fr: { @@ -13,6 +14,9 @@ const resources = { en: { translation: en, }, + es: { + translation: es, + }, }; i18n diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json new file mode 100644 index 000000000..6010450dd --- /dev/null +++ b/apps/web/src/locales/es.json @@ -0,0 +1,530 @@ +{ + "common": { + "loading": "Cargando...", + "save": "Guardar", + "cancel": "Cancelar", + "edit": "Editar", + "delete": "Eliminar", + "confirm": "Confirmar", + "close": "Cerrar", + "back": "Volver", + "next": "Siguiente", + "previous": "Anterior", + "search": "Buscar", + "filter": "Filtrar", + "sort": "Ordenar", + "view": "Ver", + "download": "Descargar", + "upload": "Subir", + "share": "Compartir", + "copy": "Copiar", + "refresh": "Actualizar", + "settings": "Configuración", + "profile": "Perfil", + "logout": "Cerrar sesión", + "login": "Iniciar sesión", + "register": "Registrarse", + "email": "Correo electrónico", + "password": "Contraseña", + "username": "Nombre de usuario", + "firstName": "Nombre", + "lastName": "Apellido", + "name": "Nombre", + "title": "Título", + "description": "Descripción", + "date": "Fecha", + "time": "Hora", + "status": "Estado", + "type": "Tipo", + "size": "Tamaño", + "actions": "Acciones", + "error": "Error", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "yes": "Sí", + "no": "No", + "never": "Nunca", + "changeTheme": "Cambiar tema", + "userMenu": "Menú de usuario", + "notifications": "Notificaciones", + "retry": "Reintentar", + "retrying": "Reintentando...", + "dismiss": "Descartar" + }, + "auth": { + "login": { + "title": "Iniciar sesión", + "subtitle": "Inicia sesión en tu cuenta Veza", + "email": "Correo electrónico", + "password": "Contraseña", + "rememberMe": "Recordarme", + "forgotPassword": "¿Olvidaste tu contraseña?", + "loginButton": "Iniciar sesión", + "noAccount": "¿No tienes una cuenta?", + "createAccount": "Crear cuenta", + "errors": { + "invalidCredentials": "Correo o contraseña incorrectos", + "accountLocked": "Cuenta bloqueada", + "emailNotVerified": "Correo no verificado" + } + }, + "register": { + "title": "Crear cuenta", + "subtitle": "Únete a la comunidad Veza", + "firstName": "Nombre", + "lastName": "Apellido", + "username": "Nombre de usuario", + "email": "Correo electrónico", + "password": "Contraseña", + "confirmPassword": "Confirmar contraseña", + "registerButton": "Crear cuenta", + "hasAccount": "¿Ya tienes una cuenta?", + "loginLink": "Iniciar sesión", + "errors": { + "passwordMismatch": "Las contraseñas no coinciden", + "emailExists": "Este correo ya está en uso", + "usernameExists": "Este nombre de usuario ya está en uso", + "weakPassword": "La contraseña debe tener al menos 12 caracteres" + } + }, + "forgotPassword": { + "title": "Contraseña olvidada", + "subtitle": "Ingresa tu correo para recibir un enlace de restablecimiento", + "email": "Correo electrónico", + "sendButton": "Enviar enlace", + "backToLogin": "Volver al inicio de sesión", + "success": "Correo de restablecimiento enviado" + } + }, + "dashboard": { + "title": "Panel de control", + "welcome": "¡Bienvenido, {{name}}!", + "goodMorning": "Buenos días", + "goodAfternoon": "Buenas tardes", + "goodEvening": "Buenas noches", + "subtitle": "Esto es lo que está pasando con tu música hoy.", + "stats": { + "totalTracks": "Pistas totales", + "totalPlaylists": "Playlists", + "totalFavorites": "Favoritos", + "totalStorage": "Almacenamiento usado", + "tracksListened": "Pistas escuchadas", + "messagesSent": "Mensajes enviados", + "favorites": "Favoritos", + "activeFriends": "Amigos activos" + }, + "fromLastMonth": "respecto al mes pasado", + "viewAll": "Ver todo", + "recentActivity": "Actividad reciente", + "recentActivityDescription": "Tus últimas interacciones en la plataforma", + "recentTracks": "Pistas recientes", + "recentTracksDescription": "Últimas adiciones a tu biblioteca", + "noTracksInLibrary": "No hay pistas en tu biblioteca", + "quickActions": "Acciones rápidas", + "quickActionsDescription": "Acceso rápido a las funciones principales", + "uploadTrack": "Subir pista", + "createPlaylist": "Crear playlist", + "discoverMusic": "Descubrir música", + "openChat": "Abrir chat", + "startChat": "Iniciar chat", + "newTrack": "Nueva pista", + "newChat": "Nuevo chat", + "library": "Biblioteca", + "inviteFriends": "Invitar amigos", + "activity": { + "newTrackAdded": "Nueva pista añadida", + "messageFrom": "Mensaje de @{{user}}", + "newFavoriteAdded": "Nuevo favorito añadido" + } + }, + "comingSoon": { + "title": "Próximamente", + "description": "Esta función está en desarrollo y estará disponible pronto.", + "notifyMe": "Notificarme", + "goBack": "Volver" + }, + "player": { + "miniPlayerAriaLabel": "Mini reproductor de audio", + "expandPlayer": "Expandir reproductor", + "closeMiniPlayer": "Cerrar mini reproductor", + "play": "Reproducir", + "pause": "Pausa", + "next": "Siguiente pista", + "previous": "Pista anterior", + "shuffleOn": "Aleatorio: Activado", + "shuffleOff": "Aleatorio: Desactivado", + "repeatOff": "Repetición: Desactivada", + "repeatTrack": "Repetición: Pista", + "repeatPlaylist": "Repetición: Playlist", + "mute": "Silenciar", + "unmute": "Activar sonido", + "showQueue": "Mostrar cola", + "hideQueue": "Ocultar cola" + }, + "chat": { + "title": "Chat", + "conversations": "Conversaciones", + "newConversation": "Nueva conversación", + "searchConversations": "Buscar conversaciones", + "noConversations": "Sin conversaciones", + "startConversation": "Iniciar una conversación", + "messages": { + "placeholder": "Escribe tu mensaje...", + "send": "Enviar", + "typing": "{{user}} está escribiendo...", + "online": "En línea", + "offline": "Desconectado", + "lastSeen": "Visto por última vez {{time}}" + }, + "errors": { + "connectionFailed": "Error al conectar al chat", + "messageFailed": "Error al enviar el mensaje", + "reconnecting": "Reconectando..." + } + }, + "library": { + "title": "Biblioteca", + "myFiles": "Mis archivos", + "favorites": "Favoritos", + "recent": "Recientes", + "search": "Buscar en la biblioteca", + "filterBy": "Filtrar por", + "sortBy": "Ordenar por", + "viewMode": { + "grid": "Vista de cuadrícula", + "list": "Vista de lista" + }, + "upload": { + "title": "Subir archivo", + "dragDrop": "Arrastra y suelta tus archivos aquí", + "or": "o", + "browseFiles": "Explorar archivos", + "supportedFormats": "Formatos soportados: MP3, WAV, FLAC, PDF, DOC, DOCX", + "maxSize": "Tamaño máximo: 100MB" + }, + "actions": { + "play": "Reproducir", + "download": "Descargar", + "share": "Compartir", + "addToFavorites": "Añadir a favoritos", + "removeFromFavorites": "Quitar de favoritos", + "edit": "Editar", + "delete": "Eliminar" + }, + "empty": { + "title": "Tu biblioteca está vacía", + "subtitle": "Comienza subiendo tus primeros archivos", + "description": "Sube tu primera pista o crea una playlist para empezar.", + "uploadButton": "Subir archivo", + "uploadTrack": "Subir pista" + } + }, + "profile": { + "title": "Perfil", + "subtitle": "Gestiona tu información personal y preferencias", + "personalInfo": "Información personal", + "updateProfile": "Actualizar tu perfil", + "edit": "Editar", + "save": "Guardar", + "cancel": "Cancelar", + "avatar": { + "title": "Foto de perfil", + "changePhoto": "Cambiar foto", + "removePhoto": "Eliminar foto" + }, + "accountInfo": "Información de la cuenta", + "memberSince": "Miembro desde", + "emailVerified": "Correo verificado", + "lastLogin": "Último acceso", + "fields": { + "firstName": "Nombre", + "lastName": "Apellido", + "username": "Nombre de usuario", + "email": "Correo electrónico", + "bio": "Bio", + "bioPlaceholder": "Cuéntanos sobre ti..." + } + }, + "settings": { + "title": "Configuración", + "subtitle": "Gestiona tus preferencias y configuración de cuenta", + "tabs": { + "appearance": "Apariencia", + "language": "Idioma", + "notifications": "Notificaciones", + "security": "Seguridad" + }, + "appearance": { + "theme": "Tema", + "themeDescription": "Elige el tema que mejor te convenga", + "light": "Claro", + "dark": "Oscuro", + "system": "Sistema", + "systemDescription": "Seguir el sistema" + }, + "language": { + "title": "Idioma y región", + "description": "Elige tu idioma preferido", + "language": "Idioma" + }, + "notifications": { + "title": "Notificaciones", + "description": "Configura tus preferencias de notificación", + "emailNotifications": "Notificaciones por correo", + "emailDescription": "Recibe notificaciones por correo electrónico", + "pushNotifications": "Notificaciones push", + "pushDescription": "Recibe notificaciones push en el navegador", + "chatNotifications": "Notificaciones de chat", + "chatDescription": "Recibe notificaciones de nuevos mensajes" + }, + "security": { + "title": "Seguridad", + "description": "Gestiona tu configuración de seguridad", + "changePassword": "Cambiar contraseña", + "changePasswordDescription": "Actualiza tu contraseña", + "twoFactor": "Autenticación de dos factores", + "twoFactorDescription": "Añade una capa adicional de seguridad", + "activeSessions": "Sesiones activas", + "activeSessionsDescription": "Gestiona tus sesiones de inicio de sesión", + "modify": "Modificar", + "configure": "Configurar", + "view": "Ver" + }, + "save": "Guardar cambios", + "saving": "Guardando..." + }, + "errors": { + "404": { + "title": "Página no encontrada", + "message": "La página que buscas no existe.", + "backHome": "Volver al inicio" + }, + "500": { + "title": "Error del servidor", + "message": "Ocurrió un error interno. Inténtalo de nuevo más tarde.", + "retry": "Reintentar" + }, + "network": { + "title": "Error de conexión", + "message": "No se pudo conectar al servidor. Verifica tu conexión a internet.", + "retry": "Reintentar" + }, + "unauthorized": { + "title": "Acceso no autorizado", + "message": "No tienes permisos para acceder a este recurso.", + "login": "Iniciar sesión" + } + }, + "navigation": { + "dashboard": "Panel de control", + "chat": "Chat", + "library": "Biblioteca", + "profile": "Perfil", + "settings": "Configuración", + "menu": "Menú", + "close": "Cerrar" + }, + "validation": { + "required": "Este campo es obligatorio", + "email": "Ingresa una dirección de correo válida", + "minLength": "Este campo debe tener al menos {{min}} caracteres", + "maxLength": "Este campo no puede exceder {{max}} caracteres", + "passwordMatch": "Las contraseñas no coinciden", + "fileSize": "El archivo no puede exceder {{max}}MB", + "fileType": "Tipo de archivo no soportado" + }, + "pwa": { + "install": { + "title": "Instalar Veza", + "description": "Accede rápidamente a Veza desde tu pantalla de inicio", + "button": "Instalar", + "installing": "Instalando...", + "later": "Más tarde", + "success": "¡Aplicación instalada correctamente!", + "error": "Error de instalación" + }, + "update": { + "title": "Actualización disponible", + "description": "Una nueva versión de Veza está disponible", + "button": "Actualizar", + "updating": "Actualizando...", + "later": "Más tarde", + "success": "¡Aplicación actualizada!", + "error": "Error de actualización" + }, + "offline": { + "title": "Modo sin conexión", + "description": "Estás sin conexión. Algunas funciones pueden estar limitadas.", + "retry": "Reintentar" + }, + "notifications": { + "permission": { + "title": "Notificaciones", + "description": "Permite las notificaciones para recibir actualizaciones importantes", + "allow": "Permitir", + "deny": "Denegar" + } + } + }, + "tracks": { + "title": "Pistas", + "upload": "Subir pista", + "play": "Reproducir", + "pause": "Pausa", + "like": "Me gusta", + "unlike": "Ya no me gusta", + "addToFavorites": "Añadir a favoritos", + "removeFromFavorites": "Quitar de favoritos", + "share": "Compartir", + "download": "Descargar", + "comments": "Comentarios", + "addComment": "Añadir un comentario", + "editComment": "Editar comentario", + "deleteComment": "Eliminar comentario", + "reply": "Responder", + "noTracks": "No hay pistas disponibles", + "noResults": "No se encontraron resultados", + "loading": "Cargando pistas...", + "duration": "Duración", + "artist": "Artista", + "album": "Álbum", + "genre": "Género", + "year": "Año", + "plays": "Reproducciones", + "likes": "Me gusta" + }, + "playlists": { + "title": "Playlists", + "create": "Crear playlist", + "edit": "Editar playlist", + "delete": "Eliminar playlist", + "follow": "Seguir", + "unfollow": "Dejar de seguir", + "following": "Siguiendo", + "followers": "Seguidores", + "share": "Compartir", + "addTrack": "Añadir pista", + "removeTrack": "Quitar pista", + "collaborators": "Colaboradores", + "addCollaborator": "Añadir colaborador", + "removeCollaborator": "Quitar colaborador", + "noPlaylists": "No hay playlists disponibles", + "loading": "Cargando playlists...", + "tracks": "Pistas", + "public": "Pública", + "private": "Privada" + }, + "notifications": { + "title": "Notificaciones", + "markAsRead": "Marcar como leída", + "markAllAsRead": "Marcar todo como leído", + "clearAll": "Borrar todo", + "noNotifications": "Sin notificaciones", + "viewAll": "Ver todas las notificaciones", + "newMessage": "Nuevo mensaje", + "trackUploaded": "Pista subida", + "userMentioned": "Te han mencionado", + "system": "Notificación del sistema", + "friendRequest": "Solicitud de amistad", + "conversationInvite": "Invitación a conversación" + }, + "search": { + "title": "Búsqueda", + "placeholder": "Buscar pistas, playlists, usuarios...", + "results": "Resultados", + "noResults": "No se encontraron resultados", + "tracks": "Pistas", + "playlists": "Playlists", + "users": "Usuarios", + "all": "Todo" + }, + "analytics": { + "title": "Analíticas", + "period": "Período", + "last7Days": "Últimos 7 días", + "last30Days": "Últimos 30 días", + "last90Days": "Últimos 90 días", + "lastYear": "Último año", + "topTracks": "Pistas populares", + "topPlaylists": "Playlists populares", + "totalPlays": "Total de reproducciones", + "totalLikes": "Total de me gusta", + "totalDownloads": "Total de descargas" + }, + "webhooks": { + "title": "Webhooks", + "create": "Crear webhook", + "edit": "Editar webhook", + "delete": "Eliminar webhook", + "test": "Probar webhook", + "regenerateKey": "Regenerar clave API", + "url": "URL", + "events": "Eventos", + "status": "Estado", + "active": "Activo", + "inactive": "Inactivo", + "noWebhooks": "Ningún webhook configurado" + }, + "admin": { + "title": "Panel de administración", + "users": "Usuarios", + "systemStats": "Estadísticas del sistema", + "auditLogs": "Registros de auditoría", + "suspiciousActivity": "Actividad sospechosa" + }, + "keyboard": { + "shortcuts": { + "title": "Atajos de teclado", + "search": "Enfocar búsqueda o navegar a la página de búsqueda", + "newMessage": "Abrir nuevo chat/mensaje", + "playPause": "Reproducir o pausar la pista actual", + "nextTrack": "Reproducir siguiente pista", + "previousTrack": "Reproducir pista anterior", + "volumeUp": "Subir volumen", + "volumeDown": "Bajar volumen", + "mute": "Activar/desactivar silencio", + "toggleSidebar": "Mostrar/ocultar barra lateral", + "escape": "Cerrar ventanas o volver", + "help": "Mostrar esta ventana de ayuda" + } + }, + "header": { + "searchPlaceholder": "¿Qué quieres escuchar?", + "searchAriaLabel": "Buscar pistas, artistas, playlists", + "online": "En línea", + "profile": "Perfil", + "signOut": "Cerrar sesión" + }, + "nav": { + "sections": { + "workspace": "Mi espacio", + "vezaNetwork": "Red Veza", + "commerce": "Comercio", + "library": "Biblioteca", + "system": "Sistema" + }, + "items": { + "dashboard": "Centro de control", + "tracks": "Proyectos", + "gear": "Arsenal", + "analytics": "Rendimiento", + "social": "Comunidad", + "feed": "Feed", + "marketplace": "Marketplace", + "live": "Sesiones en vivo", + "chat": "Canales", + "sell": "Panel de vendedor", + "wishlist": "Lista de deseos", + "purchases": "Compras", + "playlists": "Playlists", + "favoris": "Favoritos", + "queue": "Cola de reproducción", + "developer": "API de desarrollador", + "admin": "Admin" + }, + "settings": "Configuración", + "logout": "Cerrar sesión", + "skipToContent": "Ir al contenido" + } +} diff --git a/apps/web/src/stores/ui.ts b/apps/web/src/stores/ui.ts index d34950fa0..099e5f09e 100644 --- a/apps/web/src/stores/ui.ts +++ b/apps/web/src/stores/ui.ts @@ -7,7 +7,7 @@ import type { UIState, Notification } from '@/types'; // FE-TYPE-011: Fully typed store interfaces export interface UIActions { setTheme: (theme: 'light' | 'dark' | 'system') => void; - setLanguage: (language: 'en' | 'fr') => void; + setLanguage: (language: 'en' | 'fr' | 'es') => void; setSidebarOpen: (open: boolean) => void; addNotification: ( notification: Omit, diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 19d15f2a8..18d46d815 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -124,7 +124,7 @@ export interface ChatWebSocketEvent extends WebSocketEvent { // UI State export interface UIState { theme: 'light' | 'dark' | 'system'; - language: 'en' | 'fr'; + language: 'en' | 'fr' | 'es'; sidebarOpen: boolean; notifications: Notification[]; } diff --git a/apps/web/src/utils/date.test.ts b/apps/web/src/utils/date.test.ts index 251823d0e..cce492b6b 100644 --- a/apps/web/src/utils/date.test.ts +++ b/apps/web/src/utils/date.test.ts @@ -1,9 +1,15 @@ /** * Tests for Date Utilities * FE-TEST-004: Test all date utility functions + * Updated for v0.12.7: locale-aware formatting (en, fr, es) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock i18n module — default language is 'en' +vi.mock('@/lib/i18n', () => ({ default: { language: 'en' } })); + +import i18n from '@/lib/i18n'; import { formatDate, formatRelativeTime, @@ -12,13 +18,15 @@ import { getTimeAgo, formatDuration, parseDuration, + formatNumber, + formatCurrency, } from './date'; describe('date utilities', () => { beforeEach(() => { - // Mock Date.now() to have consistent tests vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + i18n.language = 'en'; }); afterEach(() => { @@ -66,47 +74,82 @@ describe('date utilities', () => { }); }); - describe('formatRelativeTime', () => { - it('should return "À l\'instant" for very recent dates', () => { + describe('formatRelativeTime — English', () => { + it('should return "Just now" for very recent dates', () => { const date = new Date('2024-01-15T11:59:30Z'); - const result = formatRelativeTime(date); - expect(result).toBe("À l'instant"); + expect(formatRelativeTime(date)).toBe('Just now'); }); it('should format minutes ago', () => { const date = new Date('2024-01-15T11:45:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('minute'); + expect(formatRelativeTime(date)).toContain('minute'); }); it('should format hours ago', () => { const date = new Date('2024-01-15T10:00:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('heure'); + expect(formatRelativeTime(date)).toContain('hour'); }); it('should format days ago', () => { const date = new Date('2024-01-14T12:00:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('jour'); + expect(formatRelativeTime(date)).toContain('day'); }); it('should format weeks ago', () => { const date = new Date('2024-01-08T12:00:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('semaine'); + expect(formatRelativeTime(date)).toContain('week'); }); it('should format months ago', () => { const date = new Date('2023-12-15T12:00:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('mois'); + expect(formatRelativeTime(date)).toContain('month'); }); it('should format years ago', () => { const date = new Date('2023-01-15T12:00:00Z'); - const result = formatRelativeTime(date); - expect(result).toContain('an'); + expect(formatRelativeTime(date)).toContain('year'); + }); + }); + + describe('formatRelativeTime — French', () => { + beforeEach(() => { + i18n.language = 'fr'; + }); + + it('should return French "just now"', () => { + const date = new Date('2024-01-15T11:59:30Z'); + expect(formatRelativeTime(date)).toBe("À l'instant"); + }); + + it('should format hours ago in French', () => { + const date = new Date('2024-01-15T10:00:00Z'); + expect(formatRelativeTime(date)).toContain('heure'); + }); + + it('should format days ago in French', () => { + const date = new Date('2024-01-14T12:00:00Z'); + expect(formatRelativeTime(date)).toContain('jour'); + }); + }); + + describe('formatRelativeTime — Spanish', () => { + beforeEach(() => { + i18n.language = 'es'; + }); + + it('should return Spanish "just now"', () => { + const date = new Date('2024-01-15T11:59:30Z'); + expect(formatRelativeTime(date)).toBe('Ahora mismo'); + }); + + it('should format hours ago in Spanish', () => { + const date = new Date('2024-01-15T10:00:00Z'); + expect(formatRelativeTime(date)).toContain('hora'); + }); + + it('should format days ago in Spanish', () => { + const date = new Date('2024-01-14T12:00:00Z'); + expect(formatRelativeTime(date)).toContain('día'); }); }); @@ -162,10 +205,21 @@ describe('date utilities', () => { expect(typeof result).toBe('string'); }); - it('should return "Hier" for yesterday', () => { + it('should return "Yesterday" in English', () => { const yesterday = new Date('2024-01-14T12:00:00Z'); - const result = getTimeAgo(yesterday); - expect(result).toBe('Hier'); + expect(getTimeAgo(yesterday)).toBe('Yesterday'); + }); + + it('should return "Hier" in French', () => { + i18n.language = 'fr'; + const yesterday = new Date('2024-01-14T12:00:00Z'); + expect(getTimeAgo(yesterday)).toBe('Hier'); + }); + + it('should return "Ayer" in Spanish', () => { + i18n.language = 'es'; + const yesterday = new Date('2024-01-14T12:00:00Z'); + expect(getTimeAgo(yesterday)).toBe('Ayer'); }); it('should return formatted date for older dates', () => { @@ -176,6 +230,27 @@ describe('date utilities', () => { }); }); + describe('formatNumber', () => { + it('should format numbers with locale separators', () => { + const result = formatNumber(1234567); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + }); + + describe('formatCurrency', () => { + it('should format currency with locale', () => { + const result = formatCurrency(99.99, 'EUR'); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('should default to EUR', () => { + const result = formatCurrency(10); + expect(result).toContain('10'); + }); + }); + describe('formatDuration', () => { it('should format seconds only', () => { expect(formatDuration(45)).toBe('0:45'); diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts index ceed2ee74..b802469ee 100644 --- a/apps/web/src/utils/date.ts +++ b/apps/web/src/utils/date.ts @@ -1,7 +1,68 @@ /** * Utilitaires pour la manipulation des dates + * Supporte les locales: en, fr, es */ +import i18n from '@/lib/i18n'; + +/** Map i18n language codes to BCP 47 locale tags */ +function getCurrentLocale(): string { + const localeMap: Record = { + fr: 'fr-FR', + en: 'en-US', + es: 'es-ES', + }; + return localeMap[i18n.language] ?? 'en-US'; +} + +/** Relative time labels per locale */ +const relativeLabels: Record string; + hoursAgo: (n: number) => string; + daysAgo: (n: number) => string; + weeksAgo: (n: number) => string; + monthsAgo: (n: number) => string; + yearsAgo: (n: number) => string; + yesterday: string; +}> = { + fr: { + justNow: "À l'instant", + minutesAgo: (n) => `Il y a ${n} minute${n > 1 ? 's' : ''}`, + hoursAgo: (n) => `Il y a ${n} heure${n > 1 ? 's' : ''}`, + daysAgo: (n) => `Il y a ${n} jour${n > 1 ? 's' : ''}`, + weeksAgo: (n) => `Il y a ${n} semaine${n > 1 ? 's' : ''}`, + monthsAgo: (n) => `Il y a ${n} mois`, + yearsAgo: (n) => `Il y a ${n} an${n > 1 ? 's' : ''}`, + yesterday: 'Hier', + }, + en: { + justNow: 'Just now', + minutesAgo: (n) => `${n} minute${n > 1 ? 's' : ''} ago`, + hoursAgo: (n) => `${n} hour${n > 1 ? 's' : ''} ago`, + daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago`, + weeksAgo: (n) => `${n} week${n > 1 ? 's' : ''} ago`, + monthsAgo: (n) => `${n} month${n > 1 ? 's' : ''} ago`, + yearsAgo: (n) => `${n} year${n > 1 ? 's' : ''} ago`, + yesterday: 'Yesterday', + }, + es: { + justNow: 'Ahora mismo', + minutesAgo: (n) => `Hace ${n} minuto${n > 1 ? 's' : ''}`, + hoursAgo: (n) => `Hace ${n} hora${n > 1 ? 's' : ''}`, + daysAgo: (n) => `Hace ${n} día${n > 1 ? 's' : ''}`, + weeksAgo: (n) => `Hace ${n} semana${n > 1 ? 's' : ''}`, + monthsAgo: (n) => `Hace ${n} mes${n > 1 ? 'es' : ''}`, + yearsAgo: (n) => `Hace ${n} año${n > 1 ? 's' : ''}`, + yesterday: 'Ayer', + }, +}; + +function getLabels() { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (relativeLabels[i18n.language] ?? relativeLabels['en'])!; +} + export function formatDate( date: string | Date, format: 'short' | 'long' | 'relative' = 'short', @@ -14,9 +75,9 @@ export function formatDate( switch (format) { case 'short': - return d.toLocaleDateString(); + return d.toLocaleDateString(getCurrentLocale()); case 'long': - return d.toLocaleDateString('fr-FR', { + return d.toLocaleDateString(getCurrentLocale(), { year: 'numeric', month: 'long', day: 'numeric', @@ -26,45 +87,46 @@ export function formatDate( case 'relative': return formatRelativeTime(d); default: - return d.toLocaleDateString(); + return d.toLocaleDateString(getCurrentLocale()); } } export function formatRelativeTime(date: Date): string { const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + const labels = getLabels(); if (diffInSeconds < 60) { - return "À l'instant"; + return labels.justNow; } const diffInMinutes = Math.floor(diffInSeconds / 60); if (diffInMinutes < 60) { - return `Il y a ${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''}`; + return labels.minutesAgo(diffInMinutes); } const diffInHours = Math.floor(diffInMinutes / 60); if (diffInHours < 24) { - return `Il y a ${diffInHours} heure${diffInHours > 1 ? 's' : ''}`; + return labels.hoursAgo(diffInHours); } const diffInDays = Math.floor(diffInHours / 24); if (diffInDays < 7) { - return `Il y a ${diffInDays} jour${diffInDays > 1 ? 's' : ''}`; + return labels.daysAgo(diffInDays); } const diffInWeeks = Math.floor(diffInDays / 7); if (diffInWeeks < 4) { - return `Il y a ${diffInWeeks} semaine${diffInWeeks > 1 ? 's' : ''}`; + return labels.weeksAgo(diffInWeeks); } const diffInMonths = Math.floor(diffInDays / 30); if (diffInMonths < 12) { - return `Il y a ${diffInMonths} mois`; + return labels.monthsAgo(diffInMonths); } const diffInYears = Math.floor(diffInDays / 365); - return `Il y a ${diffInYears} an${diffInYears > 1 ? 's' : ''}`; + return labels.yearsAgo(diffInYears); } export function isToday(date: string | Date): boolean { @@ -92,19 +154,36 @@ export function isYesterday(date: string | Date): boolean { export function getTimeAgo(date: string | Date): string { if (isToday(date)) { - return new Date(date).toLocaleTimeString('fr-FR', { + return new Date(date).toLocaleTimeString(getCurrentLocale(), { hour: '2-digit', minute: '2-digit', }); } if (isYesterday(date)) { - return 'Hier'; + return getLabels().yesterday; } return formatDate(date, 'short'); } +/** + * Format a number according to the current locale + */ +export function formatNumber(value: number): string { + return new Intl.NumberFormat(getCurrentLocale()).format(value); +} + +/** + * Format a currency amount according to the current locale + */ +export function formatCurrency(amount: number, currency = 'EUR'): string { + return new Intl.NumberFormat(getCurrentLocale(), { + style: 'currency', + currency, + }).format(amount); +} + export function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/apps/web/src/utils/storeSelectors.ts b/apps/web/src/utils/storeSelectors.ts index 1d89b1991..85b094600 100644 --- a/apps/web/src/utils/storeSelectors.ts +++ b/apps/web/src/utils/storeSelectors.ts @@ -94,7 +94,7 @@ export function useUITheme(): 'light' | 'dark' | 'system' { return useUIStore((state: UIStore) => state.theme); } -export function useUILanguage(): 'en' | 'fr' { +export function useUILanguage(): 'en' | 'fr' | 'es' { // TEMPORARY FIX: Direct store access return useUIStore((state: UIStore) => state.language); }