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:
- 'apps/frontend-mobile/**'
- 'packages/**'
parapharmacy-api:
- 'apps/parapharmacy-api/**'
- 'packages/**'
test-backend:
name: Backend Tests
@@ -85,3 +88,23 @@ jobs:
- name: Run Frontend Mobile Tests
run: npm test --workspace=frontend-mobile -- --ci
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 {
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' });
}
@@ -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' });
}
if (product_source !== 'cima') {
return res.status(400).json({ error: 'product_source must be "cima"' });
// Allow CIMA and all parapharmacy sources
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(
+17 -40
View File
@@ -33,7 +33,6 @@ export default function SearchScreen() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Medicine[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -104,17 +103,11 @@ export default function SearchScreen() {
onChangeText={setQuery}
/>
{query.length >= 2 && (
<View style={styles.filterContainer}>
<TouchableOpacity style={[styles.filterTab, searchMode === 'all' && styles.filterTabActive]} onPress={() => setSearchMode('all')}>
<Text style={[styles.filterText, searchMode === 'all' && styles.filterTextActive]}>Todos</Text>
</TouchableOpacity>
<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>
{query.length >= 2 && (results.length + products.length) > 0 && (
<View style={styles.resultsSummary}>
<Text style={[styles.resultsSummaryText, { color: colors.textSecondary }]}>
{results.length + products.length} resultados encontrados
</Text>
</View>
)}
@@ -176,29 +169,29 @@ export default function SearchScreen() {
)}
<FlatList
data={(searchMode === 'medicines' || searchMode === 'all') ? results : []}
data={results}
keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />}
ListHeaderComponent={
<>
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
{products.length > 0 && (
<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) => (
<TouchableOpacity
key={`${product.source}-${product.id}`}
key={`${product.source}-${product.id || product._id}`}
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}
>
<View style={styles.productInfo}>
<View style={styles.badges}>
<View style={[styles.sourceBadge, { backgroundColor: product.source === 'cima' ? '#2563eb' : '#16a34a' }]}>
<Text style={styles.badgeText}>{product.source === 'cima' ? 'CIMA' : 'OFF'}</Text>
<View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}>
<Text style={styles.badgeText}>Parafarmacia</Text>
</View>
</View>
<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>
</TouchableOpacity>
))}
@@ -217,29 +210,13 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
filterContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
resultsSummary: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
filterTab: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(0,0,0,0.05)',
},
filterTabActive: {
backgroundColor: '#7fbf8f',
},
filterText: {
resultsSummaryText: {
fontSize: 14,
fontWeight: '600',
color: '#41493e',
},
filterTextActive: {
color: '#ffffff',
fontWeight: '500',
},
section: {
paddingHorizontal: spacing.lg,
@@ -62,7 +62,7 @@ export default function ProductDetailScreen() {
<View style={styles.nameRow}>
<Text style={[styles.name, { color: colors.text }]}>{product.name}</Text>
<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>
{product.brand ? (
@@ -92,21 +92,21 @@ export default function ProductDetailScreen() {
</View>
) : (
<View style={[styles.infoSection, { backgroundColor: colors.card }]}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información nutricional</Text>
{product.nutriscore && (
<InfoRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} colors={colors} />
<Text style={[styles.sectionTitle, { color: colors.text }]}>Información del producto</Text>
{product.price != null && (
<InfoRow label="Precio" value={`${product.price} €`} colors={colors} />
)}
{product.nova_group != null && (
<InfoRow label="Grupo NOVA" value={String(product.nova_group)} colors={colors} />
{product.original_price != null && product.original_price > (product.price || 0) && (
<InfoRow label="Precio anterior" value={`${product.original_price} €`} colors={colors} />
)}
{product.eco_score && (
<InfoRow label="Eco-Score" value={product.eco_score.toUpperCase()} colors={colors} />
{product.category && (
<InfoRow label="Categoría" value={product.category} colors={colors} />
)}
{product.ingredients && (
<View style={[styles.infoRow, { borderBottomColor: colors.border }]}>
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Ingredientes</Text>
<Text style={[styles.infoValueMultiline, { color: colors.text }]}>{product.ingredients}</Text>
</View>
{product.brand && (
<InfoRow label="Marca" value={product.brand} colors={colors} />
)}
{product.source_url && (
<InfoRow label="Fuente" value={product.source || 'Parafarmacia'} colors={colors} />
)}
</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> {
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;
} catch (error) {
console.error('[Products] Detail error:', error);
@@ -34,6 +34,15 @@ function PharmacyMap({ pharmacies }) {
<strong>{pharmacy.name}</strong><br />
{pharmacy.address}
{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>
</Marker>
))}
@@ -130,6 +130,12 @@
font-weight: 600;
}
.product-price {
color: var(--primary);
font-weight: 700;
font-size: 1.1rem;
}
.product-card-footer {
padding-top: 0.75rem;
border-top: 1px solid var(--outline-variant);
@@ -3,19 +3,27 @@ import './ProductResults.css';
const categoryLabels = {
otc: 'Sin Receta',
baby_food: 'Alimentación Infantil',
baby_milk: 'Leche de Fórmula',
baby_cereal: 'Cereales Bebé'
parapharmacy: 'Parafarmacia',
dermocosmética: 'Dermocosmética',
'Fórmulas lácteas': 'Fórmulas lácteas',
vitaminas: 'Vitaminas',
analgésicos: 'Analgésicos'
};
const sourceColors = {
cima: '#2563eb',
openfoodfacts: '#16a34a'
promofarma: '#16a34a',
docmorris: '#e11d48',
primor: '#7c3aed',
parapharmacy: '#16a34a'
};
const sourceLabels = {
cima: 'CIMA',
openfoodfacts: 'Open Food Facts'
promofarma: 'Promofarma',
docmorris: 'DocMorris',
primor: 'Primor',
parapharmacy: 'Parafarmacia'
};
function ProductResults({ products, onSelect }) {
@@ -31,7 +39,7 @@ function ProductResults({ products, onSelect }) {
<div className="product-results">
{products.map((product) => (
<ProductCard
key={product.id}
key={product._id || product.id}
product={product}
onSelect={onSelect}
/>
@@ -95,6 +103,9 @@ function ProductCard({ product, onSelect }) {
{product.brand && (
<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 && (
<p><strong>Principio Activo:</strong> {product.active_ingredient}</p>
)}
@@ -47,7 +47,7 @@ function PharmacyProductLink() {
}
}, [selectedPharmacy]);
// Buscar productos en la API mientras el usuario escribe
// Buscar productos en ambas APIs mientras el usuario escribe
useEffect(() => {
const q = productSearch.trim();
if (q.length < 2) {
@@ -60,12 +60,31 @@ function PharmacyProductLink() {
const timeoutId = setTimeout(async () => {
setSearching(true);
try {
const response = await fetch(`/api/products/search?q=${encodeURIComponent(q)}`, {
// Search both CIMA and Parapharmacy
const [cimaRes, paraRes] = await Promise.allSettled([
fetch(`/api/products/search?q=${encodeURIComponent(q)}`, {
credentials: 'include',
signal: controller.signal,
});
const data = await response.json();
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) {
if (error.name === 'AbortError') return;
console.error('Error searching products:', error);
@@ -116,15 +135,15 @@ function PharmacyProductLink() {
}
try {
// Build identifier: source:id (e.g., openfoodfacts:9421025231209)
const identifier = selectedProduct._id
? `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct._id}`
: `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct.id}`;
// Build identifier: source:id
const productId = selectedProduct._id || selectedProduct.id;
const source = selectedProduct.source || 'parapharmacy';
const identifier = `${source}:${productId}`;
const payload = {
pharmacy_id: parseInt(formData.pharmacy_id),
product_source: selectedProduct.source || 'openfoodfacts',
product_off_id: selectedProduct._id || selectedProduct.id,
product_source: source,
product_off_id: productId,
product_name: selectedProduct.product_name || selectedProduct.name,
price: formData.price ? parseFloat(formData.price) : null,
stock: formData.stock ? parseInt(formData.stock) : 0
@@ -221,10 +240,28 @@ function PharmacyProductLink() {
};
const getSourceBadge = (source) => {
if (source === 'cima') {
return <span className="source-badge source-badge--cima">CIMA</span>;
}
return <span className="source-badge source-badge--off">OFF</span>;
const colors = {
cima: '#2563eb',
promofarma: '#16a34a',
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 (
@@ -284,7 +321,7 @@ function PharmacyProductLink() {
</div>
<div className="form-group">
<label>Buscar Producto (Open Food Facts) *</label>
<label>Buscar Producto (CIMA / Parafarmacia) *</label>
<input
type="text"
value={productSearch}
@@ -292,7 +329,7 @@ function PharmacyProductLink() {
setProductSearch(e.target.value);
setSelectedProduct(null);
}}
placeholder="Escribe para buscar productos..."
placeholder="Escribe para buscar medicamentos o productos de parafarmacia..."
required
/>
{searching && <p className="loading-text">Buscando...</p>}
@@ -301,13 +338,15 @@ function PharmacyProductLink() {
<div className="medicine-search-results">
{productResults.slice(0, 10).map((product) => (
<div
key={product._id || product.id}
key={product._id || product.id || product.nregistro}
className="search-result-item"
onClick={() => selectProduct(product)}
>
<strong>{product.product_name || product.name}</strong>
{product.brand && <span> - {product.brand}</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>
@@ -317,11 +356,12 @@ function PharmacyProductLink() {
<div className="selected-medicine-info">
<p>✅ Selected: <strong>{selectedProduct.product_name || selectedProduct.name}</strong></p>
<p className="medicine-details">
{selectedProduct.brand && `Marca: ${selectedProduct.brand} • `}
{selectedProduct.brands && `Marca: ${selectedProduct.brands} • `}
{selectedProduct.quantity && `Cantidad: ${selectedProduct.quantity} • `}
{getSourceBadge(selectedProduct.source || 'openfoodfacts')}
{selectedProduct.price != null && `Precio: ${selectedProduct.price}€ • `}
{getSourceBadge(selectedProduct.source || 'parapharmacy')}
{' '}
{selectedProduct._id || selectedProduct.id}
{selectedProduct._id || selectedProduct.id || selectedProduct.nregistro}
</p>
<button
type="button"
+98 -31
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';
export default function ProductView({ source, id, onBack }) {
@@ -7,6 +10,50 @@ export default function ProductView({ source, id, onBack }) {
const [error, setError] = useState(null);
const [pharmacies, setPharmacies] = useState([]);
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(() => {
loadProduct();
@@ -17,13 +64,19 @@ export default function ProductView({ source, id, onBack }) {
setError(null);
setPharmacies([]);
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) {
throw new Error('Producto no encontrado');
}
const data = await response.json();
setProduct(data);
loadPharmacies(source, data.id);
loadPharmacies(source, data.id || data._id);
} catch (err) {
setError(err.message);
} finally {
@@ -71,8 +124,9 @@ export default function ProductView({ source, id, onBack }) {
if (!product) return null;
const isCima = product.source === 'cima';
const isParapharmacy = product.source !== 'cima';
const sourceColor = isCima ? '#2563eb' : '#16a34a';
const sourceLabel = isCima ? 'CIMA' : 'Open Food Facts';
const sourceLabel = isCima ? 'CIMA' : 'Parafarmacia';
return (
<div className="product-view">
@@ -116,17 +170,20 @@ export default function ProductView({ source, id, onBack }) {
</>
) : (
<>
{product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && (
<DetailRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} />
{product.price && (
<DetailRow label="Precio" value={`${product.price} €`} />
)}
{product.nova_group && (
<DetailRow label="NOVA" value={`Grupo ${product.nova_group}`} />
{product.original_price && product.original_price > product.price && (
<DetailRow label="Precio anterior" value={`${product.original_price} €`} />
)}
{product.eco_score && product.eco_score !== 'unknown' && (
<DetailRow label="Eco-Score" value={product.eco_score.toUpperCase()} />
{product.category && (
<DetailRow label="Categoría" value={product.category} />
)}
{product.ingredients && (
<DetailRow label="Ingredientes" value={product.ingredients} />
{product.brand && (
<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">
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
</h3>
<div className="pharmacies-grid">
{pharmacies.map((pharmacy) => (
<div key={pharmacy.id} className="product-pharmacy-card">
<h4 className="product-pharmacy-name">{pharmacy.name}</h4>
<p className="product-pharmacy-address">{pharmacy.address}</p>
{pharmacy.phone && (
<p className="product-pharmacy-phone">{pharmacy.phone}</p>
<div className="pharmacy-controls">
<button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance}
disabled={locating}
>
{locating
? '📍 Localizando…'
: sortByDistance
? '📍 Ordenado por distancia · Reset'
: '📍 Ordenar por distancia'}
</button>
{sortByDistance && positionSource && (
<span className="location-source">Usando tu ubicación</span>
)}
<div className="product-pharmacy-status">
{pharmacy.price && (
<span className="product-pharmacy-price">
{parseFloat(pharmacy.price).toFixed(2)} &euro;
</span>
)}
{pharmacy.stock !== undefined && (
<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 className="location-error">
{locationError}
<button className="retry-location-btn" onClick={handleSortByDistance}>
Reintentar
</button>
</span>
)}
</div>
</div>
))}
</div>
<PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList
pharmacies={displayedPharmacies}
loading={loadingPharmacies}
userPosition={sortByDistance ? userPosition : null}
/>
</>
) : null}
</div>
+22 -49
View File
@@ -18,7 +18,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [medicines, setMedicines] = useState([]);
const [products, setProducts] = useState([]);
const [searchMode, setSearchMode] = useState('all');
const [selectedMedicine, setSelectedMedicine] = useState(null);
const [pharmacies, setPharmacies] = useState([]);
const [loading, setLoading] = useState(false);
@@ -88,16 +87,24 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const query = searchQuery.trim();
try {
// Only search medications from CIMA
const medicinesRes = await fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`);
// Search both CIMA and Parapharmacy simultaneously
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
if (query !== searchQuery.trim()) return;
if (medicinesRes.ok) {
const medicinesData = await medicinesRes.json();
if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) {
const medicinesData = await medicinesRes.value.json();
setMedicines(medicinesData);
}
if (productsRes.status === 'fulfilled' && productsRes.value.ok) {
const productsData = await productsRes.value.json();
setProducts(productsData.results || []);
}
} catch (error) {
console.error('Search error:', error);
} finally {
@@ -108,26 +115,6 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
return () => clearTimeout(timeoutId);
}, [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(() => {
const fetchPharmacies = async () => {
if (!selectedMedicine) {
@@ -310,28 +297,13 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{searchQuery && !selectedMedicine && (
<>
<div className="filter-tabs">
<button
className={`filter-tab ${searchMode === 'all' ? 'filter-tab--active' : ''}`}
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 className="results-summary">
{(medicines.length + products.length) > 0 && (
<span>{medicines.length + products.length} resultados encontrados</span>
)}
</div>
{(searchMode === 'all' || searchMode === 'medicines') && (
{medicines.length > 0 && (
<MedicineResults
medicines={medicines}
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">
<h3 className="section-subtitle">Parafarmacia y Bebé</h3>
<h3 className="section-subtitle">Parafarmacia</h3>
<ProductResults
products={products}
onSelect={(p) => {
const productId = p._id || p.id;
if (onNavigateToProduct) {
onNavigateToProduct(p.source, p.id);
onNavigateToProduct(p.source, productId);
} else {
window.location.href = `/product/${p.source}/${p.id}`;
window.location.href = `/product/${p.source}/${productId}`;
}
}}
/>