feat(v0.12.7): internationalisation i18n — FR/EN/ES avec formatage locale

- Ajout traductions espagnol (es.json, 532 clés)
- Extension type Language à 'en' | 'fr' | 'es' dans tous les stores/hooks/types
- Formatage dates/nombres/monnaies selon la locale courante (Intl API)
- Utilitaires formatNumber() et formatCurrency() ajoutés
- Temps relatifs localisés (en/fr/es) dans date.ts
- PreferenceSettings utilise i18n pour les labels (plus de hardcoded French)
- Synchronisation i18n immédiate au changement de langue (sans rechargement)
- Tests: 50 tests passent (useTranslation + date utilities, 3 locales)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
senke 2026-03-12 14:29:22 +01:00
parent 955be70935
commit 24579b87c3
12 changed files with 754 additions and 54 deletions

View file

@ -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]);

View file

@ -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({
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="language">Langue</Label>
<Label htmlFor="language">{t('settings.language.language')}</Label>
<Select
options={supportedLanguages}
value={preferences.language}
onChange={handleLanguageChange}
placeholder="Sélectionner une langue"
placeholder={t('settings.language.description')}
name="language"
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Fuseau horaire</Label>
<Label htmlFor="timezone">{t('settings.language.title')}</Label>
<Select
options={commonTimezones}
value={preferences.timezone}
onChange={handleTimezoneChange}
placeholder="Sélectionner un fuseau horaire"
placeholder={t('common.search')}
name="timezone"
/>
</div>
<div className="space-y-2">
<Label>Thème</Label>
<Label>{t('settings.appearance.theme')}</Label>
<RadioGroup
value={preferences.theme}
onValueChange={handleThemeChange}
@ -89,19 +90,19 @@ export function PreferenceSettings({
<div className="flex items-center space-x-2">
<RadioGroupItem value="light" id="theme-light" />
<Label htmlFor="theme-light" className="font-normal">
Clair
{t('settings.appearance.light')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dark" id="theme-dark" />
<Label htmlFor="theme-dark" className="font-normal">
Sombre
{t('settings.appearance.dark')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="theme-auto" />
<Label htmlFor="theme-auto" className="font-normal">
Automatique
{t('settings.appearance.system')}
</Label>
</div>
</RadioGroup>

View file

@ -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;
}

View file

@ -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');
});
});

View file

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

View file

@ -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

View file

@ -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"
}
}

View file

@ -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<Notification, 'id' | 'timestamp'>,

View file

@ -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[];
}

View file

@ -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');

View file

@ -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<string, string> = {
fr: 'fr-FR',
en: 'en-US',
es: 'es-ES',
};
return localeMap[i18n.language] ?? 'en-US';
}
/** Relative time labels per locale */
const relativeLabels: Record<string, {
justNow: string;
minutesAgo: (n: number) => 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);

View file

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