feat(i18n): add bilingual support (Català / Castellano)
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m44s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped

Add lightweight i18n system using React Context + useTranslation hook.
No external dependencies. Language persisted in localStorage('ff-lang').

New files:
- src/i18n/locales/ca.js (~410 translation keys)
- src/i18n/locales/es.js (~410 translation keys)
- src/i18n/LanguageContext.jsx (Provider + t() function)
- src/i18n/useTranslation.js (hook)
- src/i18n/index.js (barrel export)

Modified 22 files:
- main.jsx: wrap App with LanguageProvider
- App.test.jsx: add LanguageProvider wrapper for tests
- 13 user components: BottomNav, HomeView, LoginModal, SearchView,
  ProfileView, AlertsView, ScannerView, PharmacyList, MedicineResults,
  ProductResults, SavedNotifications, ErrorBoundary, SearchBar
- 6 admin components: AdminView, LoginForm, PharmacyManagement,
  MedicineManagement, PharmacyMedicineLink, PharmacyProductLink

ProfileView includes language selector (globe icon + modal CA/ES)
placed next to the Theme selector.

No routes changed. No API calls affected. No navigation changes.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-17 09:43:45 +02:00
parent 41d3e5cd9a
commit 38374341ed
26 changed files with 1371 additions and 384 deletions
@@ -1,7 +1,9 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from '../i18n';
import './SavedNotifications.css';
function SavedNotifications({ onClose, onNotificationChange }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [items, setItems] = useState([]);
@@ -21,7 +23,7 @@ function SavedNotifications({ onClose, onNotificationChange }) {
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
setItems(merged);
} catch (err) {
if (!cancelled) setError(err.message || 'No se pudieron cargar las notificaciones guardadas');
if (!cancelled) setError(err.message || t('savedNotifications.loadError'));
} finally {
if (!cancelled) setLoading(false);
}
@@ -43,7 +45,7 @@ function SavedNotifications({ onClose, onNotificationChange }) {
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
onNotificationChange?.();
} catch (err) {
setError(err.message || 'No se pudo eliminar la notificación');
setError(err.message || t('savedNotifications.deleteError'));
} finally {
setBusyId(null);
}
@@ -53,15 +55,15 @@ function SavedNotifications({ onClose, onNotificationChange }) {
<div className="saved-notifications-backdrop" onClick={onClose}>
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
<div className="saved-notifications-header">
<h2>🔔 Notificaciones Guardadas</h2>
<button className="saved-notifications-close" onClick={onClose} aria-label="Cerrar">×</button>
<h2>{t('savedNotifications.title')}</h2>
<button className="saved-notifications-close" onClick={onClose} aria-label={t('savedNotifications.close')}>×</button>
</div>
<div className="saved-notifications-body">
{loading && <p className="saved-notifications-status">Cargando…</p>}
{loading && <p className="saved-notifications-status">{t('savedNotifications.loading')}</p>}
{!loading && error && <p className="saved-notifications-error">{error}</p>}
{!loading && !error && items.length === 0 && (
<p className="saved-notifications-empty">
Aún no hay notificaciones guardadas. Toca la campana 🔕 en una farmacia sin stock para recibir notificaciones cuando se reponga.
{t('savedNotifications.empty')}
</p>
)}
{!loading && !error && items.length > 0 && (
@@ -78,7 +80,7 @@ function SavedNotifications({ onClose, onNotificationChange }) {
{item.scope === 'pharmacy' ? (
<>
<span className="saved-notifications-chip saved-notifications-chip--pharmacy">
🏥 {item.pharmacy_name || `Farmacia #${item.pharmacy_id}`}
🏥 {item.pharmacy_name || `${t('savedNotifications.anyPharmacy')} #${item.pharmacy_id}`}
</span>
{item.pharmacy_address && (
<span className="saved-notifications-address">{item.pharmacy_address}</span>
@@ -86,7 +88,7 @@ function SavedNotifications({ onClose, onNotificationChange }) {
</>
) : (
<span className="saved-notifications-chip">
Cualquier farmacia
{t('savedNotifications.anyPharmacy')}
</span>
)}
</div>
@@ -96,9 +98,9 @@ function SavedNotifications({ onClose, onNotificationChange }) {
className="saved-notifications-remove"
onClick={() => handleDelete(item)}
disabled={busyId === key}
aria-label="Eliminar notificación"
aria-label={t('savedNotifications.delete')}
>
{busyId === key ? '…' : 'Eliminar'}
{busyId === key ? '…' : t('savedNotifications.delete')}
</button>
</li>
);