fase 3.6: i18n espanol/ingles (530 claves, provider con fallback, interpolacion y pluralizacion, selector de idioma en menu de usuario y configuracion, convivencia con el glosario de negocio, superficies principales y paginas publicas traducidas sin alterar el espanol)

This commit is contained in:
2026-10-11 21:19:43 +00:00
parent 3ed0d327d2
commit e749a31d10
22 changed files with 2022 additions and 411 deletions

View File

@@ -44,8 +44,10 @@ import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { LanguageToggle } from './LanguageSelect';
import { useAuth } from '../lib/auth';
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useI18n, useNavLabel } from '../lib/i18n';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
@@ -122,10 +124,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { t } = useI18n();
const navLabel = useNavLabel();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: navLabel(traducirEtiqueta(it.label, term)) })),
[term, navLabel]
);
const menuSections = useMemo(() => {
let base = MENU_SECTIONS;
@@ -151,8 +155,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
.map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') }))
.filter((s) => s.items.length > 0);
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
return traducirMenu(base, term).map((s, idx) => ({
...s,
// `id` conserva la etiqueta canónica en español para el estado de
// expansión y las claves de React; `label` es la etiqueta visible.
id: base[idx].label,
label: navLabel(s.label),
items: s.items.map((it) => ({ ...it, label: navLabel(it.label) })),
}));
}, [term, navLabel, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();
@@ -273,7 +284,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label="Abrir menú">
<Button variant="ghost" size="sm" className="lg:hidden" style={{ color: 'var(--text-heading)' }} onClick={() => setMenuOpen(true)} aria-label={t('user.openMenu')}>
<Menu size={22} />
</Button>
<Link to="/" className="flex items-center gap-2">
@@ -323,7 +334,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
e.currentTarget.style.color = 'var(--text-muted)';
}}
>
Más <ChevronDown size={14} />
{t('user.more')} <ChevronDown size={14} />
</button>
<div className="absolute top-full left-0 mt-2 w-56 rounded-2xl shadow-card border opacity-0 invisible group-hover:opacity-100 group-hover:visible transition z-40" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
<div className="p-2">
@@ -356,6 +367,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<GlobalSearch className="hidden md:block" />
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
<ThemePicker />
<LanguageToggle color="var(--text-muted)" />
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
{initials || 'US'}
@@ -369,7 +381,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}}
title="Cerrar sesión"
title={t('user.logout')}
>
<LogOut size={16} />
</button>
@@ -385,7 +397,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>Meta del mes</span>
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>{t('user.goalMonth')}</span>
</div>
{goal && goal.goal > 0 ? (
<button onClick={hasRole('admin') ? openGoalModal : undefined} className={`w-full text-left ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}>
@@ -399,7 +411,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
</button>
) : (
<Button variant="outline" size="sm" className="w-full" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
<Target size={14} className="mr-1.5" /> {t('user.goalDefine')}
</Button>
)}
</div>
@@ -410,7 +422,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<div className="w-full flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
<Star size={14} style={{ color: 'var(--accent)' }} />
Accesos rápidos
{t('nav.quickLinks')}
</span>
<span className="flex items-center gap-1">
<QuickLinksModePicker direction="down" align="right" />
@@ -418,7 +430,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
type="button"
onClick={() => quick.setEditing(!quick.editing)}
style={{ color: 'var(--text-muted)' }}
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
title={quick.editing ? t('dock.finishEditing') : t('dock.editQuick')}
>
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
</button>
@@ -456,15 +468,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
{quick.editing && (
<span className="flex items-center gap-0.5 shrink-0">
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Subir">
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.up')}>
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Bajar">
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title={t('dock.down')}>
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quick.remove(item.href)}
style={{ color: 'var(--text-muted)' }} title="Quitar">
style={{ color: 'var(--text-muted)' }} title={t('dock.remove')}>
<X size={14} />
</button>
</span>
@@ -476,16 +488,16 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
</div>
)}
{visibleSections.map((section) => (
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<div key={section.id} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
<button
onClick={() => toggleSection(section.label)}
onClick={() => toggleSection(section.id)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium"
style={{ color: 'var(--text-heading)' }}
>
{section.label}
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.label) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
<ChevronDown size={14} className={`transition-transform ${expanded.includes(section.id) ? 'rotate-180' : ''}`} style={{ color: 'var(--text-muted)' }} />
</button>
{expanded.includes(section.label) && (
{expanded.includes(section.id) && (
<ul className="px-2 pb-2 space-y-1">
{section.items.map((item) => {
const Icon = item.icon;
@@ -554,7 +566,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
<img src={brand.logo_oscuro} alt={brand.nombre} className="h-8 w-auto object-contain" />
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label={t('user.closeMenu')}>
<X size={22} />
</Button>
</div>
@@ -562,7 +574,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
{quickItems.length > 0 && (
<>
<p className="px-2 pt-1 text-[10px] uppercase tracking-wider font-medium flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
<Star size={11} style={{ color: 'var(--accent)' }} /> Accesos rápidos
<Star size={11} style={{ color: 'var(--accent)' }} /> {t('nav.quickLinks')}
</p>
{quickItems.map((item) => {
const Icon = item.icon;
@@ -632,7 +644,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<LogOut size={18} /> Cerrar sesión
<LogOut size={18} /> {t('user.logout')}
</button>
</div>
</aside>
@@ -642,18 +654,18 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
title={t('user.goalTitle')}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={saveGoal} loading={goalSaving}>{t('user.goalSave')}</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('user.goalBody')}</p>
<Input label={t('user.goalField')} type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>