-
+
-
+
-
+
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);
}