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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
29
frontend/components/LanguageSelector.tsx
Normal file
29
frontend/components/LanguageSelector.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user