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