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
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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user