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
+24 -22
View File
@@ -5,6 +5,7 @@ import ProductResults from '../components/ProductResults';
import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import { useTranslation } from '../i18n';
import './SearchView.css';
const suggestions = [
@@ -15,6 +16,7 @@ const suggestions = [
];
function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigateToProduct }) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [medicines, setMedicines] = useState([]);
const [products, setProducts] = useState([]);
@@ -193,11 +195,11 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
console.error('Error saving search history:', err);
}
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
let msg = t('search.locationError');
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado. Permite el acceso a la ubicación en tu navegador.';
else if (err.code === 2) msg = 'Ubicación no disponible. Verifica que el GPS esté activado.';
else if (err.code === 3) msg = 'La ubicación tardó demasiado. Intenta de nuevo o verifica tu conexión.';
if (err.code === 1) msg = t('search.locationDenied');
else if (err.code === 2) msg = t('search.locationUnavailable');
else if (err.code === 3) msg = t('search.locationTimeout');
}
setLocationError(msg);
} finally {
@@ -223,15 +225,15 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Escriba el nombre del medicamento"
placeholder={t('search.placeholder')}
/>
{loading && <div className="loading">Buscando...</div>}
{loading && <div className="loading">{t('search.searching')}</div>}
{!searchQuery && !selectedMedicine && (
<>
<section className="suggestions-section">
<h2 className="section-title">Sugerencias</h2>
<h2 className="section-title">{t('search.suggestions')}</h2>
<div className="suggestions-grid">
{suggestions.map((s, i) => (
<button
@@ -252,7 +254,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{currentUser && recentSearches.length > 0 && (
<section className="recent-section">
<h2 className="section-title">Resultados Recientes</h2>
<h2 className="section-title">{t('search.recentResults')}</h2>
<div className="recent-list">
{recentSearches.map((r) => (
<div
@@ -285,7 +287,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Encontrar cerca
{t('search.findNearby')}
</button>
</div>
))}
@@ -299,7 +301,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
<>
<div className="results-summary">
{(medicines.length + products.length) > 0 && (
<span>{medicines.length + products.length} resultados encontrados</span>
<span>{medicines.length + products.length} {t('search.resultsFound')}</span>
)}
</div>
@@ -318,7 +320,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{products.length > 0 && (
<div className="products-section">
<h3 className="section-subtitle">Parafarmacia</h3>
<h3 className="section-subtitle">{t('search.parapharmacy')}</h3>
<ProductResults
products={products}
onSelect={(p) => {
@@ -340,9 +342,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
<div className="medicine-info">
<h2>{selectedMedicine.name}</h2>
<div className="medicine-details">
<span><strong>Ingrediente Activo:</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
<span><strong>{t('search.activeIngredient')}</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>{t('search.dosage')}</strong> {selectedMedicine.dosage}</span>
<span><strong>{t('search.form')}</strong> {selectedMedicine.form}</span>
</div>
<button
className="back-button"
@@ -351,7 +353,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
setPharmacies([]);
}}
>
← Volver a búsqueda
{t('search.backToSearch')}
</button>
</div>
@@ -363,24 +365,24 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
disabled={locating}
>
{locating
? '📍 Localizando…'
? t('search.locating')
: sortByDistance
? '📍 Ordenado por distancia · Reset'
? t('search.sortedByDistance')
: hasSavedCoords
? '📍 Ordenar por ubicación guardada'
: '📍 Ordenar por distancia'}
? t('search.sortBySavedLocation')
: t('search.sortByDistance')}
</button>
{sortByDistance && positionSource === 'profile' && (
<span className="location-source">Usando tu dirección guardada</span>
<span className="location-source">{t('search.usingSavedAddress')}</span>
)}
{sortByDistance && positionSource === 'cached' && (
<span className="location-source">Usando ubicación reciente</span>
<span className="location-source">{t('search.usingRecentLocation')}</span>
)}
{locationError && (
<span className="location-error">
{locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar
{t('search.retry')}
</button>
</span>
)}