fix: add i18n support for parapharmacy products and notification bell #48

Merged
Ichitux merged 2 commits from fix/parapharmacy-translation-and-notifications into main 2026-07-17 11:15:48 +00:00
7 changed files with 153 additions and 31 deletions
Showing only changes of commit 854de92be5 - Show all commits
+4 -2
View File
@@ -7,10 +7,12 @@ import { LoadingSpinner } from '../../components/LoadingSpinner';
import { useThemeContext } from '../../components/ThemeProvider'; import { useThemeContext } from '../../components/ThemeProvider';
import { spacing, borderRadius } from '../../constants/theme'; import { spacing, borderRadius } from '../../constants/theme';
import { Pharmacy } from '../../types'; import { Pharmacy } from '../../types';
import { useTranslation } from '../../src/i18n';
export default function MapScreen() { export default function MapScreen() {
const router = useRouter(); const router = useRouter();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const { t } = useTranslation();
const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]); const [pharmacies, setPharmacies] = useState<Pharmacy[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [region, setRegion] = useState({ const [region, setRegion] = useState({
@@ -45,7 +47,7 @@ export default function MapScreen() {
}, []); }, []);
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Cargando farmacias..." />; return <LoadingSpinner message={t('map.loadingPharmacies')} />;
} }
return ( return (
@@ -73,7 +75,7 @@ export default function MapScreen() {
<View style={[styles.legend, { backgroundColor: colors.card }]}> <View style={[styles.legend, { backgroundColor: colors.card }]}>
<Text style={[styles.legendText, { color: colors.text }]}> <Text style={[styles.legendText, { color: colors.text }]}>
{pharmacies.length} farmacias en el mapa {t('map.pharmaciesOnMap', { count: pharmacies.length })}
</Text> </Text>
</View> </View>
</View> </View>
@@ -1,7 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, Image, ActivityIndicator } from 'react-native'; import { View, Text, ScrollView, StyleSheet, Image, TouchableOpacity } from 'react-native';
import { useLocalSearchParams } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { getProduct, Product } from '../../../services/products'; import { getProduct, Product } from '../../../services/products';
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../../services/notifications';
import { useAuth } from '../../../hooks/useAuth';
import { LoadingSpinner } from '../../../components/LoadingSpinner'; import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { useThemeContext } from '../../../components/ThemeProvider'; import { useThemeContext } from '../../../components/ThemeProvider';
import { spacing, borderRadius } from '../../../constants/theme'; import { spacing, borderRadius } from '../../../constants/theme';
@@ -11,9 +14,12 @@ export default function ProductDetailScreen() {
const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); const { source, id } = useLocalSearchParams<{ source: string; id: string }>();
const { colors } = useThemeContext(); const { colors } = useThemeContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { isAuthenticated } = useAuth();
const [product, setProduct] = useState<Product | null>(null); const [product, setProduct] = useState<Product | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [togglingSub, setTogglingSub] = useState(false);
useEffect(() => { useEffect(() => {
if (!source || !id) return; if (!source || !id) return;
@@ -36,6 +42,25 @@ export default function ProductDetailScreen() {
fetchProduct(); fetchProduct();
}, [source, id]); }, [source, id]);
const handleToggleSubscription = async () => {
if (!isAuthenticated || !id) return;
setTogglingSub(true);
const productId = product?._id || product?.id || id;
try {
if (isSubscribed) {
await unsubscribeFromMedicine(productId);
setIsSubscribed(false);
} else {
await subscribeToMedicine(productId, product?.name || null);
setIsSubscribed(true);
}
} catch {
// silently fail
} finally {
setTogglingSub(false);
}
};
if (isLoading) { if (isLoading) {
return <LoadingSpinner message={t('product.loading')} />; return <LoadingSpinner message={t('product.loading')} />;
} }
@@ -63,8 +88,23 @@ export default function ProductDetailScreen() {
<View style={[styles.header, { backgroundColor: colors.card }]}> <View style={[styles.header, { backgroundColor: colors.card }]}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
<Text style={[styles.name, { color: colors.text }]}>{product.name}</Text> <Text style={[styles.name, { color: colors.text }]}>{product.name}</Text>
<View style={[styles.badge, { backgroundColor: isCima ? '#2b5bb5' : '#4caf50' }]}> <View style={styles.headerRight}>
<Text style={styles.badgeText}>{isCima ? 'CIMA' : t('product.parapharmacy')}</Text> <View style={[styles.badge, { backgroundColor: isCima ? '#2b5bb5' : '#4caf50' }]}>
<Text style={styles.badgeText}>{isCima ? 'CIMA' : t('product.parapharmacy')}</Text>
</View>
{isAuthenticated && (
<TouchableOpacity
style={[styles.bellButton, { backgroundColor: isSubscribed ? colors.primary : colors.surfaceVariant }]}
onPress={handleToggleSubscription}
disabled={togglingSub}
>
<Ionicons
name={isSubscribed ? 'notifications' : 'notifications-outline'}
size={20}
color={isSubscribed ? '#fff' : colors.textSecondary}
/>
</TouchableOpacity>
)}
</View> </View>
</View> </View>
{product.brand ? ( {product.brand ? (
@@ -171,6 +211,18 @@ const styles = StyleSheet.create({
fontSize: 22, fontSize: 22,
fontWeight: 'bold', fontWeight: 'bold',
}, },
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
bellButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
badge: { badge: {
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
@@ -28,6 +28,10 @@ const ca = {
'search.parapharmacy': 'Parafarmàcia', 'search.parapharmacy': 'Parafarmàcia',
'search.error': 'Error en cercar medicaments', 'search.error': 'Error en cercar medicaments',
// Map
'map.loadingPharmacies': 'Carregant farmàcies...',
'map.pharmaciesOnMap': '{{count}} farmàcies al mapa',
// Login // Login
'login.tab.login': 'Iniciar Sessió', 'login.tab.login': 'Iniciar Sessió',
'login.tab.register': 'Crear Compte', 'login.tab.register': 'Crear Compte',
@@ -210,6 +214,9 @@ const ca = {
'product.brand': 'Marca', 'product.brand': 'Marca',
'product.source': 'Font', 'product.source': 'Font',
'product.images': 'Imatges', 'product.images': 'Imatges',
'product.availableInPharmacies': 'Disponible a {{count}} farmàcia/es',
'product.notifyWhenAvailable': 'Notificar-me quan hi hagi estoc',
'product.notificationsActive': 'Notificacions actives',
// Components // Components
'medicineCard.noPrice': 'Sense preu', 'medicineCard.noPrice': 'Sense preu',
@@ -56,6 +56,10 @@ const es = {
'search.parapharmacy': 'Parafarmacia', 'search.parapharmacy': 'Parafarmacia',
'search.error': 'Error al buscar medicamentos', 'search.error': 'Error al buscar medicamentos',
// Map
'map.loadingPharmacies': 'Cargando farmacias...',
'map.pharmaciesOnMap': '{{count}} farmacias en el mapa',
// Scanner // Scanner
'scanner.title': 'Escanear TSI', 'scanner.title': 'Escanear TSI',
'scanner.description': 'Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos', 'scanner.description': 'Escanea el código de barras de tu tarjeta sanitaria para encontrar tus medicamentos',
@@ -210,6 +214,9 @@ const es = {
'product.brand': 'Marca', 'product.brand': 'Marca',
'product.source': 'Fuente', 'product.source': 'Fuente',
'product.images': 'Imágenes', 'product.images': 'Imágenes',
'product.availableInPharmacies': 'Disponible en {{count}} farmacia(s)',
'product.notifyWhenAvailable': 'Notificarme cuando haya stock',
'product.notificationsActive': 'Notificaciones activas',
// Components // Components
'medicineCard.noPrice': 'Sin precio', 'medicineCard.noPrice': 'Sin precio',
+26
View File
@@ -81,6 +81,32 @@ const ca = {
'product.dosage': 'Dosificació:', 'product.dosage': 'Dosificació:',
'product.viewDetails': 'Veure detalls →', 'product.viewDetails': 'Veure detalls →',
// ProductView (detail)
'productView.loading': 'Carregant...',
'productView.back': 'Tornar',
'productView.notFound': 'Producte no trobat',
'productView.parapharmacy': 'Parafarmàcia',
'productView.activeIngredient': 'Principi actiu',
'productView.dosage': 'Dosificació',
'productView.form': 'Forma farmacèutica',
'productView.prescription': 'Prescripció',
'productView.commercialized': 'Comercialitzat',
'productView.yes': 'Sí',
'productView.no': 'No',
'productView.price': 'Preu',
'productView.previousPrice': 'Preu anterior',
'productView.category': 'Categoria',
'productView.brand': 'Marca',
'productView.source': 'Font',
'productView.loadingPharmacies': 'Carregant farmàcies...',
'productView.availableInPharmacies': 'Disponible a {{count}} farmàcia/es',
'productView.locating': '📍 Localitzant…',
'productView.sortedByDistance': '📍 Ordenat per distància · Restablir',
'productView.sortByDistance': '📍 Ordenar per distància',
'productView.usingLocation': 'Usant la vostra ubicació',
'productView.retry': 'Tornar a provar',
'productView.productNotFound': 'Producte no trobat',
// LoginModal // LoginModal
'login.login': 'Iniciar sessió', 'login.login': 'Iniciar sessió',
'login.register': 'Crear compte', 'login.register': 'Crear compte',
+26
View File
@@ -81,6 +81,32 @@ const es = {
'product.dosage': 'Dosis:', 'product.dosage': 'Dosis:',
'product.viewDetails': 'Ver detalles →', 'product.viewDetails': 'Ver detalles →',
// ProductView (detail)
'productView.loading': 'Cargando...',
'productView.back': 'Volver',
'productView.notFound': 'Producto no encontrado',
'productView.parapharmacy': 'Parafarmacia',
'productView.activeIngredient': 'Principio activo',
'productView.dosage': 'Dosis',
'productView.form': 'Forma farmacéutica',
'productView.prescription': 'Prescripción',
'productView.commercialized': 'Comercializado',
'productView.yes': 'Sí',
'productView.no': 'No',
'productView.price': 'Precio',
'productView.previousPrice': 'Precio anterior',
'productView.category': 'Categoría',
'productView.brand': 'Marca',
'productView.source': 'Fuente',
'productView.loadingPharmacies': 'Cargando farmacias...',
'productView.availableInPharmacies': 'Disponible en {{count}} farmacia(s)',
'productView.locating': '📍 Localizando…',
'productView.sortedByDistance': '📍 Ordenado por distancia · Reset',
'productView.sortByDistance': '📍 Ordenar por distancia',
'productView.usingLocation': 'Usando tu ubicación',
'productView.retry': 'Reintentar',
'productView.productNotFound': 'Producto no encontrado',
// LoginModal // LoginModal
'login.login': 'Iniciar sesión', 'login.login': 'Iniciar sesión',
'login.register': 'Crear cuenta', 'login.register': 'Crear cuenta',
+28 -26
View File
@@ -2,9 +2,11 @@ import React, { useState, useEffect, useMemo } from 'react';
import PharmacyMap from '../components/PharmacyMap'; import PharmacyMap from '../components/PharmacyMap';
import PharmacyList from '../components/PharmacyList'; import PharmacyList from '../components/PharmacyList';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo'; import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import { useTranslation } from '../i18n';
import './ProductView.css'; import './ProductView.css';
export default function ProductView({ source, id, onBack }) { export default function ProductView({ source, id, onBack }) {
const { t } = useTranslation();
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -43,11 +45,11 @@ export default function ProductView({ source, id, onBack }) {
setPositionSource('browser'); setPositionSource('browser');
setSortByDistance(true); setSortByDistance(true);
} catch (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 && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado.'; if (err.code === 1) msg = t('search.locationDenied');
else if (err.code === 2) msg = 'Ubicación no disponible.'; else if (err.code === 2) msg = t('search.locationUnavailable');
else if (err.code === 3) msg = 'La ubicación tardó demasiado.'; else if (err.code === 3) msg = t('search.locationTimeout');
} }
setLocationError(msg); setLocationError(msg);
} finally { } finally {
@@ -72,7 +74,7 @@ export default function ProductView({ source, id, onBack }) {
const response = await fetch(apiUrl); const response = await fetch(apiUrl);
if (!response.ok) { if (!response.ok) {
throw new Error('Producto no encontrado'); throw new Error(t('productView.productNotFound'));
} }
const data = await response.json(); const data = await response.json();
setProduct(data); setProduct(data);
@@ -102,7 +104,7 @@ export default function ProductView({ source, id, onBack }) {
if (loading) { if (loading) {
return ( return (
<div className="product-view"> <div className="product-view">
<div className="product-loading">Cargando...</div> <div className="product-loading">{t('productView.loading')}</div>
</div> </div>
); );
} }
@@ -114,7 +116,7 @@ export default function ProductView({ source, id, onBack }) {
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
Volver {t('productView.back')}
</button> </button>
<div className="product-error">{error}</div> <div className="product-error">{error}</div>
</div> </div>
@@ -126,7 +128,7 @@ export default function ProductView({ source, id, onBack }) {
const isCima = product.source === 'cima'; const isCima = product.source === 'cima';
const isParapharmacy = product.source !== 'cima'; const isParapharmacy = product.source !== 'cima';
const sourceColor = isCima ? '#2563eb' : '#16a34a'; const sourceColor = isCima ? '#2563eb' : '#16a34a';
const sourceLabel = isCima ? 'CIMA' : 'Parafarmacia'; const sourceLabel = isCima ? 'CIMA' : t('productView.parapharmacy');
return ( return (
<div className="product-view"> <div className="product-view">
@@ -134,7 +136,7 @@ export default function ProductView({ source, id, onBack }) {
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" /> <path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
Volver {t('productView.back')}
</button> </button>
<div className="product-header"> <div className="product-header">
@@ -153,37 +155,37 @@ export default function ProductView({ source, id, onBack }) {
{isCima ? ( {isCima ? (
<> <>
{product.active_ingredient && ( {product.active_ingredient && (
<DetailRow label="Principio activo" value={product.active_ingredient} /> <DetailRow label={t('productView.activeIngredient')} value={product.active_ingredient} />
)} )}
{product.dosage && ( {product.dosage && (
<DetailRow label="Dosis" value={product.dosage} /> <DetailRow label={t('productView.dosage')} value={product.dosage} />
)} )}
{product.form && ( {product.form && (
<DetailRow label="Forma farmacéutica" value={product.form} /> <DetailRow label={t('productView.form')} value={product.form} />
)} )}
{product.prescription && ( {product.prescription && (
<DetailRow label="Prescripción" value={product.prescription} /> <DetailRow label={t('productView.prescription')} value={product.prescription} />
)} )}
{product.commercialized !== undefined && ( {product.commercialized !== undefined && (
<DetailRow label="Comercializado" value={product.commercialized ? 'Sí' : 'No'} /> <DetailRow label={t('productView.commercialized')} value={product.commercialized ? t('productView.yes') : t('productView.no')} />
)} )}
</> </>
) : ( ) : (
<> <>
{product.price && ( {product.price && (
<DetailRow label="Precio" value={`${product.price} €`} /> <DetailRow label={t('productView.price')} value={`${product.price} €`} />
)} )}
{product.original_price && product.original_price > product.price && ( {product.original_price && product.original_price > product.price && (
<DetailRow label="Precio anterior" value={`${product.original_price} €`} /> <DetailRow label={t('productView.previousPrice')} value={`${product.original_price} €`} />
)} )}
{product.category && ( {product.category && (
<DetailRow label="Categoría" value={product.category} /> <DetailRow label={t('productView.category')} value={product.category} />
)} )}
{product.brand && ( {product.brand && (
<DetailRow label="Marca" value={product.brand} /> <DetailRow label={t('productView.brand')} value={product.brand} />
)} )}
{product.source_url && ( {product.source_url && (
<DetailRow label="Fuente" value={product.source} /> <DetailRow label={t('productView.source')} value={product.source} />
)} )}
</> </>
)} )}
@@ -191,11 +193,11 @@ export default function ProductView({ source, id, onBack }) {
<div className="product-pharmacies"> <div className="product-pharmacies">
{loadingPharmacies ? ( {loadingPharmacies ? (
<div className="pharmacies-loading">Cargando farmacias...</div> <div className="pharmacies-loading">{t('productView.loadingPharmacies')}</div>
) : pharmacies.length > 0 ? ( ) : pharmacies.length > 0 ? (
<> <>
<h3 className="pharmacies-title"> <h3 className="pharmacies-title">
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'} {t('productView.availableInPharmacies', { count: pharmacies.length })}
</h3> </h3>
<div className="pharmacy-controls"> <div className="pharmacy-controls">
@@ -205,19 +207,19 @@ export default function ProductView({ source, id, onBack }) {
disabled={locating} disabled={locating}
> >
{locating {locating
? '📍 Localizando…' ? t('productView.locating')
: sortByDistance : sortByDistance
? '📍 Ordenado por distancia · Reset' ? t('productView.sortedByDistance')
: '📍 Ordenar por distancia'} : t('productView.sortByDistance')}
</button> </button>
{sortByDistance && positionSource && ( {sortByDistance && positionSource && (
<span className="location-source">Usando tu ubicación</span> <span className="location-source">{t('productView.usingLocation')}</span>
)} )}
{locationError && ( {locationError && (
<span className="location-error"> <span className="location-error">
{locationError} {locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}> <button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar {t('productView.retry')}
</button> </button>
</span> </span>
)} )}