-
Precio (€)
+
{t('admin.linkProduct.priceInput')}
- Stock
+ {t('admin.linkProduct.stock')}
- Vincular Producto
+ {t('admin.linkProduct.linkBtn')}
- Reiniciar
+ {t('admin.linkProduct.reset')}
{selectedPharmacy && (
-
Productos en {selectedPharmacy.name}
+
{t('admin.linkProduct.productsIn')} {selectedPharmacy.name}
{loading ? (
-
Cargando...
+
{t('admin.linkProduct.loading')}
) : pharmacyProducts.length === 0 ? (
-
Aún no hay productos vinculados a esta farmacia.
+
{t('admin.linkProduct.empty')}
) : (
{pharmacyProducts.map((pp) => (
@@ -427,7 +429,7 @@ function PharmacyProductLink() {
{getSourceBadge(pp.product_source)}
- Precio: {pp.price ? `€${parseFloat(pp.price).toFixed(2)}` : 'No definido'} •
+ {t('admin.linkProduct.priceLabel')} {pp.price ? `€${parseFloat(pp.price).toFixed(2)}` : t('admin.linkProduct.undefined')} •
Stock: {pp.stock || 0}
@@ -435,17 +437,17 @@ function PharmacyProductLink() {
{
- const newPrice = prompt('Introduce nuevo precio:', pp.price || '');
- const newStock = prompt('Introduce nuevo stock:', pp.stock || '0');
+ const newPrice = prompt(t('admin.linkProduct.newPrice'), pp.price || '');
+ const newStock = prompt(t('admin.linkProduct.newStock'), pp.stock || '0');
if (newPrice !== null && newStock !== null) {
handleUpdate(pp.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
}
}}
>
- Actualizar
+ {t('admin.linkProduct.update')}
handleDelete(pp.id)}>
- Eliminar
+ {t('admin.linkProduct.delete')}
diff --git a/apps/frontend/src/i18n/LanguageContext.jsx b/apps/frontend/src/i18n/LanguageContext.jsx
new file mode 100644
index 0000000..eca5b42
--- /dev/null
+++ b/apps/frontend/src/i18n/LanguageContext.jsx
@@ -0,0 +1,40 @@
+import React, { createContext, useState, useCallback, useMemo } from 'react';
+import es from './locales/es';
+import ca from './locales/ca';
+
+const locales = { es, ca };
+
+function getInitialLang() {
+ try {
+ const saved = localStorage.getItem('ff-lang');
+ if (saved === 'ca' || saved === 'es') return saved;
+ } catch {}
+ const browserLang = navigator.language || navigator.userLanguage || '';
+ return browserLang.startsWith('ca') ? 'ca' : 'es';
+}
+
+export const LanguageContext = createContext(null);
+
+export function LanguageProvider({ children }) {
+ const [lang, setLangState] = useState(getInitialLang);
+
+ const setLang = useCallback((newLang) => {
+ if (newLang !== 'ca' && newLang !== 'es') return;
+ setLangState(newLang);
+ try {
+ localStorage.setItem('ff-lang', newLang);
+ } catch {}
+ }, []);
+
+ const t = useCallback((key) => {
+ return locales[lang]?.[key] || locales.es[key] || key;
+ }, [lang]);
+
+ const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/frontend/src/i18n/index.js b/apps/frontend/src/i18n/index.js
new file mode 100644
index 0000000..34dd51f
--- /dev/null
+++ b/apps/frontend/src/i18n/index.js
@@ -0,0 +1,2 @@
+export { LanguageProvider } from './LanguageContext';
+export { useTranslation } from './useTranslation';
diff --git a/apps/frontend/src/i18n/locales/ca.js b/apps/frontend/src/i18n/locales/ca.js
new file mode 100644
index 0000000..9e01b22
--- /dev/null
+++ b/apps/frontend/src/i18n/locales/ca.js
@@ -0,0 +1,414 @@
+const ca = {
+ // BottomNav
+ 'nav.home': 'Inici',
+ 'nav.search': 'Cercar',
+ 'nav.scan': 'Escanejar',
+ 'nav.alerts': 'Alertes',
+ 'nav.profile': 'Usuari',
+
+ // HomeView
+ 'home.description': 'Trobeu els vostres medicaments a farmàcies properes',
+ 'home.searchMedicine': 'Cercar Medicament',
+ 'home.scanTSI': 'Escanejar TSI',
+
+ // SearchBar
+ 'search.placeholder': 'Escriviu el nom del medicament',
+ 'search.clear': 'Netejar cerca',
+
+ // SearchView
+ 'search.suggestions': 'Suggeriments',
+ 'search.recentResults': 'Resultats Recents',
+ 'search.findNearby': 'Cerca a prop teu',
+ 'search.resultsFound': 'resultats trobats',
+ 'search.parapharmacy': 'Parafarmàcia',
+ 'search.activeIngredient': 'Principi Actiu',
+ 'search.dosage': 'Dosificació',
+ 'search.form': 'Forma',
+ 'search.backToSearch': '← Tornar a la cerca',
+ 'search.sortByDistance': '📍 Ordenar per distància',
+ 'search.sortBySavedLocation': '📍 Ordenar per ubicació desada',
+ 'search.sortedByDistance': '📍 Ordenat per distància · Restablir',
+ 'search.locating': '📍 Localitzant…',
+ 'search.usingSavedAddress': 'Usant la vostra adreça desada',
+ 'search.usingRecentLocation': 'Usant ubicació recent',
+ 'search.retry': 'Tornar a provar',
+ 'search.locationDenied': 'Permís d\'ubicació denegat. Permeteu l\'accés a la ubicació al vostre navegador.',
+ 'search.locationUnavailable': 'Ubicació no disponible. Verifiqueu que el GPS estigui activat.',
+ 'search.locationTimeout': 'L\'ubicació ha trigat massa. Torneu-ho a provar o verifiqueu la vostra connexió.',
+ 'search.locationError': 'No s\'ha pogut obtenir la vostra ubicació',
+ 'search.searching': 'Cercant...',
+
+ // MedicineResults
+ 'medicine.noResults': 'No s\'han trobat medicaments per a',
+ 'medicine.principioActivo': 'Principi Actiu:',
+ 'medicine.dosis': 'Dosificació:',
+ 'medicine.forma': 'Forma:',
+ 'medicine.viewPharmacies': 'Veure farmàcies →',
+ 'medicine.loginForNotifications': 'Inicieu sessió per activar notificacions',
+ 'medicine.disableNotifications': 'Desactivar notificacions per a aquest medicament',
+ 'medicine.enableNotifications': 'Notificar-me quan estigui disponible',
+ 'medicine.notificationsActivated': 'Notificacions activades — clic per desactivar',
+ 'medicine.notifyWhenAvailable': 'Notificar-me quan aquest medicament estigui a una farmàcia',
+ 'medicine.subscriptionError': 'No s\'ha pogut actualitzar la subscripció',
+
+ // PharmacyList
+ 'pharmacy.loading': 'Carregant farmàcies...',
+ 'pharmacy.notFound': 'No s\'han trobat farmàcies amb aquest medicament',
+ 'pharmacy.availableAt': 'Disponible a',
+ 'pharmacy.pharmacy': 'farmàcia',
+ 'pharmacy.pharmacies': 'farmàcies',
+ 'pharmacy.howToGet': 'Com arribar',
+ 'pharmacy.inStock': '✓ En Estoc',
+ 'pharmacy.lowStock': '⚠ Estoc Baix',
+ 'pharmacy.outOfStock': '✗ Sense Estoc',
+ 'pharmacy.loginForNotifications': 'Inicieu sessió per activar notificacions',
+ 'pharmacy.disableNotificationsPharmacy': 'Desactivar notificacions per a aquesta farmàcia',
+ 'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia',
+ 'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar',
+ 'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).',
+
+ // ProductResults
+ 'product.sinReceta': 'Sense Recepta',
+ 'product.parapharmacy': 'Parafarmàcia',
+ 'product.dermocosmetica': 'Dermocosmètica',
+ 'product.formulasLacteas': 'Fórmules Làctees',
+ 'product.vitamins': 'Vitamines',
+ 'product.analgesics': 'Analgèsics',
+ 'product.noResults': 'No s\'han trobat productes',
+ 'product.brand': 'Marca:',
+ 'product.price': 'Preu:',
+ 'product.activeIngredient': 'Principi Actiu:',
+ 'product.dosage': 'Dosificació:',
+ 'product.viewDetails': 'Veure detalls →',
+
+ // LoginModal
+ 'login.login': 'Iniciar sessió',
+ 'login.register': 'Crear compte',
+ 'login.welcomeBack': 'Benvingut de nou',
+ 'login.createAccount': 'Crea el teu compte',
+ 'login.registerDescription': 'Desa la teva adreça i rep notificacions quan arribin medicaments.',
+ 'login.loginDescription': 'Inicia sessió per gestionar el teu perfil i notificacions.',
+ 'login.username': 'Usuari',
+ 'login.usernameHint': '3–32 caràcters: lletres, dígits o guió baix.',
+ 'login.password': 'Contrasenya',
+ 'login.passwordHint': 'Almenys 8 caràcters.',
+ 'login.cancel': 'Cancel·lar',
+ 'login.creating': 'Creant…',
+ 'login.loggingIn': 'Iniciant sessió…',
+ 'login.createAccountBtn': 'Crear compte',
+ 'login.loginBtn': 'Iniciar sessió',
+ 'login.passwordError': 'La contrasenya ha de tenir almenys 8 caràcters',
+ 'login.registerError': 'No s\'ha pogut crear el compte',
+ 'login.loginError': 'Error d\'inici de sessió',
+ 'login.networkError': 'Error de xarxa — torneu-ho a provar',
+
+ // AlertsView
+ 'alerts.title': 'Les Meves Alertes',
+ 'alerts.subtitle': 'Mantingueu-vos al dia amb els vostres medicaments.',
+ 'alerts.loading': 'Carregant alertes...',
+ 'alerts.loginRequired': 'Si us plau, inicieu sessió per veure les vostres alertes.',
+ 'alerts.loadError': 'No s\'han pogut carregar les alertes.',
+ 'alerts.availability': 'Disponibilitat',
+ 'alerts.noSubscriptions': 'No teniu subscripcions de disponibilitat.',
+ 'alerts.notifyWhenAvailable': 'Notificar-me quan estigui disponible',
+ 'alerts.deleteError': 'No s\'ha pogut eliminar l\'alerta de disponibilitat.',
+ 'alerts.pharmacy': 'Farmàcia',
+
+ // ScannerView
+ 'scanner.title': 'Escanejar TSI',
+ 'scanner.description': 'Escanegeu el codi de barres de la vostra targeta sanitària per veure les vostres receptes actives',
+ 'scanner.openCamera': 'Obrir càmera',
+ 'scanner.orUploadPhoto': 'O pugeu una foto de la vostra TSI',
+ 'scanner.takePhoto': 'Fer foto',
+ 'scanner.uploadFromGallery': 'Pujar de galeria',
+ 'scanner.scanImage': 'Escanejar imatge',
+ 'scanner.discard': 'Descartar',
+ 'scanner.enterCIP': 'O introduïu el codi CIP manualment',
+ 'scanner.cipPlaceholder': 'Codi CIP de 16 dígits',
+ 'scanner.search': 'Cercar',
+ 'scanner.scanning': 'Apunteu al codi de barres',
+ 'scanner.openingCamera': 'Obrint càmera…',
+ 'scanner.processingImage': 'Processant imatge…',
+ 'scanner.tsiScanned': 'TSI Escanejada',
+ 'scanner.activePrescriptions': 'Receptes Actives',
+ 'scanner.tapToFind': 'Toqueu un medicament per veure disponibilitat a farmàcies properes.',
+ 'scanner.loadingPrescriptions': 'Carregant receptes…',
+ 'scanner.noPrescriptions': 'No s\'han trobat receptes actives per a aquesta targeta.',
+ 'scanner.scanAnother': 'Escanejar una altra targeta',
+ 'scanner.tryAgain': 'Tornar a provar',
+ 'scanner.scannerUnavailable': 'L\'escàner no està disponible en aquest dispositiu.',
+ 'scanner.cameraPermissionDenied': 'Permís de càmera denegat. Activeu-lo a la configuració o introduïu el codi CIP manualment.',
+ 'scanner.invalidBarcode': 'Codi de barres invàlid. Torneu-ho a provar o introduïu el CIP manualment.',
+ 'scanner.cameraNotAvailable': 'Càmera no disponible en aquest navegador.',
+ 'scanner.invalidCIP': 'Format CIP invàlid. Ha de tenir 16 caràcters alfanumèrics.',
+ 'scanner.enterCIPError': 'Introduïu un codi CIP.',
+ 'scanner.cameraPermissionWeb': 'Permís de càmera denegat. Permeteu l\'accés i torneu-ho a provar.',
+ 'scanner.noCamera': 'No s\'ha detectat cap càmera. Introduïu el codi CIP manualment.',
+ 'scanner.imageReadError': 'No s\'ha pogut llegir la imatge. Proveu amb una altra foto.',
+ 'scanner.invalidCIPDetected': 'CIP detectat no té format vàlid. Introduïu el codi manualment.',
+ 'scanner.scanError': 'Error en escanejar:',
+ 'scanner.unknownError': 'Error desconegut',
+ 'scanner.cameraError': 'Error de càmera:',
+ 'scanner.imageProcessError': 'Error en processar la imatge:',
+
+ // ProfileView
+ 'profile.name': 'Nom',
+ 'profile.lastName': 'Cognoms',
+ 'profile.config': 'Configuració',
+ 'profile.myAddresses': 'Les Meves Adreces',
+ 'profile.theme': 'Tema',
+ 'profile.themeAuto': 'Automàtic',
+ 'profile.themeLight': 'Clar',
+ 'profile.themeDark': 'Fosc',
+ 'profile.admin': 'Panell d\'Administració',
+ 'profile.logout': 'Tancar Sessió',
+ 'profile.uploadingPhoto': 'Pujant foto...',
+ 'profile.recentSearches': 'Les vostres cerques recents:',
+ 'profile.delete': 'Eliminar',
+ 'profile.changeAvatar': 'Canviar Avatar',
+ 'profile.presetAvatar': 'Predissenyat',
+ 'profile.colors': 'Colors',
+ 'profile.upload': 'Pujar',
+ 'profile.chooseFromGallery': 'Trieu de galeria',
+ 'profile.selectExistingImage': 'Seleccioneu una imatge existent',
+ 'profile.imageTooBig': 'La imatge no pot superar els 5 MB',
+ 'profile.imageSaveError': 'Error en desar la imatge. Proveu amb una altra foto.',
+ 'profile.imageConnectionError': 'Error de connexió en desar la imatge.',
+ 'profile.imageProcessError': 'Error en processar la imatge.',
+ 'profile.configTitle': 'Configuració',
+ 'profile.firstName': 'Nom',
+ 'profile.firstNamePlaceholder': 'El vostre nom',
+ 'profile.lastNamePlaceholder': 'Els vostres cognoms',
+ 'profile.email': 'Correu electrònic',
+ 'profile.emailPlaceholder': 'el_vostre@email.com',
+ 'profile.city': 'Ciutat',
+ 'profile.cityPlaceholder': 'La vostra ciutat',
+ 'profile.address': 'Adreça',
+ 'profile.addressPlaceholder': 'Carrer Major 1, Barcelona',
+ 'profile.cancel': 'Cancel·lar',
+ 'profile.saving': 'Desant...',
+ 'profile.save': 'Desar',
+ 'profile.profileSaved': 'Perfil desat.',
+ 'profile.saveError': 'Error en desar',
+ 'profile.addressesTitle': 'Les Meves Adreces',
+ 'profile.addressLabel': 'Adreça',
+ 'profile.addressOptional': 'Etiqueta (opcional)',
+ 'profile.addressLabelPlaceholder': 'Ex: Casa, Treball, Segona residència',
+ 'profile.defaultAddress': 'Adreça predeterminada',
+ 'profile.add': 'Afegir',
+ 'profile.update': 'Actualitzar',
+ 'profile.loadingAddresses': 'Carregant adreces...',
+ 'profile.mainAddress': 'Adreça principal',
+ 'profile.default': 'Predeterminada',
+ 'profile.setDefault': 'Establir com a predeterminada',
+ 'profile.addMore': 'Afegir més',
+ 'profile.addressRequired': 'L\'adreça és obligatòria',
+ 'profile.addressSaveError': 'Error en desar l\'adreça',
+ 'profile.themeTitle': 'Tema de visualització',
+ 'profile.themeDescription': 'Trieu com es veu l\'aplicació. En mode automàtic, s\'adapta al tema del vostre dispositiu.',
+ 'profile.themeAutoDesc': 'Seguir sistema',
+ 'profile.themeLightDesc': 'Sempre clar',
+ 'profile.themeDarkDesc': 'Sempre fosc',
+ 'profile.language': 'Idioma',
+ 'profile.languageTitle': 'Idioma de l\'aplicació',
+ 'profile.languageCatalan': 'Català',
+ 'profile.languageSpanish': 'Castellà',
+
+ // SavedNotifications
+ 'savedNotifications.title': '🔔 Notificacions Desades',
+ 'savedNotifications.close': 'Tancar',
+ 'savedNotifications.loading': 'Carregant…',
+ 'savedNotifications.empty': 'Encara no hi ha notificacions desades. Toqueu la campana 🔕 a una farmàcia sense estoc per rebre notificacions quan es reposi.',
+ 'savedNotifications.anyPharmacy': 'Qualsevol farmàcia',
+ 'savedNotifications.delete': 'Eliminar notificació',
+ 'savedNotifications.loadError': 'No s\'han pogut carregar les notificacions desades',
+ 'savedNotifications.deleteError': 'No s\'ha pogut eliminar la notificació',
+
+ // ErrorBoundary
+ 'error.title': 'Alguna cosa ha fallat',
+ 'error.description': 'Ha ocorregut un error inesperat. Si us plau, recarregueu la pàgina.',
+ 'error.reload': 'Recarregar',
+
+ // AdminView
+ 'admin.title': '⚙️ Panell d\'Administració',
+ 'admin.authRequired': 'Autenticació requerida',
+ 'admin.managePharmacies': 'Gestioneu farmàcies i medicaments',
+ 'admin.logout': 'Tancar sessió',
+ 'admin.checkingAuth': 'Comprovant autenticació...',
+ 'admin.pharmacies': '🏥 Farmàcies',
+ 'admin.medicines': '💊 Medicaments',
+ 'admin.linkMedicine': '🔗 Vincular Medicament a Farmàcia',
+ 'admin.linkProduct': '🍎 Vincular Producte a Farmàcia',
+
+ // LoginForm
+ 'admin.login.title': '🔐 Accés Administració',
+ 'admin.login.description': 'Introduïu les vostres credencials per accedir al panell d\'administració',
+ 'admin.login.username': 'Usuari',
+ 'admin.login.usernamePlaceholder': 'Introduïu usuari',
+ 'admin.login.password': 'Contrasenya',
+ 'admin.login.passwordPlaceholder': 'Introduïu contrasenya',
+ 'admin.login.loggingIn': 'Iniciant sessió...',
+ 'admin.login.loginBtn': 'Iniciar sessió',
+ 'admin.login.defaultCredentials': 'Credencials per defecte:',
+ 'admin.login.changePasswordWarning': '⚠️ Canvieu la contrasenya per defecte després del primer inici de sessió!',
+ 'admin.login.failed': 'Error d\'inici de sessió',
+ 'admin.login.invalidCredentials': 'Usuari o contrasenya invàlids',
+
+ // PharmacyManagement
+ 'admin.pharmacy.title': 'Gestionar Farmàcies',
+ 'admin.pharmacy.addNew': '+ Afegir Nova Farmàcia',
+ 'admin.pharmacy.edit': 'Editar Farmàcia',
+ 'admin.pharmacy.add': 'Afegir Nova Farmàcia',
+ 'admin.pharmacy.name': 'Nom *',
+ 'admin.pharmacy.address': 'Adreça *',
+ 'admin.pharmacy.phone': 'Telèfon',
+ 'admin.pharmacy.latitude': 'Latitud',
+ 'admin.pharmacy.longitude': 'Longitud',
+ 'admin.pharmacy.openingHours': 'Horari d\'obertura',
+ 'admin.pharmacy.closed': 'Tancat',
+ 'admin.pharmacy.opensAt': 'obre a les',
+ 'admin.pharmacy.closesAt': 'tanca a les',
+ 'admin.pharmacy.saving': 'Desant...',
+ 'admin.pharmacy.update': 'Actualitzar',
+ 'admin.pharmacy.create': 'Afegir',
+ 'admin.pharmacy.cancel': 'Cancel·lar',
+ 'admin.pharmacy.loading': 'Carregant farmàcies...',
+ 'admin.pharmacy.showing': 'Mostrant',
+ 'admin.pharmacy.of': 'de',
+ 'admin.pharmacy.withinRadio': '(dins del radi)',
+ 'admin.pharmacy.empty': 'Encara no hi ha farmàcies. Importeu des de webhook o afegiu-ne una manualment.',
+ 'admin.pharmacy.noResults': 'No hi ha farmàcies en aquest radi amb coordenades. Amplieu el radi, cerqueu una altra ciutat o desactiveu el filtre de regió.',
+ 'admin.pharmacy.editBtn': 'Editar',
+ 'admin.pharmacy.deleteBtn': 'Eliminar',
+ 'admin.pharmacy.deleteConfirm': 'Esteu segur que voleu eliminar aquesta farmàcia?',
+ 'admin.pharmacy.updated': 'Farmàcia actualitzada!',
+ 'admin.pharmacy.created': 'Farmàcia afegida!',
+ 'admin.pharmacy.deleted': 'Farmàcia eliminada!',
+ 'admin.pharmacy.loadError': 'Error en carregar farmàcies',
+ 'admin.pharmacy.updateError': 'Error en actualitzar farmàcia',
+ 'admin.pharmacy.createError': 'Error en crear farmàcia',
+ 'admin.pharmacy.deleteError': 'Error en eliminar farmàcia',
+ 'admin.pharmacy.citySearch': 'Ciutat, regió i importació',
+ 'admin.pharmacy.searchCity': 'Cercar ciutat',
+ 'admin.pharmacy.chooseOne': 'Trieu-ne una',
+ 'admin.pharmacy.openDataUrl': 'URL de dades obertes',
+ 'admin.pharmacy.cityPlaceholder': 'Ex: Rubí, Madrid, València…',
+ 'admin.pharmacy.searching': 'Cercant…',
+ 'admin.pharmacy.areaPreset': 'Preset d\'àrea',
+ 'admin.pharmacy.customCoordinates': 'Coordenades personalitzades',
+ 'admin.pharmacy.areaExample': 'Exemple: Àrea de Rubí (1,5 km)',
+ 'admin.pharmacy.enterCity': 'Introduïu una ciutat o lloc.',
+ 'admin.pharmacy.setCoordinates': 'Establiu latitud, longitud i radi (useu Cercar ciutat o un preset).',
+ 'admin.pharmacy.dataSource': 'Font de dades',
+ 'admin.pharmacy.webhookLegacy': 'n8n webhook (heretat)',
+ 'admin.pharmacy.openDataJson': 'URL de dades obertes JSON',
+ 'admin.pharmacy.jsonUrl': 'URL JSON',
+ 'admin.pharmacy.importing': 'Important…',
+ 'admin.pharmacy.importWebhook': 'Importar des de webhook',
+ 'admin.pharmacy.importUrl': 'Importar des d\'URL',
+ 'admin.pharmacy.importFrom': 'Importar des de',
+ 'admin.pharmacy.showWithinRadio': 'Mostrar només farmàcies dins del radi',
+ 'admin.pharmacy.sessionExpired': 'Sessió expirada o no heu iniciat sessió. Inicieu sessió de nou al panell Admin i torneu-ho a provar.',
+ 'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.',
+ 'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.',
+ 'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP',
+ 'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.',
+ 'admin.pharmacy.saveError': 'Error en desar farmàcia',
+ 'admin.pharmacy.radius': 'Radi (m)',
+
+ // MedicineManagement
+ 'admin.medicine.title': 'Cercar Medicaments (API CIMA)',
+ 'admin.medicine.description': 'Els medicaments s\'obtenen directament de la',
+ 'admin.medicine.cimaApi': 'API de CIMA',
+ 'admin.medicine.cimaDescription': '(Agència Espanyola de Medicaments i Productes Sanitaris).',
+ 'admin.medicine.linkDescription': 'Cerqueu medicaments per vincular-los a farmàcies a la pestanya "Link Medicine".',
+ 'admin.medicine.search': 'Cercar medicaments',
+ 'admin.medicine.searchPlaceholder': 'Escriviu el nom d\'un medicament...',
+ 'admin.medicine.searching': 'Cercant a API CIMA...',
+ 'admin.medicine.found': 'S\'han trobat',
+ 'admin.medicine.medications': 'medicaments',
+ 'admin.medicine.activeIngredient': 'Principi Actiu:',
+ 'admin.medicine.dosage': 'Dosificació:',
+ 'admin.medicine.form': 'Forma:',
+ 'admin.medicine.laboratory': 'Laboratori:',
+ 'admin.medicine.registrationNumber': 'Nº Registre:',
+ 'admin.medicine.generic': 'Genèric',
+ 'admin.medicine.brand': 'Marca',
+ 'admin.medicine.noResults': 'No s\'han trobat medicaments amb aquest nom.',
+ 'admin.medicine.loadError': 'Error en cercar medicaments a l\'API CIMA',
+
+ // PharmacyMedicineLink
+ 'admin.linkMedicine.title': 'Vincular Medicament a Farmàcia',
+ 'admin.linkMedicine.pharmacy': 'Farmàcia *',
+ 'admin.linkMedicine.searchPharmacy': 'Cercar entre',
+ 'admin.linkMedicine.pharmacies': 'farmàcies per nom o adreça…',
+ 'admin.linkMedicine.loadingPharmacies': 'Carregant farmàcies…',
+ 'admin.linkMedicine.noPharmacies': 'No hi ha farmàcies que coincideixin amb',
+ 'admin.linkMedicine.changePharmacy': 'Canviar farmàcia',
+ 'admin.linkMedicine.medicine': 'Cercar Medicament (API CIMA) *',
+ 'admin.linkMedicine.searchMedicine': 'Escriviu per cercar medicaments a CIMA...',
+ 'admin.linkMedicine.searching': 'Cercant...',
+ 'admin.linkMedicine.activeIngredient': 'Principi actiu:',
+ 'admin.linkMedicine.dosage': 'Dosificació:',
+ 'admin.linkMedicine.registrationNumber': 'Nº Registre:',
+ 'admin.linkMedicine.changeMedicine': 'Canviar medicament',
+ 'admin.linkMedicine.price': 'Preu (€)',
+ 'admin.linkMedicine.stock': 'Estoc',
+ 'admin.linkMedicine.linkBtn': 'Vincular Medicament',
+ 'admin.linkMedicine.reset': 'Restablir',
+ 'admin.linkMedicine.medicationsIn': 'Medicaments a',
+ 'admin.linkMedicine.loading': 'Carregant...',
+ 'admin.linkMedicine.empty': 'Encara no hi ha medicaments vinculats a aquesta farmàcia.',
+ 'admin.linkMedicine.priceLabel': 'Preu:',
+ 'admin.linkMedicine.undefined': 'No definit',
+ 'admin.linkMedicine.newPrice': 'Introduïu nou preu:',
+ 'admin.linkMedicine.newStock': 'Introduïu nou estoc:',
+ 'admin.linkMedicine.update': 'Actualitzar',
+ 'admin.linkMedicine.delete': 'Eliminar',
+ 'admin.linkMedicine.selectMedicineFirst': 'Si us plau, seleccioneu un medicament primer',
+ 'admin.linkMedicine.linkSuccess': 'Medicament vinculat a la farmàcia correctament!',
+ 'admin.linkMedicine.linkError': 'Error en vincular medicament a farmàcia',
+ 'admin.linkMedicine.updateSuccess': 'Actualitzat correctament!',
+ 'admin.linkMedicine.updateError': 'Error en actualitzar',
+ 'admin.linkMedicine.deleteConfirm': 'Eliminar aquest medicament de la farmàcia?',
+ 'admin.linkMedicine.deleteSuccess': 'Medicament eliminat de la farmàcia!',
+ 'admin.linkMedicine.deleteError': 'Error en eliminar medicament',
+
+ // PharmacyProductLink
+ 'admin.linkProduct.title': 'Vincular Producte a Farmàcia',
+ 'admin.linkProduct.pharmacy': 'Farmàcia *',
+ 'admin.linkProduct.searchPharmacy': 'Cercar entre',
+ 'admin.linkProduct.pharmacies': 'farmàcies per nom o adreça…',
+ 'admin.linkProduct.loadingPharmacies': 'Carregant farmàcies…',
+ 'admin.linkProduct.noPharmacies': 'No hi ha farmàcies que coincideixin amb',
+ 'admin.linkProduct.changePharmacy': 'Canviar farmàcia',
+ 'admin.linkProduct.product': 'Cercar Producte (CIMA / Parafarmàcia) *',
+ 'admin.linkProduct.searchProduct': 'Escriviu per cercar medicaments o productes de parafarmàcia...',
+ 'admin.linkProduct.searching': 'Cercant...',
+ 'admin.linkProduct.brand': 'Marca:',
+ 'admin.linkProduct.price': 'Preu:',
+ 'admin.linkProduct.changeProduct': 'Canviar producte',
+ 'admin.linkProduct.priceInput': 'Preu (€)',
+ 'admin.linkProduct.stock': 'Estoc',
+ 'admin.linkProduct.linkBtn': 'Vincular Producte',
+ 'admin.linkProduct.reset': 'Restablir',
+ 'admin.linkProduct.productsIn': 'Productes a',
+ 'admin.linkProduct.loading': 'Carregant...',
+ 'admin.linkProduct.empty': 'Encara no hi ha productes vinculats a aquesta farmàcia.',
+ 'admin.linkProduct.priceLabel': 'Preu:',
+ 'admin.linkProduct.undefined': 'No definit',
+ 'admin.linkProduct.newPrice': 'Introduïu nou preu:',
+ 'admin.linkProduct.newStock': 'Introduïu nou estoc:',
+ 'admin.linkProduct.update': 'Actualitzar',
+ 'admin.linkProduct.delete': 'Eliminar',
+ 'admin.linkProduct.selectProductFirst': 'Si us plau, seleccioneu un producte primer',
+ 'admin.linkProduct.linkSuccess': 'Producte vinculat a la farmàcia correctament!',
+ 'admin.linkProduct.linkError': 'Error en vincular producte a farmàcia',
+ 'admin.linkProduct.updateSuccess': 'Actualitzat correctament!',
+ 'admin.linkProduct.updateError': 'Error en actualitzar',
+ 'admin.linkProduct.deleteConfirm': 'Eliminar aquest producte de la farmàcia?',
+ 'admin.linkProduct.deleteSuccess': 'Producte eliminat de la farmàcia!',
+ 'admin.linkProduct.deleteError': 'Error en eliminar producte',
+};
+
+export default ca;
diff --git a/apps/frontend/src/i18n/locales/es.js b/apps/frontend/src/i18n/locales/es.js
new file mode 100644
index 0000000..0015425
--- /dev/null
+++ b/apps/frontend/src/i18n/locales/es.js
@@ -0,0 +1,416 @@
+const es = {
+ // BottomNav
+ 'nav.home': 'Inicio',
+ 'nav.search': 'Buscar',
+ 'nav.scan': 'Escanear',
+ 'nav.alerts': 'Avisos',
+ 'nav.profile': 'Usuario',
+
+ // HomeView
+ 'home.description': 'Encuentra tus medicamentos en farmacias cercanas',
+ 'home.searchMedicine': 'Buscar Medicamento',
+ 'home.scanTSI': 'Escanear TSI',
+
+ // SearchBar
+ 'search.placeholder': 'Escriba el nombre del medicamento',
+ 'search.clear': 'Limpiar búsqueda',
+
+ // SearchView
+ 'search.suggestions': 'Sugerencias',
+ 'search.recentResults': 'Resultados Recientes',
+ 'search.findNearby': 'Encontrar cerca',
+ 'search.resultsFound': 'resultados encontrados',
+ 'search.parapharmacy': 'Parafarmacia',
+ 'search.activeIngredient': 'Ingrediente Activo',
+ 'search.dosage': 'Dosis',
+ 'search.form': 'Forma',
+ 'search.backToSearch': '← Volver a búsqueda',
+ 'search.sortByDistance': '📍 Ordenar por distancia',
+ 'search.sortBySavedLocation': '📍 Ordenar por ubicación guardada',
+ 'search.sortedByDistance': '📍 Ordenado por distancia · Reset',
+ 'search.locating': '📍 Localizando…',
+ 'search.usingSavedAddress': 'Usando tu dirección guardada',
+ 'search.usingRecentLocation': 'Usando ubicación reciente',
+ 'search.retry': 'Reintentar',
+ 'search.locationDenied': 'Permiso de ubicación denegado. Permite el acceso a la ubicación en tu navegador.',
+ 'search.locationUnavailable': 'Ubicación no disponible. Verifica que el GPS esté activado.',
+ 'search.locationTimeout': 'La ubicación tardó demasiado. Intenta de nuevo o verifica tu conexión.',
+ 'search.locationError': 'No se pudo obtener tu ubicación',
+ 'search.searching': 'Buscando...',
+
+ // MedicineResults
+ 'medicine.noResults': 'No se encontraron medicamentos para',
+ 'medicine.principioActivo': 'Principio Activo:',
+ 'medicine.dosis': 'Dosis:',
+ 'medicine.forma': 'Forma:',
+ 'medicine.viewPharmacies': 'Ver farmacias →',
+ 'medicine.loginForNotifications': 'Inicia sesión para activar notificaciones',
+ 'medicine.disableNotifications': 'Desactivar notificaciones para este medicamento',
+ 'medicine.enableNotifications': 'Notificarme cuando esté disponible',
+ 'medicine.notificationsActivated': 'Notificaciones activadas — clic para desactivar',
+ 'medicine.notifyWhenAvailable': 'Notificarme cuando este medicamento esté en una farmacia',
+ 'medicine.subscriptionError': 'No se pudo actualizar la suscripción',
+
+ // PharmacyList
+ 'pharmacy.loading': 'Cargando farmacias...',
+ 'pharmacy.notFound': 'No se encontraron farmacias con este medicamento',
+ 'pharmacy.availableAt': 'Disponible en',
+ 'pharmacy.pharmacy': 'farmacia',
+ 'pharmacy.pharmacies': 'farmacias',
+ 'pharmacy.howToGet': 'Cómo llegar',
+ 'pharmacy.inStock': '✓ En Stock',
+ 'pharmacy.lowStock': '⚠ Stock Bajo',
+ 'pharmacy.outOfStock': '✗ Sin Stock',
+ 'pharmacy.loginForNotifications': 'Inicia sesión para activar notificaciones',
+ 'pharmacy.disableNotificationsPharmacy': 'Desactivar notificaciones para esta farmacia',
+ 'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia',
+ 'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar',
+ 'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).',
+
+ // ProductResults
+ 'product.sinReceta': 'Sin Receta',
+ 'product.parapharmacy': 'Parafarmacia',
+ 'product.dermocosmetica': 'Dermocosmética',
+ 'product.formulasLacteas': 'Fórmulas lácteas',
+ 'product.vitamins': 'Vitaminas',
+ 'product.analgesics': 'Analgésicos',
+ 'product.noResults': 'No se encontraron productos',
+ 'product.brand': 'Marca:',
+ 'product.price': 'Precio:',
+ 'product.activeIngredient': 'Principio Activo:',
+ 'product.dosage': 'Dosis:',
+ 'product.viewDetails': 'Ver detalles →',
+
+ // LoginModal
+ 'login.login': 'Iniciar sesión',
+ 'login.register': 'Crear cuenta',
+ 'login.welcomeBack': 'Bienvenido de nuevo',
+ 'login.createAccount': 'Crea tu cuenta',
+ 'login.registerDescription': 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.',
+ 'login.loginDescription': 'Inicia sesión para gestionar tu perfil y notificaciones.',
+ 'login.username': 'Usuario',
+ 'login.usernameHint': '3–32 caracteres: letras, dígitos o guión bajo.',
+ 'login.password': 'Contraseña',
+ 'login.passwordHint': 'Al menos 8 caracteres.',
+ 'login.cancel': 'Cancelar',
+ 'login.creating': 'Creando…',
+ 'login.loggingIn': 'Iniciando sesión…',
+ 'login.createAccountBtn': 'Crear cuenta',
+ 'login.loginBtn': 'Iniciar sesión',
+ 'login.passwordError': 'La contraseña debe tener al menos 8 caracteres',
+ 'login.registerError': 'No se pudo crear la cuenta',
+ 'login.loginError': 'Inicio de sesión fallido',
+ 'login.networkError': 'Error de red — inténtalo de nuevo',
+
+ // AlertsView
+ 'alerts.title': 'Mis Avisos',
+ 'alerts.subtitle': 'Mantente al día con tus medicamentos.',
+ 'alerts.loading': 'Cargando avisos...',
+ 'alerts.loginRequired': 'Por favor, inicia sesión para ver tus avisos.',
+ 'alerts.loadError': 'No se pudieron cargar los avisos.',
+ 'alerts.availability': 'Disponibilidad',
+ 'alerts.noSubscriptions': 'No tienes suscripciones de disponibilidad.',
+ 'alerts.notifyWhenAvailable': 'Notificarme cuando esté disponible',
+ 'alerts.deleteError': 'No se pudo eliminar la alerta de disponibilidad.',
+ 'alerts.pharmacy': 'Farmacia',
+
+ // ScannerView
+ 'scanner.title': 'Escanear TSI',
+ 'scanner.description': 'Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas',
+ 'scanner.openCamera': 'Abrir cámara',
+ 'scanner.orUploadPhoto': 'O sube una foto de tu TSI',
+ 'scanner.takePhoto': 'Hacer foto',
+ 'scanner.uploadFromGallery': 'Subir de galería',
+ 'scanner.scanImage': 'Escanear imagen',
+ 'scanner.discard': 'Descartar',
+ 'scanner.enterCIP': 'O introduce el código CIP manualmente',
+ 'scanner.cipPlaceholder': 'Código CIP de 16 dígitos',
+ 'scanner.search': 'Buscar',
+ 'scanner.scanning': 'Apunta al código de barras',
+ 'scanner.openingCamera': 'Abriendo cámara…',
+ 'scanner.processingImage': 'Procesando imagen…',
+ 'scanner.tsiScanned': 'TSI Escaneada',
+ 'scanner.activePrescriptions': 'Recetas Activas',
+ 'scanner.tapToFind': 'Toca un medicamento para ver disponibilidad en farmacias cercanas.',
+ 'scanner.loadingPrescriptions': 'Cargando recetas…',
+ 'scanner.noPrescriptions': 'No se encontraron recetas activas para esta tarjeta.',
+ 'scanner.scanAnother': 'Escanear otra tarjeta',
+ 'scanner.tryAgain': 'Intentar de nuevo',
+ 'scanner.scannerUnavailable': 'El escáner no está disponible en este dispositivo.',
+ 'scanner.cameraPermissionDenied': 'Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.',
+ 'scanner.invalidBarcode': 'Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.',
+ 'scanner.cameraNotAvailable': 'Cámara no disponible en este navegador.',
+ 'scanner.invalidCIP': 'Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.',
+ 'scanner.enterCIPError': 'Introduce un código CIP.',
+ 'scanner.cameraPermissionWeb': 'Permiso de cámara denegado. Permite el acceso e intenta de nuevo.',
+ 'scanner.noCamera': 'No se detectó ninguna cámara. Introduce el código CIP manualmente.',
+ 'scanner.imageReadError': 'No se pudo leer la imagen. Intenta con otra foto.',
+ 'scanner.invalidCIPDetected': 'CIP detectado no tiene formato válido. Introduce el código manualmente.',
+ 'scanner.scanError': 'Error al escanear:',
+ 'scanner.unknownError': 'Error desconocido',
+ 'scanner.cameraError': 'Error de cámara:',
+ 'scanner.imageProcessError': 'Error al procesar la imagen:',
+
+ // ProfileView
+ 'profile.name': 'Nombre',
+ 'profile.lastName': 'Apellidos',
+ 'profile.config': 'Configuración',
+ 'profile.myAddresses': 'Mis Direcciones',
+ 'profile.theme': 'Tema',
+ 'profile.themeAuto': 'Automático',
+ 'profile.themeLight': 'Claro',
+ 'profile.themeDark': 'Oscuro',
+ 'profile.admin': 'Panel de Administración',
+ 'profile.logout': 'Cerrar Sesión',
+ 'profile.uploadingPhoto': 'Subiendo foto...',
+ 'profile.recentSearches': 'Tus búsquedas recientes:',
+ 'profile.delete': 'Eliminar',
+ 'profile.changeAvatar': 'Cambiar Avatar',
+ 'profile.presetAvatar': 'Prediseñado',
+ 'profile.colors': 'Colores',
+ 'profile.upload': 'Subir',
+ 'profile.chooseFromGallery': 'Elegir de galería',
+ 'profile.selectExistingImage': 'Selecciona una imagen existente',
+ 'profile.imageTooBig': 'La imagen no puede superar los 5 MB',
+ 'profile.imageSaveError': 'Error al guardar la imagen. Intenta con otra foto.',
+ 'profile.imageConnectionError': 'Error de conexión al guardar la imagen.',
+ 'profile.imageProcessError': 'Error al procesar la imagen.',
+ 'profile.configTitle': 'Configuración',
+ 'profile.firstName': 'Nombre',
+ 'profile.firstNamePlaceholder': 'Tu nombre',
+ 'profile.lastNamePlaceholder': 'Tus apellidos',
+ 'profile.email': 'Correo electrónico',
+ 'profile.emailPlaceholder': 'tu@email.com',
+ 'profile.city': 'Ciudad',
+ 'profile.cityPlaceholder': 'Tu ciudad',
+ 'profile.address': 'Dirección',
+ 'profile.addressPlaceholder': 'Calle Mayor 1, Madrid',
+ 'profile.cancel': 'Cancelar',
+ 'profile.saving': 'Guardando...',
+ 'profile.save': 'Guardar',
+ 'profile.profileSaved': 'Perfil guardado.',
+ 'profile.saveError': 'Error al guardar',
+ 'profile.addressesTitle': 'Mis Direcciones',
+ 'profile.addressLabel': 'Dirección',
+ 'profile.addressOptional': 'Etiqueta (opcional)',
+ 'profile.addressLabelPlaceholder': 'Ej: Casa, Trabajo, Segunda residencia',
+ 'profile.defaultAddress': 'Dirección predeterminada',
+ 'profile.add': 'Añadir',
+ 'profile.update': 'Actualizar',
+ 'profile.loadingAddresses': 'Cargando direcciones...',
+ 'profile.mainAddress': 'Dirección principal',
+ 'profile.default': 'Predeterminada',
+ 'profile.setDefault': 'Establecer como predeterminada',
+ 'profile.addMore': 'Añadir más',
+ 'profile.addressRequired': 'La dirección es obligatoria',
+ 'profile.addressSaveError': 'Error al guardar la dirección',
+ 'profile.themeTitle': 'Tema de visualización',
+ 'profile.themeDescription': 'Elige cómo se ve la aplicación. En modo automático, se adapta al tema de tu dispositivo.',
+ 'profile.themeAutoDesc': 'Seguir sistema',
+ 'profile.themeLightDesc': 'Siempre claro',
+ 'profile.themeDarkDesc': 'Siempre oscuro',
+ 'profile.language': 'Idioma',
+ 'profile.languageTitle': 'Idioma de la aplicación',
+ 'profile.languageCatalan': 'Català',
+ 'profile.languageSpanish': 'Castellano',
+
+ // SavedNotifications
+ 'savedNotifications.title': '🔔 Notificaciones Guardadas',
+ 'savedNotifications.close': 'Cerrar',
+ 'savedNotifications.loading': 'Cargando…',
+ 'savedNotifications.empty': 'Aún no hay notificaciones guardadas. Toca la campana 🔕 en una farmacia sin stock para recibir notificaciones cuando se reponga.',
+ 'savedNotifications.anyPharmacy': 'Cualquier farmacia',
+ 'savedNotifications.delete': 'Eliminar notificación',
+ 'savedNotifications.loadError': 'No se pudieron cargar las notificaciones guardadas',
+ 'savedNotifications.deleteError': 'No se pudo eliminar la notificación',
+
+ // ErrorBoundary
+ 'error.title': 'Algo salió mal',
+ 'error.description': 'Ha ocurrido un error inesperado. Por favor, recarga la página.',
+ 'error.reload': 'Recargar',
+
+ // AdminView
+ 'admin.title': '⚙️ Panel de Administración',
+ 'admin.authRequired': 'Autenticación requerida',
+ 'admin.managePharmacies': 'Gestiona farmacias y medicamentos',
+ 'admin.logout': 'Cerrar sesión',
+ 'admin.checkingAuth': 'Comprobando autenticación...',
+ 'admin.pharmacies': '🏥 Farmacias',
+ 'admin.medicines': '💊 Medicamentos',
+ 'admin.linkMedicine': '🔗 Vincular Medicamento a Farmacia',
+ 'admin.linkProduct': '🍎 Vincular Producto a Farmacia',
+
+ // LoginForm
+ 'admin.login.title': '🔐 Acceso Administración',
+ 'admin.login.description': 'Introduce tus credenciales para acceder al panel de administración',
+ 'admin.login.username': 'Usuario',
+ 'admin.login.usernamePlaceholder': 'Introduce usuario',
+ 'admin.login.password': 'Contraseña',
+ 'admin.login.passwordPlaceholder': 'Introduce contraseña',
+ 'admin.login.loggingIn': 'Iniciando sesión...',
+ 'admin.login.loginBtn': 'Iniciar sesión',
+ 'admin.login.defaultCredentials': 'Credenciales por defecto:',
+ 'admin.login.changePasswordWarning': '⚠️ ¡Cambia la contraseña por defecto tras el primer inicio de sesión!',
+ 'admin.login.failed': 'Inicio de sesión fallido',
+ 'admin.login.invalidCredentials': 'Usuario o contraseña inválidos',
+
+ // PharmacyManagement
+ 'admin.pharmacy.title': 'Gestionar Farmacias',
+ 'admin.pharmacy.addNew': '+ Añadir Nueva Farmacia',
+ 'admin.pharmacy.edit': 'Editar Farmacia',
+ 'admin.pharmacy.add': 'Añadir Nueva Farmacia',
+ 'admin.pharmacy.name': 'Nombre *',
+ 'admin.pharmacy.address': 'Dirección *',
+ 'admin.pharmacy.phone': 'Teléfono',
+ 'admin.pharmacy.latitude': 'Latitud',
+ 'admin.pharmacy.longitude': 'Longitud',
+ 'admin.pharmacy.radius': 'Radio',
+ 'admin.pharmacy.openingHours': 'Horario de apertura',
+ 'admin.pharmacy.closed': 'Cerrado',
+ 'admin.pharmacy.opensAt': 'abre a las',
+ 'admin.pharmacy.closesAt': 'cierra a las',
+ 'admin.pharmacy.saving': 'Guardando...',
+ 'admin.pharmacy.update': 'Actualizar',
+ 'admin.pharmacy.create': 'Añadir',
+ 'admin.pharmacy.cancel': 'Cancelar',
+ 'admin.pharmacy.loading': 'Cargando farmacias...',
+ 'admin.pharmacy.showing': 'Mostrando',
+ 'admin.pharmacy.of': 'de',
+ 'admin.pharmacy.withinRadio': '(dentro del radio)',
+ 'admin.pharmacy.empty': 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.',
+ 'admin.pharmacy.noResults': 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.',
+ 'admin.pharmacy.editBtn': 'Editar',
+ 'admin.pharmacy.deleteBtn': 'Eliminar',
+ 'admin.pharmacy.deleteConfirm': '¿Estás seguro de que quieres eliminar esta farmacia?',
+ 'admin.pharmacy.updated': '¡Farmacia actualizada!',
+ 'admin.pharmacy.created': '¡Farmacia añadida!',
+ 'admin.pharmacy.deleted': '¡Farmacia eliminada!',
+ 'admin.pharmacy.loadError': 'Error al cargar farmacias',
+ 'admin.pharmacy.saveError': 'Error al guardar farmacia',
+ 'admin.pharmacy.updateError': 'Error al actualizar farmacia',
+ 'admin.pharmacy.createError': 'Error al crear farmacia',
+ 'admin.pharmacy.deleteError': 'Error al eliminar farmacia',
+ 'admin.pharmacy.citySearch': 'Ciudad, región e importación',
+ 'admin.pharmacy.searchCity': 'Buscar ciudad',
+ 'admin.pharmacy.chooseOne': 'Elije una',
+ 'admin.pharmacy.openDataUrl': 'URL de datos abiertos',
+ 'admin.pharmacy.cityPlaceholder': 'Ej: Rubí, Madrid, Valencia…',
+ 'admin.pharmacy.searching': 'Buscando…',
+ 'admin.pharmacy.areaPreset': 'Preset de área',
+ 'admin.pharmacy.customCoordinates': 'Coordenadas personalizadas',
+ 'admin.pharmacy.areaExample': 'Ejemplo: Área de Rubí (1.5 km)',
+ 'admin.pharmacy.enterCity': 'Introduce una ciudad o lugar.',
+ 'admin.pharmacy.setCoordinates': 'Establece latitud, longitud y radio (usa Buscar ciudad o un preset).',
+ 'admin.pharmacy.dataSource': 'Fuente de datos',
+ 'admin.pharmacy.webhookLegacy': 'n8n webhook (heredado)',
+ 'admin.pharmacy.openDataJson': 'URL de datos abiertos JSON',
+ 'admin.pharmacy.jsonUrl': 'URL JSON',
+ 'admin.pharmacy.importing': 'Importando…',
+ 'admin.pharmacy.importWebhook': 'Importar desde webhook',
+ 'admin.pharmacy.importUrl': 'Importar desde URL',
+ 'admin.pharmacy.importFrom': 'Importar desde',
+ 'admin.pharmacy.showWithinRadio': 'Mostrar solo farmacias dentro del radio',
+ 'admin.pharmacy.sessionExpired': 'Sesión expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.',
+ 'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.',
+ 'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.',
+ 'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP',
+ 'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.',
+ 'admin.pharmacy.saveError': 'Error al guardar farmacia',
+ 'admin.pharmacy.radius': 'Radio (m)',
+
+ // MedicineManagement
+ 'admin.medicine.title': 'Buscar Medicamentos (API CIMA)',
+ 'admin.medicine.description': 'Los medicamentos ahora se obtienen directamente de la',
+ 'admin.medicine.cimaApi': 'API de CIMA',
+ 'admin.medicine.cimaDescription': '(Agencia Española de Medicamentos y Productos Sanitarios).',
+ 'admin.medicine.linkDescription': 'Busca medicamentos para vincularlos a farmacias en la pestaña "Link Medicine".',
+ 'admin.medicine.search': 'Buscar medicamentos',
+ 'admin.medicine.searchPlaceholder': 'Escribe el nombre de un medicamento...',
+ 'admin.medicine.searching': 'Buscando en API CIMA...',
+ 'admin.medicine.found': 'Se encontraron',
+ 'admin.medicine.medications': 'medicamentos',
+ 'admin.medicine.activeIngredient': 'Principio Activo:',
+ 'admin.medicine.dosage': 'Dosis:',
+ 'admin.medicine.form': 'Forma:',
+ 'admin.medicine.laboratory': 'Laboratorio:',
+ 'admin.medicine.registrationNumber': 'Nº Registro:',
+ 'admin.medicine.generic': 'Genérico',
+ 'admin.medicine.brand': 'Marca',
+ 'admin.medicine.noResults': 'No se encontraron medicamentos con ese nombre.',
+ 'admin.medicine.loadError': 'Error al buscar medicamentos en la API CIMA',
+
+ // PharmacyMedicineLink
+ 'admin.linkMedicine.title': 'Vincular Medicamento a Farmacia',
+ 'admin.linkMedicine.pharmacy': 'Farmacia *',
+ 'admin.linkMedicine.searchPharmacy': 'Buscar entre',
+ 'admin.linkMedicine.pharmacies': 'farmacias por nombre o dirección…',
+ 'admin.linkMedicine.loadingPharmacies': 'Cargando farmacias…',
+ 'admin.linkMedicine.noPharmacies': 'No hay farmacias que coincidan con',
+ 'admin.linkMedicine.changePharmacy': 'Cambiar farmacia',
+ 'admin.linkMedicine.medicine': 'Buscar Medicamento (API CIMA) *',
+ 'admin.linkMedicine.searchMedicine': 'Escribe para buscar medicamentos en CIMA...',
+ 'admin.linkMedicine.searching': 'Buscando...',
+ 'admin.linkMedicine.activeIngredient': 'Principio activo:',
+ 'admin.linkMedicine.dosage': 'Dosis:',
+ 'admin.linkMedicine.registrationNumber': 'Nº Registro:',
+ 'admin.linkMedicine.changeMedicine': 'Cambiar medicamento',
+ 'admin.linkMedicine.price': 'Precio (€)',
+ 'admin.linkMedicine.stock': 'Stock',
+ 'admin.linkMedicine.linkBtn': 'Vincular Medicamento',
+ 'admin.linkMedicine.reset': 'Reiniciar',
+ 'admin.linkMedicine.medicationsIn': 'Medicamentos en',
+ 'admin.linkMedicine.loading': 'Cargando...',
+ 'admin.linkMedicine.empty': 'Aún no hay medicamentos vinculados a esta farmacia.',
+ 'admin.linkMedicine.priceLabel': 'Precio:',
+ 'admin.linkMedicine.undefined': 'No definido',
+ 'admin.linkMedicine.newPrice': 'Introduce nuevo precio:',
+ 'admin.linkMedicine.newStock': 'Introduce nuevo stock:',
+ 'admin.linkMedicine.update': 'Actualizar',
+ 'admin.linkMedicine.delete': 'Eliminar',
+ 'admin.linkMedicine.selectMedicineFirst': 'Por favor, selecciona un medicamento primero',
+ 'admin.linkMedicine.linkSuccess': '¡Medicamento vinculado a la farmacia correctamente!',
+ 'admin.linkMedicine.linkError': 'Error al vincular medicamento a farmacia',
+ 'admin.linkMedicine.updateSuccess': '¡Actualizado correctamente!',
+ 'admin.linkMedicine.updateError': 'Error al actualizar',
+ 'admin.linkMedicine.deleteConfirm': '¿Eliminar este medicamento de la farmacia?',
+ 'admin.linkMedicine.deleteSuccess': '¡Medicamento eliminado de la farmacia!',
+ 'admin.linkMedicine.deleteError': 'Error al eliminar medicamento',
+
+ // PharmacyProductLink
+ 'admin.linkProduct.title': 'Vincular Producto a Farmacia',
+ 'admin.linkProduct.pharmacy': 'Farmacia *',
+ 'admin.linkProduct.searchPharmacy': 'Buscar entre',
+ 'admin.linkProduct.pharmacies': 'farmacias por nombre o dirección…',
+ 'admin.linkProduct.loadingPharmacies': 'Cargando farmacias…',
+ 'admin.linkProduct.noPharmacies': 'No hay farmacias que coincidan con',
+ 'admin.linkProduct.changePharmacy': 'Cambiar farmacia',
+ 'admin.linkProduct.product': 'Buscar Producto (CIMA / Parafarmacia) *',
+ 'admin.linkProduct.searchProduct': 'Escribe para buscar medicamentos o productos de parafarmacia...',
+ 'admin.linkProduct.searching': 'Buscando...',
+ 'admin.linkProduct.brand': 'Marca:',
+ 'admin.linkProduct.price': 'Precio:',
+ 'admin.linkProduct.changeProduct': 'Cambiar producto',
+ 'admin.linkProduct.priceInput': 'Precio (€)',
+ 'admin.linkProduct.stock': 'Stock',
+ 'admin.linkProduct.linkBtn': 'Vincular Producto',
+ 'admin.linkProduct.reset': 'Reiniciar',
+ 'admin.linkProduct.productsIn': 'Productos en',
+ 'admin.linkProduct.loading': 'Cargando...',
+ 'admin.linkProduct.empty': 'Aún no hay productos vinculados a esta farmacia.',
+ 'admin.linkProduct.priceLabel': 'Precio:',
+ 'admin.linkProduct.undefined': 'No definido',
+ 'admin.linkProduct.newPrice': 'Introduce nuevo precio:',
+ 'admin.linkProduct.newStock': 'Introduce nuevo stock:',
+ 'admin.linkProduct.update': 'Actualizar',
+ 'admin.linkProduct.delete': 'Eliminar',
+ 'admin.linkProduct.selectProductFirst': 'Por favor, selecciona un producto primero',
+ 'admin.linkProduct.linkSuccess': '¡Producto vinculado a la farmacia correctamente!',
+ 'admin.linkProduct.linkError': 'Error al vincular producto a farmacia',
+ 'admin.linkProduct.updateSuccess': '¡Actualizado correctamente!',
+ 'admin.linkProduct.updateError': 'Error al actualizar',
+ 'admin.linkProduct.deleteConfirm': '¿Eliminar este producto de la farmacia?',
+ 'admin.linkProduct.deleteSuccess': '¡Producto eliminado de la farmacia!',
+ 'admin.linkProduct.deleteError': 'Error al eliminar producto',
+};
+
+export default es;
diff --git a/apps/frontend/src/i18n/useTranslation.js b/apps/frontend/src/i18n/useTranslation.js
new file mode 100644
index 0000000..c6533ef
--- /dev/null
+++ b/apps/frontend/src/i18n/useTranslation.js
@@ -0,0 +1,8 @@
+import { useContext } from 'react';
+import { LanguageContext } from './LanguageContext';
+
+export function useTranslation() {
+ const ctx = useContext(LanguageContext);
+ if (!ctx) throw new Error('useTranslation must be used within LanguageProvider');
+ return ctx;
+}
diff --git a/apps/frontend/src/main.jsx b/apps/frontend/src/main.jsx
index e0341e3..34bce58 100644
--- a/apps/frontend/src/main.jsx
+++ b/apps/frontend/src/main.jsx
@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
+import { LanguageProvider } from './i18n';
import './index.css';
import { initNativeShell } from './utils/native';
import { initFaro } from './utils/faro';
@@ -20,9 +21,11 @@ window.addEventListener('unhandledrejection', (event) => {
ReactDOM.createRoot(document.getElementById('root')).render(
-
-
-
+
+
+
+
+
);
diff --git a/apps/frontend/src/views/AdminView.jsx b/apps/frontend/src/views/AdminView.jsx
index 6b9f913..19c6783 100644
--- a/apps/frontend/src/views/AdminView.jsx
+++ b/apps/frontend/src/views/AdminView.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
+import { useTranslation } from '../i18n';
import '../App.css';
import './AdminView.css';
import LoginForm from '../components/admin/LoginForm';
@@ -8,6 +9,7 @@ import PharmacyMedicineLink from '../components/admin/PharmacyMedicineLink';
import PharmacyProductLink from '../components/admin/PharmacyProductLink';
function AdminView() {
+ const { t } = useTranslation();
const [authenticated, setAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
@@ -60,7 +62,7 @@ function AdminView() {
if (loading) {
return (
-
Comprobando autenticación...
+
{t('admin.checkingAuth')}
);
}
@@ -69,8 +71,8 @@ function AdminView() {
return (
<>
@@ -84,13 +86,13 @@ function AdminView() {
diff --git a/apps/frontend/src/views/AlertsView.jsx b/apps/frontend/src/views/AlertsView.jsx
index ebb7d5f..dca5fe7 100644
--- a/apps/frontend/src/views/AlertsView.jsx
+++ b/apps/frontend/src/views/AlertsView.jsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
+import { useTranslation } from '../i18n';
import './AlertsView.css';
const iconMap = {
@@ -15,6 +16,7 @@ const iconMap = {
};
function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
+ const { t } = useTranslation();
const [availability, setAvailability] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -26,7 +28,7 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
const notifsRes = await fetch('/api/notifications/mine', { credentials: 'include' });
if (notifsRes.status === 401) {
- setError('Por favor, inicia sesión para ver tus avisos.');
+ setError(t('alerts.loginRequired'));
setLoading(false);
return;
}
@@ -43,7 +45,7 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
setAvailability(mergedAvailability);
} catch (err) {
console.error('Error fetching availability notifications:', err);
- setError('No se pudieron cargar los avisos.');
+ setError(t('alerts.loadError'));
} finally {
setLoading(false);
}
@@ -87,7 +89,7 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
}
} catch (err) {
console.error(err);
- setError('No se pudo eliminar la alerta de disponibilidad.');
+ setError(t('alerts.deleteError'));
}
};
@@ -95,21 +97,21 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) {
@@ -407,7 +412,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them