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,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>
)}