Add English language support with i18n implementation

Implemented comprehensive internationalization (i18n) for both frontend and backend:

Frontend:
- Added react-i18next configuration with Spanish (default) and English
- Created translation files (locales/es.json, locales/en.json)
- Refactored core components to use i18n: LoginPage, DashboardHeader, DataUploader
- Created LanguageSelector component with toggle between ES/EN
- Updated API client to send Accept-Language header

Backend:
- Created i18n module with translation dictionary for error messages
- Updated security.py to return localized authentication errors
- Updated api/analysis.py to return localized validation errors
- Implemented language detection from Accept-Language header

Spanish remains the default language ensuring backward compatibility.
Users can switch between languages using the language selector in the dashboard header.

https://claude.ai/code/session_1N9VX
This commit is contained in:
Claude
2026-02-06 17:46:01 +00:00
parent 9457d3d02f
commit f719d181c0
15 changed files with 768 additions and 58 deletions

View File

@@ -1,5 +1,7 @@
import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { LanguageSelector } from './LanguageSelector';
export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
@@ -16,37 +18,41 @@ interface DashboardHeaderProps {
onMetodologiaClick?: () => void;
}
const TABS: TabConfig[] = [
{ id: 'executive', label: 'Resumen', icon: LayoutDashboard },
{ id: 'dimensions', label: 'Dimensiones', icon: Layers },
{ id: 'readiness', label: 'Agentic Readiness', icon: Bot },
{ id: 'roadmap', label: 'Roadmap', icon: Map },
{ id: 'law10', label: 'Ley 10/2025', icon: Scale },
];
export function DashboardHeader({
title = 'CLIENTE DEMO - Beyond CX Analytics',
activeTab,
onTabChange,
onMetodologiaClick
}: DashboardHeaderProps) {
const { t } = useTranslation();
const TABS: TabConfig[] = [
{ id: 'executive', label: t('tabs.executive'), icon: LayoutDashboard },
{ id: 'dimensions', label: t('tabs.dimensions'), icon: Layers },
{ id: 'readiness', label: t('tabs.agenticReadiness'), icon: Bot },
{ id: 'roadmap', label: t('tabs.roadmap'), icon: Map },
{ id: 'law10', label: t('tabs.law10'), icon: Scale },
];
return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm">
{/* Top row: Title and Metodología Badge */}
{/* Top row: Title and Badges */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
{onMetodologiaClick && (
<button
onClick={onMetodologiaClick}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span>
<span className="md:hidden">Metodología</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
)}
<div className="flex items-center gap-2">
<LanguageSelector />
{onMetodologiaClick && (
<button
onClick={onMetodologiaClick}
className="inline-flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-100 text-green-800 rounded-full text-[10px] sm:text-xs font-medium hover:bg-green-200 transition-colors cursor-pointer flex-shrink-0"
>
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="hidden md:inline">{t('methodology.appliedBadge')}</span>
<span className="md:hidden">{t('methodology.appliedBadgeShort')}</span>
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" />
</button>
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback } from 'react';
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
import { TierKey } from '../types';
@@ -21,6 +22,7 @@ const formatFileSize = (bytes: number, decimals = 2) => {
};
const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [sheetUrl, setSheetUrl] = useState('');
const [status, setStatus] = useState<UploadStatus>('idle');
@@ -57,7 +59,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
setFile(selectedFile);
setSheetUrl('');
} else {
setError('Tipo de archivo no válido. Sube un CSV o Excel.');
setError(t('upload.invalidFileType'));
setFile(null);
}
}
@@ -89,19 +91,19 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
setStatus('generating');
setTimeout(() => {
const csvData = generateSyntheticCsv(selectedTier);
handleDataReady('Datos Sintéticos Generados!');
handleDataReady(t('upload.syntheticDataGenerated'));
}, 2000);
};
const handleSubmit = () => {
if (!file && !sheetUrl) {
setError('Por favor, sube un archivo o introduce una URL de Google Sheet.');
setError(t('upload.pleaseUploadFile'));
return;
}
resetState(false);
setStatus('uploading');
setTimeout(() => {
handleDataReady('Datos Recibidos!');
handleDataReady(t('upload.dataReceived'));
}, 2000);
};
@@ -114,7 +116,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-green-600 hover:bg-green-700 disabled:opacity-75 disabled:cursor-not-allowed"
>
{isAnalyzing ? <Loader2 className="animate-spin" size={20} /> : <BarChart3 size={20} />}
{isAnalyzing ? 'Analizando...' : 'Ver Dashboard de Diagnóstico'}
{isAnalyzing ? t('upload.analyzingData') : t('dashboard.viewDashboard')}
</button>
);
}
@@ -126,7 +128,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
className="w-full flex items-center justify-center gap-2 text-white px-6 py-3 rounded-lg transition-colors shadow-sm hover:shadow-md bg-blue-600 hover:bg-blue-700 disabled:opacity-75 disabled:cursor-not-allowed"
>
{status === 'uploading' ? <Loader2 className="animate-spin" size={20} /> : <Wand2 size={20} />}
{status === 'uploading' ? 'Procesando...' : 'Generar Análisis'}
{status === 'uploading' ? t('upload.processingData') : t('dashboard.generateAnalysis')}
</button>
);
};
@@ -134,10 +136,10 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
return (
<div className="bg-white rounded-xl shadow-lg p-8">
<div className="mb-6">
<span className="text-blue-600 font-semibold mb-1 block">Paso 2</span>
<h2 className="text-2xl font-bold text-slate-900">Sube tus Datos y Ejecuta el Análisis</h2>
<span className="text-blue-600 font-semibold mb-1 block">{t('stepper.step2')}</span>
<h2 className="text-2xl font-bold text-slate-900">{t('upload.title')}</h2>
<p className="text-slate-600 mt-1">
Usa una de las siguientes opciones para enviarnos tus datos para el análisis.
{t('upload.subtitle')}
</p>
</div>
@@ -158,33 +160,33 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
/>
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center">
<UploadCloud className="w-12 h-12 text-slate-400 mb-2" />
<span className="font-semibold text-blue-600">Haz clic para subir un fichero</span>
<span className="text-slate-500"> o arrástralo aquí</span>
<p className="text-xs text-slate-400 mt-2">CSV, XLSX, o XLS</p>
<span className="font-semibold text-blue-600">{t('upload.clickToUpload')}</span>
<span className="text-slate-500"> {t('upload.dragAndDrop')}</span>
<p className="text-xs text-slate-400 mt-2">{t('upload.fileTypes')}</p>
</label>
</div>
<div className="flex items-center text-slate-500">
<hr className="w-full border-slate-300" />
<span className="px-4 font-medium text-sm">O</span>
<span className="px-4 font-medium text-sm">{t('common.or')}</span>
<hr className="w-full border-slate-300" />
</div>
<div className="text-center p-4 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600 mb-3">¿No tienes datos a mano? Genera un set de datos de ejemplo.</p>
<p className="text-sm text-slate-600 mb-3">{t('upload.noDataPrompt')}</p>
<button
onClick={handleGenerateSyntheticData}
disabled={isActionInProgress}
className="flex items-center justify-center gap-2 w-full sm:w-auto mx-auto bg-fuchsia-100 text-fuchsia-700 px-6 py-3 rounded-lg hover:bg-fuchsia-200 hover:text-fuchsia-800 transition-colors shadow-sm hover:shadow-md disabled:opacity-75 disabled:cursor-not-allowed font-semibold"
>
{status === 'generating' ? <Loader2 className="animate-spin" size={20} /> : <Sparkles size={20} />}
{status === 'generating' ? 'Generando...' : 'Generar Datos Sintéticos'}
{status === 'generating' ? t('upload.generating') : t('upload.generateSyntheticData')}
</button>
</div>
<div className="flex items-center text-slate-500">
<hr className="w-full border-slate-300" />
<span className="px-4 font-medium text-sm">O</span>
<span className="px-4 font-medium text-sm">{t('common.or')}</span>
<hr className="w-full border-slate-300" />
</div>
@@ -192,7 +194,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
<Sheet className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="url"
placeholder="Pega la URL de tu Google Sheet aquí"
placeholder={t('upload.googleSheetPlaceholder')}
value={sheetUrl}
onChange={(e) => {
resetState();
@@ -249,7 +251,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
{status === 'success' && (
<div className="flex items-center justify-center gap-2 p-4 bg-green-50 border border-green-200 text-green-800 rounded-lg">
<CheckCircle className="w-6 h-6 flex-shrink-0" />
<span className="font-semibold">{successMessage} ¡Listo para analizar!</span>
<span className="font-semibold">{successMessage} {t('upload.readyToAnalyze')}</span>
</div>
)}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
export function LanguageSelector() {
const { i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === 'es' ? 'en' : 'es';
i18n.changeLanguage(newLang);
localStorage.setItem('language', newLang);
};
const currentLang = i18n.language === 'es' ? 'ES' : 'EN';
const nextLang = i18n.language === 'es' ? 'EN' : 'ES';
return (
<button
onClick={toggleLanguage}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-full text-xs font-medium hover:bg-slate-200 transition-colors cursor-pointer"
title={`Switch to ${nextLang}`}
>
<Languages className="w-3.5 h-3.5" />
<span className="font-semibold">{currentLang}</span>
</button>
);
}
export default LanguageSelector;

View File

@@ -3,9 +3,11 @@ import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Lock, User } from 'lucide-react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../utils/AuthContext';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const { login } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -14,14 +16,14 @@ const LoginPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
toast.error('Introduce usuario y contraseña');
toast.error(t('auth.credentialsRequired'));
return;
}
setIsSubmitting(true);
try {
await login(username, password);
toast.success('Sesión iniciada');
await login(username, password);
toast.success(t('auth.sessionStarted'));
} catch (err) {
console.error('Error en login', err);
const msg =
@@ -48,14 +50,14 @@ const LoginPage: React.FC = () => {
Beyond Diagnostic
</h1>
<p className="text-sm text-slate-500">
Inicia sesión para acceder al análisis
{t('auth.loginTitle')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700">
Usuario
{t('auth.username')}
</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
@@ -73,7 +75,7 @@ const LoginPage: React.FC = () => {
<div className="space-y-1">
<label className="block text-sm font-medium text-slate-700">
Contraseña
{t('auth.password')}
</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
@@ -94,11 +96,11 @@ const LoginPage: React.FC = () => {
disabled={isSubmitting}
className="w-full inline-flex items-center justify-center rounded-2xl bg-indigo-600 text-white text-sm font-medium py-2.5 shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Entrando…' : 'Entrar'}
{isSubmitting ? t('auth.enteringButton') : t('auth.enterButton')}
</button>
<p className="text-[11px] text-slate-400 text-center mt-2">
La sesión permanecerá activa durante 1 hora.
{t('auth.sessionInfo')}
</p>
</form>
</motion.div>