From 38374341edaafad164544aac7ddc9b75610c982f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Fri, 17 Jul 2026 09:43:45 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(i18n):=20add=20bilingual=20support=20(?= =?UTF-8?q?Catal=C3=A0=20/=20Castellano)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/frontend/src/App.test.jsx | 17 +- apps/frontend/src/components/BottomNav.jsx | 12 +- .../frontend/src/components/ErrorBoundary.jsx | 10 +- apps/frontend/src/components/LoginModal.jsx | 32 +- .../src/components/MedicineResults.jsx | 27 +- apps/frontend/src/components/PharmacyList.jsx | 29 +- .../src/components/ProductResults.jsx | 29 +- .../src/components/SavedNotifications.jsx | 22 +- apps/frontend/src/components/SearchBar.jsx | 4 +- .../src/components/admin/LoginForm.jsx | 24 +- .../components/admin/MedicineManagement.jsx | 32 +- .../components/admin/PharmacyManagement.jsx | 141 +++--- .../components/admin/PharmacyMedicineLink.jsx | 72 +-- .../components/admin/PharmacyProductLink.jsx | 72 +-- apps/frontend/src/i18n/LanguageContext.jsx | 40 ++ apps/frontend/src/i18n/index.js | 2 + apps/frontend/src/i18n/locales/ca.js | 414 +++++++++++++++++ apps/frontend/src/i18n/locales/es.js | 416 ++++++++++++++++++ apps/frontend/src/i18n/useTranslation.js | 8 + apps/frontend/src/main.jsx | 9 +- apps/frontend/src/views/AdminView.jsx | 22 +- apps/frontend/src/views/AlertsView.jsx | 22 +- apps/frontend/src/views/HomeView.jsx | 8 +- apps/frontend/src/views/ProfileView.jsx | 175 +++++--- apps/frontend/src/views/ScannerView.jsx | 70 +-- apps/frontend/src/views/SearchView.jsx | 46 +- 26 files changed, 1371 insertions(+), 384 deletions(-) create mode 100644 apps/frontend/src/i18n/LanguageContext.jsx create mode 100644 apps/frontend/src/i18n/index.js create mode 100644 apps/frontend/src/i18n/locales/ca.js create mode 100644 apps/frontend/src/i18n/locales/es.js create mode 100644 apps/frontend/src/i18n/useTranslation.js diff --git a/apps/frontend/src/App.test.jsx b/apps/frontend/src/App.test.jsx index d2f2f90..d76c0b2 100644 --- a/apps/frontend/src/App.test.jsx +++ b/apps/frontend/src/App.test.jsx @@ -1,8 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, act } from '@testing-library/react' +import { LanguageProvider } from './i18n' import HomeView from './views/HomeView.jsx' import SearchView from './views/SearchView.jsx' +function Wrapper({ children }) { + return {children} +} + beforeEach(() => { vi.useFakeTimers() }) @@ -16,14 +21,14 @@ describe('HomeView', () => { it('renders two action buttons on the home screen', () => { const onSearch = vi.fn() const onScan = vi.fn() - render() + render(, { wrapper: Wrapper }) expect(screen.getByRole('button', { name: /buscar medicamento/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /escanear tsi/i })).toBeInTheDocument() }) it('calls onSearchClick when Buscar Medicamento is clicked', async () => { const onSearch = vi.fn() - render() + render(, { wrapper: Wrapper }) fireEvent.click(screen.getByRole('button', { name: /buscar medicamento/i })) expect(onSearch).toHaveBeenCalledTimes(1) }) @@ -31,13 +36,13 @@ describe('HomeView', () => { describe('SearchView', () => { it('renders search bar with placeholder', () => { - render() + render(, { wrapper: Wrapper }) expect(screen.getByPlaceholderText(/escriba el nombre del medicamento/i)).toBeInTheDocument() }) it('does not fetch for queries shorter than 2 chars', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') - render() + render(, { wrapper: Wrapper }) fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), { target: { value: 'a' }, @@ -59,7 +64,7 @@ describe('SearchView', () => { json: async () => medicines, }) - render() + render(, { wrapper: Wrapper }) fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), { target: { value: 'ibu' }, @@ -81,7 +86,7 @@ describe('SearchView', () => { json: async () => medicines, }) - render() + render(, { wrapper: Wrapper }) const input = screen.getByPlaceholderText(/escriba el nombre del medicamento/i) fireEvent.change(input, { target: { value: 'ibu' } }) diff --git a/apps/frontend/src/components/BottomNav.jsx b/apps/frontend/src/components/BottomNav.jsx index f586be2..7d172d5 100644 --- a/apps/frontend/src/components/BottomNav.jsx +++ b/apps/frontend/src/components/BottomNav.jsx @@ -1,13 +1,15 @@ import { IconHome, IconSearch, IconScan, IconBell, IconUser } from './icons'; +import { useTranslation } from '../i18n'; import './BottomNav.css'; function BottomNav({ activeTab, onChange, isLoggedIn, badgeCount }) { + const { t } = useTranslation(); const tabs = [ - { id: 'home', label: 'Inicio', Icon: IconHome }, - { id: 'search', label: 'Buscar', Icon: IconSearch }, - { id: 'scan', label: 'Escanear', Icon: IconScan, elevated: true }, - { id: 'alerts', label: 'Avisos', Icon: IconBell, badge: badgeCount > 0, badgeCount }, - { id: 'profile', label: 'Usuario', Icon: IconUser }, + { id: 'home', label: t('nav.home'), Icon: IconHome }, + { id: 'search', label: t('nav.search'), Icon: IconSearch }, + { id: 'scan', label: t('nav.scan'), Icon: IconScan, elevated: true }, + { id: 'alerts', label: t('nav.alerts'), Icon: IconBell, badge: badgeCount > 0, badgeCount }, + { id: 'profile', label: t('nav.profile'), Icon: IconUser }, ]; return ( diff --git a/apps/frontend/src/components/ErrorBoundary.jsx b/apps/frontend/src/components/ErrorBoundary.jsx index 97044b6..7affacd 100644 --- a/apps/frontend/src/components/ErrorBoundary.jsx +++ b/apps/frontend/src/components/ErrorBoundary.jsx @@ -1,6 +1,9 @@ import React from 'react'; +import { LanguageContext } from '../i18n/LanguageContext'; export default class ErrorBoundary extends React.Component { + static contextType = LanguageContext; + constructor(props) { super(props); this.state = { hasError: false, error: null }; @@ -25,6 +28,7 @@ export default class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { + const t = this.context?.t || ((k) => k); return (
-

Algo salió mal

-

Ha ocurrido un error inesperado. Por favor, recarga la página.

+

{t('error.title')}

+

{t('error.description')}

); diff --git a/apps/frontend/src/components/LoginModal.jsx b/apps/frontend/src/components/LoginModal.jsx index 6182a7a..8be5e12 100644 --- a/apps/frontend/src/components/LoginModal.jsx +++ b/apps/frontend/src/components/LoginModal.jsx @@ -1,7 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from '../i18n'; import './LoginModal.css'; function LoginModal({ onLogin, onClose, initialMode = 'login' }) { + const { t } = useTranslation(); const [mode, setMode] = useState(initialMode === 'register' ? 'register' : 'login'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -25,7 +27,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) { const u = username.trim(); if (!u || !password) return; if (mode === 'register' && password.length < 8) { - setError('La contraseña debe tener al menos 8 caracteres'); + setError(t('login.passwordError')); return; } setLoading(true); @@ -40,12 +42,12 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) { }); const data = await res.json().catch(() => ({})); if (!res.ok) { - setError(data.error || (mode === 'register' ? 'No se pudo crear la cuenta' : 'Inicio de sesión fallido')); + setError(data.error || (mode === 'register' ? t('login.registerError') : t('login.loginError'))); } else { onLogin(data.user); } } catch { - setError('Error de red — inténtalo de nuevo'); + setError(t('login.networkError')); } finally { setLoading(false); } @@ -71,7 +73,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) { onClick={() => setMode('login')} disabled={loading} > - Iniciar sesión + {t('login.login')} - +

{isRegister - ? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.' - : 'Inicia sesión para gestionar tu perfil y notificaciones.'} + ? t('login.registerDescription') + : t('login.loginDescription')}

- + {isRegister && ( -

3–32 caracteres: letras, dígitos o guión bajo.

+

{t('login.usernameHint')}

)}
- + {isRegister && ( -

Al menos 8 caracteres.

+

{t('login.passwordHint')}

)}
{error &&

{error}

} @@ -133,7 +135,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) { onClick={onClose} disabled={loading} > - Cancelar + {t('login.cancel')}
diff --git a/apps/frontend/src/components/MedicineResults.jsx b/apps/frontend/src/components/MedicineResults.jsx index 5c28627..b68cecf 100644 --- a/apps/frontend/src/components/MedicineResults.jsx +++ b/apps/frontend/src/components/MedicineResults.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation } from '../i18n'; import './MedicineResults.css'; import { pushSupported, @@ -8,10 +9,11 @@ import { } from '../utils/notifications.js'; function MedicineResults({ medicines, onSelect, query, currentUser, onLoginRequest }) { + const { t } = useTranslation(); if (medicines.length === 0 && query.length >= 2) { return (
-

No se encontraron medicamentos para "{query}"

+

{t('medicine.noResults')} "{query}"

); } @@ -32,6 +34,7 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque } function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) { + const { t } = useTranslation(); const nregistro = medicine.nregistro || medicine.id; const [subscribed, setSubscribed] = useState(() => isSubscribedLocally(nregistro)); const [busy, setBusy] = useState(false); @@ -49,7 +52,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) { return; } if (!supported) { - setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).'); + setError(t('pharmacy.notificationsRequired')); return; } if (busy) return; @@ -65,7 +68,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) { } } catch (err) { console.error('[notify] toggle failed:', err); - setError(err.message || 'No se pudo actualizar la suscripción'); + setError(err.message || t('medicine.subscriptionError')); } finally { setBusy(false); } @@ -83,29 +86,29 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) { aria-pressed={subscribed && !!currentUser} aria-label={ !currentUser - ? 'Inicia sesión para activar notificaciones' + ? t('medicine.loginForNotifications') : subscribed - ? 'Desactivar notificaciones para este medicamento' - : 'Notificarme cuando esté disponible' + ? t('medicine.disableNotifications') + : t('medicine.enableNotifications') } title={ !currentUser - ? 'Inicia sesión para activar notificaciones' + ? t('medicine.loginForNotifications') : subscribed - ? 'Notificaciones activadas — clic para desactivar' - : 'Notificarme cuando este medicamento esté en una farmacia' + ? t('medicine.notificationsActivated') + : t('medicine.notifyWhenAvailable') } > {subscribed && currentUser ? '🔔' : '🔕'}
-

Principio Activo: {medicine.active_ingredient}

-

Dosis: {medicine.dosage} • Forma: {medicine.form}

+

{t('medicine.principioActivo')} {medicine.active_ingredient}

+

{t('medicine.dosis')} {medicine.dosage} • {t('medicine.forma')} {medicine.form}

{error &&

e.stopPropagation()}>{error}

}
- Ver farmacias → + {t('medicine.viewPharmacies')}
); diff --git a/apps/frontend/src/components/PharmacyList.jsx b/apps/frontend/src/components/PharmacyList.jsx index 45a8a06..54c883e 100644 --- a/apps/frontend/src/components/PharmacyList.jsx +++ b/apps/frontend/src/components/PharmacyList.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useTranslation } from '../i18n'; import './PharmacyList.css'; import { haversineKm, formatDistance } from '../utils/geo'; import { getOpenStatus } from '../utils/hours'; @@ -10,10 +11,11 @@ import { } from '../utils/notifications.js'; function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser, onLoginRequest }) { + const { t } = useTranslation(); if (loading) { return (
-

Cargando farmacias...

+

{t('pharmacy.loading')}

); } @@ -21,7 +23,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser if (pharmacies.length === 0) { return (
-

No se encontraron farmacias con este medicamento

+

{t('pharmacy.notFound')}

); } @@ -29,7 +31,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser return (

- Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'} + {t('pharmacy.availableAt')} {pharmacies.length} {pharmacies.length === 1 ? t('pharmacy.pharmacy') : t('pharmacy.pharmacies')}

{pharmacies.map((pharmacy) => { @@ -55,6 +57,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser } function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequest, userPosition }) { + const { t } = useTranslation(); const nregistro = medicine?.nregistro || medicine?.id; const supported = pushSupported(); const outOfStock = pharmacy.stock !== undefined && pharmacy.stock <= 0; @@ -77,7 +80,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ return; } if (!supported) { - setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).'); + setError(t('pharmacy.notificationsRequired')); return; } if (busy) return; @@ -93,7 +96,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ } } catch (err) { console.error('[notify] pharmacy toggle failed:', err); - setError(err.message || 'No se pudo actualizar la suscripción'); + setError(err.message || t('medicine.subscriptionError')); } finally { setBusy(false); } @@ -118,17 +121,17 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ aria-pressed={subscribed && !!currentUser} aria-label={ !currentUser - ? 'Inicia sesión para activar notificaciones' + ? t('pharmacy.loginForNotifications') : subscribed - ? 'Desactivar notificaciones para esta farmacia' - : 'Notificarme cuando llegue a esta farmacia' + ? t('pharmacy.disableNotificationsPharmacy') + : t('pharmacy.notifyWhenArrives') } title={ !currentUser - ? 'Inicia sesión para activar notificaciones' + ? t('pharmacy.loginForNotifications') : subscribed - ? 'Notificaciones activadas para esta farmacia — clic para desactivar' - : 'Notificarme cuando llegue a esta farmacia' + ? t('pharmacy.notificationsActivatedPharmacy') + : t('pharmacy.notifyWhenArrives') } > {subscribed && currentUser ? '🔔' : '🔕'} @@ -161,7 +164,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ - Cómo llegar + {t('pharmacy.howToGet')} )}
@@ -170,7 +173,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ )} {pharmacy.stock !== undefined && ( 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}> - {pharmacy.stock > 20 ? '✓ En Stock' : pharmacy.stock > 0 ? `⚠ Stock Bajo (${pharmacy.stock})` : '✗ Sin Stock'} + {pharmacy.stock > 20 ? t('pharmacy.inStock') : pharmacy.stock > 0 ? `${t('pharmacy.lowStock')} (${pharmacy.stock})` : t('pharmacy.outOfStock')} )}
diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx index f1d98a0..694cf19 100644 --- a/apps/frontend/src/components/ProductResults.jsx +++ b/apps/frontend/src/components/ProductResults.jsx @@ -1,13 +1,14 @@ import React from 'react'; +import { useTranslation } from '../i18n'; import './ProductResults.css'; const categoryLabels = { - otc: 'Sin Receta', - parapharmacy: 'Parafarmacia', - dermocosmética: 'Dermocosmética', - 'Fórmulas lácteas': 'Fórmulas lácteas', - vitaminas: 'Vitaminas', - analgésicos: 'Analgésicos' + otc: 'product.sinReceta', + parapharmacy: 'product.parapharmacy', + dermocosmética: 'product.dermocosmetica', + 'Fórmulas lácteas': 'product.formulasLacteas', + vitaminas: 'product.vitamins', + analgésicos: 'product.analgesics' }; const sourceColors = { @@ -27,10 +28,11 @@ const sourceLabels = { }; function ProductResults({ products, onSelect }) { + const { t } = useTranslation(); if (!products || products.length === 0) { return (
-

No se encontraron productos

+

{t('product.noResults')}

); } @@ -49,6 +51,7 @@ function ProductResults({ products, onSelect }) { } function ProductCard({ product, onSelect }) { + const { t } = useTranslation(); const nutriScore = product.nutriscore; const nutriScoreColors = { a: '#16a34a', @@ -83,7 +86,7 @@ function ProductCard({ product, onSelect }) { {sourceLabels[product.source]} - {categoryLabels[product.category] || product.category} + {categoryLabels[product.category] ? t(categoryLabels[product.category]) : product.category} {product.source === 'openfoodfacts' && nutriScore && ( {product.brand && ( -

Marca: {product.brand}

+

{t('product.brand')} {product.brand}

)} {product.price != null && ( -

Precio: {product.price} €

+

{t('product.price')} {product.price} €

)} {product.source === 'cima' && product.active_ingredient && ( -

Principio Activo: {product.active_ingredient}

+

{t('product.activeIngredient')} {product.active_ingredient}

)} {product.source === 'cima' && product.dosage && ( -

Dosis: {product.dosage}

+

{t('product.dosage')} {product.dosage}

)}
- Ver detalles → + {t('product.viewDetails')}
diff --git a/apps/frontend/src/components/SavedNotifications.jsx b/apps/frontend/src/components/SavedNotifications.jsx index 9bd5882..f1bb385 100644 --- a/apps/frontend/src/components/SavedNotifications.jsx +++ b/apps/frontend/src/components/SavedNotifications.jsx @@ -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 }) {
e.stopPropagation()}>
-

🔔 Notificaciones Guardadas

- +

{t('savedNotifications.title')}

+
- {loading &&

Cargando…

} + {loading &&

{t('savedNotifications.loading')}

} {!loading && error &&

{error}

} {!loading && !error && items.length === 0 && (

- Aún no hay notificaciones guardadas. Toca la campana 🔕 en una farmacia sin stock para recibir notificaciones cuando se reponga. + {t('savedNotifications.empty')}

)} {!loading && !error && items.length > 0 && ( @@ -78,7 +80,7 @@ function SavedNotifications({ onClose, onNotificationChange }) { {item.scope === 'pharmacy' ? ( <> - 🏥 {item.pharmacy_name || `Farmacia #${item.pharmacy_id}`} + 🏥 {item.pharmacy_name || `${t('savedNotifications.anyPharmacy')} #${item.pharmacy_id}`} {item.pharmacy_address && ( {item.pharmacy_address} @@ -86,7 +88,7 @@ function SavedNotifications({ onClose, onNotificationChange }) { ) : ( - Cualquier farmacia + {t('savedNotifications.anyPharmacy')} )}
@@ -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')} ); diff --git a/apps/frontend/src/components/SearchBar.jsx b/apps/frontend/src/components/SearchBar.jsx index 051e937..df27562 100644 --- a/apps/frontend/src/components/SearchBar.jsx +++ b/apps/frontend/src/components/SearchBar.jsx @@ -1,7 +1,9 @@ import React from 'react'; +import { useTranslation } from '../i18n'; import './SearchBar.css'; function SearchBar({ value, onChange, placeholder }) { + const { t } = useTranslation(); return (
@@ -22,7 +24,7 @@ function SearchBar({ value, onChange, placeholder }) { diff --git a/apps/frontend/src/components/admin/LoginForm.jsx b/apps/frontend/src/components/admin/LoginForm.jsx index db937e3..5777a05 100644 --- a/apps/frontend/src/components/admin/LoginForm.jsx +++ b/apps/frontend/src/components/admin/LoginForm.jsx @@ -1,7 +1,9 @@ import React, { useState } from 'react'; +import { useTranslation } from '../../i18n'; import './LoginForm.css'; function LoginForm({ onLogin }) { + const { t } = useTranslation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); @@ -25,14 +27,14 @@ function LoginForm({ onLogin }) { const data = await response.json(); if (!response.ok) { - throw new Error(data.error || 'Inicio de sesión fallido'); + throw new Error(data.error || t('admin.login.failed')); } // Success - notify parent component onLogin(data.user); } catch (error) { console.error('Login error:', error); - setError(error.message || 'Usuario o contraseña inválidos'); + setError(error.message || t('admin.login.invalidCredentials')); } finally { setLoading(false); } @@ -42,8 +44,8 @@ function LoginForm({ onLogin }) {
-

🔐 Acceso Administración

-

Introduce tus credenciales para acceder al panel de administración

+

{t('admin.login.title')}

+

{t('admin.login.description')}

@@ -54,13 +56,13 @@ function LoginForm({ onLogin }) { )}
- + setUsername(e.target.value)} - placeholder="Introduce usuario" + placeholder={t('admin.login.usernamePlaceholder')} required autoFocus disabled={loading} @@ -68,13 +70,13 @@ function LoginForm({ onLogin }) {
- + setPassword(e.target.value)} - placeholder="Introduce contraseña" + placeholder={t('admin.login.passwordPlaceholder')} required disabled={loading} /> @@ -85,16 +87,16 @@ function LoginForm({ onLogin }) { className="login-button" disabled={loading || !username || !password} > - {loading ? 'Iniciando sesión...' : 'Iniciar sesión'} + {loading ? t('admin.login.loggingIn') : t('admin.login.loginBtn')}

- Credenciales por defecto: admin / admin123 + {t('admin.login.defaultCredentials')} admin / admin123

- ⚠️ ¡Cambia la contraseña por defecto tras el primer inicio de sesión! + {t('admin.login.changePasswordWarning')}

diff --git a/apps/frontend/src/components/admin/MedicineManagement.jsx b/apps/frontend/src/components/admin/MedicineManagement.jsx index bda5c9e..d4ebc77 100644 --- a/apps/frontend/src/components/admin/MedicineManagement.jsx +++ b/apps/frontend/src/components/admin/MedicineManagement.jsx @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from '../../i18n'; import './AdminComponents.css'; const SEARCH_DEBOUNCE_MS = 400; function MedicineManagement() { + const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); const [medicines, setMedicines] = useState([]); const [loading, setLoading] = useState(false); @@ -29,7 +31,7 @@ function MedicineManagement() { } catch (error) { if (error.name === 'AbortError') return; console.error('Error searching medicines:', error); - alert('Error al buscar medicamentos en la API CIMA'); + alert(t('admin.medicine.loadError')); } finally { if (!controller.signal.aborted) setLoading(false); } @@ -44,47 +46,47 @@ function MedicineManagement() { return (
-

Buscar Medicamentos (API CIMA)

+

{t('admin.medicine.title')}

-

ℹ️ Los medicamentos ahora se obtienen directamente de la API de CIMA (Agencia Española de Medicamentos y Productos Sanitarios).

-

Busca medicamentos para vincularlos a farmacias en la pestaña "Link Medicine".

+

ℹ️ {t('admin.medicine.description')} {t('admin.medicine.cimaApi')} {t('admin.medicine.cimaDescription')}

+

{t('admin.medicine.linkDescription')}

- + setSearchQuery(e.target.value)} - placeholder="Escribe el nombre de un medicamento..." + placeholder={t('admin.medicine.searchPlaceholder')} />
- {loading &&
Buscando en API CIMA...
} + {loading &&
{t('admin.medicine.searching')}
} {!loading && medicines.length > 0 && (
-

Se encontraron {medicines.length} medicamentos

+

{t('admin.medicine.found')} {medicines.length} {t('admin.medicine.medications')}

{medicines.map((medicine) => (

{medicine.name}

{medicine.active_ingredient && ( -

Principio Activo: {medicine.active_ingredient}

+

{t('admin.medicine.activeIngredient')} {medicine.active_ingredient}

)}

- {medicine.dosage && Dosis: {medicine.dosage}} + {medicine.dosage && {t('admin.medicine.dosage')} {medicine.dosage}} {medicine.dosage && medicine.form && ' • '} - {medicine.form && Forma: {medicine.form}} + {medicine.form && {t('admin.medicine.form')} {medicine.form}}

- Laboratorio: {medicine.laboratory} • - Nº Registro: {medicine.nregistro} • - {medicine.generic ? ' Genérico' : ' Marca'} + {t('admin.medicine.laboratory')} {medicine.laboratory} • + {t('admin.medicine.registrationNumber')} {medicine.nregistro} • + {medicine.generic ? ` ${t('admin.medicine.generic')}` : ` ${t('admin.medicine.brand')}`}

@@ -93,7 +95,7 @@ function MedicineManagement() { )} {!loading && searchQuery.trim().length >= 2 && medicines.length === 0 && ( -

No se encontraron medicamentos con ese nombre.

+

{t('admin.medicine.noResults')}

)}
); diff --git a/apps/frontend/src/components/admin/PharmacyManagement.jsx b/apps/frontend/src/components/admin/PharmacyManagement.jsx index 317b984..9cd7b1f 100644 --- a/apps/frontend/src/components/admin/PharmacyManagement.jsx +++ b/apps/frontend/src/components/admin/PharmacyManagement.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import './AdminComponents.css'; import { DAY_KEYS, DAY_LABEL } from '../../utils/hours'; +import { useTranslation } from '../../i18n'; function emptyHoursDraft() { const draft = {}; @@ -55,18 +56,20 @@ function haversineMeters(lat1, lon1, lat2, lon2) { return 2 * R * Math.asin(Math.sqrt(Math.min(1, a))); } -const REGION_PRESETS = [ - { id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' }, - { - id: 'rubi', - label: 'Ejemplo: Área de Rubí (1.5 km)', - lat: '41.5631', - lon: '2.0038', - radio: '1500', - }, -]; +function getRegionPresets(t) { + return [ + { id: 'custom', label: t('admin.pharmacy.customCoordinates'), lat: '', lon: '', radio: '' }, + { + id: 'rubi', + label: t('admin.pharmacy.areaExample'), + lat: '41.5631', + lon: '2.0038', + radio: '1500', + }, + ]; +} -async function geocodeErrorMessage(response) { +async function geocodeErrorMessage(response, t) { const text = await response.text(); let body = {}; try { @@ -76,19 +79,21 @@ async function geocodeErrorMessage(response) { } if (typeof body.error === 'string' && body.error.trim()) return body.error; if (response.status === 401) { - return 'Sesión expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.'; + return t('admin.pharmacy.sessionExpired'); } if (response.status === 404) { const looksLikeHtml = /]/i.test(text || ''); if (looksLikeHtml) { - return 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.'; + return t('admin.pharmacy.apiNotFound'); } - return 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.'; + return t('admin.pharmacy.geocodingNotFound'); } - return `Búsqueda fallida (HTTP ${response.status}).`; + return `${t('admin.pharmacy.searchFailed')} ${response.status}).`; } function PharmacyManagement() { + const { t } = useTranslation(); + const REGION_PRESETS = getRegionPresets(t); const [pharmacies, setPharmacies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -132,7 +137,7 @@ function PharmacyManagement() { setPharmacies(data); } catch (error) { console.error('Error fetching pharmacies:', error); - alert('Error al cargar farmacias'); + alert(t('admin.pharmacy.loadError')); } finally { setLoading(false); } @@ -165,7 +170,7 @@ function PharmacyManagement() { e?.preventDefault(); const q = cityQuery.trim(); if (!q) { - setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' }); + setCityLookupMessage({ type: 'err', text: t('admin.pharmacy.enterCity') }); return; } setCityLookupLoading(true); @@ -175,7 +180,7 @@ function PharmacyManagement() { credentials: 'include', }); if (!response.ok) { - throw new Error(await geocodeErrorMessage(response)); + throw new Error(await geocodeErrorMessage(response, t)); } const data = await response.json(); setRegionLat(String(data.lat)); @@ -253,7 +258,7 @@ function PharmacyManagement() { const lon = parseFloat(regionLon); const radio = parseFloat(regionRadio); if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) { - throw new Error('Establece latitud, longitud y radio (usa Buscar ciudad o un preset).'); + throw new Error(t('admin.pharmacy.setCoordinates')); } const response = await fetch('/api/admin/pharmacies/import-external', { method: 'POST', @@ -307,7 +312,7 @@ function PharmacyManagement() { if (!response.ok) { const error = await response.json(); - throw new Error(error.error || 'Error al actualizar farmacia'); + throw new Error(error.error || t('admin.pharmacy.updateError')); } } else { const response = await fetch('/api/admin/pharmacies', { @@ -319,16 +324,16 @@ function PharmacyManagement() { if (!response.ok) { const error = await response.json(); - throw new Error(error.error || 'Error al crear farmacia'); + throw new Error(error.error || t('admin.pharmacy.createError')); } } resetForm(); fetchPharmacies(); - alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!'); + alert(editingPharmacy ? t('admin.pharmacy.updated') : t('admin.pharmacy.created')); } catch (error) { console.error('Error saving pharmacy:', error); - alert(`Error al guardar farmacia: ${error.message}`); + alert(`${t('admin.pharmacy.saveError')}: ${error.message}`); } finally { setSaving(false); } @@ -348,7 +353,7 @@ function PharmacyManagement() { }; const handleDelete = async (id) => { - if (!confirm('¿Estás seguro de que quieres eliminar esta farmacia?')) return; + if (!confirm(t('admin.pharmacy.deleteConfirm'))) return; try { const response = await fetch(`/api/admin/pharmacies/${id}`, { @@ -356,13 +361,13 @@ function PharmacyManagement() { credentials: 'include', }); - if (!response.ok) throw new Error('Error al eliminar farmacia'); + if (!response.ok) throw new Error(t('admin.pharmacy.deleteError')); fetchPharmacies(); - alert('¡Farmacia eliminada!'); + alert(t('admin.pharmacy.deleted')); } catch (error) { console.error('Error deleting pharmacy:', error); - alert('Error al eliminar farmacia'); + alert(t('admin.pharmacy.deleteError')); } }; @@ -391,7 +396,7 @@ function PharmacyManagement() { return (
-

Gestionar Farmacias

+

{t('admin.pharmacy.title')}

-

Ciudad, región e importación

+

{t('admin.pharmacy.citySearch')}

- Buscar ciudad establece latitud, longitud y radio para el filtro de mapa y las importaciones. - Elige una fuente de datos: OpenStreetMap es gratuita (sin clave);{' '} + {t('admin.pharmacy.searchCity')} establece latitud, longitud y radio para el filtro de mapa y las importaciones. + {t('admin.pharmacy.chooseOne')} fuente de datos: OpenStreetMap es gratuita (sin clave);{' '} URL de datos abiertos carga JSON alojado por ti. Geocodificación usa{' '} OpenStreetMap @@ -418,11 +423,11 @@ function PharmacyManagement() {

- + { setCityQuery(e.target.value); @@ -436,7 +441,7 @@ function PharmacyManagement() { className="btn-secondary city-lookup-submit" disabled={cityLookupLoading} > - {cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'} + {cityLookupLoading ? t('admin.pharmacy.searching') : t('admin.pharmacy.searchCity')} {cityLookupMessage && ( @@ -449,7 +454,7 @@ function PharmacyManagement() { )}
- +
- +
- +
- +
{importMode === 'openData' && (
- + {importing - ? 'Importando…' + ? t('admin.pharmacy.importing') : importMode === 'webhook' - ? 'Importar desde webhook' + ? t('admin.pharmacy.importWebhook') : importMode === 'openData' - ? 'Importar desde URL' - : `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`} + ? t('admin.pharmacy.importUrl') + : `${t('admin.pharmacy.importFrom')} ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
@@ -568,10 +573,10 @@ function PharmacyManagement() { {showForm && (
-

{editingPharmacy ? 'Editar Farmacia' : 'Añadir Nueva Farmacia'}

+

{editingPharmacy ? t('admin.pharmacy.edit') : t('admin.pharmacy.add')}

- +
- +
- +
- +
- +
- Horario de apertura -

Marca un día como Cerrado si la farmacia no abre ese día.

+ {t('admin.pharmacy.openingHours')} +

{t('admin.pharmacy.dayClosed')}

{DAY_KEYS.map((day) => { const d = hoursDraft[day]; return ( @@ -635,14 +640,14 @@ function PharmacyManagement() { checked={d.closed} onChange={(e) => updateDay(day, { closed: e.target.checked })} /> - Cerrado + {t('admin.pharmacy.closed')} updateDay(day, { open: e.target.value })} - aria-label={`${DAY_LABEL[day]} abre a las`} + aria-label={`${DAY_LABEL[day]} ${t('admin.pharmacy.opensAt')}`} /> – updateDay(day, { close: e.target.value })} - aria-label={`${DAY_LABEL[day]} cierra a las`} + aria-label={`${DAY_LABEL[day]} ${t('admin.pharmacy.closesAt')}`} />
); @@ -659,28 +664,28 @@ function PharmacyManagement() {
)} {loading ? ( -
Cargando farmacias...
+
{t('admin.pharmacy.loading')}
) : (

- Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias - {filterByRegion && ' (dentro del radio)'} + {t('admin.pharmacy.showing')} {displayedPharmacies.length} {t('admin.pharmacy.of')} {pharmacies.length} farmacias + {filterByRegion && ` ${t('admin.pharmacy.withinRadio')}`}

{displayedPharmacies.length === 0 ? (

{pharmacies.length === 0 - ? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.' - : 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'} + ? t('admin.pharmacy.empty') + : t('admin.pharmacy.noResults')}

) : ( displayedPharmacies.map((pharmacy) => ( @@ -697,10 +702,10 @@ function PharmacyManagement() {
diff --git a/apps/frontend/src/components/admin/PharmacyMedicineLink.jsx b/apps/frontend/src/components/admin/PharmacyMedicineLink.jsx index 664cb1b..51aac57 100644 --- a/apps/frontend/src/components/admin/PharmacyMedicineLink.jsx +++ b/apps/frontend/src/components/admin/PharmacyMedicineLink.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from '../../i18n'; import './AdminComponents.css'; const MAX_PHARMACY_RESULTS = 25; @@ -8,6 +9,7 @@ function normalize(s) { } function PharmacyMedicineLink() { + const { t } = useTranslation(); const [pharmacies, setPharmacies] = useState([]); const [medicineSearch, setMedicineSearch] = useState(''); const [medicineResults, setMedicineResults] = useState([]); @@ -111,7 +113,7 @@ function PharmacyMedicineLink() { e.preventDefault(); if (!selectedMedicine) { - alert('Por favor, selecciona un medicamento primero'); + alert(t('admin.linkMedicine.selectMedicineFirst')); return; } @@ -131,16 +133,16 @@ function PharmacyMedicineLink() { body: JSON.stringify(payload) }); - if (!response.ok) throw new Error('Error al vincular medicamento a farmacia'); + if (!response.ok) throw new Error(t('admin.linkMedicine.linkError')); resetForm(); if (selectedPharmacy) { fetchPharmacyMedicines(selectedPharmacy.id); } - alert('¡Medicamento vinculado a la farmacia correctamente!'); + alert(t('admin.linkMedicine.linkSuccess')); } catch (error) { console.error('Error linking medicine:', error); - alert('Error al vincular medicamento a farmacia'); + alert(t('admin.linkMedicine.linkError')); } }; @@ -153,18 +155,18 @@ function PharmacyMedicineLink() { body: JSON.stringify({ price, stock }) }); - if (!response.ok) throw new Error('Error al actualizar'); + if (!response.ok) throw new Error(t('admin.linkMedicine.updateError')); fetchPharmacyMedicines(selectedPharmacy.id); - alert('¡Actualizado correctamente!'); + alert(t('admin.linkMedicine.updateSuccess')); } catch (error) { console.error('Error updating:', error); - alert('Error al actualizar'); + alert(t('admin.linkMedicine.updateError')); } }; const handleDelete = async (id) => { - if (!confirm('¿Eliminar este medicamento de la farmacia?')) return; + if (!confirm(t('admin.linkMedicine.deleteConfirm'))) return; try { const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, { @@ -172,13 +174,13 @@ function PharmacyMedicineLink() { credentials: 'include' }); - if (!response.ok) throw new Error('Error al eliminar'); + if (!response.ok) throw new Error(t('admin.linkMedicine.deleteError')); fetchPharmacyMedicines(selectedPharmacy.id); - alert('¡Medicamento eliminado de la farmacia!'); + alert(t('admin.linkMedicine.deleteSuccess')); } catch (error) { console.error('Error deleting:', error); - alert('Error al eliminar medicamento'); + alert(t('admin.linkMedicine.deleteError')); } }; @@ -216,11 +218,11 @@ function PharmacyMedicineLink() { return (
-

Vincular Medicamento a Farmacia

+

{t('admin.linkMedicine.title')}

- + setPharmacyDropdownOpen(true)} onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)} - placeholder={pharmacies.length ? `Buscar entre ${pharmacies.length} farmacias por nombre o dirección…` : 'Cargando farmacias…'} + placeholder={pharmacies.length ? `${t('admin.linkMedicine.searchPharmacy')} ${pharmacies.length} ${t('admin.linkMedicine.pharmacies')}` : t('admin.linkMedicine.loadingPharmacies')} autoComplete="off" required={!selectedPharmacy} /> @@ -243,7 +245,7 @@ function PharmacyMedicineLink() {
{filteredPharmacies.length === 0 ? (
- No hay farmacias que coincidan con "{pharmacyQuery}" + {t('admin.linkMedicine.noPharmacies')} "{pharmacyQuery}"
) : ( filteredPharmacies.map((pharmacy) => ( @@ -264,14 +266,14 @@ function PharmacyMedicineLink() {

✅ Selected: {selectedPharmacy.name}

{selectedPharmacy.address}

)}
- + - {searching &&

Buscando...

} + {searching &&

{t('admin.linkMedicine.searching')}

} {medicineResults.length > 0 && !selectedMedicine && (
@@ -304,9 +306,9 @@ function PharmacyMedicineLink() {

✅ Selected: {selectedMedicine.name}

- {selectedMedicine.active_ingredient && `Principio activo: ${selectedMedicine.active_ingredient} • `} - {selectedMedicine.dosage && `Dosis: ${selectedMedicine.dosage} • `} - Nº Registro: {selectedMedicine.nregistro} + {selectedMedicine.active_ingredient && `${t('admin.linkMedicine.activeIngredient')} ${selectedMedicine.active_ingredient} • `} + {selectedMedicine.dosage && `${t('admin.linkMedicine.dosage')} ${selectedMedicine.dosage} • `} + {t('admin.linkMedicine.registrationNumber')} {selectedMedicine.nregistro}

)} @@ -324,7 +326,7 @@ function PharmacyMedicineLink() {
- +
- +
{selectedPharmacy && (
-

Medicamentos en {selectedPharmacy.name}

+

{t('admin.linkMedicine.medicationsIn')} {selectedPharmacy.name}

{loading ? ( -
Cargando...
+
{t('admin.linkMedicine.loading')}
) : pharmacyMedicines.length === 0 ? ( -

Aún no hay medicamentos vinculados a esta farmacia.

+

{t('admin.linkMedicine.empty')}

) : (
{pharmacyMedicines.map((pm) => ( @@ -369,7 +371,7 @@ function PharmacyMedicineLink() {

{pm.medicine_name}

- Precio: {pm.price ? `€${parseFloat(pm.price).toFixed(2)}` : 'No definido'} • + {t('admin.linkMedicine.priceLabel')} {pm.price ? `€${parseFloat(pm.price).toFixed(2)}` : t('admin.linkMedicine.undefined')} • Stock: {pm.stock || 0}

@@ -377,17 +379,17 @@ function PharmacyMedicineLink() {
diff --git a/apps/frontend/src/components/admin/PharmacyProductLink.jsx b/apps/frontend/src/components/admin/PharmacyProductLink.jsx index e562c35..0523f5d 100644 --- a/apps/frontend/src/components/admin/PharmacyProductLink.jsx +++ b/apps/frontend/src/components/admin/PharmacyProductLink.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from '../../i18n'; import './AdminComponents.css'; const MAX_PHARMACY_RESULTS = 25; @@ -8,6 +9,7 @@ function normalize(s) { } function PharmacyProductLink() { + const { t } = useTranslation(); const [pharmacies, setPharmacies] = useState([]); const [productSearch, setProductSearch] = useState(''); const [productResults, setProductResults] = useState([]); @@ -130,7 +132,7 @@ function PharmacyProductLink() { e.preventDefault(); if (!selectedProduct) { - alert('Por favor, selecciona un producto primero'); + alert(t('admin.linkProduct.selectProductFirst')); return; } @@ -156,16 +158,16 @@ function PharmacyProductLink() { body: JSON.stringify(payload) }); - if (!response.ok) throw new Error('Error al vincular producto a farmacia'); + if (!response.ok) throw new Error(t('admin.linkProduct.linkError')); resetForm(); if (selectedPharmacy) { fetchPharmacyProducts(selectedPharmacy.id); } - alert('¡Producto vinculado a la farmacia correctamente!'); + alert(t('admin.linkProduct.linkSuccess')); } catch (error) { console.error('Error linking product:', error); - alert('Error al vincular producto a farmacia'); + alert(t('admin.linkProduct.linkError')); } }; @@ -178,18 +180,18 @@ function PharmacyProductLink() { body: JSON.stringify({ price, stock }) }); - if (!response.ok) throw new Error('Error al actualizar'); + if (!response.ok) throw new Error(t('admin.linkProduct.updateError')); fetchPharmacyProducts(selectedPharmacy.id); - alert('¡Actualizado correctamente!'); + alert(t('admin.linkProduct.updateSuccess')); } catch (error) { console.error('Error updating:', error); - alert('Error al actualizar'); + alert(t('admin.linkProduct.updateError')); } }; const handleDelete = async (id) => { - if (!confirm('¿Eliminar este producto de la farmacia?')) return; + if (!confirm(t('admin.linkProduct.deleteConfirm'))) return; try { const response = await fetch(`/api/admin/pharmacy-products/${id}`, { @@ -197,13 +199,13 @@ function PharmacyProductLink() { credentials: 'include' }); - if (!response.ok) throw new Error('Error al eliminar'); + if (!response.ok) throw new Error(t('admin.linkProduct.deleteError')); fetchPharmacyProducts(selectedPharmacy.id); - alert('¡Producto eliminado de la farmacia!'); + alert(t('admin.linkProduct.deleteSuccess')); } catch (error) { console.error('Error deleting:', error); - alert('Error al eliminar producto'); + alert(t('admin.linkProduct.deleteError')); } }; @@ -266,11 +268,11 @@ function PharmacyProductLink() { return (
-

Vincular Producto a Farmacia

+

{t('admin.linkProduct.title')}

- + setPharmacyDropdownOpen(true)} onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)} - placeholder={pharmacies.length ? `Buscar entre ${pharmacies.length} farmacias por nombre o dirección…` : 'Cargando farmacias…'} + placeholder={pharmacies.length ? `${t('admin.linkProduct.searchPharmacy')} ${pharmacies.length} ${t('admin.linkProduct.pharmacies')}` : t('admin.linkProduct.loadingPharmacies')} autoComplete="off" required={!selectedPharmacy} /> @@ -293,7 +295,7 @@ function PharmacyProductLink() {
{filteredPharmacies.length === 0 ? (
- No hay farmacias que coincidan con "{pharmacyQuery}" + {t('admin.linkProduct.noPharmacies')} "{pharmacyQuery}"
) : ( filteredPharmacies.map((pharmacy) => ( @@ -314,14 +316,14 @@ function PharmacyProductLink() {

✅ Selected: {selectedPharmacy.name}

{selectedPharmacy.address}

)}
- + - {searching &&

Buscando...

} + {searching &&

{t('admin.linkProduct.searching')}

} {productResults.length > 0 && !selectedProduct && (
@@ -356,9 +358,9 @@ function PharmacyProductLink() {

✅ Selected: {selectedProduct.product_name || selectedProduct.name}

- {selectedProduct.brand && `Marca: ${selectedProduct.brand} • `} - {selectedProduct.brands && `Marca: ${selectedProduct.brands} • `} - {selectedProduct.price != null && `Precio: ${selectedProduct.price}€ • `} + {selectedProduct.brand && `${t('admin.linkProduct.brand')} ${selectedProduct.brand} • `} + {selectedProduct.brands && `${t('admin.linkProduct.brand')} ${selectedProduct.brands} • `} + {selectedProduct.price != null && `${t('admin.linkProduct.price')} ${selectedProduct.price}€ • `} {getSourceBadge(selectedProduct.source || 'parapharmacy')} {' '} {selectedProduct._id || selectedProduct.id || selectedProduct.nregistro} @@ -371,7 +373,7 @@ function PharmacyProductLink() { setProductSearch(''); }} > - Cambiar producto + {t('admin.linkProduct.changeProduct')}

)} @@ -379,7 +381,7 @@ function PharmacyProductLink() {
- +
- +
{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() {
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 ( <>
-

⚙️ Panel de Administración

-

Autenticación requerida

+

{t('admin.title')}

+

{t('admin.authRequired')}

@@ -84,13 +86,13 @@ function AdminView() {
-

⚙️ Panel de Administración

-

Gestiona farmacias y medicamentos

+

{t('admin.title')}

+

{t('admin.managePharmacies')}

👤 {user?.username}
@@ -102,25 +104,25 @@ function AdminView() { className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`} onClick={() => setActiveTab('pharmacies')} > - 🏥 Farmacias + {t('admin.pharmacies')}
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 }) {
-

Mis Avisos

-

Mantente al día con tus medicamentos.

+

{t('alerts.title')}

+

{t('alerts.subtitle')}

- {loading &&

Cargando avisos...

} + {loading &&

{t('alerts.loading')}

} {!loading && error &&

{error}

} {!loading && !error && ( <> {/* Availability Section */}
-

Disponibilidad

+

{t('alerts.availability')}

{availability.length === 0 ? ( -

No tienes suscripciones de disponibilidad.

+

{t('alerts.noSubscriptions')}

) : ( availability.map((alert) => { const cardColor = alert.scope === 'pharmacy' ? 'primary' : 'tertiary'; @@ -132,7 +134,7 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) { {alert.scope === 'pharmacy' ? (
- 🏥 {alert.pharmacy_name || `Farmacia #${alert.pharmacy_id}`} + 🏥 {alert.pharmacy_name || `${t('alerts.pharmacy')} #${alert.pharmacy_id}`} {alert.pharmacy_address && (

@@ -142,7 +144,7 @@ function AlertsView({ onNotificationChange, onNavigateToMedicine }) {

) : ( - 🔔 Notificarme cuando esté disponible + 🔔 {t('alerts.notifyWhenAvailable')} )}
diff --git a/apps/frontend/src/views/HomeView.jsx b/apps/frontend/src/views/HomeView.jsx index fe2cb1d..982d118 100644 --- a/apps/frontend/src/views/HomeView.jsx +++ b/apps/frontend/src/views/HomeView.jsx @@ -1,7 +1,9 @@ import React from 'react'; +import { useTranslation } from '../i18n'; import './HomeView.css'; function HomeView({ onScanClick, onSearchClick }) { + const { t } = useTranslation(); return (
@@ -9,7 +11,7 @@ function HomeView({ onScanClick, onSearchClick }) { FarmaClic FarmaClic
-

Encuentra tus medicamentos en farmacias cercanas

+

{t('home.description')}

@@ -21,7 +23,7 @@ function HomeView({ onScanClick, onSearchClick }) {
- Buscar Medicamento + {t('home.searchMedicine')} @@ -41,7 +43,7 @@ function HomeView({ onScanClick, onSearchClick }) {
- Escanear TSI + {t('home.scanTSI')} diff --git a/apps/frontend/src/views/ProfileView.jsx b/apps/frontend/src/views/ProfileView.jsx index 3f49d82..639a887 100644 --- a/apps/frontend/src/views/ProfileView.jsx +++ b/apps/frontend/src/views/ProfileView.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useRef } from 'react'; +import { useTranslation } from '../i18n'; import './ProfileView.css'; const AVATARS = [ @@ -35,6 +36,7 @@ function resolveAvatarUrl(url) { } function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, theme, onThemeChange }) { + const { t, lang, setLang } = useTranslation(); const [firstName, setFirstName] = useState(currentUser?.first_name || ''); const [lastName, setLastName] = useState(currentUser?.last_name || ''); const [avatarUrl, setAvatarUrl] = useState(resolveAvatarUrl(currentUser?.avatar_url)); @@ -60,6 +62,9 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them // Theme modal state const [showTheme, setShowTheme] = useState(false); + // Language modal state + const [showLanguage, setShowLanguage] = useState(false); + // Addresses modal state const [showAddresses, setShowAddresses] = useState(false); const [addresses, setAddresses] = useState([]); @@ -143,7 +148,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them e?.preventDefault(); const addr = formAddress.trim(); if (!addr) { - setFormError('La dirección es obligatoria'); + setFormError(t('profile.addressRequired')); return; } setFormSaving(true); @@ -163,7 +168,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || 'Error al guardar la dirección'); + throw new Error(err.error || t('profile.addressSaveError')); } setShowAddressForm(false); setEditingAddressId(null); @@ -222,16 +227,16 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them }); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Error al guardar (HTTP ${res.status})`); + throw new Error(err.error || t('profile.saveError')); } const updated = await res.json(); onProfileSaved?.(updated); setFirstName(updated.first_name || ''); setLastName(updated.last_name || ''); - setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setConfigFeedback({ type: 'ok', text: t('profile.profileSaved') }); setTimeout(() => setShowConfig(false), 1200); } catch (err) { - setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + setConfigFeedback({ type: 'err', text: err.message || t('profile.saveError') }); } finally { setConfigSaving(false); } @@ -246,7 +251,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them setUploading(true); if (file.size > 5 * 1024 * 1024) { - setUploadError('La imagen no puede superar los 5 MB'); + setUploadError(t('profile.imageTooBig')); setUploading(false); return; } @@ -268,17 +273,17 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them onProfileSaved?.(updated); } else { setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url)); - setUploadError('Error al guardar la imagen. Intenta con otra foto.'); + setUploadError(t('profile.imageSaveError')); } } catch (err) { setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url)); - setUploadError('Error de conexión al guardar la imagen.'); + setUploadError(t('profile.imageConnectionError')); } setUploading(false); }; reader.readAsDataURL(file); } catch (err) { - setUploadError('Error al procesar la imagen.'); + setUploadError(t('profile.imageProcessError')); setUploading(false); } } @@ -371,18 +376,18 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them onChange={handleAvatarUpload} style={{ display: 'none' }} /> - {uploading &&

Subiendo foto...

} + {uploading &&

{t('profile.uploadingPhoto')}

} {uploadError &&

{uploadError}

}
{/* Read-only Name Section */}
-

Nombre

+

{t('profile.name')}

{firstName || '—'}

-

Apellidos

+

{t('profile.lastName')}

{lastName || '—'}

@@ -395,7 +400,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
- Configuración + {t('profile.config')} @@ -407,7 +412,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
- Mis Direcciones + {t('profile.myAddresses')} @@ -419,9 +424,24 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
- Tema + {t('profile.theme')} - {theme === 'auto' ? 'Automático' : theme === 'light' ? 'Claro' : 'Oscuro'} + {theme === 'auto' ? t('profile.themeAuto') : theme === 'light' ? t('profile.themeLight') : t('profile.themeDark')} + + + + + + +
- Panel de Administración + {t('profile.admin')} @@ -469,7 +489,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
- Cerrar Sesión + {t('profile.logout')} {/* Avatar Modal */} @@ -477,7 +497,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
setShowAvatarModal(false)}>
e.stopPropagation()}>
-

Cambiar Avatar

+

{t('profile.changeAvatar')}

@@ -488,7 +508,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them - Prediseñado + {t('profile.presetAvatar')}
@@ -545,8 +565,8 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
-

Elegir de galería

-

Selecciona una imagen existente

+

{t('profile.chooseFromGallery')}

+

{t('profile.selectExistingImage')}

@@ -561,57 +581,57 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
setShowConfig(false)}>
e.stopPropagation()}>
-

Configuración

+

{t('profile.configTitle')}

- + setConfigFirstName(e.target.value)} - placeholder="Tu nombre" + placeholder={t('profile.firstNamePlaceholder')} disabled={configSaving} />
- + setConfigLastName(e.target.value)} - placeholder="Tus apellidos" + placeholder={t('profile.lastNamePlaceholder')} disabled={configSaving} />
- + setConfigEmail(e.target.value)} - placeholder="tu@email.com" + placeholder={t('profile.emailPlaceholder')} disabled={configSaving} />
- + setConfigCity(e.target.value)} - placeholder="Tu ciudad" + placeholder={t('profile.cityPlaceholder')} disabled={configSaving} />
- + setConfigAddress(e.target.value)} - placeholder="Calle Mayor 1, Madrid" + placeholder={t('profile.addressPlaceholder')} disabled={configSaving} />
@@ -622,10 +642,10 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
@@ -638,29 +658,29 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
{ setShowAddresses(false); setShowAddressForm(false); }}>
e.stopPropagation()}>
-

Mis Direcciones

+

{t('profile.addressesTitle')}

{showAddressForm && (
- + setFormAddress(e.target.value)} - placeholder="Calle Mayor 1, Madrid" + placeholder={t('profile.addressPlaceholder')} disabled={formSaving} />
- + setFormLabel(e.target.value)} - placeholder="Ej: Casa, Trabajo, Segunda residencia" + placeholder={t('profile.addressLabelPlaceholder')} disabled={formSaving} />
@@ -671,15 +691,15 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them onChange={(e) => setFormDefault(e.target.checked)} disabled={formSaving} /> - Dirección predeterminada + {t('profile.defaultAddress')} {formError &&

{formError}

}
@@ -687,15 +707,15 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them {!showAddressForm && ( <> {addressesLoading ? ( -

Cargando direcciones...

+

{t('profile.loadingAddresses')}

) : (
{currentUser?.address && (
- Dirección principal + {t('profile.mainAddress')} {currentUser.address} - Predeterminada + {t('profile.default')}
)} @@ -705,16 +725,16 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them {addr.label && {addr.label}} {addr.address}
- - )} @@ -742,16 +762,16 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick, them
setShowTheme(false)}>
e.stopPropagation()}>
-

Tema de visualización

+

{t('profile.themeTitle')}

-

Elige cómo se ve la aplicación. En modo automático, se adapta al tema de tu dispositivo.

+

{t('profile.themeDescription')}

{[ - { value: 'auto', label: 'Automático', desc: 'Seguir sistema', icon: 'M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z' }, - { value: 'light', label: 'Claro', desc: 'Siempre claro', icon: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.42 0-.39.39-.39 1.03 0 1.42l1.06 1.06c.39.39 1.03.39 1.42 0s.39-1.03 0-1.42L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.42 0-.39.39-.39 1.03 0 1.42l1.06 1.06c.39.39 1.03.39 1.42 0 .39-.39.39-1.03 0-1.42l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.42-.39-.39-1.03-.39-1.42 0l-1.06 1.06c-.39.39-.39 1.03 0 1.42s1.03.39 1.42 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.42-.39-.39-1.03-.39-1.42 0l-1.06 1.06c-.39.39-.39 1.03 0 1.42s1.03.39 1.42 0l1.06-1.06z' }, - { value: 'dark', label: 'Oscuro', desc: 'Siempre oscuro', icon: 'M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z' }, + { value: 'auto', label: t('profile.themeAuto'), desc: t('profile.themeAutoDesc'), icon: 'M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z' }, + { value: 'light', label: t('profile.themeLight'), desc: t('profile.themeLightDesc'), icon: 'M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.42 0-.39.39-.39 1.03 0 1.42l1.06 1.06c.39.39 1.03.39 1.42 0s.39-1.03 0-1.42L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.42 0-.39.39-.39 1.03 0 1.42l1.06 1.06c.39.39 1.03.39 1.42 0 .39-.39.39-1.03 0-1.42l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.42-.39-.39-1.03-.39-1.42 0l-1.06 1.06c-.39.39-.39 1.03 0 1.42s1.03.39 1.42 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.42-.39-.39-1.03-.39-1.42 0l-1.06 1.06c-.39.39-.39 1.03 0 1.42s1.03.39 1.42 0l1.06-1.06z' }, + { value: 'dark', label: t('profile.themeDark'), desc: t('profile.themeDarkDesc'), icon: 'M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z' }, ].map(({ value, label, desc, icon }) => (
)} + + {/* Language Modal */} + {showLanguage && ( +
setShowLanguage(false)}> +
e.stopPropagation()}> +
+

{t('profile.languageTitle')}

+ +
+
+
+ {[ + { value: 'es', label: t('profile.languageSpanish'), icon: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z' }, + { value: 'ca', label: t('profile.languageCatalan'), icon: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95c-.32-1.25-.78-2.45-1.38-3.56 1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2 0 .68.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2 0-.68.07-1.35.16-2h4.68c.09.65.16 1.32.16 2 0 .68-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2 0-.68-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z' }, + ].map(({ value, label, icon }) => ( + + ))} +
+
+
+
+ )}
); } diff --git a/apps/frontend/src/views/ScannerView.jsx b/apps/frontend/src/views/ScannerView.jsx index 7e2ab17..dc23314 100644 --- a/apps/frontend/src/views/ScannerView.jsx +++ b/apps/frontend/src/views/ScannerView.jsx @@ -1,4 +1,5 @@ import React, { useState, useCallback, useRef } from 'react'; +import { useTranslation } from '../i18n'; import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning'; import { Capacitor } from '@capacitor/core'; import { BrowserMultiFormatReader } from '@zxing/browser'; @@ -25,6 +26,7 @@ function playBeep() { } function ScannerView({ onClose, onSelectMedicine }) { + const { t } = useTranslation(); const videoRef = useRef(null); const streamRef = useRef(null); const rafRef = useRef(null); @@ -78,7 +80,7 @@ function ScannerView({ onClose, onSelectMedicine }) { try { const supported = await BarcodeScanner.isSupported(); if (!supported) { - setErrorMsg('El escáner no está disponible en este dispositivo.'); + setErrorMsg(t('scanner.scannerUnavailable')); setPhase('error'); return; } @@ -87,7 +89,7 @@ function ScannerView({ onClose, onSelectMedicine }) { if (permission.camera !== 'granted') { const request = await BarcodeScanner.requestPermissions(); if (request.camera !== 'granted') { - setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.'); + setErrorMsg(t('scanner.cameraPermissionDenied')); setPhase('error'); return; } @@ -104,7 +106,7 @@ function ScannerView({ onClose, onSelectMedicine }) { const rawValue = barcode?.rawValue; if (!rawValue || !CIP_REGEX.test(rawValue)) { - setErrorMsg('Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.'); + setErrorMsg(t('scanner.invalidBarcode')); setPhase('error'); return; } @@ -118,7 +120,7 @@ function ScannerView({ onClose, onSelectMedicine }) { setPhase('idle'); return; } - setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`); + setErrorMsg(`${t('scanner.scanError')} ${err.message || t('scanner.unknownError')}`); setPhase('error'); } } @@ -128,7 +130,7 @@ function ScannerView({ onClose, onSelectMedicine }) { console.log('[Scanner] Iniciando escaneo web...'); try { if (!navigator.mediaDevices?.getUserMedia) { - setErrorMsg('Cámara no disponible en este navegador.'); + setErrorMsg(t('scanner.cameraNotAvailable')); setPhase('error'); return; } @@ -206,11 +208,11 @@ function ScannerView({ onClose, onSelectMedicine }) { console.error('[Scanner] Error general:', err); stopCamera(); if (err.name === 'NotAllowedError') { - setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.'); + setErrorMsg(t('scanner.cameraPermissionWeb')); } else if (err.name === 'NotFoundError') { - setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.'); + setErrorMsg(t('scanner.noCamera')); } else { - setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`); + setErrorMsg(`${t('scanner.cameraError')} ${err.message || t('scanner.unknownError')}`); } setPhase('error'); } @@ -228,12 +230,12 @@ function ScannerView({ onClose, onSelectMedicine }) { e.preventDefault(); const cip = manualCip.trim(); if (!cip) { - setErrorMsg('Introduce un código CIP.'); + setErrorMsg(t('scanner.enterCIPError')); setPhase('error'); return; } if (!CIP_REGEX.test(cip)) { - setErrorMsg('Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.'); + setErrorMsg(t('scanner.invalidCIP')); setPhase('error'); return; } @@ -268,14 +270,14 @@ function ScannerView({ onClose, onSelectMedicine }) { const data = await ocrRes.json(); if (!ocrRes.ok) { - setErrorMsg(data.error || 'No se pudo leer la imagen. Intenta con otra foto.'); + setErrorMsg(data.error || t('scanner.imageReadError')); setPhase('error'); return; } const cip = data.cip; if (!CIP_REGEX.test(cip)) { - setErrorMsg(`CIP detectado "${cip}" no tiene formato válido. Introduce el código manualmente.`); + setErrorMsg(t('scanner.invalidCIPDetected')); setPhase('error'); return; } @@ -286,7 +288,7 @@ function ScannerView({ onClose, onSelectMedicine }) { setPhase('prescriptions'); fetchPrescriptions(cip); } catch (err) { - setErrorMsg(`Error al procesar la imagen: ${err.message || 'Error desconocido'}`); + setErrorMsg(`${t('scanner.imageProcessError')} ${err.message || t('scanner.unknownError')}`); setPhase('error'); } finally { setOcrLoading(false); @@ -330,8 +332,8 @@ function ScannerView({ onClose, onSelectMedicine }) {
-

Escanear TSI

-

Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas

+

{t('scanner.title')}

+

{t('scanner.description')}

{phase !== 'prescriptions' && ( @@ -343,7 +345,7 @@ function ScannerView({ onClose, onSelectMedicine }) {

{errorMsg}

)} @@ -355,18 +357,18 @@ function ScannerView({ onClose, onSelectMedicine }) { - Abrir cámara + {t('scanner.openCamera')}
- +
-

Apunta al código de barras

+

{t('scanner.scanning')}

)} {phase === 'scanning' && isNative && (
-

Abriendo cámara…

+

{t('scanner.openingCamera')}

)} @@ -424,7 +426,7 @@ function ScannerView({ onClose, onSelectMedicine }) { {ocrLoading ? (
-

Procesando imagen…

+

{t('scanner.processingImage')}

) : (
@@ -434,14 +436,14 @@ function ScannerView({ onClose, onSelectMedicine }) { - Escanear imagen + {t('scanner.scanImage')}
)} @@ -449,20 +451,20 @@ function ScannerView({ onClose, onSelectMedicine }) { )}
- +
setManualCip(e.target.value)} maxLength={16} autoComplete="off" spellCheck={false} /> - +
@@ -475,23 +477,23 @@ function ScannerView({ onClose, onSelectMedicine }) {
-

TSI Escaneada

+

{t('scanner.tsiScanned')}

{scannedCip}

-

Recetas Activas

-

Toca un medicamento para ver disponibilidad en farmacias cercanas.

+

{t('scanner.activePrescriptions')}

+

{t('scanner.tapToFind')}

{loadingPrescriptions && (
-

Cargando recetas…

+

{t('scanner.loadingPrescriptions')}

)} {!loadingPrescriptions && prescriptions.length === 0 && ( -

No se encontraron recetas activas para esta tarjeta.

+

{t('scanner.noPrescriptions')}

)}
    @@ -523,7 +525,7 @@ function ScannerView({ onClose, onSelectMedicine }) { - Escanear otra tarjeta + {t('scanner.scanAnother')}
)} diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 43ca1ba..10c2893 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -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 - {loading &&
Buscando...
} + {loading &&
{t('search.searching')}
} {!searchQuery && !selectedMedicine && ( <>
-

Sugerencias

+

{t('search.suggestions')}

{suggestions.map((s, i) => (
))} @@ -299,7 +301,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate <>
{(medicines.length + products.length) > 0 && ( - {medicines.length + products.length} resultados encontrados + {medicines.length + products.length} {t('search.resultsFound')} )}
@@ -318,7 +320,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate {products.length > 0 && (
-

Parafarmacia

+

{t('search.parapharmacy')}

{ @@ -340,9 +342,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate

{selectedMedicine.name}

- Ingrediente Activo: {selectedMedicine.active_ingredient} - Dosis: {selectedMedicine.dosage} - Forma: {selectedMedicine.form} + {t('search.activeIngredient')} {selectedMedicine.active_ingredient} + {t('search.dosage')} {selectedMedicine.dosage} + {t('search.form')} {selectedMedicine.form}
@@ -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')} {sortByDistance && positionSource === 'profile' && ( - Usando tu dirección guardada + {t('search.usingSavedAddress')} )} {sortByDistance && positionSource === 'cached' && ( - Usando ubicación reciente + {t('search.usingRecentLocation')} )} {locationError && ( {locationError} )} From ee958d4525af7d40b064c91d0eed8635a3b139de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Fri, 17 Jul 2026 10:22:14 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(i18n-mobile):=20add=20bilingual=20supp?= =?UTF-8?q?ort=20(Catal=C3=A0=20/=20Castellano)=20for=20React=20Native=20a?= =?UTF-8?q?pp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lightweight i18n system matching the web app architecture. Language persisted in AsyncStorage('ff-lang'). New files: - src/i18n/locales/ca.js (~190 translation keys) - src/i18n/locales/es.js (~190 translation keys) - src/i18n/LanguageContext.jsx (Provider + AsyncStorage) - src/i18n/useTranslation.js (hook) - src/i18n/index.js (barrel export) Modified 16 files: - app/_layout.tsx: wrap with LanguageProvider, translate stack titles - app/(tabs)/_layout.tsx: translate tab bar labels - app/(tabs)/index.tsx: translate home screen - app/(tabs)/search.tsx: translate search screen - app/(tabs)/alerts.tsx: translate alerts screen - app/(tabs)/profile.tsx: translate profile + add language selector (CA/ES) - app/(tabs)/scan.tsx: translate scanner screen - app/auth/login.tsx: translate login/register - app/medicine/[id].tsx: translate medicine detail - app/pharmacy/[id].tsx: translate pharmacy detail - app/product/[source]/[id].tsx: translate product detail - 5 components: MedicineCard, StockBadge, SearchBar, LoadingSpinner, BarcodeScanner Language selector in Profile screen (globe icon + CA/ES toggle). No routes changed. No API calls affected. --- apps/frontend-mobile/app/(tabs)/_layout.tsx | 12 +- apps/frontend-mobile/app/(tabs)/alerts.tsx | 36 +-- apps/frontend-mobile/app/(tabs)/index.tsx | 8 +- apps/frontend-mobile/app/(tabs)/profile.tsx | 137 ++++++----- apps/frontend-mobile/app/(tabs)/scan.tsx | 36 +-- apps/frontend-mobile/app/(tabs)/search.tsx | 18 +- apps/frontend-mobile/app/_layout.tsx | 24 +- apps/frontend-mobile/app/auth/login.tsx | 48 ++-- apps/frontend-mobile/app/medicine/[id].tsx | 44 ++-- apps/frontend-mobile/app/pharmacy/[id].tsx | 20 +- .../app/product/[source]/[id].tsx | 36 +-- .../components/BarcodeScanner.tsx | 14 +- .../components/LoadingSpinner.tsx | 6 +- .../components/MedicineCard.tsx | 4 +- apps/frontend-mobile/components/SearchBar.tsx | 6 +- .../frontend-mobile/components/StockBadge.tsx | 4 +- .../src/i18n/LanguageContext.jsx | 50 ++++ apps/frontend-mobile/src/i18n/index.js | 2 + apps/frontend-mobile/src/i18n/locales/ca.js | 228 ++++++++++++++++++ apps/frontend-mobile/src/i18n/locales/es.js | 228 ++++++++++++++++++ .../src/i18n/useTranslation.js | 8 + 21 files changed, 771 insertions(+), 198 deletions(-) create mode 100644 apps/frontend-mobile/src/i18n/LanguageContext.jsx create mode 100644 apps/frontend-mobile/src/i18n/index.js create mode 100644 apps/frontend-mobile/src/i18n/locales/ca.js create mode 100644 apps/frontend-mobile/src/i18n/locales/es.js create mode 100644 apps/frontend-mobile/src/i18n/useTranslation.js diff --git a/apps/frontend-mobile/app/(tabs)/_layout.tsx b/apps/frontend-mobile/app/(tabs)/_layout.tsx index cbbb307..9a78409 100644 --- a/apps/frontend-mobile/app/(tabs)/_layout.tsx +++ b/apps/frontend-mobile/app/(tabs)/_layout.tsx @@ -3,6 +3,7 @@ import { Ionicons } from '@expo/vector-icons'; import { View, StyleSheet, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useThemeContext } from '../../components/ThemeProvider'; +import { useTranslation } from '../../src/i18n'; import { shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -25,6 +26,7 @@ export default function TabLayout() { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { colors } = useThemeContext(); + const { t } = useTranslation(); // Use safe area insets if available, otherwise use standard Android nav bar height const bottomPadding = insets.bottom > 0 ? insets.bottom : ANDROID_NAV_BAR_HEIGHT; @@ -62,7 +64,7 @@ export default function TabLayout() { ( ), @@ -71,7 +73,7 @@ export default function TabLayout() { ( ), @@ -80,7 +82,7 @@ export default function TabLayout() { , tabBarLabel: () => null, }} @@ -88,7 +90,7 @@ export default function TabLayout() { ( ), @@ -103,7 +105,7 @@ export default function TabLayout() { ( ), diff --git a/apps/frontend-mobile/app/(tabs)/alerts.tsx b/apps/frontend-mobile/app/(tabs)/alerts.tsx index 949ead5..6763623 100644 --- a/apps/frontend-mobile/app/(tabs)/alerts.tsx +++ b/apps/frontend-mobile/app/(tabs)/alerts.tsx @@ -6,6 +6,7 @@ import { useThemeContext } from '../../components/ThemeProvider'; import { useAuth } from '../../hooks/useAuth'; import { spacing, borderRadius, shadows } from '../../constants/theme'; import { LoadingSpinner } from '../../components/LoadingSpinner'; +import { useTranslation } from '../../src/i18n'; import api from '../../services/api'; const TABLET_MIN_WIDTH = 768; @@ -22,6 +23,7 @@ interface NotificationItem { } export default function AlertsScreen() { + const { t } = useTranslation(); const router = useRouter(); const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; @@ -54,7 +56,7 @@ export default function AlertsScreen() { ); setItems(merged); } catch (err: any) { - setError(err.message || 'No se pudieron cargar las notificaciones'); + setError(err.message || t('alerts.loadError')); } finally { setIsLoading(false); } @@ -63,12 +65,12 @@ export default function AlertsScreen() { async function handleDelete(item: NotificationItem) { const key = `${item.scope}:${item.id}`; Alert.alert( - 'Eliminar notificación', - `¿Eliminar la notificación de ${item.medicine_name || item.medicine_nregistro}?`, + t('alerts.delete'), + `${t('alerts.deleteConfirm')} ${item.medicine_name || item.medicine_nregistro}?`, [ - { text: 'Cancelar', style: 'cancel' }, + { text: t('alerts.cancel'), style: 'cancel' }, { - text: 'Eliminar', + text: t('alerts.confirmDelete'), style: 'destructive', onPress: async () => { setDeletingId(key); @@ -78,7 +80,7 @@ export default function AlertsScreen() { }); setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id))); } catch (err: any) { - Alert.alert('Error', err.message || 'No se pudo eliminar'); + Alert.alert('Error', err.message || t('alerts.deleteError')); } finally { setDeletingId(null); } @@ -105,8 +107,8 @@ export default function AlertsScreen() { /> {item.scope === 'pharmacy' - ? item.pharmacy_name || `Farmacia #${item.pharmacy_id}` - : 'Cualquier farmacia'} + ? item.pharmacy_name || `${t('alerts.pharmacy')} #${item.pharmacy_id}` + : t('alerts.anyPharmacy')} {item.pharmacy_address && ( @@ -132,22 +134,22 @@ export default function AlertsScreen() { } if (isLoading || authLoading) { - return ; + return ; } if (!isAuthenticated) { return ( - Inicia sesión para continuar + {t('alerts.loginRequired')} - Necesitas estar autenticado para ver tus notificaciones + {t('alerts.loginDescription')} router.push('/auth/login')} > - Iniciar Sesión + {t('alerts.loginBtn')} ); @@ -156,9 +158,9 @@ export default function AlertsScreen() { return ( - Notificaciones Guardadas + {t('alerts.title')} - Recibe avisos cuando medicamentos sin stock se repongan + {t('alerts.description')} @@ -166,7 +168,7 @@ export default function AlertsScreen() { {error} - Reintentar + {t('alerts.retry')} )} @@ -174,9 +176,9 @@ export default function AlertsScreen() { {!error && items.length === 0 && ( - Sin notificaciones + {t('alerts.empty')} - Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga. + {t('alerts.emptyDescription')} )} diff --git a/apps/frontend-mobile/app/(tabs)/index.tsx b/apps/frontend-mobile/app/(tabs)/index.tsx index e932910..99ce917 100644 --- a/apps/frontend-mobile/app/(tabs)/index.tsx +++ b/apps/frontend-mobile/app/(tabs)/index.tsx @@ -3,6 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, Image, useWindowDimensions } import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useThemeContext } from '../../components/ThemeProvider'; +import { useTranslation } from '../../src/i18n'; import { spacing, borderRadius, shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -12,6 +13,7 @@ export default function HomeScreen() { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { colors } = useThemeContext(); + const { t } = useTranslation(); return ( @@ -23,7 +25,7 @@ export default function HomeScreen() { /> FarmaClic - Encuentra tus medicamentos en farmacias cercanas + {t('home.description')} @@ -37,7 +39,7 @@ export default function HomeScreen() { - Buscar Medicamento + {t('home.searchMedicine')} @@ -51,7 +53,7 @@ export default function HomeScreen() { - Escanear TSI + {t('home.scanTSI')} diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index 0af19a0..bef1f9e 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -6,6 +6,7 @@ import * as ImagePicker from 'expo-image-picker'; import { useAuth } from '../../hooks/useAuth'; import { useThemeContext } from '../../components/ThemeProvider'; import { useThemeStore, ThemeMode } from '../../store/themeStore'; +import { useTranslation } from '../../src/i18n'; import { spacing, borderRadius, shadows } from '../../constants/theme'; import api from '../../services/api'; @@ -62,10 +63,10 @@ interface Address { created_at: string; } -const THEME_OPTIONS: { mode: ThemeMode; label: string; icon: string }[] = [ - { mode: 'system', label: 'Sistema', icon: 'phone-portrait-outline' }, - { mode: 'light', label: 'Claro', icon: 'sunny-outline' }, - { mode: 'dark', label: 'Oscuro', icon: 'moon-outline' }, +const THEME_OPTIONS: { mode: ThemeMode; labelKey: string; icon: string }[] = [ + { mode: 'system', labelKey: 'profile.themeSystem', icon: 'phone-portrait-outline' }, + { mode: 'light', labelKey: 'profile.themeLight', icon: 'sunny-outline' }, + { mode: 'dark', labelKey: 'profile.themeDark', icon: 'moon-outline' }, ]; export default function ProfileScreen() { @@ -74,6 +75,7 @@ export default function ProfileScreen() { const isTablet = width >= TABLET_MIN_WIDTH; const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); const { colors, isDark } = useThemeContext(); + const { t, lang, setLang } = useTranslation(); const themeMode = useThemeStore((s) => s.mode); const setThemeMode = useThemeStore((s) => s.setMode); @@ -146,10 +148,10 @@ export default function ProfileScreen() { }); setFirstName(res.data.first_name || ''); setLastName(res.data.last_name || ''); - setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setConfigFeedback({ type: 'ok', text: t('profile.profileSaved') }); setTimeout(() => setShowConfig(false), 1200); } catch (err: any) { - setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + setConfigFeedback({ type: 'err', text: err.message || t('profile.saveError') }); } finally { setConfigSaving(false); } } @@ -164,7 +166,7 @@ export default function ProfileScreen() { async function handleTakePhoto() { const { status } = await ImagePicker.requestCameraPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permiso requerido', 'Necesitamos permiso para acceder a la cámara.'); return; } + if (status !== 'granted') { Alert.alert(t('profile.cameraPermission'), t('profile.cameraPermissionDesc')); return; } const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, aspect: [1, 1], quality: 0.8, base64: true }); if (!result.canceled && result.assets[0]?.base64) { const dataUri = `data:${result.assets[0].mimeType};base64,${result.assets[0].base64}`; @@ -204,12 +206,12 @@ export default function ProfileScreen() { async function handleAddressSave() { const addr = formAddress.trim(); - if (!addr) { setFormError('La dirección es obligatoria'); return; } + if (!addr) { setFormError(t('profile.addressRequired')); return; } setFormSaving(true); setFormError(''); try { const url = editingAddressId ? `/api/addresses/${editingAddressId}` : '/api/addresses'; const res = await fetch(url, { method: editingAddressId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ address: addr, label: formLabel.trim(), is_default: formDefault }) }); - if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Error al guardar'); } + if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || t('profile.saveError')); } setShowAddressForm(false); setEditingAddressId(null); loadAddresses(); } catch (err: any) { setFormError(err.message); } finally { setFormSaving(false); } } @@ -223,9 +225,9 @@ export default function ProfileScreen() { } const handleLogout = () => { - Alert.alert('Cerrar Sesión', '¿Estás seguro que deseas cerrar sesión?', [ - { text: 'Cancelar', style: 'cancel' }, - { text: 'Cerrar Sesión', style: 'destructive', onPress: async () => { await logout(); router.replace('/auth/login'); } }, + Alert.alert(t('profile.logoutTitle'), t('profile.logoutConfirm'), [ + { text: t('profile.logoutCancel'), style: 'cancel' }, + { text: t('profile.logoutConfirmBtn'), style: 'destructive', onPress: async () => { await logout(); router.replace('/auth/login'); } }, ]); }; @@ -246,12 +248,12 @@ export default function ProfileScreen() { - Inicia Sesión + {t('profile.loginTitle')} - Inicia sesión para acceder a tu perfil, notificaciones y más + {t('profile.loginDescription')} router.push('/auth/login')}> - Iniciar Sesión + {t('profile.loginBtn')} @@ -287,15 +289,15 @@ export default function ProfileScreen() { - Datos personales + {t('profile.personalData')} - Nombre + {t('profile.firstName')} {firstName || '—'} - Apellidos + {t('profile.lastName')} {lastName || '—'} @@ -306,7 +308,7 @@ export default function ProfileScreen() { - Apariencia + {t('profile.appearance')} {THEME_OPTIONS.map((opt) => ( @@ -316,7 +318,30 @@ export default function ProfileScreen() { onPress={() => setThemeMode(opt.mode)} > - {opt.label} + {t(opt.labelKey)} + + ))} + + + + {/* Language card */} + + + + {t('profile.language')} + + + {[ + { value: 'ca' as const, labelKey: 'profile.languageCatalan', icon: 'language-outline' }, + { value: 'es' as const, labelKey: 'profile.languageSpanish', icon: 'language-outline' }, + ].map((opt) => ( + setLang(opt.value)} + > + + {t(opt.labelKey)} ))} @@ -328,7 +353,7 @@ export default function ProfileScreen() { - Configuración + {t('profile.config')} @@ -338,7 +363,7 @@ export default function ProfileScreen() { - Mis Direcciones + {t('profile.myAddresses')} @@ -349,7 +374,7 @@ export default function ProfileScreen() { - Panel Admin + {t('profile.admin')} @@ -361,7 +386,7 @@ export default function ProfileScreen() { - Búsquedas recientes + {t('profile.recentSearches')} {searchHistory.map((item, i) => ( @@ -381,7 +406,7 @@ export default function ProfileScreen() { {/* Logout */} - Cerrar Sesión + {t('profile.logout')} @@ -394,16 +419,16 @@ export default function ProfileScreen() { - Cambiar Avatar + {t('profile.changeAvatar')} setShowAvatarModal(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> - {(['presets', 'colors', 'upload'] as const).map((t) => ( - setAvatarTab(t)}> - - {t === 'presets' ? 'Prediseñado' : t === 'colors' ? 'Colores' : 'Subir'} + {(['presets', 'colors', 'upload'] as const).map((tab) => ( + setAvatarTab(tab)}> + + {tab === 'presets' ? t('profile.presetAvatar') : tab === 'colors' ? t('profile.colors') : t('profile.upload')} ))} @@ -433,8 +458,8 @@ export default function ProfileScreen() { - Tomar foto - Usa la cámara de tu dispositivo + {t('profile.takePhoto')} + {t('profile.takePhotoDesc')} @@ -443,8 +468,8 @@ export default function ProfileScreen() { - Elegir de galería - Selecciona una imagen existente + {t('profile.chooseGallery')} + {t('profile.chooseGalleryDesc')} @@ -461,28 +486,28 @@ export default function ProfileScreen() { - Configuración + {t('profile.configTitle')} setShowConfig(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> {[ - { label: 'Nombre', value: configFirstName, onChange: setConfigFirstName, placeholder: 'Tu nombre', icon: 'person-outline' }, - { label: 'Apellidos', value: configLastName, onChange: setConfigLastName, placeholder: 'Tus apellidos', icon: 'person-outline' }, - { label: 'Correo electrónico', value: configEmail, onChange: setConfigEmail, placeholder: 'tu@email.com', icon: 'mail-outline', keyboard: 'email-address' as const }, - { label: 'Ciudad', value: configCity, onChange: setConfigCity, placeholder: 'Tu ciudad', icon: 'business-outline' }, - { label: 'Dirección', value: configAddress, onChange: setConfigAddress, placeholder: 'Calle Mayor 1, Madrid', icon: 'location-outline' }, + { labelKey: 'profile.firstNameLabel', placeholderKey: 'profile.firstNamePlaceholder', value: configFirstName, onChange: setConfigFirstName, icon: 'person-outline' }, + { labelKey: 'profile.lastNameLabel', placeholderKey: 'profile.lastNamePlaceholder', value: configLastName, onChange: setConfigLastName, icon: 'person-outline' }, + { labelKey: 'profile.email', placeholder: 'tu@email.com', value: configEmail, onChange: setConfigEmail, icon: 'mail-outline', keyboard: 'email-address' as const }, + { labelKey: 'profile.city', placeholderKey: 'profile.cityPlaceholder', value: configCity, onChange: setConfigCity, icon: 'business-outline' }, + { labelKey: 'profile.address', placeholderKey: 'profile.addressPlaceholder', value: configAddress, onChange: setConfigAddress, icon: 'location-outline' }, ].map((field) => ( - - {field.label} + + {t(field.labelKey)} setShowConfig(false)} disabled={configSaving}> - Cancelar + {t('profile.cancel')} - {configSaving ? : Guardar} + {configSaving ? : {t('profile.save')}} @@ -518,7 +543,7 @@ export default function ProfileScreen() { - Mis Direcciones + {t('profile.addressesTitle')} { setShowAddresses(false); setShowAddressForm(false); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}> @@ -527,22 +552,22 @@ export default function ProfileScreen() { {showAddressForm ? ( - Dirección + {t('profile.addressLabel')} - + - Etiqueta (opcional) + {t('profile.addressOptional')} - + setFormDefault(!formDefault)} disabled={formSaving}> - Dirección predeterminada + {t('profile.defaultAddress')} {formError ? ( @@ -551,10 +576,10 @@ export default function ProfileScreen() { ) : null} { setShowAddressForm(false); setFormError(''); }} disabled={formSaving}> - Cancelar + {t('profile.cancel')} - {formSaving ? : {editingAddressId ? 'Actualizar' : 'Añadir'}} + {formSaving ? : {editingAddressId ? t('profile.update') : t('profile.add')}} @@ -567,7 +592,7 @@ export default function ProfileScreen() { {user?.address && ( - Principal + {t('profile.mainAddress')} {user.address} @@ -580,7 +605,7 @@ export default function ProfileScreen() { {addr.address} {!addr.is_default && ( handleSetDefault(addr.id)}> - Marcar como predeterminada + {t('profile.setDefault')} )} @@ -596,7 +621,7 @@ export default function ProfileScreen() { ))} - Añadir dirección + {t('profile.addAddress')} )} diff --git a/apps/frontend-mobile/app/(tabs)/scan.tsx b/apps/frontend-mobile/app/(tabs)/scan.tsx index 7fcd15d..f01ad53 100644 --- a/apps/frontend-mobile/app/(tabs)/scan.tsx +++ b/apps/frontend-mobile/app/(tabs)/scan.tsx @@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import * as ImagePicker from 'expo-image-picker'; import { useThemeContext } from '../../components/ThemeProvider'; +import { useTranslation } from '../../src/i18n'; import { spacing, borderRadius, shadows } from '../../constants/theme'; const TABLET_MIN_WIDTH = 768; @@ -13,6 +14,7 @@ export default function ScanTabScreen() { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { colors } = useThemeContext(); + const { t } = useTranslation(); const [manualNumber, setManualNumber] = useState(''); const [selectedImage, setSelectedImage] = useState(null); @@ -20,8 +22,8 @@ export default function ScanTabScreen() { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') { Alert.alert( - 'Permiso requerido', - 'Necesitamos acceso a la cámara para tomar fotos del dispositivo.' + t('scanner.cameraPermission'), + t('scanner.cameraPermissionDesc') ); return; } @@ -41,8 +43,8 @@ export default function ScanTabScreen() { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { Alert.alert( - 'Permiso requerido', - 'Necesitamos acceso a la galería para seleccionar fotos.' + t('scanner.galleryPermission'), + t('scanner.galleryPermissionDesc') ); return; } @@ -61,7 +63,7 @@ export default function ScanTabScreen() { const handleManualSubmit = () => { const trimmed = manualNumber.trim(); if (trimmed.length === 0) { - Alert.alert('Campo requerido', 'Por favor, introduce el número de la tarjeta.'); + Alert.alert(t('scanner.fieldRequired'), t('scanner.fieldRequiredDesc')); return; } router.push(`/medicine/${trimmed}`); @@ -76,9 +78,9 @@ export default function ScanTabScreen() { - Escanear TSI + {t('scanner.title')} - Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos + {t('scanner.description')} - Iniciar escaneo + {t('scanner.startScan')} {/* Separator */} - o + {t('scanner.or')} @@ -103,10 +105,10 @@ export default function ScanTabScreen() { - Subir foto del dispositivo + {t('scanner.uploadPhoto')} - Toma o selecciona una foto del dispositivo sanitario + {t('scanner.uploadDescription')} @@ -117,7 +119,7 @@ export default function ScanTabScreen() { onPress={handleTakePhoto} > - Tomar foto + {t('scanner.takePhoto')} - Galería + {t('scanner.gallery')} {selectedImage && ( - Foto seleccionada correctamente + {t('scanner.photoSelected')} )} @@ -140,17 +142,17 @@ export default function ScanTabScreen() { - Introducir número manualmente + {t('scanner.manualEntry')} - Escribe el número de tu tarjeta sanitaria + {t('scanner.manualDescription')} = TABLET_MIN_WIDTH; const { isAuthenticated } = useAuth(); @@ -68,7 +70,7 @@ export default function SearchScreen() { const data = await searchMedicines(debouncedQuery); setResults(data); } catch (err) { - setError('Error al buscar medicamentos'); + setError(t('search.error')); console.error(err); } finally { setIsLoading(false); @@ -106,7 +108,7 @@ export default function SearchScreen() { {query.length >= 2 && (results.length + products.length) > 0 && ( - {results.length + products.length} resultados encontrados + {results.length + products.length} {t('search.resultsFound')} )} @@ -114,7 +116,7 @@ export default function SearchScreen() { {showSuggestions && ( <> - Sugerencias + {t('search.suggestions')} {suggestions.map((s) => ( 0 && ( - Búsquedas recientes + {t('search.recentSearches')} {recentSearches.map((term) => ( )} - {isLoading && } + {isLoading && } {error && ( @@ -164,7 +166,7 @@ export default function SearchScreen() { {!isLoading && !error && results.length === 0 && products.length === 0 && query.length >= 2 && ( - No se encontraron resultados + {t('search.noResults')} )} @@ -176,7 +178,7 @@ export default function SearchScreen() { <> {products.length > 0 && ( - Parafarmacia + {t('search.parapharmacy')} {products.map((product) => ( - Parafarmacia + {t('search.parapharmacy')} {product.name} diff --git a/apps/frontend-mobile/app/_layout.tsx b/apps/frontend-mobile/app/_layout.tsx index e024ff0..3283cf9 100644 --- a/apps/frontend-mobile/app/_layout.tsx +++ b/apps/frontend-mobile/app/_layout.tsx @@ -8,6 +8,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useAuthStore } from '../store/authStore'; import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications'; import { ThemeProvider, useThemeContext } from '../components/ThemeProvider'; +import { LanguageProvider, useTranslation } from '../src/i18n'; import { initFaro } from '../services/faro'; // Boot Faro RUM once, as early as possible. @@ -21,6 +22,7 @@ const bgDark = require('../assets/bg_dark.png'); function RootLayoutInner() { const { checkAuth } = useAuthStore(); const { colors, isDark } = useThemeContext(); + const { t } = useTranslation(); const notificationListener = useRef>(); const responseListener = useRef>(); @@ -56,30 +58,30 @@ function RootLayoutInner() { @@ -112,11 +114,13 @@ export default function RootLayout() { - - - - - + + + + + + + diff --git a/apps/frontend-mobile/app/auth/login.tsx b/apps/frontend-mobile/app/auth/login.tsx index a45a96e..ae19f64 100644 --- a/apps/frontend-mobile/app/auth/login.tsx +++ b/apps/frontend-mobile/app/auth/login.tsx @@ -22,6 +22,7 @@ import { saveBiometricCredentials, getBiometricUsername, } from '../../services/biometrics'; +import { useTranslation } from '../../src/i18n'; type Tab = 'login' | 'register'; @@ -29,6 +30,7 @@ export default function LoginScreen() { const router = useRouter(); const { login } = useAuthStore(); const { colors } = useThemeContext(); + const { t } = useTranslation(); const [tab, setTab] = useState('login'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -64,17 +66,17 @@ export default function LoginScreen() { const handleSubmit = async () => { if (!username.trim() || !password) { - Alert.alert('Error', 'Por favor completa todos los campos'); + Alert.alert(t('login.alert.error'), t('login.alert.fillFields')); return; } if (isRegister) { if (password.length < 8) { - Alert.alert('Error', 'La contraseña debe tener al menos 8 caracteres'); + Alert.alert(t('login.alert.error'), t('login.alert.passwordLength')); return; } if (password !== confirmPassword) { - Alert.alert('Error', 'Las contraseñas no coinciden'); + Alert.alert(t('login.alert.error'), t('login.alert.passwordMismatch')); return; } } @@ -83,8 +85,8 @@ export default function LoginScreen() { try { if (isRegister) { await register(username.trim(), password); - Alert.alert('Éxito', 'Cuenta creada correctamente', [ - { text: 'OK', onPress: () => setTab('login') }, + Alert.alert(t('login.alert.success'), t('login.alert.accountCreated'), [ + { text: t('login.alert.ok'), onPress: () => setTab('login') }, ]); } else { await login(username.trim(), password); @@ -95,8 +97,8 @@ export default function LoginScreen() { } } catch (error) { Alert.alert( - 'Error', - isRegister ? 'No se pudo crear la cuenta' : 'Credenciales incorrectas' + t('login.alert.error'), + isRegister ? t('login.alert.createError') : t('login.alert.wrongCredentials') ); } finally { setIsLoading(false); @@ -105,7 +107,7 @@ export default function LoginScreen() { const handleBiometricLogin = async () => { if (!biometricUsername) { - Alert.alert('Error', 'No hay credenciales biométricas guardadas'); + Alert.alert(t('login.alert.error'), t('login.alert.noBiometrics')); return; } @@ -116,10 +118,10 @@ export default function LoginScreen() { await login(biometricUsername, ''); router.replace('/(tabs)'); } else { - Alert.alert('Error', 'Autenticación biométrica fallida'); + Alert.alert(t('login.alert.error'), t('login.alert.biometricFailed')); } } catch (error) { - Alert.alert('Error', 'Error en la autenticación biométrica'); + Alert.alert(t('login.alert.error'), t('login.alert.biometricError')); } finally { setIsLoading(false); } @@ -158,7 +160,7 @@ export default function LoginScreen() { !isRegister && styles.tabTextActive, ]} > - Iniciar Sesión + {t('login.tab.login')} - Crear Cuenta + {t('login.tab.register')} @@ -185,12 +187,12 @@ export default function LoginScreen() { {/* Header */} - {isRegister ? 'Crea tu cuenta' : 'Bienvenido de nuevo'} + {isRegister ? t('login.title.register') : t('login.title.login')} {isRegister - ? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.' - : 'Inicia sesión para gestionar tu perfil y notificaciones.'} + ? t('login.subtitle.register') + : t('login.subtitle.login')} {/* Username */} @@ -200,7 +202,7 @@ export default function LoginScreen() { style={[styles.input, { color: colors.text }]} value={username} onChangeText={setUsername} - placeholder="Usuario" + placeholder={t('login.username')} placeholderTextColor={colors.textSecondary} autoCapitalize="none" autoCorrect={false} @@ -215,7 +217,7 @@ export default function LoginScreen() { style={[styles.input, { color: colors.text }]} value={password} onChangeText={setPassword} - placeholder="Contraseña" + placeholder={t('login.password')} placeholderTextColor={colors.textSecondary} secureTextEntry autoComplete={isRegister ? 'new-password' : 'current-password'} @@ -230,7 +232,7 @@ export default function LoginScreen() { style={[styles.input, { color: colors.text }]} value={confirmPassword} onChangeText={setConfirmPassword} - placeholder="Confirmar contraseña" + placeholder={t('login.confirmPassword')} placeholderTextColor={colors.textSecondary} secureTextEntry autoComplete="new-password" @@ -242,10 +244,10 @@ export default function LoginScreen() { {isRegister && ( - 3-32 caracteres para el usuario + {t('login.hint.username')} - Mínimo 8 caracteres para la contraseña + {t('login.hint.password')} )} @@ -260,7 +262,7 @@ export default function LoginScreen() { ) : ( - {isRegister ? 'Crear Cuenta' : 'Iniciar Sesión'} + {isRegister ? t('login.tab.register') : t('login.tab.login')} )} @@ -273,7 +275,7 @@ export default function LoginScreen() { disabled={isLoading} > - Iniciar con biometría + {t('login.biometric')} )} @@ -284,7 +286,7 @@ export default function LoginScreen() { onPress={() => setTab(isRegister ? 'login' : 'register')} > - {isRegister ? '¿Ya tienes cuenta? Inicia sesión' : '¿No tienes cuenta? Regístrate'} + {isRegister ? t('login.link.toLogin') : t('login.link.toRegister')} diff --git a/apps/frontend-mobile/app/medicine/[id].tsx b/apps/frontend-mobile/app/medicine/[id].tsx index e384c87..1914c45 100644 --- a/apps/frontend-mobile/app/medicine/[id].tsx +++ b/apps/frontend-mobile/app/medicine/[id].tsx @@ -11,6 +11,7 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Medicine, PharmacyMedicine } from '../../types'; +import { useTranslation } from '../../src/i18n'; function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; @@ -43,6 +44,7 @@ export default function MedicineDetailScreen() { const router = useRouter(); const { colors } = useThemeContext(); const { isAuthenticated } = useAuth(); + const { t } = useTranslation(); const [medicine, setMedicine] = useState(null); const [pharmacies, setPharmacies] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -86,7 +88,7 @@ export default function MedicineDetailScreen() { try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { - setLocationError('Permiso de ubicación denegado'); + setLocationError(t('medicine.locationDenied')); setLocating(false); return; } @@ -95,7 +97,7 @@ export default function MedicineDetailScreen() { setUserPosition({ lat: pos.coords.latitude, lon: pos.coords.longitude }); setSortByDistance(true); } catch { - setLocationError('No se pudo obtener tu ubicación'); + setLocationError(t('medicine.locationError')); } finally { setLocating(false); } @@ -156,13 +158,13 @@ export default function MedicineDetailScreen() { }; if (isLoading) { - return ; + return ; } if (!medicine) { return ( - Medicamento no encontrado + {t('medicine.notFound')} ); } @@ -190,23 +192,23 @@ export default function MedicineDetailScreen() { - - - - + + + + - + {locatedPharmacies.length > 0 && ( - Mapa próximamente… + {t('medicine.mapSoon')} )} @@ -214,7 +216,7 @@ export default function MedicineDetailScreen() { - Farmacias ({sortedPharmacies.length}) + {t('medicine.pharmacies')} ({sortedPharmacies.length}) {locating - ? 'Localizando…' + ? t('medicine.locating') : sortByDistance - ? 'Distancia · Reset' - : 'Ordenar por distancia'} + ? t('medicine.distanceReset') + : t('medicine.sortByDistance')} @@ -236,13 +238,13 @@ export default function MedicineDetailScreen() { {locationError} - Reintentar + {t('medicine.retry')} )} {sortedPharmacies.length === 0 ? ( - No hay farmacias disponibles + {t('medicine.noPharmacies')} ) : ( sortedPharmacies.map((pharm) => { const lat = getPharmacyLat(pharm); @@ -271,10 +273,10 @@ export default function MedicineDetailScreen() { {pharm.price != null ? ( {pharm.price.toFixed(2)} € ) : ( - Consultar precio + {t('medicine.checkPrice')} )} {pharm.stock > 0 && ( - Stock: {pharm.stock} + {t('pharmacy.stock')}{pharm.stock} )} @@ -284,7 +286,7 @@ export default function MedicineDetailScreen() { onPress={() => handleDirections(lat, lon)} > - Cómo llegar + {t('medicine.howToGet')} )} diff --git a/apps/frontend-mobile/app/pharmacy/[id].tsx b/apps/frontend-mobile/app/pharmacy/[id].tsx index 3a8c2e5..a2165a0 100644 --- a/apps/frontend-mobile/app/pharmacy/[id].tsx +++ b/apps/frontend-mobile/app/pharmacy/[id].tsx @@ -10,12 +10,14 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Pharmacy, PharmacyMedicine } from '../../types'; +import { useTranslation } from '../../src/i18n'; export default function PharmacyDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const { colors } = useThemeContext(); const { isAuthenticated } = useAuth(); + const { t } = useTranslation(); const [pharmacy, setPharmacy] = useState(null); const [medicines, setMedicines] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -77,13 +79,13 @@ export default function PharmacyDetailScreen() { }; if (isLoading) { - return ; + return ; } if (!pharmacy) { return ( - Farmacia no encontrada + {t('pharmacy.notFound')} ); } @@ -97,12 +99,12 @@ export default function PharmacyDetailScreen() { - Llamar + {t('pharmacy.call')} - Cómo llegar + {t('pharmacy.howToGet')} @@ -143,11 +145,11 @@ export default function PharmacyDetailScreen() { - Medicamentos ({medicines.length}) + {t('pharmacy.medications')} ({medicines.length}) {medicines.length === 0 ? ( - No hay medicamentos disponibles + {t('pharmacy.noMedications')} ) : ( medicines.map((med) => { const isSub = subscribedMeds.has(med.medicine_nregistro); @@ -159,11 +161,11 @@ export default function PharmacyDetailScreen() { > {med.medicine_name} - Reg: {med.medicine_nregistro} + {t('pharmacy.reg')}{med.medicine_nregistro} {med.price.toFixed(2)} € - Stock: {med.stock} + {t('pharmacy.stock')}{med.stock} {isAuthenticated && ( @@ -177,7 +179,7 @@ export default function PharmacyDetailScreen() { color={isSub ? '#fff' : colors.textSecondary} /> - {isSub ? 'Notificaciones activas' : 'Notificarme cuando haya stock'} + {isSub ? t('pharmacy.notificationsActive') : t('pharmacy.notifyWhenAvailable')} )} diff --git a/apps/frontend-mobile/app/product/[source]/[id].tsx b/apps/frontend-mobile/app/product/[source]/[id].tsx index c5f6557..21170ac 100644 --- a/apps/frontend-mobile/app/product/[source]/[id].tsx +++ b/apps/frontend-mobile/app/product/[source]/[id].tsx @@ -5,10 +5,12 @@ import { getProduct, Product } from '../../../services/products'; import { LoadingSpinner } from '../../../components/LoadingSpinner'; import { useThemeContext } from '../../../components/ThemeProvider'; import { spacing, borderRadius } from '../../../constants/theme'; +import { useTranslation } from '../../../src/i18n'; export default function ProductDetailScreen() { const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); const { colors } = useThemeContext(); + const { t } = useTranslation(); const [product, setProduct] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(false); @@ -35,13 +37,13 @@ export default function ProductDetailScreen() { }, [source, id]); if (isLoading) { - return ; + return ; } if (error || !product) { return ( - Producto no encontrado + {t('product.notFound')} ); } @@ -54,7 +56,7 @@ export default function ProductDetailScreen() { ) : ( - Sin imagen + {t('product.noImage')} )} @@ -62,7 +64,7 @@ export default function ProductDetailScreen() { {product.name} - {isCima ? 'CIMA' : 'Parafarmacia'} + {isCima ? 'CIMA' : t('product.parapharmacy')} {product.brand ? ( @@ -75,45 +77,45 @@ export default function ProductDetailScreen() { {isCima ? ( - Detalles CIMA + {t('product.cimaDetails')} {product.active_ingredient && ( - + )} {product.dosage && ( - + )} {product.form && ( - + )} {product.prescription && ( - + )} - + ) : ( - Información del producto + {t('product.productInfo')} {product.price != null && ( - + )} {product.original_price != null && product.original_price > (product.price || 0) && ( - + )} {product.category && ( - + )} {product.brand && ( - + )} {product.source_url && ( - + )} )} {isCima && product.photos && product.photos.length > 0 && ( - Imágenes + {t('product.images')} {product.photos.map((photo, i) => ( diff --git a/apps/frontend-mobile/components/BarcodeScanner.tsx b/apps/frontend-mobile/components/BarcodeScanner.tsx index 028bef1..322a67f 100644 --- a/apps/frontend-mobile/components/BarcodeScanner.tsx +++ b/apps/frontend-mobile/components/BarcodeScanner.tsx @@ -4,6 +4,7 @@ import { CameraView, useCameraPermissions } from 'expo-camera'; import { Ionicons } from '@expo/vector-icons'; import { useThemeContext } from './ThemeProvider'; import { spacing, borderRadius } from '../constants/theme'; +import { useTranslation } from '../src/i18n'; interface BarcodeScannerProps { onBarcodeScanned: (barcode: string) => void; @@ -14,6 +15,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp const [permission, requestPermission] = useCameraPermissions(); const [scanned, setScanned] = useState(false); const { colors } = useThemeContext(); + const { t } = useTranslation(); if (!permission) { return ; @@ -23,15 +25,15 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp return ( - Permiso de cámara requerido + {t('barcodeScanner.permissionRequired')} - Necesitamos acceso a la cámara para escanear códigos de barras + {t('barcodeScanner.permissionDesc')} - Conceder permiso + {t('barcodeScanner.grantPermission')} - Cancelar + {t('barcodeScanner.cancel')} ); @@ -63,7 +65,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp - Apunta la cámara al código de barras del medicamento + {t('barcodeScanner.scanningHint')} @@ -77,7 +79,7 @@ export function BarcodeScanner({ onBarcodeScanned, onClose }: BarcodeScannerProp style={[styles.scanAgainButton, { backgroundColor: colors.primary }]} onPress={() => setScanned(false)} > - Escanear de nuevo + {t('barcodeScanner.scanAgain')} )} diff --git a/apps/frontend-mobile/components/LoadingSpinner.tsx b/apps/frontend-mobile/components/LoadingSpinner.tsx index 4a536ef..de96f0c 100644 --- a/apps/frontend-mobile/components/LoadingSpinner.tsx +++ b/apps/frontend-mobile/components/LoadingSpinner.tsx @@ -2,18 +2,20 @@ import React from 'react'; import { View, ActivityIndicator, Text, StyleSheet } from 'react-native'; import { useThemeContext } from './ThemeProvider'; import { spacing } from '../constants/theme'; +import { useTranslation } from '../src/i18n'; interface LoadingSpinnerProps { message?: string; } -export function LoadingSpinner({ message = 'Cargando...' }: LoadingSpinnerProps) { +export function LoadingSpinner({ message }: LoadingSpinnerProps) { const { colors } = useThemeContext(); + const { t } = useTranslation(); return ( - {message} + {message ?? t('loadingSpinner.loading')} ); } diff --git a/apps/frontend-mobile/components/MedicineCard.tsx b/apps/frontend-mobile/components/MedicineCard.tsx index 993d4c0..c64eb52 100644 --- a/apps/frontend-mobile/components/MedicineCard.tsx +++ b/apps/frontend-mobile/components/MedicineCard.tsx @@ -6,6 +6,7 @@ import { useThemeContext } from './ThemeProvider'; import { spacing, borderRadius } from '../constants/theme'; import { StockBadge } from './StockBadge'; import { Medicine } from '../types'; +import { useTranslation } from '../src/i18n'; const TABLET_MIN_WIDTH = 768; @@ -18,6 +19,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) { const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { colors } = useThemeContext(); + const { t } = useTranslation(); const handlePress = () => { router.push(`/medicine/${medicine.nregistro}`); @@ -44,7 +46,7 @@ export function MedicineCard({ medicine }: MedicineCardProps) { - {medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : 'Sin precio'} + {medicine.precio != null ? `${medicine.precio.toFixed(2)} €` : t('medicineCard.noPrice')} diff --git a/apps/frontend-mobile/components/SearchBar.tsx b/apps/frontend-mobile/components/SearchBar.tsx index 7f37650..06b95c5 100644 --- a/apps/frontend-mobile/components/SearchBar.tsx +++ b/apps/frontend-mobile/components/SearchBar.tsx @@ -3,6 +3,7 @@ import { View, TextInput, StyleSheet, TouchableOpacity, useWindowDimensions } fr import { Ionicons } from '@expo/vector-icons'; import { useThemeContext } from './ThemeProvider'; import { spacing, borderRadius } from '../constants/theme'; +import { useTranslation } from '../src/i18n'; const TABLET_MIN_WIDTH = 768; @@ -14,7 +15,7 @@ interface SearchBarProps { } export function SearchBar({ - placeholder = 'Buscar medicamentos...', + placeholder, onSearch, value, onChangeText @@ -22,6 +23,7 @@ export function SearchBar({ const { width } = useWindowDimensions(); const isTablet = width >= TABLET_MIN_WIDTH; const { colors } = useThemeContext(); + const { t } = useTranslation(); const [localValue, setLocalValue] = useState(value || ''); const handleChange = (text: string) => { @@ -44,7 +46,7 @@ export function SearchBar({ { if (stock === 0) { @@ -25,7 +27,7 @@ export function StockBadge({ stock }: StockBadgeProps) { return ( - {stock === 0 ? 'Sin stock' : stock < 5 ? `Bajo (${stock})` : `Disponible (${stock})`} + {stock === 0 ? t('stockBadge.outOfStock') : stock < 5 ? `${t('stockBadge.lowStock')} (${stock})` : `${t('stockBadge.inStock')} (${stock})`} ); diff --git a/apps/frontend-mobile/src/i18n/LanguageContext.jsx b/apps/frontend-mobile/src/i18n/LanguageContext.jsx new file mode 100644 index 0000000..a5058ad --- /dev/null +++ b/apps/frontend-mobile/src/i18n/LanguageContext.jsx @@ -0,0 +1,50 @@ +import React, { createContext, useState, useCallback, useMemo } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import es from './locales/es'; +import ca from './locales/ca'; + +const locales = { es, ca }; +const STORAGE_KEY = 'ff-lang'; + +function getInitialLang() { + // Default to 'es' - will be updated from AsyncStorage in the provider + return 'es'; +} + +export const LanguageContext = createContext(null); + +export function LanguageProvider({ children }) { + const [lang, setLangState] = useState(getInitialLang); + const [ready, setReady] = useState(false); + + // Load saved language on mount + React.useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then((saved) => { + if (saved === 'ca' || saved === 'es') { + setLangState(saved); + } + // Default to 'es' if no saved preference + setReady(true); + }).catch(() => { + setReady(true); + }); + }, []); + + const setLang = useCallback((newLang) => { + if (newLang !== 'ca' && newLang !== 'es') return; + setLangState(newLang); + AsyncStorage.setItem(STORAGE_KEY, newLang).catch(() => {}); + }, []); + + const t = useCallback((key) => { + return locales[lang]?.[key] || locales.es[key] || key; + }, [lang]); + + const value = useMemo(() => ({ lang, setLang, t, ready }), [lang, setLang, t, ready]); + + return ( + + {children} + + ); +} diff --git a/apps/frontend-mobile/src/i18n/index.js b/apps/frontend-mobile/src/i18n/index.js new file mode 100644 index 0000000..34dd51f --- /dev/null +++ b/apps/frontend-mobile/src/i18n/index.js @@ -0,0 +1,2 @@ +export { LanguageProvider } from './LanguageContext'; +export { useTranslation } from './useTranslation'; diff --git a/apps/frontend-mobile/src/i18n/locales/ca.js b/apps/frontend-mobile/src/i18n/locales/ca.js new file mode 100644 index 0000000..8d8c690 --- /dev/null +++ b/apps/frontend-mobile/src/i18n/locales/ca.js @@ -0,0 +1,228 @@ +const ca = { + // Tab bar + 'nav.home': 'Inici', + 'nav.search': 'Cercar', + 'nav.scan': 'Escanejar', + 'nav.alerts': 'Alertes', + 'nav.profile': 'Perfil', + + // Stack screens + 'stack.medicine': 'Medicament', + 'stack.pharmacy': 'Farmàcia', + 'stack.scanner': 'Escanejar', + 'stack.login': 'Iniciar Sessió', + 'stack.register': 'Registrar-se', + + // Home + 'home.description': 'Trobeu els vostres medicaments a farmàcies properes', + 'home.searchMedicine': 'Cercar Medicament', + 'home.scanTSI': 'Escanejar TSI', + + // Search + 'search.placeholder': 'Cercar medicaments...', + 'search.suggestions': 'Suggeriments', + 'search.recentSearches': 'Cerques recents', + 'search.loading': 'Cercant...', + 'search.noResults': 'No s\'han trobat resultats', + 'search.resultsFound': 'resultats trobats', + 'search.parapharmacy': 'Parafarmàcia', + 'search.error': 'Error en cercar medicaments', + + // Login + 'login.tab.login': 'Iniciar Sessió', + 'login.tab.register': 'Crear Compte', + 'login.title.login': 'Benvingut de nou', + 'login.title.register': 'Crea el teu compte', + 'login.subtitle.login': 'Inicia sessió per gestionar el teu perfil i notificacions.', + 'login.subtitle.register': 'Desa la teva adreça i rep notificacions quan arribin medicaments.', + 'login.username': 'Usuari', + 'login.password': 'Contrasenya', + 'login.confirmPassword': 'Confirmar contrasenya', + 'login.hint.username': '3-32 caràcters per a l\'usuari', + 'login.hint.password': 'Mínim 8 caràcters per a la contrasenya', + 'login.biometric': 'Iniciar amb biometria', + 'login.link.toLogin': 'Ja tens compte? Inicia sessió', + 'login.link.toRegister': 'No tens compte? Registra\'t', + 'login.alert.error': 'Error', + 'login.alert.fillFields': 'Si us plau, completeu tots els camps', + 'login.alert.passwordLength': 'La contrasenya ha de tenir almenys 8 caràcters', + 'login.alert.passwordMismatch': 'Les contrasenyes no coincideixen', + 'login.alert.success': 'Èxit', + 'login.alert.accountCreated': 'Compte creat correctament', + 'login.alert.ok': 'D\'acord', + 'login.alert.createError': 'No s\'ha pogut crear el compte', + 'login.alert.wrongCredentials': 'Credencials incorrectes', + 'login.alert.noBiometrics': 'No hi ha credencials biomètriques guardades', + 'login.alert.biometricFailed': 'Autenticació biomètrica fallida', + 'login.alert.biometricError': 'Error en l\'autenticació biomètrica', + + // Scanner + 'scanner.title': 'Escanejar TSI', + 'scanner.description': 'Escanegeu el codi de barres de la vostra targeta sanitària per trobar els vostres medicaments', + 'scanner.startScan': 'Iniciar escaneig', + 'scanner.or': 'o', + 'scanner.uploadPhoto': 'Pujar foto del dispositiu', + 'scanner.uploadDescription': 'Tireu o seleccioneu una foto del dispositiu sanitari', + 'scanner.takePhoto': 'Fer foto', + 'scanner.gallery': 'Galeria', + 'scanner.photoSelected': 'Foto seleccionada correctament', + 'scanner.manualEntry': 'Introduir número manualment', + 'scanner.manualDescription': 'Escriviu el número de la vostra targeta sanitària', + 'scanner.manualPlaceholder': 'Introduïu el número de targeta', + 'scanner.cameraPermission': 'Permís de càmera requerit', + 'scanner.cameraPermissionDesc': 'Necessitem accés a la càmera per fer fotos del dispositiu.', + 'scanner.galleryPermission': 'Permís requerit', + 'scanner.galleryPermissionDesc': 'Necessitem accés a la galeria per seleccionar fotos.', + 'scanner.fieldRequired': 'Camp requerit', + 'scanner.fieldRequiredDesc': 'Si us plau, introduïu el número de la targeta.', + + // Alerts + 'alerts.title': 'Notificacions Desades', + 'alerts.description': 'Rebeu avisos quan medicaments sense estoc es repoixin', + 'alerts.loading': 'Carregant notificacions...', + 'alerts.loginRequired': 'Inicieu sessió per continuar', + 'alerts.loginDescription': 'Necessiteu estar autenticat per veure les vostres notificacions', + 'alerts.loginBtn': 'Iniciar Sessió', + 'alerts.empty': 'Sense notificacions', + 'alerts.emptyDescription': 'Toqueu la campana a una farmàcia sense estoc per rebre notificacions quan es repoixi.', + 'alerts.retry': 'Tornar a provar', + 'alerts.delete': 'Eliminar notificació', + 'alerts.deleteConfirm': 'Eliminar la notificació de', + 'alerts.cancel': 'Cancel·lar', + 'alerts.confirmDelete': 'Eliminar', + 'alerts.deleteError': 'No s\'ha pogut eliminar', + 'alerts.loadError': 'No s\'han pogut carregar les notificacions', + 'alerts.pharmacy': 'Farmàcia', + 'alerts.anyPharmacy': 'Qualsevol farmàcia', + + // Profile + 'profile.title': 'Perfil', + 'profile.loginTitle': 'Iniciar Sessió', + 'profile.loginDescription': 'Inicieu sessió per accedir al vostre perfil, notificacions i més', + 'profile.loginBtn': 'Iniciar Sessió', + 'profile.personalData': 'Dades personals', + 'profile.firstName': 'Nom', + 'profile.lastName': 'Cognoms', + 'profile.appearance': 'Aparença', + 'profile.config': 'Configuració', + 'profile.myAddresses': 'Les Meves Adreces', + 'profile.admin': 'Panell Admin', + 'profile.recentSearches': 'Cerques recents', + 'profile.logout': 'Tancar Sessió', + 'profile.logoutConfirm': 'Esteu segur que voleu tancar la sessió?', + 'profile.logoutTitle': 'Tancar Sessió', + 'profile.logoutCancel': 'Cancel·lar', + 'profile.logoutConfirmBtn': 'Tancar Sessió', + 'profile.changeAvatar': 'Canviar Avatar', + 'profile.presetAvatar': 'Predissenyat', + 'profile.colors': 'Colors', + 'profile.upload': 'Pujar', + 'profile.takePhoto': 'Fer foto', + 'profile.takePhotoDesc': 'Useu la càmera del vostre dispositiu', + 'profile.chooseGallery': 'Trieu de galeria', + 'profile.chooseGalleryDesc': 'Seleccioneu una imatge existent', + 'profile.configTitle': 'Configuració', + 'profile.firstNameLabel': 'Nom', + 'profile.firstNamePlaceholder': 'El vostre nom', + 'profile.lastNameLabel': 'Cognoms', + 'profile.lastNamePlaceholder': 'Els vostres cognoms', + 'profile.email': 'Correu electrònic', + 'profile.city': 'Ciutat', + 'profile.cityPlaceholder': 'La vostra ciutat', + 'profile.address': 'Adreça', + 'profile.addressPlaceholder': 'Carrer Major 1, Barcelona', + 'profile.cancel': 'Cancel·lar', + 'profile.save': 'Desar', + 'profile.profileSaved': 'Perfil desat.', + 'profile.saveError': 'Error en desar', + 'profile.addressRequired': 'L\'adreça és obligatòria', + 'profile.addressesTitle': 'Les Meves Adreces', + 'profile.addressLabel': 'Adreça', + 'profile.addressOptional': 'Etiqueta (opcional)', + 'profile.addressLabelPlaceholder': 'Casa, Treball...', + 'profile.defaultAddress': 'Adreça predeterminada', + 'profile.update': 'Actualitzar', + 'profile.add': 'Afegir', + 'profile.mainAddress': 'Principal', + 'profile.setDefault': 'Establir com a predeterminada', + 'profile.addAddress': 'Afegir adreça', + 'profile.cameraPermission': 'Permís requerit', + 'profile.cameraPermissionDesc': 'Necessitem permís per accedir a la càmera.', + 'profile.themeSystem': 'Sistema', + 'profile.themeLight': 'Clar', + 'profile.themeDark': 'Fosc', + 'profile.language': 'Idioma', + 'profile.languageTitle': 'Idioma de l\'aplicació', + 'profile.languageCatalan': 'Català', + 'profile.languageSpanish': 'Castellà', + + // Medicine detail + 'medicine.loading': 'Carregant medicament...', + 'medicine.notFound': 'Medicament no trobat', + 'medicine.activeIngredient': 'Principi actiu', + 'medicine.laboratory': 'Laboratori', + 'medicine.form': 'Forma farmacèutica', + 'medicine.dosage': 'Dosificació', + 'medicine.price': 'Preu', + 'medicine.notAvailable': 'No disponible', + 'medicine.registration': 'Registre', + 'medicine.mapSoon': 'Mapa properament…', + 'medicine.pharmacies': 'Farmàcies', + 'medicine.locating': 'Localitzant…', + 'medicine.distanceReset': 'Distància · Restablir', + 'medicine.sortByDistance': 'Ordenar per distància', + 'medicine.retry': 'Tornar a provar', + 'medicine.noPharmacies': 'No hi ha farmàcies disponibles', + 'medicine.checkPrice': 'Consultar preu', + 'medicine.howToGet': 'Com arribar', + 'medicine.locationDenied': 'Permís d\'ubicació denegat', + 'medicine.locationError': 'No s\'ha pogut obtenir la vostra ubicació', + + // Pharmacy detail + 'pharmacy.loading': 'Carregant farmàcia...', + 'pharmacy.notFound': 'Farmàcia no trobada', + 'pharmacy.call': 'Trucar', + 'pharmacy.howToGet': 'Com arribar', + 'pharmacy.medications': 'Medicaments', + 'pharmacy.noMedications': 'No hi ha medicaments disponibles', + 'pharmacy.reg': 'Reg: ', + 'pharmacy.stock': 'Estoc: ', + 'pharmacy.notificationsActive': 'Notificacions actives', + 'pharmacy.notifyWhenAvailable': 'Notificar-me quan hi hagi estoc', + + // Product detail + 'product.loading': 'Carregant producte...', + 'product.notFound': 'Producte no trobat', + 'product.noImage': 'Sense imatge', + 'product.parapharmacy': 'Parafarmàcia', + 'product.cimaDetails': 'Detalls CIMA', + 'product.activeIngredient': 'Principi actiu', + 'product.dosage': 'Dosificació', + 'product.form': 'Forma farmacèutica', + 'product.dispensation': 'Tipus de dispensació', + 'product.commercialized': 'Comercialitzat', + 'product.yes': 'Sí', + 'product.no': 'No', + 'product.productInfo': 'Informació del producte', + 'product.price': 'Preu', + 'product.previousPrice': 'Preu anterior', + 'product.category': 'Categoria', + 'product.brand': 'Marca', + 'product.source': 'Font', + 'product.images': 'Imatges', + + // Components + 'medicineCard.noPrice': 'Sense preu', + 'stockBadge.outOfStock': 'Sense estoc', + 'stockBadge.lowStock': 'Baix', + 'stockBadge.inStock': 'Disponible', + 'loadingSpinner.loading': 'Carregant...', + 'barcodeScanner.permissionRequired': 'Permís de càmera requerit', + 'barcodeScanner.permissionDesc': 'Necessitem accés a la càmera per escanejar codis de barres', + 'barcodeScanner.grantPermission': 'Concedir permís', + 'barcodeScanner.cancel': 'Cancel·lar', + 'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament', + 'barcodeScanner.scanAgain': 'Escanejar de nou', +}; + +export default ca; diff --git a/apps/frontend-mobile/src/i18n/locales/es.js b/apps/frontend-mobile/src/i18n/locales/es.js new file mode 100644 index 0000000..7157343 --- /dev/null +++ b/apps/frontend-mobile/src/i18n/locales/es.js @@ -0,0 +1,228 @@ +const es = { + // Tab bar + 'nav.home': 'Inicio', + 'nav.search': 'Buscar', + 'nav.scan': 'Escanear', + 'nav.alerts': 'Avisos', + 'nav.profile': 'Perfil', + + // Stack screens + 'stack.medicine': 'Medicamento', + 'stack.pharmacy': 'Farmacia', + 'stack.scanner': 'Escanear', + 'stack.login': 'Iniciar Sesión', + 'stack.register': 'Registrarse', + + // Login / Register + 'login.tab.login': 'Iniciar Sesión', + 'login.tab.register': 'Crear Cuenta', + 'login.title.login': 'Bienvenido de nuevo', + 'login.title.register': 'Crea tu cuenta', + 'login.subtitle.login': 'Inicia sesión para gestionar tu perfil y notificaciones.', + 'login.subtitle.register': 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.', + 'login.username': 'Usuario', + 'login.password': 'Contraseña', + 'login.confirmPassword': 'Confirmar contraseña', + 'login.hint.username': '3-32 caracteres para el usuario', + 'login.hint.password': 'Mínimo 8 caracteres para la contraseña', + 'login.biometric': 'Iniciar con biometría', + 'login.link.toLogin': '¿Ya tienes cuenta? Inicia sesión', + 'login.link.toRegister': '¿No tienes cuenta? Regístrate', + 'login.alert.error': 'Error', + 'login.alert.fillFields': 'Por favor completa todos los campos', + 'login.alert.passwordLength': 'La contraseña debe tener al menos 8 caracteres', + 'login.alert.passwordMismatch': 'Las contraseñas no coinciden', + 'login.alert.success': 'Éxito', + 'login.alert.accountCreated': 'Cuenta creada correctamente', + 'login.alert.ok': 'OK', + 'login.alert.createError': 'No se pudo crear la cuenta', + 'login.alert.wrongCredentials': 'Credenciales incorrectas', + 'login.alert.noBiometrics': 'No hay credenciales biométricas guardadas', + 'login.alert.biometricFailed': 'Autenticación biométrica fallida', + 'login.alert.biometricError': 'Error en la autenticación biométrica', + + // Home + 'home.description': 'Encuentra tus medicamentos en farmacias cercanas', + 'home.searchMedicine': 'Buscar Medicamento', + 'home.scanTSI': 'Escanear TSI', + + // Search + 'search.placeholder': 'Buscar medicamentos...', + 'search.suggestions': 'Sugerencias', + 'search.recentSearches': 'Búsquedas recientes', + 'search.loading': 'Buscando...', + 'search.noResults': 'No se encontraron resultados', + 'search.resultsFound': 'resultados encontrados', + 'search.parapharmacy': 'Parafarmacia', + 'search.error': 'Error al buscar medicamentos', + + // Scanner + 'scanner.title': 'Escanear TSI', + 'scanner.description': 'Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos', + 'scanner.startScan': 'Iniciar escaneo', + 'scanner.or': 'o', + 'scanner.uploadPhoto': 'Subir foto del dispositivo', + 'scanner.uploadDescription': 'Toma o selecciona una foto del dispositivo sanitario', + 'scanner.takePhoto': 'Tomar foto', + 'scanner.gallery': 'Galería', + 'scanner.photoSelected': 'Foto seleccionada correctamente', + 'scanner.manualEntry': 'Introducir número manualmente', + 'scanner.manualDescription': 'Escribe el número de tu tarjeta sanitaria', + 'scanner.manualPlaceholder': 'Introduce el número de tarjeta', + 'scanner.cameraPermission': 'Permiso de cámara requerido', + 'scanner.cameraPermissionDesc': 'Necesitamos acceso a la cámara para tomar fotos del dispositivo.', + 'scanner.galleryPermission': 'Permiso requerido', + 'scanner.galleryPermissionDesc': 'Necesitamos acceso a la galería para seleccionar fotos.', + 'scanner.fieldRequired': 'Campo requerido', + 'scanner.fieldRequiredDesc': 'Por favor, introduce el número de la tarjeta.', + + // Alerts + 'alerts.title': 'Notificaciones Guardadas', + 'alerts.description': 'Recibe avisos cuando medicamentos sin stock se repongan', + 'alerts.loading': 'Cargando notificaciones...', + 'alerts.loginRequired': 'Inicia sesión para continuar', + 'alerts.loginDescription': 'Necesitas estar autenticado para ver tus notificaciones', + 'alerts.loginBtn': 'Iniciar Sesión', + 'alerts.empty': 'Sin notificaciones', + 'alerts.emptyDescription': 'Toca la campana en una farmacia sin stock para recibir notificaciones cuando se reponga.', + 'alerts.retry': 'Reintentar', + 'alerts.delete': 'Eliminar notificación', + 'alerts.deleteConfirm': '¿Eliminar la notificación de', + 'alerts.cancel': 'Cancelar', + 'alerts.confirmDelete': 'Eliminar', + 'alerts.deleteError': 'No se pudo eliminar', + 'alerts.loadError': 'No se pudieron cargar las notificaciones', + 'alerts.pharmacy': 'Farmacia', + 'alerts.anyPharmacy': 'Cualquier farmacia', + + // Profile + 'profile.title': 'Perfil', + 'profile.loginTitle': 'Inicia Sesión', + 'profile.loginDescription': 'Inicia sesión para acceder a tu perfil, notificaciones y más', + 'profile.loginBtn': 'Iniciar Sesión', + 'profile.personalData': 'Datos personales', + 'profile.firstName': 'Nombre', + 'profile.lastName': 'Apellidos', + 'profile.appearance': 'Apariencia', + 'profile.config': 'Configuración', + 'profile.myAddresses': 'Mis Direcciones', + 'profile.admin': 'Panel Admin', + 'profile.recentSearches': 'Búsquedas recientes', + 'profile.logout': 'Cerrar Sesión', + 'profile.logoutConfirm': '¿Estás seguro que deseas cerrar sesión?', + 'profile.logoutTitle': 'Cerrar Sesión', + 'profile.logoutCancel': 'Cancelar', + 'profile.logoutConfirmBtn': 'Cerrar Sesión', + 'profile.changeAvatar': 'Cambiar Avatar', + 'profile.presetAvatar': 'Prediseñado', + 'profile.colors': 'Colores', + 'profile.upload': 'Subir', + 'profile.takePhoto': 'Tomar foto', + 'profile.takePhotoDesc': 'Usa la cámara de tu dispositivo', + 'profile.chooseGallery': 'Elegir de galería', + 'profile.chooseGalleryDesc': 'Selecciona una imagen existente', + 'profile.configTitle': 'Configuración', + 'profile.firstNameLabel': 'Nombre', + 'profile.firstNamePlaceholder': 'Tu nombre', + 'profile.lastNameLabel': 'Apellidos', + 'profile.lastNamePlaceholder': 'Tus apellidos', + 'profile.email': 'Correo electrónico', + 'profile.city': 'Ciudad', + 'profile.cityPlaceholder': 'Tu ciudad', + 'profile.address': 'Dirección', + 'profile.addressPlaceholder': 'Calle Mayor 1, Madrid', + 'profile.cancel': 'Cancelar', + 'profile.save': 'Guardar', + 'profile.profileSaved': 'Perfil guardado.', + 'profile.saveError': 'Error al guardar', + 'profile.addressRequired': 'La dirección es obligatoria', + 'profile.addressesTitle': 'Mis Direcciones', + 'profile.addressLabel': 'Dirección', + 'profile.addressOptional': 'Etiqueta (opcional)', + 'profile.addressLabelPlaceholder': 'Casa, Trabajo...', + 'profile.defaultAddress': 'Dirección predeterminada', + 'profile.update': 'Actualizar', + 'profile.add': 'Añadir', + 'profile.mainAddress': 'Principal', + 'profile.setDefault': 'Marcar como predeterminada', + 'profile.addAddress': 'Añadir dirección', + 'profile.cameraPermission': 'Permiso requerido', + 'profile.cameraPermissionDesc': 'Necesitamos permiso para acceder a la cámara.', + 'profile.themeSystem': 'Sistema', + 'profile.themeLight': 'Claro', + 'profile.themeDark': 'Oscuro', + 'profile.language': 'Idioma', + 'profile.languageTitle': 'Idioma de la aplicación', + 'profile.languageCatalan': 'Català', + 'profile.languageSpanish': 'Castellano', + + // Medicine detail + 'medicine.loading': 'Cargando medicamento...', + 'medicine.notFound': 'Medicamento no encontrado', + 'medicine.activeIngredient': 'Principio activo', + 'medicine.laboratory': 'Laboratorio', + 'medicine.form': 'Forma farmacéutica', + 'medicine.dosage': 'Dosificación', + 'medicine.price': 'Precio', + 'medicine.notAvailable': 'No disponible', + 'medicine.registration': 'Registro', + 'medicine.mapSoon': 'Mapa próximamente…', + 'medicine.pharmacies': 'Farmacias', + 'medicine.locating': 'Localizando…', + 'medicine.distanceReset': 'Distancia · Reset', + 'medicine.sortByDistance': 'Ordenar por distancia', + 'medicine.retry': 'Reintentar', + 'medicine.noPharmacies': 'No hay farmacias disponibles', + 'medicine.checkPrice': 'Consultar precio', + 'medicine.howToGet': 'Cómo llegar', + 'medicine.locationDenied': 'Permiso de ubicación denegado', + 'medicine.locationError': 'No se pudo obtener tu ubicación', + + // Pharmacy detail + 'pharmacy.loading': 'Cargando farmacia...', + 'pharmacy.notFound': 'Farmacia no encontrada', + 'pharmacy.call': 'Llamar', + 'pharmacy.howToGet': 'Cómo llegar', + 'pharmacy.medications': 'Medicamentos', + 'pharmacy.noMedications': 'No hay medicamentos disponibles', + 'pharmacy.reg': 'Reg: ', + 'pharmacy.stock': 'Stock: ', + 'pharmacy.notificationsActive': 'Notificaciones activas', + 'pharmacy.notifyWhenAvailable': 'Notificarme cuando haya stock', + + // Product detail + 'product.loading': 'Cargando producto...', + 'product.notFound': 'Producto no encontrado', + 'product.noImage': 'Sin imagen', + 'product.parapharmacy': 'Parafarmacia', + 'product.cimaDetails': 'Detalles CIMA', + 'product.activeIngredient': 'Principio activo', + 'product.dosage': 'Dosificación', + 'product.form': 'Forma farmacéutica', + 'product.dispensation': 'Tipo de dispensación', + 'product.commercialized': 'Comercializado', + 'product.yes': 'Sí', + 'product.no': 'No', + 'product.productInfo': 'Información del producto', + 'product.price': 'Precio', + 'product.previousPrice': 'Precio anterior', + 'product.category': 'Categoría', + 'product.brand': 'Marca', + 'product.source': 'Fuente', + 'product.images': 'Imágenes', + + // Components + 'medicineCard.noPrice': 'Sin precio', + 'stockBadge.outOfStock': 'Sin stock', + 'stockBadge.lowStock': 'Bajo', + 'stockBadge.inStock': 'Disponible', + 'loadingSpinner.loading': 'Cargando...', + 'barcodeScanner.permissionRequired': 'Permiso de cámara requerido', + 'barcodeScanner.permissionDesc': 'Necesitamos acceso a la cámara para escanear códigos de barras', + 'barcodeScanner.grantPermission': 'Conceder permiso', + 'barcodeScanner.cancel': 'Cancelar', + 'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento', + 'barcodeScanner.scanAgain': 'Escanear de nuevo', +}; + +export default es; diff --git a/apps/frontend-mobile/src/i18n/useTranslation.js b/apps/frontend-mobile/src/i18n/useTranslation.js new file mode 100644 index 0000000..c6533ef --- /dev/null +++ b/apps/frontend-mobile/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; +}