Compare commits

10 Commits

Author SHA1 Message Date
Antoni Nuñez Romeu de8fdbb904 feat: add sort by distance and directions to ProductView
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Failing after 17s
Run Tests on Branches / Frontend Tests (push) Failing after 16s
Run Tests on Branches / Frontend Mobile Tests (push) Failing after 18s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
- Added sort by distance functionality (same as SearchView)
- Added PharmacyList component with distance display
- Added 'Cómo llegar' button in map popup (Google Maps directions)
- Uses haversineKm for distance calculation
2026-07-16 16:55:48 +02:00
Antoni Nuñez Romeu 6155fdcd15 fix: add pharmacy map to ProductView and fix pharmacies endpoint
- Added PharmacyMap component to ProductView
- Updated backend to accept all valid sources for pharmacies endpoint
- Shows map with pharmacy locations and list of pharmacies with prices
2026-07-16 16:48:25 +02:00
Antoni Nuñez Romeu 08b7437803 fix: add 'seed' and 'manual' to valid product sources
Products created via seed script or manual entry have source='seed'
or 'manual', which wasn't in the valid sources list.
2026-07-16 16:40:41 +02:00
Antoni Nuñez Romeu 26e7d92079 fix: handle both array and object responses in admin search
The CIMA search API returns {results: [...]} but the admin panel
was treating it as an array, causing CIMA products to be lost.

Now handles both formats:
- Array: [...]
- Object: {results: [...]}
2026-07-16 16:32:24 +02:00
Antoni Nuñez Romeu dca7e373cd fix: use parapharmacy endpoint for all non-CIMA sources
- ProductView now checks if source is 'cima' to decide endpoint
- All other sources (seed, promofarma, docmorris, primor) use parapharmacy endpoint
- Same fix applied to mobile getProduct function

Before: only 'parapharmacy' source used parapharmacy endpoint
After: all non-CIMA sources use parapharmacy endpoint
2026-07-16 16:23:57 +02:00
Antoni Nuñez Romeu 63b25e3f4f fix: use _id for MongoDB products in frontend
- SearchView now uses p._id || p.id for product navigation
- ProductResults uses product._id || product.id for React key
- Fixes 'undefined' error when clicking parapharmacy products
2026-07-16 16:18:22 +02:00
Antoni Nuñez Romeu e5dcfbd2f5 fix: allow parapharmacy sources in pharmacy-products endpoint
Updated validation to accept: cima, promofarma, docmorris, primor, parapharmacy

Before: product_source must be 'cima'
After: product_source must be one of: cima, promofarma, docmorris, primor, parapharmacy
2026-07-16 16:02:25 +02:00
Antoni Nuñez Romeu 3666f4dee8 fix: update ProductResults and admin panel for parapharmacy
- Updated ProductResults component with new source labels
- Added price display for parapharmacy products
- Updated admin panel to search both CIMA and Parapharmacy
- Updated getSourceBadge to handle parapharmacy sources
- Added CSS for product price display
2026-07-16 15:58:35 +02:00
Antoni Nuñez Romeu 3429b7e548 fix: product detail view for parapharmacy products
- Updated ProductView to use correct endpoint for parapharmacy
- Updated getProduct service to handle parapharmacy products
- Shows price, category, brand, source for parapharmacy products
- Updated labels from 'OFF' to 'Parafarmacia'
2026-07-16 15:47:14 +02:00
Antoni Nuñez Romeu 570d4ab43e feat: unified search - remove filter tabs, show all results together
- Frontend now searches both CIMA and Parapharmacy simultaneously
- Removed filter tabs (Todos/Medicamentos/Parafarmacia)
- Results show in a single unified list
- Mobile: Shows medicines first, then parapharmacy section
- Web: Same unified approach with results summary
2026-07-16 15:36:08 +02:00
11 changed files with 291 additions and 175 deletions
+23
View File
@@ -28,6 +28,9 @@ jobs:
frontend-mobile: frontend-mobile:
- 'apps/frontend-mobile/**' - 'apps/frontend-mobile/**'
- 'packages/**' - 'packages/**'
parapharmacy-api:
- 'apps/parapharmacy-api/**'
- 'packages/**'
test-backend: test-backend:
name: Backend Tests name: Backend Tests
@@ -85,3 +88,23 @@ jobs:
- name: Run Frontend Mobile Tests - name: Run Frontend Mobile Tests
run: npm test --workspace=frontend-mobile -- --ci run: npm test --workspace=frontend-mobile -- --ci
continue-on-error: true continue-on-error: true
test-parapharmacy-api:
name: Parapharmacy API Tests
needs: detect-changes
if: needs.detect-changes.outputs.parapharmacy-api == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Install parapharmacy-api native deps
run: npm rebuild sqlite3 bcrypt --workspace=farma-clic-parapharmacy-api
- name: Run Parapharmacy API Tests
run: npm test --workspace=farma-clic-parapharmacy-api
+7 -3
View File
@@ -780,7 +780,9 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => {
try { try {
const { source, productId } = req.params; const { source, productId } = req.params;
if (source !== 'cima') { // Accept all valid sources
const validSources = ['cima', 'promofarma', 'docmorris', 'primor', 'parapharmacy', 'seed', 'manual'];
if (!validSources.includes(source)) {
return res.status(400).json({ error: 'Invalid source' }); return res.status(400).json({ error: 'Invalid source' });
} }
@@ -2125,8 +2127,10 @@ app.post('/api/admin/pharmacy-products', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' }); return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' });
} }
if (product_source !== 'cima') { // Allow CIMA and all parapharmacy sources
return res.status(400).json({ error: 'product_source must be "cima"' }); const validSources = ['cima', 'promofarma', 'docmorris', 'primor', 'parapharmacy', 'seed', 'manual'];
if (!validSources.includes(product_source)) {
return res.status(400).json({ error: `product_source must be one of: ${validSources.join(', ')}` });
} }
const existing = await userDbGet( const existing = await userDbGet(
+17 -40
View File
@@ -33,7 +33,6 @@ export default function SearchScreen() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<Medicine[]>([]); const [results, setResults] = useState<Medicine[]>([]);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -104,17 +103,11 @@ export default function SearchScreen() {
onChangeText={setQuery} onChangeText={setQuery}
/> />
{query.length >= 2 && ( {query.length >= 2 && (results.length + products.length) > 0 && (
<View style={styles.filterContainer}> <View style={styles.resultsSummary}>
<TouchableOpacity style={[styles.filterTab, searchMode === 'all' && styles.filterTabActive]} onPress={() => setSearchMode('all')}> <Text style={[styles.resultsSummaryText, { color: colors.textSecondary }]}>
<Text style={[styles.filterText, searchMode === 'all' && styles.filterTextActive]}>Todos</Text> {results.length + products.length} resultados encontrados
</TouchableOpacity> </Text>
<TouchableOpacity style={[styles.filterTab, searchMode === 'medicines' && styles.filterTabActive]} onPress={() => setSearchMode('medicines')}>
<Text style={[styles.filterText, searchMode === 'medicines' && styles.filterTextActive]}>Medicamentos</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.filterTab, searchMode === 'products' && styles.filterTabActive]} onPress={() => setSearchMode('products')}>
<Text style={[styles.filterText, searchMode === 'products' && styles.filterTextActive]}>Parafarmacia</Text>
</TouchableOpacity>
</View> </View>
)} )}
@@ -176,29 +169,29 @@ export default function SearchScreen() {
)} )}
<FlatList <FlatList
data={(searchMode === 'medicines' || searchMode === 'all') ? results : []} data={results}
keyExtractor={(item) => item.nregistro} keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />} renderItem={({ item }) => <MedicineCard medicine={item} />}
ListHeaderComponent={ ListHeaderComponent={
<> <>
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( {products.length > 0 && (
<View style={styles.section}> <View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Parafarmacia y Bebé</Text> <Text style={[styles.sectionTitle, { color: colors.text }]}>Parafarmacia</Text>
{products.map((product) => ( {products.map((product) => (
<TouchableOpacity <TouchableOpacity
key={`${product.source}-${product.id}`} key={`${product.source}-${product.id || product._id}`}
style={[styles.productCard, { backgroundColor: colors.card, borderColor: colors.border }]} style={[styles.productCard, { backgroundColor: colors.card, borderColor: colors.border }]}
onPress={() => router.push(`/product/${product.source}/${product.id}`)} onPress={() => router.push(`/product/${product.source}/${product.id || product._id}`)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.productInfo}> <View style={styles.productInfo}>
<View style={styles.badges}> <View style={styles.badges}>
<View style={[styles.sourceBadge, { backgroundColor: product.source === 'cima' ? '#2563eb' : '#16a34a' }]}> <View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}>
<Text style={styles.badgeText}>{product.source === 'cima' ? 'CIMA' : 'OFF'}</Text> <Text style={styles.badgeText}>Parafarmacia</Text>
</View> </View>
</View> </View>
<Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text> <Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text>
<Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand}</Text> <Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand} • {product.price}€</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -217,29 +210,13 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
filterContainer: { resultsSummary: {
flexDirection: 'row', paddingHorizontal: spacing.lg,
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
}, },
filterTab: { resultsSummaryText: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(0,0,0,0.05)',
},
filterTabActive: {
backgroundColor: '#7fbf8f',
},
filterText: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '500',
color: '#41493e',
},
filterTextActive: {
color: '#ffffff',
}, },
section: { section: {
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
@@ -62,7 +62,7 @@ export default function ProductDetailScreen() {
<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.badge, { backgroundColor: isCima ? '#2b5bb5' : '#4caf50' }]}>
<Text style={styles.badgeText}>{isCima ? 'CIMA' : 'OFF'}</Text> <Text style={styles.badgeText}>{isCima ? 'CIMA' : 'Parafarmacia'}</Text>
</View> </View>
</View> </View>
{product.brand ? ( {product.brand ? (
@@ -92,21 +92,21 @@ export default function ProductDetailScreen() {
</View> </View>
) : ( ) : (
<View style={[styles.infoSection, { backgroundColor: colors.card }]}> <View style={[styles.infoSection, { backgroundColor: colors.card }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información nutricional</Text> <Text style={[styles.sectionTitle, { color: colors.text }]}>Información del producto</Text>
{product.nutriscore && ( {product.price != null && (
<InfoRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} colors={colors} /> <InfoRow label="Precio" value={`${product.price} €`} colors={colors} />
)} )}
{product.nova_group != null && ( {product.original_price != null && product.original_price > (product.price || 0) && (
<InfoRow label="Grupo NOVA" value={String(product.nova_group)} colors={colors} /> <InfoRow label="Precio anterior" value={`${product.original_price} €`} colors={colors} />
)} )}
{product.eco_score && ( {product.category && (
<InfoRow label="Eco-Score" value={product.eco_score.toUpperCase()} colors={colors} /> <InfoRow label="Categoría" value={product.category} colors={colors} />
)} )}
{product.ingredients && ( {product.brand && (
<View style={[styles.infoRow, { borderBottomColor: colors.border }]}> <InfoRow label="Marca" value={product.brand} colors={colors} />
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Ingredientes</Text> )}
<Text style={[styles.infoValueMultiline, { color: colors.text }]}>{product.ingredients}</Text> {product.source_url && (
</View> <InfoRow label="Fuente" value={product.source || 'Parafarmacia'} colors={colors} />
)} )}
</View> </View>
)} )}
+7 -1
View File
@@ -105,7 +105,13 @@ export async function getParapharmacyBrands(): Promise<string[]> {
export async function getProduct(source: string, id: string): Promise<Product | null> { export async function getProduct(source: string, id: string): Promise<Product | null> {
try { try {
const { data } = await api.get<Product>(`/products/${source}/${id}`); // Use parapharmacy endpoint for all non-CIMA sources
const isCima = source === 'cima';
const endpoint = isCima
? `/products/${source}/${id}`
: `/products/parapharmacy/${id}`;
const { data } = await api.get<Product>(endpoint);
return data; return data;
} catch (error) { } catch (error) {
console.error('[Products] Detail error:', error); console.error('[Products] Detail error:', error);
@@ -34,6 +34,15 @@ function PharmacyMap({ pharmacies }) {
<strong>{pharmacy.name}</strong><br /> <strong>{pharmacy.name}</strong><br />
{pharmacy.address} {pharmacy.address}
{pharmacy.phone && <><br />{pharmacy.phone}</>} {pharmacy.phone && <><br />{pharmacy.phone}</>}
<br />
<a
href={`https://www.google.com/maps/dir/?api=1&destination=${pharmacy.latitude},${pharmacy.longitude}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', marginTop: '8px', display: 'inline-block' }}
>
📍 Cómo llegar
</a>
</Popup> </Popup>
</Marker> </Marker>
))} ))}
@@ -130,6 +130,12 @@
font-weight: 600; font-weight: 600;
} }
.product-price {
color: var(--primary);
font-weight: 700;
font-size: 1.1rem;
}
.product-card-footer { .product-card-footer {
padding-top: 0.75rem; padding-top: 0.75rem;
border-top: 1px solid var(--outline-variant); border-top: 1px solid var(--outline-variant);
@@ -3,19 +3,27 @@ import './ProductResults.css';
const categoryLabels = { const categoryLabels = {
otc: 'Sin Receta', otc: 'Sin Receta',
baby_food: 'Alimentación Infantil', parapharmacy: 'Parafarmacia',
baby_milk: 'Leche de Fórmula', dermocosmética: 'Dermocosmética',
baby_cereal: 'Cereales Bebé' 'Fórmulas lácteas': 'Fórmulas lácteas',
vitaminas: 'Vitaminas',
analgésicos: 'Analgésicos'
}; };
const sourceColors = { const sourceColors = {
cima: '#2563eb', cima: '#2563eb',
openfoodfacts: '#16a34a' promofarma: '#16a34a',
docmorris: '#e11d48',
primor: '#7c3aed',
parapharmacy: '#16a34a'
}; };
const sourceLabels = { const sourceLabels = {
cima: 'CIMA', cima: 'CIMA',
openfoodfacts: 'Open Food Facts' promofarma: 'Promofarma',
docmorris: 'DocMorris',
primor: 'Primor',
parapharmacy: 'Parafarmacia'
}; };
function ProductResults({ products, onSelect }) { function ProductResults({ products, onSelect }) {
@@ -31,7 +39,7 @@ function ProductResults({ products, onSelect }) {
<div className="product-results"> <div className="product-results">
{products.map((product) => ( {products.map((product) => (
<ProductCard <ProductCard
key={product.id} key={product._id || product.id}
product={product} product={product}
onSelect={onSelect} onSelect={onSelect}
/> />
@@ -95,6 +103,9 @@ function ProductCard({ product, onSelect }) {
{product.brand && ( {product.brand && (
<p><strong>Marca:</strong> {product.brand}</p> <p><strong>Marca:</strong> {product.brand}</p>
)} )}
{product.price != null && (
<p className="product-price"><strong>Precio:</strong> {product.price} €</p>
)}
{product.source === 'cima' && product.active_ingredient && ( {product.source === 'cima' && product.active_ingredient && (
<p><strong>Principio Activo:</strong> {product.active_ingredient}</p> <p><strong>Principio Activo:</strong> {product.active_ingredient}</p>
)} )}
@@ -47,7 +47,7 @@ function PharmacyProductLink() {
} }
}, [selectedPharmacy]); }, [selectedPharmacy]);
// Buscar productos en la API mientras el usuario escribe // Buscar productos en ambas APIs mientras el usuario escribe
useEffect(() => { useEffect(() => {
const q = productSearch.trim(); const q = productSearch.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -60,12 +60,31 @@ function PharmacyProductLink() {
const timeoutId = setTimeout(async () => { const timeoutId = setTimeout(async () => {
setSearching(true); setSearching(true);
try { try {
const response = await fetch(`/api/products/search?q=${encodeURIComponent(q)}`, { // Search both CIMA and Parapharmacy
credentials: 'include', const [cimaRes, paraRes] = await Promise.allSettled([
signal: controller.signal, fetch(`/api/products/search?q=${encodeURIComponent(q)}`, {
}); credentials: 'include',
const data = await response.json(); signal: controller.signal,
setProductResults(Array.isArray(data) ? data : []); }),
fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(q)}&limit=10`, {
credentials: 'include',
signal: controller.signal,
})
]);
const cimaData = cimaRes.status === 'fulfilled' ? await cimaRes.value.json() : { results: [] };
const paraData = paraRes.status === 'fulfilled' ? await paraRes.value.json() : { results: [] };
// Handle both array and object responses
const cimaResults = Array.isArray(cimaData) ? cimaData : (cimaData.results || []);
const paraResults = Array.isArray(paraData) ? paraData : (paraData.results || []);
const allResults = [
...cimaResults,
...paraResults.map(p => ({ ...p, source: p.source || 'parapharmacy' }))
];
setProductResults(allResults);
} catch (error) { } catch (error) {
if (error.name === 'AbortError') return; if (error.name === 'AbortError') return;
console.error('Error searching products:', error); console.error('Error searching products:', error);
@@ -116,15 +135,15 @@ function PharmacyProductLink() {
} }
try { try {
// Build identifier: source:id (e.g., openfoodfacts:9421025231209) // Build identifier: source:id
const identifier = selectedProduct._id const productId = selectedProduct._id || selectedProduct.id;
? `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct._id}` const source = selectedProduct.source || 'parapharmacy';
: `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct.id}`; const identifier = `${source}:${productId}`;
const payload = { const payload = {
pharmacy_id: parseInt(formData.pharmacy_id), pharmacy_id: parseInt(formData.pharmacy_id),
product_source: selectedProduct.source || 'openfoodfacts', product_source: source,
product_off_id: selectedProduct._id || selectedProduct.id, product_off_id: productId,
product_name: selectedProduct.product_name || selectedProduct.name, product_name: selectedProduct.product_name || selectedProduct.name,
price: formData.price ? parseFloat(formData.price) : null, price: formData.price ? parseFloat(formData.price) : null,
stock: formData.stock ? parseInt(formData.stock) : 0 stock: formData.stock ? parseInt(formData.stock) : 0
@@ -221,10 +240,28 @@ function PharmacyProductLink() {
}; };
const getSourceBadge = (source) => { const getSourceBadge = (source) => {
if (source === 'cima') { const colors = {
return <span className="source-badge source-badge--cima">CIMA</span>; cima: '#2563eb',
} promofarma: '#16a34a',
return <span className="source-badge source-badge--off">OFF</span>; docmorris: '#e11d48',
primor: '#7c3aed',
parapharmacy: '#16a34a'
};
const labels = {
cima: 'CIMA',
promofarma: 'Promofarma',
docmorris: 'DocMorris',
primor: 'Primor',
parapharmacy: 'Parafarmacia'
};
return (
<span
className="source-badge"
style={{ backgroundColor: colors[source] || '#6b7280' }}
>
{labels[source] || source}
</span>
);
}; };
return ( return (
@@ -284,7 +321,7 @@ function PharmacyProductLink() {
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Buscar Producto (Open Food Facts) *</label> <label>Buscar Producto (CIMA / Parafarmacia) *</label>
<input <input
type="text" type="text"
value={productSearch} value={productSearch}
@@ -292,22 +329,24 @@ function PharmacyProductLink() {
setProductSearch(e.target.value); setProductSearch(e.target.value);
setSelectedProduct(null); setSelectedProduct(null);
}} }}
placeholder="Escribe para buscar productos..." placeholder="Escribe para buscar medicamentos o productos de parafarmacia..."
required required
/> />
{searching && <p className="loading-text">Buscando...</p>} {searching && <p className="loading-text">Buscando...</p>}
{productResults.length > 0 && !selectedProduct && ( {productResults.length > 0 && !selectedProduct && (
<div className="medicine-search-results"> <div className="medicine-search-results">
{productResults.slice(0, 10).map((product) => ( {productResults.slice(0, 10).map((product) => (
<div <div
key={product._id || product.id} key={product._id || product.id || product.nregistro}
className="search-result-item" className="search-result-item"
onClick={() => selectProduct(product)} onClick={() => selectProduct(product)}
> >
<strong>{product.product_name || product.name}</strong> <strong>{product.product_name || product.name}</strong>
{product.brand && <span> - {product.brand}</span>}
{product.brands && <span> - {product.brands}</span>} {product.brands && <span> - {product.brands}</span>}
{product.quantity && <span> ({product.quantity})</span>} {product.price != null && <span> - {product.price}€</span>}
{getSourceBadge(product.source || 'parapharmacy')}
</div> </div>
))} ))}
</div> </div>
@@ -317,15 +356,16 @@ function PharmacyProductLink() {
<div className="selected-medicine-info"> <div className="selected-medicine-info">
<p>✅ Selected: <strong>{selectedProduct.product_name || selectedProduct.name}</strong></p> <p>✅ Selected: <strong>{selectedProduct.product_name || selectedProduct.name}</strong></p>
<p className="medicine-details"> <p className="medicine-details">
{selectedProduct.brand && `Marca: ${selectedProduct.brand} • `}
{selectedProduct.brands && `Marca: ${selectedProduct.brands} • `} {selectedProduct.brands && `Marca: ${selectedProduct.brands} • `}
{selectedProduct.quantity && `Cantidad: ${selectedProduct.quantity} • `} {selectedProduct.price != null && `Precio: ${selectedProduct.price}€ • `}
{getSourceBadge(selectedProduct.source || 'openfoodfacts')} {getSourceBadge(selectedProduct.source || 'parapharmacy')}
{' '} {' '}
{selectedProduct._id || selectedProduct.id} {selectedProduct._id || selectedProduct.id || selectedProduct.nregistro}
</p> </p>
<button <button
type="button" type="button"
className="btn-small" className="btn-small"
onClick={() => { onClick={() => {
setSelectedProduct(null); setSelectedProduct(null);
setProductSearch(''); setProductSearch('');
+101 -34
View File
@@ -1,4 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import PharmacyMap from '../components/PharmacyMap';
import PharmacyList from '../components/PharmacyList';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import './ProductView.css'; import './ProductView.css';
export default function ProductView({ source, id, onBack }) { export default function ProductView({ source, id, onBack }) {
@@ -7,6 +10,50 @@ export default function ProductView({ source, id, onBack }) {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [pharmacies, setPharmacies] = useState([]); const [pharmacies, setPharmacies] = useState([]);
const [loadingPharmacies, setLoadingPharmacies] = useState(false); const [loadingPharmacies, setLoadingPharmacies] = useState(false);
const [sortByDistance, setSortByDistance] = useState(false);
const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null);
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
const hasSavedCoords = product?.latitude != null && product?.longitude != null;
const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies;
return [...pharmacies].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1;
return (
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
);
});
}, [pharmacies, sortByDistance, userPosition]);
const handleSortByDistance = async () => {
if (sortByDistance) {
setSortByDistance(false);
return;
}
setLocationError('');
setLocating(true);
try {
const pos = await getUserPosition();
setUserPosition(pos);
setPositionSource('browser');
setSortByDistance(true);
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado.';
else if (err.code === 2) msg = 'Ubicación no disponible.';
else if (err.code === 3) msg = 'La ubicación tardó demasiado.';
}
setLocationError(msg);
} finally {
setLocating(false);
}
};
useEffect(() => { useEffect(() => {
loadProduct(); loadProduct();
@@ -17,13 +64,19 @@ export default function ProductView({ source, id, onBack }) {
setError(null); setError(null);
setPharmacies([]); setPharmacies([]);
try { try {
const response = await fetch(`/api/products/${source}/${id}`); // Use parapharmacy endpoint for all non-CIMA sources
const isCima = source === 'cima';
const apiUrl = isCima
? `/api/products/${source}/${id}`
: `/api/products/parapharmacy/${id}`;
const response = await fetch(apiUrl);
if (!response.ok) { if (!response.ok) {
throw new Error('Producto no encontrado'); throw new Error('Producto no encontrado');
} }
const data = await response.json(); const data = await response.json();
setProduct(data); setProduct(data);
loadPharmacies(source, data.id); loadPharmacies(source, data.id || data._id);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -71,8 +124,9 @@ export default function ProductView({ source, id, onBack }) {
if (!product) return null; if (!product) return null;
const isCima = product.source === 'cima'; const isCima = product.source === 'cima';
const isParapharmacy = product.source !== 'cima';
const sourceColor = isCima ? '#2563eb' : '#16a34a'; const sourceColor = isCima ? '#2563eb' : '#16a34a';
const sourceLabel = isCima ? 'CIMA' : 'Open Food Facts'; const sourceLabel = isCima ? 'CIMA' : 'Parafarmacia';
return ( return (
<div className="product-view"> <div className="product-view">
@@ -116,17 +170,20 @@ export default function ProductView({ source, id, onBack }) {
</> </>
) : ( ) : (
<> <>
{product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && ( {product.price && (
<DetailRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} /> <DetailRow label="Precio" value={`${product.price} €`} />
)} )}
{product.nova_group && ( {product.original_price && product.original_price > product.price && (
<DetailRow label="NOVA" value={`Grupo ${product.nova_group}`} /> <DetailRow label="Precio anterior" value={`${product.original_price} €`} />
)} )}
{product.eco_score && product.eco_score !== 'unknown' && ( {product.category && (
<DetailRow label="Eco-Score" value={product.eco_score.toUpperCase()} /> <DetailRow label="Categoría" value={product.category} />
)} )}
{product.ingredients && ( {product.brand && (
<DetailRow label="Ingredientes" value={product.ingredients} /> <DetailRow label="Marca" value={product.brand} />
)}
{product.source_url && (
<DetailRow label="Fuente" value={product.source} />
)} )}
</> </>
)} )}
@@ -140,29 +197,39 @@ export default function ProductView({ source, id, onBack }) {
<h3 className="pharmacies-title"> <h3 className="pharmacies-title">
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'} Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
</h3> </h3>
<div className="pharmacies-grid">
{pharmacies.map((pharmacy) => ( <div className="pharmacy-controls">
<div key={pharmacy.id} className="product-pharmacy-card"> <button
<h4 className="product-pharmacy-name">{pharmacy.name}</h4> className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
<p className="product-pharmacy-address">{pharmacy.address}</p> onClick={handleSortByDistance}
{pharmacy.phone && ( disabled={locating}
<p className="product-pharmacy-phone">{pharmacy.phone}</p> >
)} {locating
<div className="product-pharmacy-status"> ? '📍 Localizando…'
{pharmacy.price && ( : sortByDistance
<span className="product-pharmacy-price"> ? '📍 Ordenado por distancia · Reset'
{parseFloat(pharmacy.price).toFixed(2)} &euro; : '📍 Ordenar por distancia'}
</span> </button>
)} {sortByDistance && positionSource && (
{pharmacy.stock !== undefined && ( <span className="location-source">Usando tu ubicación</span>
<span className={`product-pharmacy-stock ${pharmacy.stock > 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'} {locationError && (
</span> <span className="location-error">
)} {locationError}
</div> <button className="retry-location-btn" onClick={handleSortByDistance}>
</div> Reintentar
))} </button>
</span>
)}
</div> </div>
<PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList
pharmacies={displayedPharmacies}
loading={loadingPharmacies}
userPosition={sortByDistance ? userPosition : null}
/>
</> </>
) : null} ) : null}
</div> </div>
+22 -49
View File
@@ -18,7 +18,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const [searchQuery, setSearchQuery] = useState(initialQuery); const [searchQuery, setSearchQuery] = useState(initialQuery);
const [medicines, setMedicines] = useState([]); const [medicines, setMedicines] = useState([]);
const [products, setProducts] = useState([]); const [products, setProducts] = useState([]);
const [searchMode, setSearchMode] = useState('all');
const [selectedMedicine, setSelectedMedicine] = useState(null); const [selectedMedicine, setSelectedMedicine] = useState(null);
const [pharmacies, setPharmacies] = useState([]); const [pharmacies, setPharmacies] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -88,16 +87,24 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const query = searchQuery.trim(); const query = searchQuery.trim();
try { try {
// Only search medications from CIMA // Search both CIMA and Parapharmacy simultaneously
const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`); const [medicinesRes, productsRes] = await Promise.allSettled([
fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`),
fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`)
]);
// Only update if this search is still the current one // Only update if this search is still the current one
if (query !== searchQuery.trim()) return; if (query !== searchQuery.trim()) return;
if (medicinesRes.ok) { if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) {
const medicinesData = await medicinesRes.json(); const medicinesData = await medicinesRes.value.json();
setMedicines(medicinesData); setMedicines(medicinesData);
} }
if (productsRes.status === 'fulfilled' && productsRes.value.ok) {
const productsData = await productsRes.value.json();
setProducts(productsData.results || []);
}
} catch (error) { } catch (error) {
console.error('Search error:', error); console.error('Search error:', error);
} finally { } finally {
@@ -108,26 +115,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [searchQuery]); }, [searchQuery]);
// Search parapharmacy when user switches to Parafarmacia tab
useEffect(() => {
if (searchMode !== 'products' || searchQuery.trim().length < 2) return;
const searchProducts = async () => {
const query = searchQuery.trim();
try {
const response = await fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`);
if (response.ok) {
const data = await response.json();
setProducts(data.results || []);
}
} catch (error) {
console.error('Parapharmacy search error:', error);
}
};
searchProducts();
}, [searchMode, searchQuery]);
useEffect(() => { useEffect(() => {
const fetchPharmacies = async () => { const fetchPharmacies = async () => {
if (!selectedMedicine) { if (!selectedMedicine) {
@@ -310,28 +297,13 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{searchQuery && !selectedMedicine && ( {searchQuery && !selectedMedicine && (
<> <>
<div className="filter-tabs"> <div className="results-summary">
<button {(medicines.length + products.length) > 0 && (
className={`filter-tab ${searchMode === 'all' ? 'filter-tab--active' : ''}`} <span>{medicines.length + products.length} resultados encontrados</span>
onClick={() => setSearchMode('all')} )}
>
Todos ({medicines.length + products.length})
</button>
<button
className={`filter-tab ${searchMode === 'medicines' ? 'filter-tab--active' : ''}`}
onClick={() => setSearchMode('medicines')}
>
Medicamentos ({medicines.length})
</button>
<button
className={`filter-tab ${searchMode === 'products' ? 'filter-tab--active' : ''}`}
onClick={() => setSearchMode('products')}
>
Parafarmacia ({products.length})
</button>
</div> </div>
{(searchMode === 'all' || searchMode === 'medicines') && ( {medicines.length > 0 && (
<MedicineResults <MedicineResults
medicines={medicines} medicines={medicines}
onSelect={(m) => { onSelect={(m) => {
@@ -344,16 +316,17 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
/> />
)} )}
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( {products.length > 0 && (
<div className="products-section"> <div className="products-section">
<h3 className="section-subtitle">Parafarmacia y Bebé</h3> <h3 className="section-subtitle">Parafarmacia</h3>
<ProductResults <ProductResults
products={products} products={products}
onSelect={(p) => { onSelect={(p) => {
const productId = p._id || p.id;
if (onNavigateToProduct) { if (onNavigateToProduct) {
onNavigateToProduct(p.source, p.id); onNavigateToProduct(p.source, productId);
} else { } else {
window.location.href = `/product/${p.source}/${p.id}`; window.location.href = `/product/${p.source}/${productId}`;
} }
}} }}
/> />