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} )}