Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8fdbb904 | |||
| 6155fdcd15 | |||
| 08b7437803 | |||
| 26e7d92079 | |||
| dca7e373cd | |||
| 63b25e3f4f | |||
| e5dcfbd2f5 | |||
| 3666f4dee8 | |||
| 3429b7e548 | |||
| 570d4ab43e |
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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)} €
|
: '📍 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>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user