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

@@ -7,11 +7,12 @@ import math
from uuid import uuid4 from uuid import uuid4
from typing import Optional, Any, Literal from typing import Optional, Any, Literal
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Header
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from beyond_api.security import get_current_user from beyond_api.security import get_current_user
from beyond_api.services.analysis_service import run_analysis_collect_json from beyond_api.services.analysis_service import run_analysis_collect_json
from beyond_api.i18n import t, get_language_from_header
# Cache paths - same as in cache.py # Cache paths - same as in cache.py
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache")) CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
@@ -52,6 +53,7 @@ async def analysis_endpoint(
economy_json: Optional[str] = Form(default=None), economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"), analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
accept_language: Optional[str] = Header(None),
): ):
""" """
Ejecuta el pipeline sobre un CSV subido (multipart/form-data) y devuelve Ejecuta el pipeline sobre un CSV subido (multipart/form-data) y devuelve
@@ -62,12 +64,13 @@ async def analysis_endpoint(
- "premium": usa la configuración completa por defecto - "premium": usa la configuración completa por defecto
(p.ej. beyond_metrics_config.json), sin romper lo existente. (p.ej. beyond_metrics_config.json), sin romper lo existente.
""" """
lang = get_language_from_header(accept_language)
# Validar `analysis` (por si llega algo raro) # Validar `analysis` (por si llega algo raro)
if analysis not in {"basic", "premium"}: if analysis not in {"basic", "premium"}:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="analysis debe ser 'basic' o 'premium'.", detail=t("analysis.invalid_type", lang),
) )
# 1) Parseo de economía (si viene) # 1) Parseo de economía (si viene)
@@ -78,7 +81,7 @@ async def analysis_endpoint(
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="economy_json no es un JSON válido.", detail=t("analysis.invalid_economy_json", lang),
) )
# 2) Guardar el CSV subido en una carpeta de trabajo # 2) Guardar el CSV subido en una carpeta de trabajo
@@ -159,23 +162,26 @@ async def analysis_cached_endpoint(
economy_json: Optional[str] = Form(default=None), economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"), analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user), current_user: str = Depends(get_current_user),
accept_language: Optional[str] = Header(None),
): ):
""" """
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor. Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
Útil para re-analizar sin tener que subir el archivo de nuevo. Útil para re-analizar sin tener que subir el archivo de nuevo.
""" """
lang = get_language_from_header(accept_language)
# Validar que existe el archivo cacheado # Validar que existe el archivo cacheado
if not CACHED_FILE.exists(): if not CACHED_FILE.exists():
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail="No hay archivo cacheado en el servidor. Sube un archivo primero.", detail=t("analysis.no_cached_file", lang),
) )
# Validar `analysis` # Validar `analysis`
if analysis not in {"basic", "premium"}: if analysis not in {"basic", "premium"}:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="analysis debe ser 'basic' o 'premium'.", detail=t("analysis.invalid_type", lang),
) )
# Parseo de economía (si viene) # Parseo de economía (si viene)
@@ -186,7 +192,7 @@ async def analysis_cached_endpoint(
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="economy_json no es un JSON válido.", detail=t("analysis.invalid_economy_json", lang),
) )
# Extraer metadatos del CSV # Extraer metadatos del CSV
@@ -204,7 +210,7 @@ async def analysis_cached_endpoint(
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Error ejecutando análisis: {str(e)}", detail=t("analysis.execution_error", lang, error=str(e)),
) )
# Limpiar NaN/inf para que el JSON sea válido # Limpiar NaN/inf para que el JSON sea válido

128
backend/beyond_api/i18n.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Módulo de internacionalización para Beyond API.
Proporciona traducciones para mensajes de error, validaciones y clasificaciones.
"""
from typing import Literal
Language = Literal["es", "en"]
# Diccionario de traducciones
MESSAGES = {
"es": {
# Errores de autenticación
"auth.credentials_required": "Credenciales requeridas",
"auth.incorrect_credentials": "Credenciales incorrectas",
# Errores de análisis
"analysis.invalid_type": "analysis debe ser 'basic' o 'premium'.",
"analysis.invalid_economy_json": "economy_json no es un JSON válido.",
"analysis.no_cached_file": "No hay archivo cacheado en el servidor. Sube un archivo primero.",
"analysis.execution_error": "Error ejecutando análisis: {error}",
# Errores de validación
"validation.field_not_numeric": "El campo '{field}' debe ser numérico (float). Valor recibido: {value}",
"validation.segments_not_dict": "customer_segments debe ser un diccionario {segment: level}",
"validation.segment_value_not_str": "El valor de customer_segments['{key}'] debe ser str. Valor recibido: {value}",
"validation.csv_not_found": "El CSV no existe: {path}",
"validation.not_csv_file": "La ruta no apunta a un fichero CSV: {path}",
"validation.missing_columns": "Faltan columnas obligatorias para {metric}: {missing}",
# Clasificaciones Agentic Readiness
"agentic.ready_for_copilot": "Listo para Copilot",
"agentic.ready_for_copilot_desc": "Procesos con predictibilidad y simplicidad suficientes para asistencia IA (sugerencias en tiempo real, autocompletado).",
"agentic.optimize_first": "Optimizar primero",
"agentic.optimize_first_desc": "Estandarizar procesos y reducir variabilidad antes de implementar asistencia IA.",
"agentic.requires_human": "Requiere gestión humana",
"agentic.requires_human_desc": "Procesos complejos o variables que necesitan intervención humana antes de considerar automatización.",
},
"en": {
# Authentication errors
"auth.credentials_required": "Credentials required",
"auth.incorrect_credentials": "Incorrect credentials",
# Analysis errors
"analysis.invalid_type": "analysis must be 'basic' or 'premium'.",
"analysis.invalid_economy_json": "economy_json is not valid JSON.",
"analysis.no_cached_file": "No cached file on server. Upload a file first.",
"analysis.execution_error": "Error executing analysis: {error}",
# Validation errors
"validation.field_not_numeric": "Field '{field}' must be numeric (float). Received value: {value}",
"validation.segments_not_dict": "customer_segments must be a dictionary {segment: level}",
"validation.segment_value_not_str": "Value of customer_segments['{key}'] must be str. Received value: {value}",
"validation.csv_not_found": "CSV does not exist: {path}",
"validation.not_csv_file": "Path does not point to a CSV file: {path}",
"validation.missing_columns": "Missing required columns for {metric}: {missing}",
# Agentic Readiness classifications
"agentic.ready_for_copilot": "Ready for Copilot",
"agentic.ready_for_copilot_desc": "Processes with sufficient predictability and simplicity for AI assistance (real-time suggestions, autocomplete).",
"agentic.optimize_first": "Optimize first",
"agentic.optimize_first_desc": "Standardize processes and reduce variability before implementing AI assistance.",
"agentic.requires_human": "Requires human management",
"agentic.requires_human_desc": "Complex or variable processes that need human intervention before considering automation.",
}
}
def t(key: str, lang: Language = "es", **kwargs) -> str:
"""
Traduce un mensaje al idioma especificado.
Args:
key: Clave del mensaje (ej: 'auth.credentials_required')
lang: Idioma ('es' o 'en'). Por defecto 'es'
**kwargs: Variables para interpolación (ej: field='nombre', value='123')
Returns:
Mensaje traducido con variables interpoladas
Examples:
>>> t('auth.credentials_required', 'en')
'Credentials required'
>>> t('validation.field_not_numeric', 'en', field='age', value='abc')
"Field 'age' must be numeric (float). Received value: abc"
"""
messages = MESSAGES.get(lang, MESSAGES["es"])
message = messages.get(key, key)
# Interpolar variables si se proporcionan
if kwargs:
try:
return message.format(**kwargs)
except KeyError:
# Si falta alguna variable, devolver el mensaje sin interpolar
return message
return message
def get_language_from_header(accept_language: str | None) -> Language:
"""
Extrae el idioma preferido del header Accept-Language.
Args:
accept_language: Valor del header Accept-Language (ej: 'en-US,en;q=0.9,es;q=0.8')
Returns:
'en' si el idioma preferido es inglés, 'es' en caso contrario
Examples:
>>> get_language_from_header('en-US,en;q=0.9')
'en'
>>> get_language_from_header('es-ES,es;q=0.9')
'es'
>>> get_language_from_header(None)
'es'
"""
if not accept_language:
return "es"
# Extraer el primer idioma del header
primary_lang = accept_language.split(',')[0].split('-')[0].strip().lower()
return "en" if primary_lang == "en" else "es"

View File

@@ -2,8 +2,11 @@ from __future__ import annotations
import os import os
import secrets import secrets
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing import Optional
from .i18n import t, get_language_from_header
# auto_error=False para que no dispare el popup nativo del navegador automáticamente # auto_error=False para que no dispare el popup nativo del navegador automáticamente
security = HTTPBasic(auto_error=False) security = HTTPBasic(auto_error=False)
@@ -13,16 +16,21 @@ BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026") BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str: def get_current_user(
credentials: HTTPBasicCredentials | None = Depends(security),
accept_language: Optional[str] = Header(None)
) -> str:
""" """
Valida el usuario/contraseña vía HTTP Basic. Valida el usuario/contraseña vía HTTP Basic.
NO envía WWW-Authenticate para evitar el popup nativo del navegador NO envía WWW-Authenticate para evitar el popup nativo del navegador
(el frontend tiene su propio formulario de login). (el frontend tiene su propio formulario de login).
""" """
lang = get_language_from_header(accept_language)
if credentials is None: if credentials is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales requeridas", detail=t("auth.credentials_required", lang),
) )
correct_username = secrets.compare_digest(credentials.username, BASIC_USER) correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
@@ -31,7 +39,7 @@ def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security
if not (correct_username and correct_password): if not (correct_username and correct_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas", detail=t("auth.incorrect_credentials", lang),
) )
return credentials.username return credentials.username

View File

@@ -1,5 +1,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { LayoutDashboard, Layers, Bot, Map, ShieldCheck, Info, Scale } from 'lucide-react'; 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'; export type TabId = 'executive' | 'dimensions' | 'readiness' | 'roadmap' | 'law10';
@@ -16,37 +18,41 @@ interface DashboardHeaderProps {
onMetodologiaClick?: () => void; 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({ export function DashboardHeader({
title = 'CLIENTE DEMO - Beyond CX Analytics', title = 'CLIENTE DEMO - Beyond CX Analytics',
activeTab, activeTab,
onTabChange, onTabChange,
onMetodologiaClick onMetodologiaClick
}: DashboardHeaderProps) { }: 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 ( return (
<header className="sticky top-0 z-50 bg-white border-b border-slate-200 shadow-sm"> <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="max-w-7xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1> <h1 className="text-base sm:text-xl font-bold text-slate-800 truncate">{title}</h1>
{onMetodologiaClick && ( <div className="flex items-center gap-2">
<button <LanguageSelector />
onClick={onMetodologiaClick} {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" <button
> onClick={onMetodologiaClick}
<ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" /> 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"
<span className="hidden md:inline">Metodología de Transformación de Datos aplicada</span> >
<span className="md:hidden">Metodología</span> <ShieldCheck className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<Info className="w-2.5 h-2.5 sm:w-3 sm:h-3 opacity-60" /> <span className="hidden md:inline">{t('methodology.appliedBadge')}</span>
</button> <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>
</div> </div>

View File

@@ -1,5 +1,6 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react'; import { UploadCloud, File, Sheet, Loader2, CheckCircle, Sparkles, Wand2, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { generateSyntheticCsv } from '../utils/syntheticDataGenerator'; import { generateSyntheticCsv } from '../utils/syntheticDataGenerator';
import { TierKey } from '../types'; import { TierKey } from '../types';
@@ -21,6 +22,7 @@ const formatFileSize = (bytes: number, decimals = 2) => {
}; };
const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => { const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisReady, isAnalyzing }) => {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [sheetUrl, setSheetUrl] = useState(''); const [sheetUrl, setSheetUrl] = useState('');
const [status, setStatus] = useState<UploadStatus>('idle'); const [status, setStatus] = useState<UploadStatus>('idle');
@@ -57,7 +59,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
setFile(selectedFile); setFile(selectedFile);
setSheetUrl(''); setSheetUrl('');
} else { } else {
setError('Tipo de archivo no válido. Sube un CSV o Excel.'); setError(t('upload.invalidFileType'));
setFile(null); setFile(null);
} }
} }
@@ -89,19 +91,19 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
setStatus('generating'); setStatus('generating');
setTimeout(() => { setTimeout(() => {
const csvData = generateSyntheticCsv(selectedTier); const csvData = generateSyntheticCsv(selectedTier);
handleDataReady('Datos Sintéticos Generados!'); handleDataReady(t('upload.syntheticDataGenerated'));
}, 2000); }, 2000);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (!file && !sheetUrl) { if (!file && !sheetUrl) {
setError('Por favor, sube un archivo o introduce una URL de Google Sheet.'); setError(t('upload.pleaseUploadFile'));
return; return;
} }
resetState(false); resetState(false);
setStatus('uploading'); setStatus('uploading');
setTimeout(() => { setTimeout(() => {
handleDataReady('Datos Recibidos!'); handleDataReady(t('upload.dataReceived'));
}, 2000); }, 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" 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 ? <Loader2 className="animate-spin" size={20} /> : <BarChart3 size={20} />}
{isAnalyzing ? 'Analizando...' : 'Ver Dashboard de Diagnóstico'} {isAnalyzing ? t('upload.analyzingData') : t('dashboard.viewDashboard')}
</button> </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" 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' ? <Loader2 className="animate-spin" size={20} /> : <Wand2 size={20} />}
{status === 'uploading' ? 'Procesando...' : 'Generar Análisis'} {status === 'uploading' ? t('upload.processingData') : t('dashboard.generateAnalysis')}
</button> </button>
); );
}; };
@@ -134,10 +136,10 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
return ( return (
<div className="bg-white rounded-xl shadow-lg p-8"> <div className="bg-white rounded-xl shadow-lg p-8">
<div className="mb-6"> <div className="mb-6">
<span className="text-blue-600 font-semibold mb-1 block">Paso 2</span> <span className="text-blue-600 font-semibold mb-1 block">{t('stepper.step2')}</span>
<h2 className="text-2xl font-bold text-slate-900">Sube tus Datos y Ejecuta el Análisis</h2> <h2 className="text-2xl font-bold text-slate-900">{t('upload.title')}</h2>
<p className="text-slate-600 mt-1"> <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> </p>
</div> </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"> <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" /> <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="font-semibold text-blue-600">{t('upload.clickToUpload')}</span>
<span className="text-slate-500"> o arrástralo aquí</span> <span className="text-slate-500"> {t('upload.dragAndDrop')}</span>
<p className="text-xs text-slate-400 mt-2">CSV, XLSX, o XLS</p> <p className="text-xs text-slate-400 mt-2">{t('upload.fileTypes')}</p>
</label> </label>
</div> </div>
<div className="flex items-center text-slate-500"> <div className="flex items-center text-slate-500">
<hr className="w-full border-slate-300" /> <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" /> <hr className="w-full border-slate-300" />
</div> </div>
<div className="text-center p-4 bg-slate-50 rounded-lg"> <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 <button
onClick={handleGenerateSyntheticData} onClick={handleGenerateSyntheticData}
disabled={isActionInProgress} 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" 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' ? <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> </button>
</div> </div>
<div className="flex items-center text-slate-500"> <div className="flex items-center text-slate-500">
<hr className="w-full border-slate-300" /> <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" /> <hr className="w-full border-slate-300" />
</div> </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" /> <Sheet className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input <input
type="url" type="url"
placeholder="Pega la URL de tu Google Sheet aquí" placeholder={t('upload.googleSheetPlaceholder')}
value={sheetUrl} value={sheetUrl}
onChange={(e) => { onChange={(e) => {
resetState(); resetState();
@@ -249,7 +251,7 @@ const DataUploader: React.FC<DataUploaderProps> = ({ selectedTier, onAnalysisRea
{status === 'success' && ( {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"> <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" /> <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> </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 { motion } from 'framer-motion';
import { Lock, User } from 'lucide-react'; import { Lock, User } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../utils/AuthContext'; import { useAuth } from '../utils/AuthContext';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { t } = useTranslation();
const { login } = useAuth(); const { login } = useAuth();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -14,14 +16,14 @@ const LoginPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!username || !password) { if (!username || !password) {
toast.error('Introduce usuario y contraseña'); toast.error(t('auth.credentialsRequired'));
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await login(username, password); await login(username, password);
toast.success('Sesión iniciada'); toast.success(t('auth.sessionStarted'));
} catch (err) { } catch (err) {
console.error('Error en login', err); console.error('Error en login', err);
const msg = const msg =
@@ -48,14 +50,14 @@ const LoginPage: React.FC = () => {
Beyond Diagnostic Beyond Diagnostic
</h1> </h1>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Inicia sesión para acceder al análisis {t('auth.loginTitle')}
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-slate-700">
Usuario {t('auth.username')}
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400"> <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"> <div className="space-y-1">
<label className="block text-sm font-medium text-slate-700"> <label className="block text-sm font-medium text-slate-700">
Contraseña {t('auth.password')}
</label> </label>
<div className="relative"> <div className="relative">
<span className="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400"> <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} 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" 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> </button>
<p className="text-[11px] text-slate-400 text-center mt-2"> <p className="text-[11px] text-slate-400 text-center mt-2">
La sesión permanecerá activa durante 1 hora. {t('auth.sessionInfo')}
</p> </p>
</form> </form>
</motion.div> </motion.div>

21
frontend/i18n.ts Normal file
View File

@@ -0,0 +1,21 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import es from './locales/es.json';
import en from './locales/en.json';
// Configuración de i18next
i18n
.use(initReactI18next)
.init({
resources: {
es: { translation: es },
en: { translation: en },
},
lng: localStorage.getItem('language') || 'es', // Español por defecto
fallbackLng: 'es', // Si falla una traducción, usa español
interpolation: {
escapeValue: false, // React ya escapa por defecto
},
});
export default i18n;

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './i18n'; // Inicializar i18n
import App from './App'; import App from './App';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');

207
frontend/locales/en.json Normal file
View File

@@ -0,0 +1,207 @@
{
"common": {
"or": "OR",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"view": "View",
"back": "Back",
"next": "Next",
"previous": "Previous",
"search": "Search",
"filter": "Filter",
"export": "Export",
"import": "Import"
},
"auth": {
"login": "Log in",
"logout": "Log out",
"loginTitle": "Log in to access the analysis",
"username": "Username",
"password": "Password",
"enterButton": "Enter",
"enteringButton": "Logging in…",
"sessionInfo": "The session will remain active for 1 hour.",
"sessionExpired": "Session expired or incorrect credentials. Please log in again.",
"loginRequired": "You must log in to analyze data.",
"credentialsRequired": "Enter username and password",
"sessionStarted": "Session started"
},
"upload": {
"title": "Upload Your Data and Run Analysis",
"uploadFile": "Upload File",
"clickToUpload": "Click to upload a file",
"dragAndDrop": "or drag and drop here",
"fileTypes": "CSV, XLSX, or XLS",
"googleSheetPlaceholder": "Paste your Google Sheet URL here",
"invalidFileType": "Invalid file type. Upload a CSV or Excel file.",
"pleaseUploadFile": "Please upload a file or enter a Google Sheet URL.",
"generateSyntheticData": "Generate Synthetic Data",
"syntheticDataGenerated": "Synthetic Data Generated!",
"dataReceived": "Data Received!",
"noDataPrompt": "Don't have data on hand? Generate a sample dataset.",
"processingData": "Processing...",
"analyzingData": "Analyzing...",
"generating": "Generating...",
"readyToAnalyze": "Ready to analyze!",
"dataProcessed": "Data Processed",
"recordsAnalyzed": "Records analyzed",
"monthsHistory": "Months of history",
"sourceSystem": "Source system",
"highConfidence": "Confidence: High",
"mediumConfidence": "Confidence: Medium",
"lowConfidence": "Confidence: Low",
"subtitle": "Use one of the following options to send us your data for analysis."
},
"stepper": {
"step1": "Step 1",
"step2": "Step 2",
"step3": "Step 3",
"selectTier": "Select Tier",
"uploadData": "Upload Data",
"viewResults": "View Results"
},
"tiers": {
"gold": {
"name": "GOLD Analysis",
"description": "5 complete dimensions with advanced Agentic Readiness",
"features": [
"5 dimensions: Volumetry, Efficiency, Effectiveness, Complexity, Agentic Readiness",
"Generative AI: Personalized insights by OpenAI",
"Transformation roadmap: AUTOMATE, ASSIST, AUGMENT phases",
"Prioritization matrix with maturity levels",
"Advanced benchmarking against industry",
"Exportable PDF reports"
]
},
"silver": {
"name": "SILVER Analysis",
"description": "4 core dimensions with operational metrics",
"features": [
"4 dimensions: Volumetry, Efficiency, Effectiveness, Economy",
"Analysis without generative AI",
"Simplified roadmap",
"Basic benchmark",
"Opportunity matrix"
]
},
"express": {
"name": "EXPRESS Analysis",
"description": "Quick diagnosis of volumetry and efficiency",
"features": [
"2 dimensions: Volumetry, Efficiency",
"Instant analysis",
"No AI or roadmap",
"Ideal for quick tests"
]
}
},
"dashboard": {
"title": "Diagnostic Dashboard",
"viewDashboard": "View Diagnostic Dashboard",
"generateAnalysis": "Generate Analysis",
"analyzing": "Analyzing...",
"analysisComplete": "Analysis completed!",
"dataLoadedFromCache": "Data loaded from cache!",
"reloadPage": "Reload Page"
},
"tabs": {
"executive": "Summary",
"dimensions": "Dimensions",
"agenticReadiness": "Agentic Readiness",
"roadmap": "Roadmap",
"law10": "Law 10/2025"
},
"dimensions": {
"volumetry": "Volumetry & Distribution",
"operationalPerformance": "Operational Efficiency",
"effectiveness": "Effectiveness & Resolution",
"complexity": "Complexity & Predictability",
"economy": "Economy & Costs",
"agenticReadiness": "Agentic Readiness"
},
"healthStatus": {
"excellent": "EXCELLENT",
"excellentDesc": "Top quartile, role model",
"good": "GOOD",
"goodDesc": "Above benchmarks, solid performance",
"medium": "MEDIUM",
"mediumDesc": "Within expected range",
"low": "LOW",
"lowDesc": "Needs improvement, below benchmarks",
"critical": "CRITICAL",
"criticalDesc": "Requires immediate intervention"
},
"benchmark": {
"title": "Industry Benchmark (P50)",
"aboveBenchmark": "Above benchmarks, solid performance",
"belowBenchmark": "Needs improvement, below benchmarks",
"withinRange": "Within expected range"
},
"roadmap": {
"wave1": "Wave 1: AUTOMATE",
"wave2": "Wave 2: ASSIST",
"wave3": "Wave 3: AUGMENT",
"quickWins": "Quick Wins (0-6 months)",
"buildCapability": "Build Capability (6-12 months)",
"transform": "Transform (12-18 months)",
"automate": "Automate",
"duration3to6": "3-6 months",
"duration6to12": "6-12 months",
"duration12to18": "12-18 months"
},
"opportunities": {
"viewCriticalActions": "View Critical Actions",
"exploreImprovements": "Explore Improvements",
"inGoodState": "In good state",
"prioritize": "Prioritize",
"optimize": "Optimize",
"maintain": "Maintain"
},
"agenticReadiness": {
"score": "Agentic Readiness Score",
"confidence": "Confidence",
"readyForCopilot": "Ready for Copilot",
"readyForCopilotDesc": "Processes with sufficient predictability and simplicity for AI assistance (real-time suggestions, autocomplete).",
"optimizeFirst": "Optimize first",
"optimizeFirstDesc": "Standardize processes and reduce variability before implementing AI assistance.",
"requiresHumanManagement": "Requires human management",
"requiresHumanManagementDesc": "Complex or variable processes that need human intervention before considering automation."
},
"economicModel": {
"title": "Economic Model",
"costPerInteraction": "Cost per interaction (CPI) by channel",
"totalCost": "Total Cost",
"avgCost": "Average Cost",
"costBreakdown": "Cost Breakdown",
"enterCostPerHour": "Please enter the cost per hour for the agent.",
"noCostConfig": "No cost configuration"
},
"charts": {
"volumeByDayAndHour": "Volume by day of week and hour",
"ahtDistributionBySkill": "AHT distribution by skill",
"resolutionFunnelBySkill": "Resolution funnel (P50) by skill",
"csatDistribution": "CSAT distribution"
},
"errors": {
"renderError": "Rendering Error",
"componentError": "This component encountered an error and could not render correctly.",
"viewTechnicalDetails": "View technical details",
"errorInComponent": "Error in {{componentName}}",
"somethingWentWrong": "Something went wrong",
"tryAgain": "Try again"
},
"methodology": {
"title": "Methodology",
"description": "Learn how we calculate metrics",
"close": "Close",
"appliedBadge": "Data Transformation Methodology Applied",
"appliedBadgeShort": "Methodology"
}
}

207
frontend/locales/es.json Normal file
View File

@@ -0,0 +1,207 @@
{
"common": {
"or": "O",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"cancel": "Cancelar",
"confirm": "Confirmar",
"close": "Cerrar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"view": "Ver",
"back": "Volver",
"next": "Siguiente",
"previous": "Anterior",
"search": "Buscar",
"filter": "Filtrar",
"export": "Exportar",
"import": "Importar"
},
"auth": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"loginTitle": "Inicia sesión para acceder al análisis",
"username": "Usuario",
"password": "Contraseña",
"enterButton": "Entrar",
"enteringButton": "Entrando…",
"sessionInfo": "La sesión permanecerá activa durante 1 hora.",
"sessionExpired": "Sesión caducada o credenciales incorrectas. Vuelve a iniciar sesión.",
"loginRequired": "Debes iniciar sesión para analizar datos.",
"credentialsRequired": "Introduce usuario y contraseña",
"sessionStarted": "Sesión iniciada"
},
"upload": {
"title": "Sube tus Datos y Ejecuta el Análisis",
"uploadFile": "Subir Archivo",
"clickToUpload": "Haz clic para subir un fichero",
"dragAndDrop": "o arrastra y suelta aquí",
"fileTypes": "CSV, XLSX, o XLS",
"googleSheetPlaceholder": "Pega la URL de tu Google Sheet aquí",
"invalidFileType": "Tipo de archivo no válido. Sube un CSV o Excel.",
"pleaseUploadFile": "Por favor, sube un archivo o introduce una URL de Google Sheet.",
"generateSyntheticData": "Generar Datos Sintéticos",
"syntheticDataGenerated": "Datos Sintéticos Generados!",
"dataReceived": "Datos Recibidos!",
"noDataPrompt": "¿No tienes datos a mano? Genera un set de datos de ejemplo.",
"processingData": "Procesando...",
"analyzingData": "Analizando...",
"generating": "Generando...",
"readyToAnalyze": "¡Listo para analizar!",
"dataProcessed": "Datos Procesados",
"recordsAnalyzed": "Registros analizados",
"monthsHistory": "Meses de histórico",
"sourceSystem": "Sistema origen",
"highConfidence": "Confianza: Alta",
"mediumConfidence": "Confianza: Media",
"lowConfidence": "Confianza: Baja",
"subtitle": "Usa una de las siguientes opciones para enviarnos tus datos para el análisis."
},
"stepper": {
"step1": "Paso 1",
"step2": "Paso 2",
"step3": "Paso 3",
"selectTier": "Seleccionar Tier",
"uploadData": "Subir Datos",
"viewResults": "Ver Resultados"
},
"tiers": {
"gold": {
"name": "Análisis GOLD",
"description": "5 dimensiones completas con Agentic Readiness avanzado",
"features": [
"5 dimensiones: Volumetría, Eficiencia, Efectividad, Complejidad, Agentic Readiness",
"IA Generativa: Insights personalizados por OpenAI",
"Roadmap de transformación: Fases AUTOMATE, ASSIST, AUGMENT",
"Matriz de priorización con niveles de madurez",
"Benchmarking avanzado contra industria",
"Informes exportables en PDF"
]
},
"silver": {
"name": "Análisis SILVER",
"description": "4 dimensiones core con métricas operativas",
"features": [
"4 dimensiones: Volumetría, Eficiencia, Efectividad, Economía",
"Análisis sin IA generativa",
"Roadmap simplificado",
"Benchmark básico",
"Matriz de oportunidades"
]
},
"express": {
"name": "Análisis EXPRESS",
"description": "Diagnóstico rápido de volumetría y eficiencia",
"features": [
"2 dimensiones: Volumetría, Eficiencia",
"Análisis instantáneo",
"Sin IA ni roadmap",
"Ideal para pruebas rápidas"
]
}
},
"dashboard": {
"title": "Dashboard de Diagnóstico",
"viewDashboard": "Ver Dashboard de Diagnóstico",
"generateAnalysis": "Generar Análisis",
"analyzing": "Analizando...",
"analysisComplete": "¡Análisis completado!",
"dataLoadedFromCache": "¡Datos cargados desde caché!",
"reloadPage": "Recargar Página"
},
"tabs": {
"executive": "Resumen",
"dimensions": "Dimensiones",
"agenticReadiness": "Agentic Readiness",
"roadmap": "Roadmap",
"law10": "Ley 10/2025"
},
"dimensions": {
"volumetry": "Volumetría & Distribución",
"operationalPerformance": "Eficiencia Operativa",
"effectiveness": "Efectividad & Resolución",
"complexity": "Complejidad & Predictibilidad",
"economy": "Economía & Costes",
"agenticReadiness": "Agentic Readiness"
},
"healthStatus": {
"excellent": "EXCELENTE",
"excellentDesc": "Top quartile, modelo a seguir",
"good": "BUENO",
"goodDesc": "Por encima de benchmarks, desempeño sólido",
"medium": "MEDIO",
"mediumDesc": "Dentro de rango esperado",
"low": "BAJO",
"lowDesc": "Requiere mejora, por debajo de benchmarks",
"critical": "CRÍTICO",
"criticalDesc": "Necesita intervención inmediata"
},
"benchmark": {
"title": "Benchmark Industria (P50)",
"aboveBenchmark": "Por encima de benchmarks, desempeño sólido",
"belowBenchmark": "Requiere mejora, por debajo de benchmarks",
"withinRange": "Dentro de rango esperado"
},
"roadmap": {
"wave1": "Wave 1: AUTOMATE",
"wave2": "Wave 2: ASSIST",
"wave3": "Wave 3: AUGMENT",
"quickWins": "Quick Wins (0-6 meses)",
"buildCapability": "Build Capability (6-12 meses)",
"transform": "Transform (12-18 meses)",
"automate": "Automatizar",
"duration3to6": "3-6 meses",
"duration6to12": "6-12 meses",
"duration12to18": "12-18 meses"
},
"opportunities": {
"viewCriticalActions": "Ver Acciones Críticas",
"explorImprovements": "Explorar Mejoras",
"inGoodState": "En buen estado",
"prioritize": "Priorizar",
"optimize": "Optimizar",
"maintain": "Mantener"
},
"agenticReadiness": {
"score": "Agentic Readiness Score",
"confidence": "Confianza",
"readyForCopilot": "Listo para Copilot",
"readyForCopilotDesc": "Procesos con predictibilidad y simplicidad suficientes para asistencia IA (sugerencias en tiempo real, autocompletado).",
"optimizeFirst": "Optimizar primero",
"optimizeFirstDesc": "Estandarizar procesos y reducir variabilidad antes de implementar asistencia IA.",
"requiresHumanManagement": "Requiere gestión humana",
"requiresHumanManagementDesc": "Procesos complejos o variables que necesitan intervención humana antes de considerar automatización."
},
"economicModel": {
"title": "Modelo Económico",
"costPerInteraction": "Coste por interacción (CPI) por canal",
"totalCost": "Coste Total",
"avgCost": "Coste Promedio",
"costBreakdown": "Desglose de Costes",
"enterCostPerHour": "Por favor, introduce el coste por hora del agente.",
"noCostConfig": "Sin configuración de costes"
},
"charts": {
"volumeByDayAndHour": "Volumen por día de la semana y hora",
"ahtDistributionBySkill": "Distribución de AHT por skill",
"resolutionFunnelBySkill": "Funnel de resolución (P50) por skill",
"csatDistribution": "Distribución de CSAT"
},
"errors": {
"renderError": "Error de Renderizado",
"componentError": "Este componente encontró un error y no pudo renderizarse correctamente.",
"viewTechnicalDetails": "Ver detalles técnicos",
"errorInComponent": "Error en {{componentName}}",
"somethingWentWrong": "Algo salió mal",
"tryAgain": "Intentar de nuevo"
},
"methodology": {
"title": "Metodología",
"description": "Conoce cómo calculamos las métricas",
"close": "Cerrar",
"appliedBadge": "Metodología de Transformación de Datos aplicada",
"appliedBadgeShort": "Metodología"
}
}

View File

@@ -12,11 +12,13 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^25.8.4",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.4",
"recharts": "^3.4.1", "recharts": "^3.4.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
@@ -261,6 +263,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2300,6 +2311,46 @@
"csstype": "^3.0.10" "csstype": "^3.0.10"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/immer": { "node_modules/immer": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@@ -2518,6 +2569,33 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-i18next": {
"version": "16.5.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
@@ -2791,7 +2869,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -2988,6 +3066,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/wmf": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",

View File

@@ -13,11 +13,13 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^25.8.4",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.5.4",
"recharts": "^3.4.1", "recharts": "^3.4.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },

View File

@@ -1,5 +1,6 @@
// utils/AuthContext.tsx // utils/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useEffect, useState } from 'react';
import i18n from '../i18n';
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
@@ -61,6 +62,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: basic, Authorization: basic,
'Accept-Language': i18n.language || 'es',
}, },
}); });
} catch (err) { } catch (err) {

View File

@@ -1,5 +1,6 @@
// utils/apiClient.ts // utils/apiClient.ts
import type { TierKey } from '../types'; import type { TierKey } from '../types';
import i18n from '../i18n';
type SegmentMapping = { type SegmentMapping = {
high_value_queues: string[]; high_value_queues: string[];
@@ -85,6 +86,7 @@ export async function callAnalysisApiRaw(params: {
body: formData, body: formData,
headers: { headers: {
...authHeaders, ...authHeaders,
'Accept-Language': i18n.language || 'es',
}, },
}); });