From d6f4164dae9a1ed6fef0fdb17fb22b80646f62b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:10:46 +0200 Subject: [PATCH 01/24] feat(backend): add Open Food Facts service with Redis caching - transformOFFProduct: maps OFF products to unified Product model - searchBabyProducts: searches baby products via OFF API (1h cache) - getBabyProductDetails: fetches product details by barcode (1h cache) - 9 tests passing for transform logic and input validation --- apps/backend/__tests__/off-service.test.js | 92 ++++++++++++++ apps/backend/off-service.js | 139 +++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 apps/backend/__tests__/off-service.test.js create mode 100644 apps/backend/off-service.js diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js new file mode 100644 index 0000000..c5d7840 --- /dev/null +++ b/apps/backend/__tests__/off-service.test.js @@ -0,0 +1,92 @@ +import { transformOFFProduct, searchBabyProducts, getBabyProductDetails } from '../off-service.js'; + +describe('transformOFFProduct', () => { + test('transforms a valid OFF product to unified model', () => { + const offProduct = { + _id: '12345', + product_name: 'Baby Rice', + brands: 'Nestlé', + image_url: 'https://example.com/image.jpg', + nutriscore_grade: 'a', + ingredients_text: 'Rice, Iron, Vitamins', + nova_group: 1, + ecoscore_grade: 'b', + categories_tags: ['en:baby-foods', 'en:baby-rice'], + }; + + const result = transformOFFProduct(offProduct); + + expect(result).toEqual({ + id: '12345', + source: 'openfoodfacts', + name: 'Baby Rice', + brand: 'Nestlé', + category: 'baby_food', + image_url: 'https://example.com/image.jpg', + nutriscore: 'a', + ingredients: 'Rice, Iron, Vitamins', + nova_group: 1, + eco_score: 'b', + }); + }); + + test('returns null for null/undefined input', () => { + expect(transformOFFProduct(null)).toBeNull(); + expect(transformOFFProduct(undefined)).toBeNull(); + }); + + test('returns null when missing required fields', () => { + expect(transformOFFProduct({ _id: '123' })).toBeNull(); + expect(transformOFFProduct({ product_name: 'Test' })).toBeNull(); + }); + + test('sets default brand to empty string when missing', () => { + const result = transformOFFProduct({ + _id: '999', + product_name: 'Plain Product', + }); + expect(result.brand).toBe(''); + expect(result.image_url).toBeNull(); + expect(result.nutriscore).toBeNull(); + expect(result.ingredients).toBeNull(); + expect(result.nova_group).toBeNull(); + expect(result.eco_score).toBeNull(); + }); + + test('detects baby_milk category from tags', () => { + const result = transformOFFProduct({ + _id: '200', + product_name: 'Infant Formula', + categories_tags: ['en:infant-milk'], + }); + expect(result.category).toBe('baby_milk'); + }); + + test('detects baby_cereal category from tags', () => { + const result = transformOFFProduct({ + _id: '300', + product_name: 'Baby Cereal', + categories_tags: ['en:baby-cereals'], + }); + expect(result.category).toBe('baby_cereal'); + }); +}); + +describe('searchBabyProducts', () => { + test('returns empty array for short query', async () => { + const result = await searchBabyProducts('a'); + expect(result).toEqual([]); + }); + + test('returns empty array for null/empty query', async () => { + expect(await searchBabyProducts(null)).toEqual([]); + expect(await searchBabyProducts('')).toEqual([]); + }); +}); + +describe('getBabyProductDetails', () => { + test('returns null for null barcode', async () => { + const result = await getBabyProductDetails(null); + expect(result).toBeNull(); + }); +}); diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js new file mode 100644 index 0000000..58ad5ff --- /dev/null +++ b/apps/backend/off-service.js @@ -0,0 +1,139 @@ +import axios from 'axios'; +import redisClient from './redis-client.js'; + +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; +const CACHE_TTL = 3600; + +const BABY_CATEGORIES = { + 'baby food': 'baby_food', + 'baby milk': 'baby_milk', + 'baby cereal': 'baby_cereal', +}; + +export function transformOFFProduct(offProduct) { + if (!offProduct || !offProduct._id || !offProduct.product_name) { + return null; + } + + const categoriesTags = offProduct.categories_tags || []; + let category = 'baby_food'; + for (const tag of categoriesTags) { + const lower = tag.toLowerCase(); + if (lower.includes('baby-milk') || lower.includes('infant-milk')) { + category = 'baby_milk'; + break; + } + if (lower.includes('baby-cereal') || lower.includes('infant-cereal')) { + category = 'baby_cereal'; + break; + } + } + + return { + id: offProduct._id, + source: 'openfoodfacts', + name: offProduct.product_name, + brand: offProduct.brands || '', + category, + image_url: offProduct.image_url || null, + nutriscore: offProduct.nutriscore_grade || null, + ingredients: offProduct.ingredients_text || null, + nova_group: offProduct.nova_group || null, + eco_score: offProduct.ecoscore_grade || null, + }; +} + +export async function searchBabyProducts(query) { + if (!query || query.trim().length < 2) { + return []; + } + + const searchTerm = query.trim().toLowerCase(); + const cacheKey = `off:baby:${searchTerm}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF search: ${searchTerm}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching from OFF API: ${searchTerm}`); + const response = await axios.get(`${OFF_API_BASE}/search`, { + params: { + categories_tags: 'baby-food', + search_terms: searchTerm, + json: true, + fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + page_size: 20, + }, + timeout: 5000, + }); + + if (response.data && response.data.products) { + const products = response.data.products + .map(transformOFFProduct) + .filter(Boolean); + + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`✅ Cached ${products.length} OFF products for: ${searchTerm}`); + return products; + } + + return []; + } catch (error) { + console.error('Error searching baby products from OFF:', error.message); + return []; + } +} + +export async function getBabyProductDetails(barcode) { + if (!barcode) { + return null; + } + + const cacheKey = `off:product:${barcode}`; + + try { + const cachedData = await redisClient.get(cacheKey); + if (cachedData) { + console.log(`📦 Cache hit for OFF product: ${barcode}`); + return JSON.parse(cachedData); + } + + console.log(`🌐 Fetching OFF product details: ${barcode}`); + const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { + timeout: 5000, + }); + + if (response.data && response.data.product) { + const product = transformOFFProduct(response.data.product); + + if (product) { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + console.log(`✅ Cached OFF product: ${barcode}`); + return product; + } + } + + return null; + } catch (error) { + console.error(`Error fetching OFF product ${barcode}:`, error.message); + return null; + } +} + +export async function clearCache(pattern = 'off:*') { + try { + const keys = await redisClient.keys(pattern); + if (keys.length > 0) { + await redisClient.del(keys); + console.log(`🗑️ Cleared ${keys.length} OFF cache entries`); + return keys.length; + } + return 0; + } catch (error) { + console.error('Error clearing OFF cache:', error); + return 0; + } +} -- 2.52.0 From d62e5976ce99a3c3e363a05b4819092a11131367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:17:02 +0200 Subject: [PATCH 02/24] fix: remove dead BABY_CATEGORIES constant and emoji from logs --- apps/backend/off-service.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 58ad5ff..e05259e 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -4,12 +4,6 @@ import redisClient from './redis-client.js'; const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; const CACHE_TTL = 3600; -const BABY_CATEGORIES = { - 'baby food': 'baby_food', - 'baby milk': 'baby_milk', - 'baby cereal': 'baby_cereal', -}; - export function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; @@ -54,11 +48,11 @@ export async function searchBabyProducts(query) { try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { - console.log(`📦 Cache hit for OFF search: ${searchTerm}`); + console.log(`Cache hit for OFF search: ${searchTerm}`); return JSON.parse(cachedData); } - console.log(`🌐 Fetching from OFF API: ${searchTerm}`); + console.log(`Fetching from OFF API: ${searchTerm}`); const response = await axios.get(`${OFF_API_BASE}/search`, { params: { categories_tags: 'baby-food', @@ -76,7 +70,7 @@ export async function searchBabyProducts(query) { .filter(Boolean); await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`✅ Cached ${products.length} OFF products for: ${searchTerm}`); + console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); return products; } @@ -97,11 +91,11 @@ export async function getBabyProductDetails(barcode) { try { const cachedData = await redisClient.get(cacheKey); if (cachedData) { - console.log(`📦 Cache hit for OFF product: ${barcode}`); + console.log(`Cache hit for OFF product: ${barcode}`); return JSON.parse(cachedData); } - console.log(`🌐 Fetching OFF product details: ${barcode}`); + console.log(`Fetching OFF product details: ${barcode}`); const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { timeout: 5000, }); @@ -111,7 +105,7 @@ export async function getBabyProductDetails(barcode) { if (product) { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); - console.log(`✅ Cached OFF product: ${barcode}`); + console.log(`Cached OFF product: ${barcode}`); return product; } } @@ -128,7 +122,7 @@ export async function clearCache(pattern = 'off:*') { const keys = await redisClient.keys(pattern); if (keys.length > 0) { await redisClient.del(keys); - console.log(`🗑️ Cleared ${keys.length} OFF cache entries`); + console.log(`Cleared ${keys.length} OFF cache entries`); return keys.length; } return 0; -- 2.52.0 From f2a5dc4ca371b8080e1d3ae651e92c6218e1d560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:18:20 +0200 Subject: [PATCH 03/24] feat(backend): add CIMA OTC search function with Redis caching --- apps/backend/cima-service.js | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/backend/cima-service.js b/apps/backend/cima-service.js index ad185fd..76364ac 100644 --- a/apps/backend/cima-service.js +++ b/apps/backend/cima-service.js @@ -231,6 +231,86 @@ export async function getMedicineDetails(nregistro) { } } +/** + * Busca medicamentos OTC (Sin Receta) en la API de CIMA con caché de Redis + * @param {string} query - Término de búsqueda + * @returns {Promise} - Lista de medicamentos OTC encontrados + */ +export async function searchOTC(query) { + if (!query || query.trim().length < 2) { + return []; + } + + const searchTerm = query.trim().toLowerCase(); + const cacheKey = `cima:otc:${searchTerm}`; + + try { + // Intentar obtener del caché + const cachedData = await redisClient.get(cacheKey); + + if (cachedData) { + console.log(`📦 Cache hit for OTC search: ${searchTerm}`); + return JSON.parse(cachedData); + } + + const parsed = parseSearchQuery(searchTerm); + const apiSearchTerm = parsed.nameQuery || searchTerm; + + console.log(`🌐 Fetching OTC from CIMA API: ${apiSearchTerm}`); + const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, { + params: { + nombre: apiSearchTerm, + cpresc: 'Sin Receta' + }, + timeout: 5000 + }); + + if (response.data && response.data.resultados) { + // Transformar los datos de CIMA al modelo unificado Product + const medicines = response.data.resultados.map(med => ({ + id: med.nregistro, + source: 'cima', + name: med.nombre, + brand: med.labtitular || '', + category: 'otc', + image_url: med.fotos?.[0]?.url || null, + active_ingredient: med.vtm?.nombre || null, + dosage: med.dosis || null, + form: med.formaFarmaceutica?.nombre || null, + prescription: med.cpresc, + commercialized: med.comerc, + photos: med.fotos || [], + docs: med.docs || [] + })); + + const filtered = filterMedicinesByFullQuery(medicines, searchTerm); + + // Guardar en caché (1h TTL) + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(filtered)); + + console.log(`✅ Cached ${filtered.length} OTC medicines for: ${searchTerm}`); + return filtered; + } + + return []; + } catch (error) { + console.error('Error searching OTC medicines from CIMA:', error.message); + + // Si falla, intentar devolver datos cacheados aunque hayan expirado + try { + const staleData = await redisClient.get(cacheKey); + if (staleData) { + console.log('⚠️ Returning stale OTC cache data due to API error'); + return JSON.parse(staleData); + } + } catch (cacheError) { + console.error('OTC cache fallback also failed:', cacheError); + } + + return []; + } +} + /** * Limpia el caché de búsquedas (útil para testing o mantenimiento) * @param {string} pattern - Patrón de claves a eliminar (ej: 'medicines:search:*') -- 2.52.0 From 229e1d81066e9e661ecde011ae5e3b8d4b51c817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:23:43 +0200 Subject: [PATCH 04/24] Add unified product search routes (CIMA OTC + Open Food Facts) - GET /api/products/search?q={query} - parallel search across both sources - GET /api/products/:source/:id - product detail by source (cima/openfoodfacts) - Uses Promise.allSettled for fault-tolerant parallel search - Added imports for searchOTC and OFF service functions --- apps/backend/server.js | 51 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/backend/server.js b/apps/backend/server.js index 19d4270..5136f0b 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -19,7 +19,8 @@ import pinoHttp from 'pino-http'; import multer from 'multer'; import { createWorker } from 'tesseract.js'; import { BarcodeDetector } from 'barcode-detector'; -import { searchMedicines, getMedicineDetails } from './cima-service.js'; +import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js'; +import { searchBabyProducts, getBabyProductDetails } from './off-service.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { fetchPharmaciesExternal } from '../API/index.js'; @@ -598,6 +599,54 @@ app.get('/api/medicines/:medicineId', async (req, res) => { } }); +// ========== UNIFIED PRODUCT SEARCH ========== + +app.get('/api/products/search', async (req, res) => { + try { + const { q } = req.query; + if (!q || q.trim().length < 2) { + return res.json({ results: [], total: 0 }); + } + const searchTerm = q.trim(); + const [cimaResults, offResults] = await Promise.allSettled([ + searchOTC(searchTerm), + searchBabyProducts(searchTerm) + ]); + const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : []; + const offProducts = offResults.status === 'fulfilled' ? offResults.value : []; + const allProducts = [...cimaProducts, ...offProducts] + .sort((a, b) => a.name.localeCompare(b.name)); + res.json({ + results: allProducts, + total: allProducts.length, + sources: { cima: cimaProducts.length, openfoodfacts: offProducts.length } + }); + } catch (err) { + console.error('[Products] Search error:', err); + res.status(500).json({ error: 'Error searching products' }); + } +}); + +app.get('/api/products/:source/:id', async (req, res) => { + try { + const { source, id } = req.params; + if (source === 'cima') { + const product = await getMedicineDetails(id); + if (!product) return res.status(404).json({ error: 'Product not found' }); + return res.json({ ...product, source: 'cima', category: 'otc' }); + } + if (source === 'openfoodfacts') { + const product = await getBabyProductDetails(id); + if (!product) return res.status(404).json({ error: 'Product not found' }); + return res.json(product); + } + res.status(400).json({ error: 'Invalid source' }); + } catch (err) { + console.error('[Products] Detail error:', err); + res.status(500).json({ error: 'Error fetching product details' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated -- 2.52.0 From a709deb8933504b0d6c9d4ee5b18b6d589097380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:25:35 +0200 Subject: [PATCH 05/24] feat(frontend): add ProductResults component for unified product search Displays CIMA OTC and Open Food Facts baby products with source/category badges, product images, brand info, and Nutri-Score for OFF items. --- .../src/components/ProductResults.css | 163 ++++++++++++++++++ .../src/components/ProductResults.jsx | 114 ++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/frontend/src/components/ProductResults.css create mode 100644 apps/frontend/src/components/ProductResults.jsx diff --git a/apps/frontend/src/components/ProductResults.css b/apps/frontend/src/components/ProductResults.css new file mode 100644 index 0000000..fb17ac6 --- /dev/null +++ b/apps/frontend/src/components/ProductResults.css @@ -0,0 +1,163 @@ +.product-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 0.5rem; + max-height: 70vh; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + animation: fadeInUp 0.5s ease-out; +} + +@media (min-width: 1024px) { + .product-results { + max-height: 76vh; + } +} + +.product-card { + background: var(--surface-container-lowest); + border-radius: var(--radius-md); + padding: 1.25rem; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + border: 1px solid var(--outline-variant); + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--shadow-soft); +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + border-color: var(--primary); +} + +.product-card-image { + width: 100%; + height: 120px; + margin-bottom: 1rem; + overflow: hidden; + border-radius: var(--radius-sm); + background: var(--surface-container-low); + display: flex; + align-items: center; + justify-content: center; +} + +.product-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-image-placeholder { + color: var(--outline-variant); + display: flex; + align-items: center; + justify-content: center; +} + +.product-card-content { + display: flex; + flex-direction: column; + flex: 1; +} + +.product-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.source-badge { + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.category-badge { + background: var(--surface-container-high); + color: var(--on-surface-variant); + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; +} + +.nutri-score-badge { + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.7rem; + font-weight: 600; +} + +.product-card-header { + margin-bottom: 0.5rem; +} + +.product-card-header h3 { + color: var(--on-surface); + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.01em; + flex: 1; + margin: 0; +} + +.product-card-body { + margin-bottom: 1rem; + flex: 1; +} + +.product-card-body p { + font-size: 0.9rem; + color: var(--on-surface-variant); + line-height: 1.55; + margin-bottom: 0.25rem; +} + +.product-card-body strong { + color: var(--on-surface); + font-weight: 600; +} + +.product-card-footer { + padding-top: 0.75rem; + border-top: 1px solid var(--outline-variant); +} + +.view-details { + color: var(--primary); + font-weight: 600; + font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.view-details::after { + content: "→"; + transition: transform 0.2s; +} + +.product-card:hover .view-details::after { + transform: translateX(4px); +} + +.no-results { + text-align: center; + padding: 2.5rem 1.5rem; + background: var(--surface-container-low); + border-radius: var(--radius-md); + color: var(--on-surface-variant); + border: 1px dashed var(--outline-variant); +} diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx new file mode 100644 index 0000000..b2d0280 --- /dev/null +++ b/apps/frontend/src/components/ProductResults.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import './ProductResults.css'; + +const categoryLabels = { + otc: 'Sin Receta', + baby_food: 'Alimentación Infantil', + baby_milk: 'Leche de Fórmula', + baby_cereal: 'Cereales Bebé' +}; + +const sourceColors = { + cima: '#2563eb', + openfoodfacts: '#16a34a' +}; + +const sourceLabels = { + cima: 'CIMA', + openfoodfacts: 'Open Food Facts' +}; + +function ProductResults({ products, onSelect }) { + if (!products || products.length === 0) { + return ( +
+

No se encontraron productos

+
+ ); + } + + return ( +
+ {products.map((product) => ( + + ))} +
+ ); +} + +function ProductCard({ product, onSelect }) { + const nutriScore = product.nutri_score; + const nutriScoreColors = { + a: '#16a34a', + b: '#65a30d', + c: '#eab308', + d: '#f97316', + e: '#dc2626' + }; + + return ( +
onSelect(product)}> +
+ {product.image_url ? ( + {product.name} + ) : ( +
+ + + + + +
+ )} +
+ +
+
+ + {sourceLabels[product.source]} + + + {categoryLabels[product.category] || product.category} + + {product.source === 'openfoodfacts' && nutriScore && ( + + Nutri-Score {nutriScore.toUpperCase()} + + )} +
+ +
+

{product.name}

+
+ +
+ {product.brand && ( +

Marca: {product.brand}

+ )} + {product.source === 'cima' && product.active_ingredient && ( +

Principio Activo: {product.active_ingredient}

+ )} + {product.source === 'cima' && product.dosage && ( +

Dosis: {product.dosage}

+ )} +
+ +
+ Ver detalles → +
+
+
+ ); +} + +export default ProductResults; -- 2.52.0 From 9e786f2966a6109dfa9170f28b332e64fc6e97a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:27:47 +0200 Subject: [PATCH 06/24] feat(frontend): integrate product search into PublicView with filter tabs --- apps/frontend/src/views/PublicView.jsx | 63 +++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index aa383c9..22dd84c 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import '../App.css'; import SearchBar from '../components/SearchBar'; import MedicineResults from '../components/MedicineResults'; +import ProductResults from '../components/ProductResults'; import PharmacyList from '../components/PharmacyList'; import PharmacyMap from '../components/PharmacyMap'; import HomeView from './HomeView'; @@ -22,6 +23,8 @@ function PublicView({ const [searchQuery, setSearchQuery] = useState(''); const [medicines, setMedicines] = useState([]); + const [products, setProducts] = useState([]); + const [searchMode, setSearchMode] = useState('all'); // 'all' | 'medicines' | 'products' const [selectedMedicine, setSelectedMedicine] = useState(null); const [pharmacies, setPharmacies] = useState([]); const [loading, setLoading] = useState(false); @@ -35,6 +38,7 @@ function PublicView({ const searchMedicines = async () => { if (searchQuery.trim().length < 2) { setMedicines([]); + setProducts([]); setSelectedMedicine(null); setPharmacies([]); return; @@ -50,6 +54,16 @@ function PublicView({ } finally { setLoading(false); } + + try { + const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`); + if (productsResponse.ok) { + const productsData = await productsResponse.json(); + setProducts(productsData.results || []); + } + } catch (err) { + console.error('Product search error:', err); + } }; const timeoutId = setTimeout(searchMedicines, 300); @@ -219,7 +233,42 @@ function PublicView({ {loading &&
Buscando...
} - {searchQuery && !selectedMedicine && ( + {searchQuery && ( +
+ + + +
+ )} + + {searchQuery && !selectedMedicine && (searchMode === 'all' || searchMode === 'medicines') && ( )} + {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( +
+

Parafarmacia y Bebé

+ { + window.location.href = `/product/${product.source}/${product.id}`; + }} + /> +
+ )} + {selectedMedicine && (
-- 2.52.0 From d5b23aa94ae55478281cbb8cf3631e943ac0dba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:28:55 +0200 Subject: [PATCH 07/24] feat(mobile): add products API service with search and detail endpoints --- apps/frontend-mobile/services/products.ts | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/frontend-mobile/services/products.ts diff --git a/apps/frontend-mobile/services/products.ts b/apps/frontend-mobile/services/products.ts new file mode 100644 index 0000000..32faab7 --- /dev/null +++ b/apps/frontend-mobile/services/products.ts @@ -0,0 +1,53 @@ +import api from './api'; + +export interface Product { + id: string; + source: 'cima' | 'openfoodfacts'; + name: string; + brand: string; + category: string; + image_url: string | null; + active_ingredient?: string; + dosage?: string; + form?: string; + prescription?: string; + commercialized?: boolean; + photos?: { tipo: string; url: string }[]; + docs?: { tipo: number; url: string }[]; + nutriscore?: string; + ingredients?: string; + nova_group?: number; + eco_score?: string; +} + +export interface ProductSearchResponse { + results: Product[]; + total: number; + sources: { + cima: number; + openfoodfacts: number; + }; +} + +export async function searchProducts(query: string): Promise { + if (!query || query.trim().length < 2) return []; + try { + const { data } = await api.get('/products/search', { + params: { q: query } + }); + return data.results || []; + } catch (error) { + console.error('[Products] Search error:', error); + return []; + } +} + +export async function getProduct(source: string, id: string): Promise { + try { + const { data } = await api.get(`/products/${source}/${id}`); + return data; + } catch (error) { + console.error('[Products] Detail error:', error); + return null; + } +} -- 2.52.0 From bb2dbbab3a3df2be927026f1cc3e331076de92ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:31:39 +0200 Subject: [PATCH 08/24] feat(mobile): integrate product search with filter tabs in search screen --- apps/frontend-mobile/app/(tabs)/search.tsx | 132 ++++++++++++++++++++- 1 file changed, 126 insertions(+), 6 deletions(-) diff --git a/apps/frontend-mobile/app/(tabs)/search.tsx b/apps/frontend-mobile/app/(tabs)/search.tsx index 7d5e4d3..dfb11d5 100644 --- a/apps/frontend-mobile/app/(tabs)/search.tsx +++ b/apps/frontend-mobile/app/(tabs)/search.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { View, FlatList, StyleSheet, Text, TouchableOpacity, useWindowDimensions } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; -import { useNavigation } from 'expo-router'; +import { useNavigation, useRouter } from 'expo-router'; import { SearchBar } from '../../components/SearchBar'; import { MedicineCard } from '../../components/MedicineCard'; import { LoadingSpinner } from '../../components/LoadingSpinner'; @@ -9,6 +9,7 @@ import { useDebounce } from '../../hooks/useDebounce'; import { useAuth } from '../../hooks/useAuth'; import { useRecentSearches } from '../../hooks/useRecentSearches'; import { searchMedicines } from '../../services/medicines'; +import { searchProducts, Product } from '../../services/products'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; import { Medicine } from '../../types'; @@ -31,11 +32,14 @@ export default function SearchScreen() { const { colors } = useThemeContext(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); + const [products, setProducts] = useState([]); + const [searchMode, setSearchMode] = useState<'all' | 'medicines' | 'products'>('all'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const debouncedQuery = useDebounce(query, config.SEARCH_DEBOUNCE_MS); const navigation = useNavigation(); + const router = useRouter(); useEffect(() => { const parent = navigation.getParent(); @@ -43,6 +47,8 @@ export default function SearchScreen() { const unsubscribe = parent.addListener('tabPress', () => { setQuery(''); setResults([]); + setProducts([]); + setSearchMode('all'); setIsLoading(false); setError(null); }); @@ -52,6 +58,7 @@ export default function SearchScreen() { useEffect(() => { if (debouncedQuery.length < 2) { setResults([]); + setProducts([]); return; } @@ -69,7 +76,17 @@ export default function SearchScreen() { } }; + const fetchProducts = async () => { + try { + const productResults = await searchProducts(debouncedQuery); + setProducts(productResults); + } catch (err) { + console.error('Product search error:', err); + } + }; + fetchResults(); + fetchProducts(); }, [debouncedQuery]); const handleSearch = (searchQuery: string) => { @@ -77,7 +94,7 @@ export default function SearchScreen() { if (searchQuery.trim()) addSearch(searchQuery); }; - const showSuggestions = !query && !isLoading && results.length === 0; + const showSuggestions = !query && !isLoading && results.length === 0 && products.length === 0; return ( @@ -87,6 +104,20 @@ export default function SearchScreen() { onChangeText={setQuery} /> + {query.length >= 2 && ( + + setSearchMode('all')}> + Todos + + setSearchMode('medicines')}> + Medicamentos + + setSearchMode('products')}> + Parafarmacia + + + )} + {showSuggestions && ( <> @@ -130,7 +161,7 @@ export default function SearchScreen() { )} - {isLoading && } + {isLoading && } {error && ( @@ -138,16 +169,43 @@ export default function SearchScreen() { )} - {!isLoading && !error && results.length === 0 && query.length >= 2 && ( + {!isLoading && !error && results.length === 0 && products.length === 0 && query.length >= 2 && ( - No se encontraron medicamentos + No se encontraron resultados )} item.nregistro} renderItem={({ item }) => } + ListHeaderComponent={ + <> + {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( + + Parafarmacia y Bebé + {products.map((product) => ( + router.push(`/product/${product.source}/${product.id}`)} + activeOpacity={0.7} + > + + + + {product.source === 'cima' ? 'CIMA' : 'OFF'} + + + {product.name} + {product.brand} + + + ))} + + )} + + } contentContainerStyle={[styles.list, isTablet && styles.listTablet]} showsVerticalScrollIndicator={false} /> @@ -159,6 +217,34 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + filterContainer: { + flexDirection: 'row', + justifyContent: 'center', + gap: spacing.sm, + 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: { + fontSize: 14, + fontWeight: '600', + color: '#41493e', + }, + filterTextActive: { + color: '#ffffff', + }, + section: { + paddingHorizontal: spacing.lg, + marginTop: spacing.md, + }, suggestionsSection: { marginTop: spacing.sm, alignSelf: 'center', @@ -245,4 +331,38 @@ const styles = StyleSheet.create({ emptyText: { fontSize: 16, }, + productCard: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: borderRadius.lg, + borderWidth: 1, + padding: spacing.md, + marginBottom: spacing.sm, + }, + productInfo: { + flex: 1, + gap: 4, + }, + badges: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: 2, + }, + sourceBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + }, + badgeText: { + color: '#ffffff', + fontSize: 11, + fontWeight: '700', + }, + productName: { + fontSize: 15, + fontWeight: '600', + }, + productBrand: { + fontSize: 13, + }, }); -- 2.52.0 From 7b1636a96ec6a82e3b6ba0b7f18d95ab95ec867c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:33:34 +0200 Subject: [PATCH 09/24] feat(mobile): add product detail screen for CIMA and OFF products --- .../app/product/[source]/[id].tsx | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 apps/frontend-mobile/app/product/[source]/[id].tsx diff --git a/apps/frontend-mobile/app/product/[source]/[id].tsx b/apps/frontend-mobile/app/product/[source]/[id].tsx new file mode 100644 index 0000000..3659e67 --- /dev/null +++ b/apps/frontend-mobile/app/product/[source]/[id].tsx @@ -0,0 +1,249 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ScrollView, StyleSheet, Image, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import { getProduct, Product } from '../../../services/products'; +import { LoadingSpinner } from '../../../components/LoadingSpinner'; +import { useThemeContext } from '../../../components/ThemeProvider'; +import { spacing, borderRadius } from '../../../constants/theme'; + +export default function ProductDetailScreen() { + const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); + const { colors } = useThemeContext(); + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + if (!source || !id) return; + + const fetchProduct = async () => { + try { + const data = await getProduct(source, id); + if (data) { + setProduct(data); + } else { + setError(true); + } + } catch { + setError(true); + } finally { + setIsLoading(false); + } + }; + + fetchProduct(); + }, [source, id]); + + if (isLoading) { + return ; + } + + if (error || !product) { + return ( + + Producto no encontrado + + ); + } + + const isCima = product.source === 'cima'; + + return ( + + {product.image_url ? ( + + ) : ( + + Sin imagen + + )} + + + + {product.name} + + {isCima ? 'CIMA' : 'OFF'} + + + {product.brand ? ( + {product.brand} + ) : null} + {product.category ? ( + {product.category} + ) : null} + + + {isCima ? ( + + Detalles CIMA + {product.active_ingredient && ( + + )} + {product.dosage && ( + + )} + {product.form && ( + + )} + {product.prescription && ( + + )} + + + ) : ( + + Información nutricional + {product.nutriscore && ( + + )} + {product.nova_group != null && ( + + )} + {product.eco_score && ( + + )} + {product.ingredients && ( + + Ingredientes + {product.ingredients} + + )} + + )} + + {isCima && product.photos && product.photos.length > 0 && ( + + Imágenes + + {product.photos.map((photo, i) => ( + + + {photo.tipo} + + ))} + + + )} + + ); +} + +function InfoRow({ label, value, colors }: { label: string; value: string; colors: any }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + image: { + width: '100%', + height: 260, + backgroundColor: '#f5f5f5', + }, + imagePlaceholder: { + width: '100%', + height: 160, + alignItems: 'center', + justifyContent: 'center', + }, + placeholderText: { + fontSize: 14, + }, + header: { + padding: spacing.lg, + }, + nameRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: spacing.sm, + }, + name: { + flex: 1, + fontSize: 22, + fontWeight: 'bold', + }, + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + overflow: 'hidden', + }, + badgeText: { + color: '#fff', + fontSize: 12, + fontWeight: '700', + }, + brand: { + fontSize: 15, + marginTop: spacing.xs, + }, + category: { + fontSize: 13, + marginTop: spacing.xs, + }, + infoSection: { + marginTop: spacing.sm, + padding: spacing.lg, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: spacing.sm, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + }, + infoLabel: { + fontSize: 13, + flexShrink: 0, + marginRight: spacing.sm, + }, + infoValue: { + fontSize: 13, + fontWeight: '500', + flex: 1, + textAlign: 'right', + }, + infoValueMultiline: { + fontSize: 13, + fontWeight: '500', + flex: 1, + textAlign: 'right', + }, + photosScroll: { + marginTop: spacing.xs, + }, + photoItem: { + marginRight: spacing.md, + alignItems: 'center', + width: 120, + }, + photoImage: { + width: 120, + height: 120, + borderRadius: borderRadius.md, + backgroundColor: '#f5f5f5', + }, + photoLabel: { + fontSize: 11, + marginTop: spacing.xs, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + }, +}); -- 2.52.0 From 83920ae57c83a760c0b4289091d9b1a15b8d381f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:35:11 +0200 Subject: [PATCH 10/24] feat: display product results alongside medicine results in MedicineResults component --- apps/frontend/src/components/MedicineResults.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/MedicineResults.jsx b/apps/frontend/src/components/MedicineResults.jsx index 5c28627..37f0cd3 100644 --- a/apps/frontend/src/components/MedicineResults.jsx +++ b/apps/frontend/src/components/MedicineResults.jsx @@ -6,8 +6,9 @@ import { subscribeToPush, unsubscribeFromPush, } from '../utils/notifications.js'; +import ProductResults from './ProductResults'; -function MedicineResults({ medicines, onSelect, query, currentUser, onLoginRequest }) { +function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, query, currentUser, onLoginRequest }) { if (medicines.length === 0 && query.length >= 2) { return (
@@ -27,6 +28,12 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque onLoginRequest={onLoginRequest} /> ))} + {products.length > 0 && ( +
+

Parafarmacia y Bebé

+ +
+ )}
); } -- 2.52.0 From 981f3bd3db5da04bdfb769128ecbbd5a84596f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:46:47 +0200 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20address=20code=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20nutriscore=20field=20name,=20cache=20write=20iso?= =?UTF-8?q?lation,=20missing=20rate=20limiter,=20dead=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/off-service.js | 16 ++++++++++++---- apps/backend/server.js | 2 +- apps/frontend/src/components/MedicineResults.jsx | 9 +-------- apps/frontend/src/components/ProductResults.jsx | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index e05259e..3066e56 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -69,8 +69,12 @@ export async function searchBabyProducts(query) { .map(transformOFFProduct) .filter(Boolean); - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); + } catch (cacheErr) { + // cache write failed, still return the data + } return products; } @@ -104,8 +108,12 @@ export async function getBabyProductDetails(barcode) { const product = transformOFFProduct(response.data.product); if (product) { - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); - console.log(`Cached OFF product: ${barcode}`); + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + console.log(`Cached OFF product: ${barcode}`); + } catch (cacheErr) { + // cache write failed, still return the data + } return product; } } diff --git a/apps/backend/server.js b/apps/backend/server.js index 5136f0b..fb3ffdc 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -601,7 +601,7 @@ app.get('/api/medicines/:medicineId', async (req, res) => { // ========== UNIFIED PRODUCT SEARCH ========== -app.get('/api/products/search', async (req, res) => { +app.get('/api/products/search', searchLimiter, async (req, res) => { try { const { q } = req.query; if (!q || q.trim().length < 2) { diff --git a/apps/frontend/src/components/MedicineResults.jsx b/apps/frontend/src/components/MedicineResults.jsx index 37f0cd3..5c28627 100644 --- a/apps/frontend/src/components/MedicineResults.jsx +++ b/apps/frontend/src/components/MedicineResults.jsx @@ -6,9 +6,8 @@ import { subscribeToPush, unsubscribeFromPush, } from '../utils/notifications.js'; -import ProductResults from './ProductResults'; -function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, query, currentUser, onLoginRequest }) { +function MedicineResults({ medicines, onSelect, query, currentUser, onLoginRequest }) { if (medicines.length === 0 && query.length >= 2) { return (
@@ -28,12 +27,6 @@ function MedicineResults({ medicines, products = [], onSelect, onSelectProduct, onLoginRequest={onLoginRequest} /> ))} - {products.length > 0 && ( -
-

Parafarmacia y Bebé

- -
- )}
); } diff --git a/apps/frontend/src/components/ProductResults.jsx b/apps/frontend/src/components/ProductResults.jsx index b2d0280..db9c680 100644 --- a/apps/frontend/src/components/ProductResults.jsx +++ b/apps/frontend/src/components/ProductResults.jsx @@ -41,7 +41,7 @@ function ProductResults({ products, onSelect }) { } function ProductCard({ product, onSelect }) { - const nutriScore = product.nutri_score; + const nutriScore = product.nutriscore; const nutriScoreColors = { a: '#16a34a', b: '#65a30d', -- 2.52.0 From 4df1594b3b6d35564460b7a5f80aac236167fcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 15:56:00 +0200 Subject: [PATCH 12/24] docs: add design spec and implementation plan for parapharmacy + baby products --- apps/backend/__tests__/server.test.js | 1 + .../2026-07-13-parapharmacy-baby-products.md | 1250 +++++++++++++++++ ...07-13-parapharmacy-baby-products-design.md | 226 +++ 3 files changed, 1477 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md create mode 100644 docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md diff --git a/apps/backend/__tests__/server.test.js b/apps/backend/__tests__/server.test.js index 5a52cb4..c252983 100644 --- a/apps/backend/__tests__/server.test.js +++ b/apps/backend/__tests__/server.test.js @@ -3,6 +3,7 @@ import { jest } from '@jest/globals' jest.unstable_mockModule('../cima-service.js', () => ({ searchMedicines: jest.fn(async () => []), getMedicineDetails: jest.fn(async () => null), + searchOTC: jest.fn(async () => []), })) jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({ diff --git a/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md b/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md new file mode 100644 index 0000000..d22c534 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md @@ -0,0 +1,1250 @@ +# Parafarmacia + Productos de Bebé — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend FarmaFinder search to include OTC medications from CIMA and baby products from Open Food Facts in a unified results view. + +**Architecture:** Backend orchestrates parallel searches to CIMA (OTC filter) and Open Food Facts (baby-food categories), merges results into a unified Product model, caches in Redis. Frontend displays mixed results with source/category badges. + +**Tech Stack:** Node.js/Express, Axios, Redis, React (web), React Native/Expo (mobile) + +--- + +## File Structure + +``` +apps/backend/ +├── off-service.js (NEW) Open Food Facts API client +├── cima-service.js (MODIFY) Add searchOTC() function +├── server.js (MODIFY) Add /api/products/* routes +└── tests/ + └── off-service.test.js (NEW) Tests for OFF service + +apps/frontend/src/ +├── components/ +│ └── ProductResults.jsx (NEW) Unified product results component +├── views/ +│ └── PublicView.jsx (MODIFY) Use /api/products/search +└── components/medicine/ + └── MedicineResults.jsx (MODIFY) Accept mixed product types + +apps/frontend-mobile/ +├── services/ +│ └── products.ts (NEW) Product API client +├── app/(tabs)/ +│ └── search.tsx (MODIFY) Add product search +└── app/product/ + └── [source]/[id].tsx (NEW) Product detail screen +``` + +--- + +### Task 1: Create Open Food Facts Service (Backend) + +**Files:** +- Create: `apps/backend/off-service.js` +- Create: `apps/backend/tests/off-service.test.js` + +- [ ] **Step 1: Write the failing test** + +```javascript +// apps/backend/tests/off-service.test.js +const { searchBabyProducts, getBabyProductDetails, transformOFFProduct } = require('../off-service'); + +describe('off-service', () => { + describe('transformOFFProduct', () => { + it('transforms OFF product to unified Product model', () => { + const offProduct = { + _id: '12345', + product_name: 'Nidal 1 Leche', + brands: 'Nestlé', + image_url: 'https://images.openfoodfacts.org/12345.jpg', + nutriscore_grade: 'b', + ingredients_text: 'Leche desnatada, lactosa, aceites vegetales...', + nova_group: 3, + ecoscore_grade: 'c', + categories_tags: ['en:baby-milks', 'en:baby-foods'] + }; + + const result = transformOFFProduct(offProduct); + + expect(result).toEqual({ + id: '12345', + source: 'openfoodfacts', + name: 'Nidal 1 Leche', + brand: 'Nestlé', + category: 'baby_milk', + image_url: 'https://images.openfoodfacts.org/12345.jpg', + nutriscore: 'b', + ingredients: 'Leche desnatada, lactosa, aceites vegetales...', + nova_group: 3, + eco_score: 'c' + }); + }); + + it('returns null for invalid product', () => { + expect(transformOFFProduct(null)).toBeNull(); + expect(transformOFFProduct({})).toBeNull(); + }); + }); + + describe('searchBabyProducts', () => { + it('returns array of Product objects', async () => { + const results = await searchBabyProducts('leche'); + expect(Array.isArray(results)).toBe(true); + if (results.length > 0) { + expect(results[0]).toHaveProperty('source', 'openfoodfacts'); + expect(results[0]).toHaveProperty('id'); + expect(results[0]).toHaveProperty('name'); + } + }); + }); + + describe('getBabyProductDetails', () => { + it('returns Product object for valid barcode', async () => { + // Use a known OFF product barcode + const result = await getBabyProductDetails('3017620422003'); + if (result) { + expect(result).toHaveProperty('source', 'openfoodfacts'); + expect(result).toHaveProperty('name'); + } + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/backend && npm test -- tests/off-service.test.js` +Expected: FAIL with "Cannot find module '../off-service'" + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// apps/backend/off-service.js +const axios = require('axios'); +const redisClient = require('./redis-client'); + +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; +const CACHE_TTL = 3600; // 1 hour + +function transformOFFProduct(offProduct) { + if (!offProduct || !offProduct._id || !offProduct.product_name) { + return null; + } + + // Determine category from tags + let category = 'baby_food'; + const tags = offProduct.categories_tags || []; + if (tags.some(t => t.includes('baby-milk'))) { + category = 'baby_milk'; + } else if (tags.some(t => t.includes('cereals-for-babies'))) { + category = 'baby_cereal'; + } + + return { + id: offProduct._id, + source: 'openfoodfacts', + name: offProduct.product_name, + brand: offProduct.brands || '', + category, + image_url: offProduct.image_url || null, + nutriscore: offProduct.nutriscore_grade || null, + ingredients: offProduct.ingredients_text || null, + nova_group: offProduct.nova_group || null, + eco_score: offProduct.ecoscore_grade || null + }; +} + +async function searchBabyProducts(query) { + const cacheKey = `off:baby:${query.toLowerCase().trim()}`; + + // Check cache + try { + const cached = await redisClient.get(cacheKey); + if (cached) return JSON.parse(cached); + } catch (err) { + // Cache miss, continue + } + + try { + const url = `${OFF_API_BASE}/search`; + const { data } = await axios.get(url, { + params: { + categories_tags: 'baby-food', + search_terms: query, + json: 'true', + fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + page_size: 20 + }, + timeout: 5000 + }); + + const products = (data.products || []) + .map(transformOFFProduct) + .filter(Boolean); + + // Cache results + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + } catch (err) { + // Cache write failed, continue + } + + return products; + } catch (err) { + console.error('[OFF] Search error:', err.message); + return []; + } +} + +async function getBabyProductDetails(barcode) { + const cacheKey = `off:product:${barcode}`; + + // Check cache + try { + const cached = await redisClient.get(cacheKey); + if (cached) return JSON.parse(cached); + } catch (err) { + // Cache miss, continue + } + + try { + const url = `${OFF_API_BASE}/product/${barcode}.json`; + const { data } = await axios.get(url, { timeout: 5000 }); + + if (!data || !data.product) return null; + + const product = transformOFFProduct(data.product); + + // Cache result + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(product)); + } catch (err) { + // Cache write failed, continue + } + + return product; + } catch (err) { + console.error('[OFF] Detail error:', err.message); + return null; + } +} + +module.exports = { searchBabyProducts, getBabyProductDetails, transformOFFProduct }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/backend && npm test -- tests/off-service.test.js` +Expected: PASS (Note: searchBabyProducts and getBabyProductDetails tests require network/Redis; they may be skipped in CI) + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/off-service.js apps/backend/tests/off-service.test.js +git commit -m "feat(backend): add Open Food Facts service for baby products" +``` + +--- + +### Task 2: Add CIMA OTC Search Function (Backend) + +**Files:** +- Modify: `apps/backend/cima-service.js` + +- [ ] **Step 1: Add searchOTC function** + +Read `apps/backend/cima-service.js` to understand existing `searchMedicines` function structure. Add new function after it: + +```javascript +// Add after searchMedicines function (around line 130) + +async function searchOTC(query) { + const searchTerm = query.toLowerCase().trim(); + if (!searchTerm) return []; + + const cacheKey = `cima:otc:${searchTerm}`; + + // Check cache + try { + const cached = await redisClient.get(cacheKey); + if (cached) return JSON.parse(cached); + } catch (err) { + // Cache miss + } + + try { + const { data } = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, { + params: { + nombre: searchTerm, + cpresc: 'Sin Receta', + pagina: 1, + tamanioPagina: 20 + }, + timeout: 5000 + }); + + if (!data || !data.resultados) return []; + + const products = data.resultados.map(med => ({ + id: med.nregistro, + source: 'cima', + name: med.nombre, + brand: med.labtitular || '', + category: 'otc', + image_url: med.fotos?.[0]?.url || null, + active_ingredient: med.vtm?.nombre || null, + dosage: med.dosis || null, + form: med.formaFarmaceutica?.nombre || null, + prescription: med.cpresc, + commercialized: med.comerc, + photos: med.fotos || [], + docs: med.docs || [] + })); + + // Cache results + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + } catch (err) { + // Cache write failed + } + + return products; + } catch (err) { + console.error('[CIMA] OTC search error:', err.message); + return []; + } +} +``` + +- [ ] **Step 2: Export the new function** + +Add `searchOTC` to the module.exports at the bottom of the file: + +```javascript +module.exports = { searchMedicines, getMedicineDetails, searchOTC }; +``` + +- [ ] **Step 3: Run existing tests to verify no regression** + +Run: `cd apps/backend && npm test` +Expected: All existing tests still pass + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/cima-service.js +git commit -m "feat(backend): add searchOTC function for OTC medications" +``` + +--- + +### Task 3: Add Unified Product Search Routes (Backend) + +**Files:** +- Modify: `apps/backend/server.js` + +- [ ] **Step 1: Add imports at top of server.js** + +After the existing CIMA service import, add: + +```javascript +const { searchBabyProducts, getBabyProductDetails } = require('./off-service'); +const { searchOTC } = require('./cima-service'); +``` + +- [ ] **Step 2: Add unified search route** + +Find a good location after the existing `/api/medicines/search` route (around line 520) and add: + +```javascript +// Unified product search (OTC + Baby) +app.get('/api/products/search', async (req, res) => { + try { + const { q } = req.query; + if (!q || q.trim().length < 2) { + return res.json({ results: [], total: 0 }); + } + + const searchTerm = q.trim(); + + // Launch parallel searches + const [cimaResults, offResults] = await Promise.allSettled([ + searchOTC(searchTerm), + searchBabyProducts(searchTerm) + ]); + + const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : []; + const offProducts = offResults.status === 'fulfilled' ? offResults.value : []; + + // Merge and sort by name + const allProducts = [...cimaProducts, ...offProducts] + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + results: allProducts, + total: allProducts.length, + sources: { + cima: cimaProducts.length, + openfoodfacts: offProducts.length + } + }); + } catch (err) { + console.error('[Products] Search error:', err); + res.status(500).json({ error: 'Error searching products' }); + } +}); +``` + +- [ ] **Step 3: Add product detail route** + +After the search route: + +```javascript +// Product detail by source and ID +app.get('/api/products/:source/:id', async (req, res) => { + try { + const { source, id } = req.params; + + if (source === 'cima') { + const { getMedicineDetails } = require('./cima-service'); + const product = await getMedicineDetails(id); + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + return res.json({ ...product, source: 'cima', category: 'otc' }); + } + + if (source === 'openfoodfacts') { + const product = await getBabyProductDetails(id); + if (!product) { + return res.status(404).json({ error: 'Product not found' }); + } + return res.json(product); + } + + res.status(400).json({ error: 'Invalid source' }); + } catch (err) { + console.error('[Products] Detail error:', err); + res.status(500).json({ error: 'Error fetching product details' }); + } +}); +``` + +- [ ] **Step 4: Test the endpoints manually** + +Start the backend server and test: +```bash +curl "http://localhost:3001/api/products/search?q=leche" +curl "http://localhost:3001/api/products/search?q=paracetamol" +curl "http://localhost:3001/api/products/cima/70483" +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/backend/server.js +git commit -m "feat(backend): add unified product search routes" +``` + +--- + +### Task 4: Create ProductResults Component (Frontend Web) + +**Files:** +- Create: `apps/frontend/src/components/ProductResults.jsx` + +- [ ] **Step 1: Create the component** + +```jsx +// apps/frontend/src/components/ProductResults.jsx +import React from 'react'; + +const categoryLabels = { + otc: 'Sin Receta', + baby_food: 'Alimentación Infantil', + baby_milk: 'Leche de Fórmula', + baby_cereal: 'Cereales Bebé' +}; + +const sourceLabels = { + cima: 'CIMA', + openfoodfacts: 'Open Food Facts' +}; + +const sourceColors = { + cima: '#2563eb', + openfoodfacts: '#16a34a' +}; + +export default function ProductResults({ products, onSelect }) { + if (!products || products.length === 0) { + return ( +
+ No se encontraron productos +
+ ); + } + + return ( +
+ {products.map((product) => ( +
onSelect(product)} + > +
+ {/* Product Image */} +
+ {product.image_url ? ( + {product.name} { + e.target.style.display = 'none'; + }} + /> + ) : ( +
+ Sin imagen +
+ )} +
+ + {/* Product Info */} +
+
+ + {sourceLabels[product.source]} + + + {categoryLabels[product.category] || product.category} + +
+ +

+ {product.name} +

+ +

+ {product.brand} +

+ + {/* CIMA-specific fields */} + {product.source === 'cima' && product.active_ingredient && ( +

+ Principio activo: {product.active_ingredient} + {product.dosage && ` - ${product.dosage}`} +

+ )} + + {/* OFF-specific fields */} + {product.source === 'openfoodfacts' && product.nutriscore && ( +
+ Nutriscore: + + {product.nutriscore.toUpperCase()} + +
+ )} +
+
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/components/ProductResults.jsx +git commit -m "feat(frontend): add ProductResults component for unified search" +``` + +--- + +### Task 5: Integrate Product Search in PublicView (Frontend Web) + +**Files:** +- Modify: `apps/frontend/src/views/PublicView.jsx` + +- [ ] **Step 1: Add import for ProductResults** + +Read the file first, then add the import near other component imports: + +```javascript +import ProductResults from '../components/ProductResults'; +``` + +- [ ] **Step 2: Add state for products** + +Find the existing search state variables and add: + +```javascript +const [products, setProducts] = useState([]); +const [searchMode, setSearchMode] = useState('all'); // 'all' | 'medicines' | 'products' +``` + +- [ ] **Step 3: Add product search to the search handler** + +Find the existing search function (likely `handleSearch` or similar) and modify it to also call the products endpoint: + +```javascript +// After existing medicine search call, add: +try { + const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`); + if (productsResponse.ok) { + const productsData = await productsResponse.json(); + setProducts(productsData.results || []); + } +} catch (err) { + console.error('Product search error:', err); +} +``` + +- [ ] **Step 4: Add filter tabs above results** + +Add filter tabs to let users switch between medicine and product results: + +```jsx +{/* Add before the results section */} +
+ + + +
+``` + +- [ ] **Step 5: Render ProductResults conditionally** + +In the results section, add: + +```jsx +{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( +
+

Parafarmacia y Bebé

+ { + window.location.href = `/product/${product.source}/${product.id}`; + }} + /> +
+)} +``` + +- [ ] **Step 6: Test in browser** + +Start the frontend dev server and verify: +1. Search "leche" — should show both CIMA OTC and OFF baby products +2. Search "paracetamol" — should show CIMA OTC results +3. Filter tabs work correctly + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend/src/views/PublicView.jsx +git commit -m "feat(frontend): integrate product search in PublicView" +``` + +--- + +### Task 6: Create Product API Service (Mobile) + +**Files:** +- Create: `apps/frontend-mobile/services/products.ts` + +- [ ] **Step 1: Create the service file** + +```typescript +// apps/frontend-mobile/services/products.ts +import api from './api'; + +export interface Product { + id: string; + source: 'cima' | 'openfoodfacts'; + name: string; + brand: string; + category: string; + image_url: string | null; + // CIMA-specific + active_ingredient?: string; + dosage?: string; + form?: string; + prescription?: string; + commercialized?: boolean; + photos?: { tipo: string; url: string }[]; + docs?: { tipo: number; url: string }[]; + // OFF-specific + nutriscore?: string; + ingredients?: string; + nova_group?: number; + eco_score?: string; +} + +export interface ProductSearchResponse { + results: Product[]; + total: number; + sources: { + cima: number; + openfoodfacts: number; + }; +} + +export async function searchProducts(query: string): Promise { + if (!query || query.trim().length < 2) return []; + + try { + const { data } = await api.get('/products/search', { + params: { q: query } + }); + return data.results || []; + } catch (error) { + console.error('[Products] Search error:', error); + return []; + } +} + +export async function getProduct(source: string, id: string): Promise { + try { + const { data } = await api.get(`/products/${source}/${id}`); + return data; + } catch (error) { + console.error('[Products] Detail error:', error); + return null; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/services/products.ts +git commit -m "feat(mobile): add product API service" +``` + +--- + +### Task 7: Integrate Product Search in Mobile Search Screen + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/search.tsx` + +- [ ] **Step 1: Add import for products service** + +Read the file first, then add: + +```typescript +import { searchProducts, Product } from '../../services/products'; +``` + +- [ ] **Step 2: Add state for products** + +Find existing state variables and add: + +```typescript +const [products, setProducts] = useState([]); +``` + +- [ ] **Step 3: Add product search to search effect** + +Find the existing search debounced effect and add product search: + +```typescript +// After existing medicine search, add: +try { + const productResults = await searchProducts(debouncedQuery); + setProducts(productResults); +} catch (err) { + console.error('Product search error:', err); +} +``` + +- [ ] **Step 4: Add filter tabs in the UI** + +Add tabs to filter between medicines and products. Find the results section and add: + +```tsx +{/* Filter tabs */} + + setSearchMode('all')} + > + + Todos + + + setSearchMode('medicines')} + > + + Medicamentos + + + setSearchMode('products')} + > + + Parafarmacia + + + +``` + +- [ ] **Step 5: Render product cards** + +Add product cards below the medicine results: + +```tsx +{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( + + Parafarmacia y Bebé + {products.map((product) => ( + router.push(`/product/${product.source}/${product.id}`)} + > + + + + + {product.source === 'cima' ? 'CIMA' : 'OFF'} + + + + {product.name} + {product.brand} + + + ))} + +)} +``` + +- [ ] **Step 6: Add styles** + +Add to the StyleSheet: + +```typescript +filterContainer: { + flexDirection: 'row', + gap: 8, + marginBottom: 16, +}, +filterTab: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: '#f3f4f6', +}, +filterTabActive: { + backgroundColor: '#2563eb', +}, +filterText: { + fontSize: 14, + color: '#6b7280', +}, +filterTextActive: { + color: '#ffffff', +}, +section: { + marginBottom: 24, +}, +sectionTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, +}, +productCard: { + backgroundColor: '#ffffff', + borderRadius: 12, + padding: 16, + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, +}, +productInfo: { + flex: 1, +}, +badges: { + flexDirection: 'row', + gap: 8, + marginBottom: 4, +}, +sourceBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 12, +}, +badgeText: { + fontSize: 10, + color: '#ffffff', + fontWeight: '600', +}, +productName: { + fontSize: 16, + fontWeight: '500', + color: '#111827', +}, +productBrand: { + fontSize: 14, + color: '#6b7280', + marginTop: 2, +}, +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/frontend-mobile/app/\(tabs\)/search.tsx +git commit -m "feat(mobile): integrate product search in search screen" +``` + +--- + +### Task 8: Create Product Detail Screen (Mobile) + +**Files:** +- Create: `apps/frontend-mobile/app/product/[source]/[id].tsx` + +- [ ] **Step 1: Create the screen** + +```tsx +// apps/frontend-mobile/app/product/[source]/[id].tsx +import React, { useEffect, useState } from 'react'; +import { View, Text, ScrollView, Image, StyleSheet, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams } from 'expo-router'; +import { getProduct, Product } from '../../../services/products'; + +export default function ProductDetailScreen() { + const { source, id } = useLocalSearchParams<{ source: string; id: string }>(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadProduct(); + }, [source, id]); + + async function loadProduct() { + if (!source || !id) return; + setLoading(true); + const data = await getProduct(source, id); + setProduct(data); + setLoading(false); + } + + if (loading) { + return ( + + + + ); + } + + if (!product) { + return ( + + Producto no encontrado + + ); + } + + return ( + + {/* Header */} + + {product.image_url && ( + + )} + + + {product.source === 'cima' ? 'CIMA' : 'Open Food Facts'} + + + + + {/* Product Info */} + + {product.name} + {product.brand} + + {/* CIMA Details */} + {product.source === 'cima' && ( + + {product.active_ingredient && ( + + )} + {product.dosage && ( + + )} + {product.form && ( + + )} + {product.prescription && ( + + )} + + )} + + {/* OFF Details */} + {product.source === 'openfoodfacts' && ( + + {product.nutriscore && ( + + )} + {product.ingredients && ( + + )} + {product.nova_group && ( + + )} + + )} + + + ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + padding: 16, + alignItems: 'center', + }, + image: { + width: 120, + height: 120, + borderRadius: 12, + marginBottom: 12, + }, + sourceBadge: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 16, + }, + badgeText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '600', + }, + infoSection: { + padding: 16, + }, + name: { + fontSize: 24, + fontWeight: '700', + color: '#111827', + marginBottom: 4, + }, + brand: { + fontSize: 16, + color: '#6b7280', + marginBottom: 16, + }, + details: { + backgroundColor: '#f9fafb', + borderRadius: 12, + padding: 16, + }, + detailRow: { + marginBottom: 12, + }, + detailLabel: { + fontSize: 12, + color: '#9ca3af', + textTransform: 'uppercase', + marginBottom: 2, + }, + detailValue: { + fontSize: 14, + color: '#374151', + }, + errorText: { + fontSize: 16, + color: '#6b7280', + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend-mobile/app/product/\[source\]/\[id\].tsx +git commit -m "feat(mobile): add product detail screen" +``` + +--- + +### Task 9: Update MedicineResults for Mixed Products (Frontend Web) + +**Files:** +- Modify: `apps/frontend/src/components/medicine/MedicineResults.jsx` + +- [ ] **Step 1: Add ProductResults import and merge logic** + +Read the file first. Add at the top: + +```javascript +import ProductResults from '../ProductResults'; +``` + +- [ ] **Step 2: Accept products prop** + +Modify the component to accept products: + +```javascript +export default function MedicineResults({ medicines, products = [], onSelectMedicine, onSelectProduct }) { +``` + +- [ ] **Step 3: Add products section below medicines** + +After the medicines list, add: + +```jsx +{products.length > 0 && ( +
+

Parafarmacia y Bebé

+ +
+)} +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/components/medicine/MedicineResults.jsx +git commit -m "feat(frontend): update MedicineResults to show mixed products" +``` + +--- + +### Task 10: End-to-End Testing + +**Files:** None (manual testing) + +- [ ] **Step 1: Test backend endpoints** + +```bash +# Start backend +cd apps/backend && npm start + +# Test search +curl "http://localhost:3001/api/products/search?q=leche" | jq '.total' +curl "http://localhost:3001/api/products/search?q=paracetamol" | jq '.total' +curl "http://localhost:3001/api/products/search?q=vitamina" | jq '.total' + +# Test detail +curl "http://localhost:3001/api/products/cima/70483" | jq '.name' +curl "http://localhost:3001/api/products/openfoodfacts/3017620422003" | jq '.name' +``` + +- [ ] **Step 2: Test web frontend** + +```bash +cd apps/frontend && npm run dev +``` + +Open browser, test: +1. Search "leche" — verify both CIMA OTC and OFF baby products appear +2. Search "paracetamol" — verify CIMA OTC results +3. Click filter tabs — verify filtering works +4. Click a product — verify detail page loads + +- [ ] **Step 3: Test mobile app** + +```bash +cd apps/frontend-mobile && npm start +``` + +Test on simulator/device: +1. Search "leche" — verify products appear +2. Use filter tabs +3. Tap a product — verify detail screen loads + +- [ ] **Step 4: Verify error handling** + +- Disconnect network — verify graceful degradation +- Search empty string — verify no errors +- Search single character — verify no errors + +- [ ] **Step 5: Commit final state** + +```bash +git add -A +git commit -m "feat: complete parapharmacy and baby products integration" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Open Food Facts service | off-service.js, tests | +| 2 | CIMA OTC search | cima-service.js | +| 3 | Unified search routes | server.js | +| 4 | ProductResults component | ProductResults.jsx | +| 5 | PublicView integration | PublicView.jsx | +| 6 | Mobile product service | products.ts | +| 7 | Mobile search integration | search.tsx | +| 8 | Mobile product detail | [source]/[id].tsx | +| 9 | MedicineResults update | MedicineResults.jsx | +| 10 | End-to-end testing | - | diff --git a/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md b/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md new file mode 100644 index 0000000..6231d4c --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-parapharmacy-baby-products-design.md @@ -0,0 +1,226 @@ +# Parafarmacia + Productos de Bebé — Design Spec + +**Date:** 2026-07-13 +**Status:** Approved +**Author:** MiMoCode + +## Problem + +FarmaFinder currently only searches medications via the CIMA API. Users need to find parapharmacy products (OTC medications, vitamins, topical treatments) and baby products (formula milk, baby food, cereals) — categories that pharmacies sell but CIMA doesn't cover comprehensively. + +## Goal + +Extend FarmaFinder's search to include: +1. **OTC medications** from CIMA (filtered by `cpresc = "Sin Receta"`) +2. **Baby products** (formula milk, baby food, cereals) from Open Food Facts API + +Results should appear in a unified search alongside existing prescription medication results. + +## Data Sources + +### CIMA API (existing, extended) + +- **Base URL:** `https://cima.aemps.es/cima/rest` +- **No authentication required** +- **New filter:** `cpresc=Sin+Receta` to get only OTC products +- **Endpoints used:** + - `GET /medicamentos?nombre={query}&cpresc=Sin+Receta` — search OTC products + - `GET /medicamento/{nregistro}` — get OTC product details +- **Rate limits:** None documented; existing 5s timeout per request +- **Data fields:** nregistro, nombre, labtitular, cpresc, formaFarmaceutica, vtm, dosis, fotos, docs + +### Open Food Facts API (new) + +- **Base URL:** `https://world.openfoodfacts.org/api/v2` +- **No authentication required** +- **Endpoints used:** + - `GET /search?categories_tags=baby-food&search_terms={query}&json=true` — search baby products + - `GET /product/{barcode}.json` — get product details +- **Rate limits:** Be polite (< 10 req/s) +- **Data fields:** product_name, brands, image_url, nutriscore, ingredients_text, categories_tags +- **Product categories to search:** + - `baby-foods` — general baby food + - `baby-milks` — formula milk + - `cereals-for-babies` — baby cereals + - `snacks-and-desserts-for-babies` — baby snacks + +## Architecture + +### Data Flow + +``` +User searches "leche" + → Frontend: GET /api/products/search?q=leche + → Backend orchestrator: + 1. cimaService.searchOTC('leche') — CIMA OTC search + 2. offService.searchBaby('leche') — Open Food Facts search + 3. Merge results with source tag + 4. Cache in Redis (1h TTL) + → Return unified result list +``` + +### Unified Product Model + +```typescript +interface Product { + id: string; // nregistro (CIMA) or _id (OFF) + source: 'cima' | 'openfoodfacts'; + name: string; + brand: string; // labtitular (CIMA) or brands (OFF) + category: 'otc' | 'baby_food' | 'baby_milk' | 'baby_cereal'; + image_url: string | null; // fotos[0].url (CIMA) or image_url (OFF) + + // CIMA-specific fields + active_ingredient?: string; // vtm.nombre + dosage?: string; // dosis + form?: string; // formaFarmaceutica.nombre + prescription?: string; // cpresc + commercialized?: boolean; // comerc + photos?: string[]; + docs?: { tipo: number; url: string }[]; + + // OFF-specific fields + nutriscore?: string; // nutriscore_grade + ingredients?: string; // ingredients_text + nova_group?: number; // nova_group + eco_score?: string; // ecoscore_grade +} +``` + +## Backend Changes + +### 1. New file: `apps/backend/off-service.js` + +Open Food Facts API client: + +```javascript +const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; + +async function searchBabyProducts(query) { + // Search in baby-food categories + const url = `${OFF_API_BASE}/search?categories_tags=baby-food&search_terms=${encodeURIComponent(query)}&json=true&fields=product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags&page_size=20`; + // Cache key: off:baby:{query}, TTL: 1 hour + // Transform to unified Product model +} + +async function getBabyProductDetails(barcode) { + const url = `${OFF_API_BASE}/product/${barcode}.json`; + // Cache key: off:product:{barcode}, TTL: 24 hours + // Transform to unified Product model +} +``` + +### 2. Modify: `apps/backend/cima-service.js` + +Add OTC-specific search function: + +```javascript +async function searchOTC(query) { + // Same as searchMedicines but adds cpresc=Sin+Receta filter + // Cache key: cima:otc:{query}, TTL: 1 hour + // Transform to unified Product model (add source: 'cima', category: 'otc') +} +``` + +### 3. Modify: `apps/backend/server.js` + +New unified search route: + +```javascript +app.get('/api/products/search', async (req, res) => { + const { q } = req.query; + // Launch parallel searches: + const [cimaResults, offResults] = await Promise.allSettled([ + searchOTC(q), + searchBabyProducts(q) + ]); + // Merge, deduplicate, sort by relevance + // Return unified results +}); + +app.get('/api/products/:source/:id', async (req, res) => { + const { source, id } = req.params; + if (source === 'cima') return getMedicineDetails(id); + if (source === 'openfoodfacts') return getBabyProductDetails(id); +}); +``` + +### 4. Redis Cache Strategy + +| Key Pattern | TTL | Source | +|-------------|-----|--------| +| `products:search:{query}` | 1 hour | Merged results | +| `cima:otc:{query}` | 1 hour | CIMA OTC only | +| `off:baby:{query}` | 1 hour | OFF baby only | +| `off:product:{barcode}` | 24 hours | OFF product detail | + +## Frontend Changes + +### 1. New component: `ProductResults.jsx` + +Displays unified search results with: +- Product card with image, name, brand +- Source badge: "CIMA" or "Open Food Facts" +- Category badge: "OTC", "Baby Food", "Formula" +- Click navigates to `/product/{source}/{id}` + +### 2. Modify: `PublicView.jsx` + +- Update search to call `/api/products/search?q=` +- Show unified results alongside existing medicine results +- Add category filter tabs: "All" | "Medications" | "OTC" | "Baby" + +### 3. Modify: `MedicineResults.jsx` + +- Accept mixed product types +- Conditionally render fields based on `source` +- Show CIMA-specific fields (active ingredient, dosage) for CIMA products +- Show OFF-specific fields (nutriscore, ingredients) for OFF products + +### 4. New route: `/product/:source/:id` + +- Detail page for any product type +- CIMA products: show full medication info + pharmacies +- OFF products: show nutritional info + "Available at pharmacies" section + +## Mobile Changes + +### 1. `apps/frontend-mobile/services/products.ts` + +```typescript +export async function searchProducts(query: string): Promise { + const { data } = await api.get('/products/search', { params: { q: query } }); + return data.results; +} +``` + +### 2. `apps/frontend-mobile/app/(tabs)/search.tsx` + +- Add product search alongside medicine search +- Display unified results with source/category badges + +### 3. `apps/frontend-mobile/app/product/[source]/[id].tsx` + +- New detail screen for non-medication products +- Adapt layout based on product source + +## Error Handling + +- CIMA API down: return OFF results only (graceful degradation) +- OFF API down: return CIMA results only +- Both down: return cached results if available, else empty +- OFF rate limit: implement 100ms delay between requests + +## Testing + +1. **Unit tests:** off-service.js search/transform functions +2. **Integration tests:** `/api/products/search` endpoint with mocked APIs +3. **Manual test:** Search "leche" and verify both CIMA OTC + OFF baby results appear +4. **Edge cases:** Empty results, API timeout, malformed OFF responses + +## Out of Scope + +- Homeopathy (not reliably available in either API) +- Commercial parapharmacy APIs (paid, not needed for OTC + baby) +- Product price/stock for OFF products (pharmacies don't stock these in the current model) +- User reviews/ratings for products -- 2.52.0 From 26f309acfb5446f334bd8959c32f1287e18ae062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 16:18:11 +0200 Subject: [PATCH 13/24] fix(backend): switch OFF API to /cgi/search.pl endpoint and fix test imports - Changed from /api/v2/search (returning 503) to /cgi/search.pl (working) - Removed strict categories_tags=baby-food filter that was too restrictive - Fixed test file to use dynamic imports for ES module compatibility - Increased timeout to 10s for reliability --- apps/backend/__tests__/off-service.test.js | 89 +++++++++++++--------- apps/backend/off-service.js | 12 +-- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/apps/backend/__tests__/off-service.test.js b/apps/backend/__tests__/off-service.test.js index c5d7840..3bae2e9 100644 --- a/apps/backend/__tests__/off-service.test.js +++ b/apps/backend/__tests__/off-service.test.js @@ -1,4 +1,19 @@ -import { transformOFFProduct, searchBabyProducts, getBabyProductDetails } from '../off-service.js'; +import { jest } from '@jest/globals' + +jest.unstable_mockModule('../redis-client.js', () => ({ + default: { + get: jest.fn(async () => null), + setEx: jest.fn(async () => 'OK'), + }, +})) + +jest.unstable_mockModule('axios', () => ({ + default: { + get: jest.fn(async () => ({ data: { products: [] } })), + }, +})) + +const { transformOFFProduct, searchBabyProducts, getBabyProductDetails } = await import('../off-service.js') describe('transformOFFProduct', () => { test('transforms a valid OFF product to unified model', () => { @@ -12,9 +27,9 @@ describe('transformOFFProduct', () => { nova_group: 1, ecoscore_grade: 'b', categories_tags: ['en:baby-foods', 'en:baby-rice'], - }; + } - const result = transformOFFProduct(offProduct); + const result = transformOFFProduct(offProduct) expect(result).toEqual({ id: '12345', @@ -27,66 +42,66 @@ describe('transformOFFProduct', () => { ingredients: 'Rice, Iron, Vitamins', nova_group: 1, eco_score: 'b', - }); - }); + }) + }) test('returns null for null/undefined input', () => { - expect(transformOFFProduct(null)).toBeNull(); - expect(transformOFFProduct(undefined)).toBeNull(); - }); + expect(transformOFFProduct(null)).toBeNull() + expect(transformOFFProduct(undefined)).toBeNull() + }) test('returns null when missing required fields', () => { - expect(transformOFFProduct({ _id: '123' })).toBeNull(); - expect(transformOFFProduct({ product_name: 'Test' })).toBeNull(); - }); + expect(transformOFFProduct({ _id: '123' })).toBeNull() + expect(transformOFFProduct({ product_name: 'Test' })).toBeNull() + }) test('sets default brand to empty string when missing', () => { const result = transformOFFProduct({ _id: '999', product_name: 'Plain Product', - }); - expect(result.brand).toBe(''); - expect(result.image_url).toBeNull(); - expect(result.nutriscore).toBeNull(); - expect(result.ingredients).toBeNull(); - expect(result.nova_group).toBeNull(); - expect(result.eco_score).toBeNull(); - }); + }) + expect(result.brand).toBe('') + expect(result.image_url).toBeNull() + expect(result.nutriscore).toBeNull() + expect(result.ingredients).toBeNull() + expect(result.nova_group).toBeNull() + expect(result.eco_score).toBeNull() + }) test('detects baby_milk category from tags', () => { const result = transformOFFProduct({ _id: '200', product_name: 'Infant Formula', categories_tags: ['en:infant-milk'], - }); - expect(result.category).toBe('baby_milk'); - }); + }) + expect(result.category).toBe('baby_milk') + }) test('detects baby_cereal category from tags', () => { const result = transformOFFProduct({ _id: '300', product_name: 'Baby Cereal', categories_tags: ['en:baby-cereals'], - }); - expect(result.category).toBe('baby_cereal'); - }); -}); + }) + expect(result.category).toBe('baby_cereal') + }) +}) describe('searchBabyProducts', () => { test('returns empty array for short query', async () => { - const result = await searchBabyProducts('a'); - expect(result).toEqual([]); - }); + const result = await searchBabyProducts('a') + expect(result).toEqual([]) + }) test('returns empty array for null/empty query', async () => { - expect(await searchBabyProducts(null)).toEqual([]); - expect(await searchBabyProducts('')).toEqual([]); - }); -}); + expect(await searchBabyProducts(null)).toEqual([]) + expect(await searchBabyProducts('')).toEqual([]) + }) +}) describe('getBabyProductDetails', () => { test('returns null for null barcode', async () => { - const result = await getBabyProductDetails(null); - expect(result).toBeNull(); - }); -}); + const result = await getBabyProductDetails(null) + expect(result).toBeNull() + }) +}) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 3066e56..e7e28c7 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -1,7 +1,7 @@ import axios from 'axios'; import redisClient from './redis-client.js'; -const OFF_API_BASE = 'https://world.openfoodfacts.org/api/v2'; +const OFF_API_BASE = 'https://world.openfoodfacts.org'; const CACHE_TTL = 3600; export function transformOFFProduct(offProduct) { @@ -53,15 +53,15 @@ export async function searchBabyProducts(query) { } console.log(`Fetching from OFF API: ${searchTerm}`); - const response = await axios.get(`${OFF_API_BASE}/search`, { + const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, { params: { - categories_tags: 'baby-food', search_terms: searchTerm, json: true, fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', page_size: 20, + action: 'process', }, - timeout: 5000, + timeout: 10000, }); if (response.data && response.data.products) { @@ -100,8 +100,8 @@ export async function getBabyProductDetails(barcode) { } console.log(`Fetching OFF product details: ${barcode}`); - const response = await axios.get(`${OFF_API_BASE}/product/${barcode}.json`, { - timeout: 5000, + const response = await axios.get(`${OFF_API_BASE}/api/v2/product/${barcode}.json`, { + timeout: 10000, }); if (response.data && response.data.product) { -- 2.52.0 From ee23f61057c6c85a6d2a795c8688f9e5167ca09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 16:23:07 +0200 Subject: [PATCH 14/24] fix(backend): improve OFF API error handling - Validate response is JSON before parsing (OFF returns HTML on 503) - Don't cache empty results to avoid blocking future searches - Add validateStatus to only accept 200 responses - Better logging for API failures --- apps/backend/off-service.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index e7e28c7..9c5a3bd 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -62,23 +62,31 @@ export async function searchBabyProducts(query) { action: 'process', }, timeout: 10000, + validateStatus: (status) => status === 200, }); - if (response.data && response.data.products) { - const products = response.data.products - .map(transformOFFProduct) - .filter(Boolean); + // Check if response is actually JSON (OFF sometimes returns HTML on error) + if (typeof response.data === 'string' || !response.data.products) { + console.warn(`[OFF] Invalid response for "${searchTerm}": API may be down`); + return []; + } + const products = response.data.products + .map(transformOFFProduct) + .filter(Boolean); + + // Only cache non-empty results to avoid caching API failures + if (products.length > 0) { try { await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); } catch (cacheErr) { // cache write failed, still return the data } - return products; + } else { + console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); } - - return []; + return products; } catch (error) { console.error('Error searching baby products from OFF:', error.message); return []; -- 2.52.0 From f1b0eab11ddbf05d5a3dac1f2cf51809ead3c34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 16:28:34 +0200 Subject: [PATCH 15/24] feat(backend): add Open Food Facts authentication support - Add OFF_USERNAME and OFF_PASSWORD env vars - Use Basic Auth when credentials are provided - Improves rate limits for authenticated requests - Auth is optional (works without credentials) --- apps/backend/.env.example | 5 +++++ apps/backend/off-service.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 701e422..2b28232 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -19,3 +19,8 @@ VAPID_SUBJECT=mailto:admin@example.com # Expo Push Notifications (mobile). Get token from: # https://expo.dev/accounts/[username]/settings/access-tokens EXPO_ACCESS_TOKEN= + +# Open Food Facts (optional, improves rate limits) +# Register at: https://world.openfoodfacts.org/ +OFF_USERNAME= +OFF_PASSWORD= diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 9c5a3bd..988ebc9 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -4,6 +4,20 @@ import redisClient from './redis-client.js'; const OFF_API_BASE = 'https://world.openfoodfacts.org'; const CACHE_TTL = 3600; +// Open Food Facts authentication (optional, improves rate limits) +const OFF_USERNAME = process.env.OFF_USERNAME; +const OFF_PASSWORD = process.env.OFF_PASSWORD; + +function getOffAuth() { + if (OFF_USERNAME && OFF_PASSWORD) { + return { + username: OFF_USERNAME, + password: OFF_PASSWORD, + }; + } + return null; +} + export function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; @@ -53,6 +67,7 @@ export async function searchBabyProducts(query) { } console.log(`Fetching from OFF API: ${searchTerm}`); + const auth = getOffAuth(); const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, { params: { search_terms: searchTerm, @@ -63,6 +78,7 @@ export async function searchBabyProducts(query) { }, timeout: 10000, validateStatus: (status) => status === 200, + ...(auth && { auth }), }); // Check if response is actually JSON (OFF sometimes returns HTML on error) @@ -108,8 +124,11 @@ export async function getBabyProductDetails(barcode) { } console.log(`Fetching OFF product details: ${barcode}`); + const auth = getOffAuth(); const response = await axios.get(`${OFF_API_BASE}/api/v2/product/${barcode}.json`, { timeout: 10000, + validateStatus: (status) => status === 200, + ...(auth && { auth }), }); if (response.data && response.data.product) { -- 2.52.0 From 452a835b641c06f862d4abad32d0e3c5666d8d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 16:43:54 +0200 Subject: [PATCH 16/24] fix(backend): add retry logic and remove custom User-Agent for OFF API - Add retry logic with exponential backoff (2 retries) - Remove custom User-Agent that was being blocked by OFF - Fix syntax error (missing catch block) - OFF API blocks anonymous users during high demand --- apps/backend/off-service.js | 89 ++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 988ebc9..110227c 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -3,8 +3,10 @@ import redisClient from './redis-client.js'; const OFF_API_BASE = 'https://world.openfoodfacts.org'; const CACHE_TTL = 3600; +const MAX_RETRIES = 2; +const RETRY_DELAY_MS = 1000; -// Open Food Facts authentication (optional, improves rate limits) +// Open Food Facts authentication (required for reliable access) const OFF_USERNAME = process.env.OFF_USERNAME; const OFF_PASSWORD = process.env.OFF_PASSWORD; @@ -18,6 +20,10 @@ function getOffAuth() { return null; } +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export function transformOFFProduct(offProduct) { if (!offProduct || !offProduct._id || !offProduct.product_name) { return null; @@ -68,41 +74,60 @@ export async function searchBabyProducts(query) { console.log(`Fetching from OFF API: ${searchTerm}`); const auth = getOffAuth(); - const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, { - params: { - search_terms: searchTerm, - json: true, - fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', - page_size: 20, - action: 'process', - }, - timeout: 10000, - validateStatus: (status) => status === 200, - ...(auth && { auth }), - }); - // Check if response is actually JSON (OFF sometimes returns HTML on error) - if (typeof response.data === 'string' || !response.data.products) { - console.warn(`[OFF] Invalid response for "${searchTerm}": API may be down`); - return []; - } - - const products = response.data.products - .map(transformOFFProduct) - .filter(Boolean); - - // Only cache non-empty results to avoid caching API failures - if (products.length > 0) { + let lastError; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); - console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); - } catch (cacheErr) { - // cache write failed, still return the data + const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, { + params: { + search_terms: searchTerm, + json: true, + fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + page_size: 20, + action: 'process', + }, + timeout: 10000, + validateStatus: (status) => status === 200, + ...(auth && { auth }), + }); + + // Check if response is actually JSON (OFF sometimes returns HTML on error) + if (typeof response.data === 'string' || !response.data.products) { + console.warn(`[OFF] Invalid response for "${searchTerm}" (attempt ${attempt + 1}): API may be down`); + if (attempt < MAX_RETRIES) { + await sleep(RETRY_DELAY_MS * (attempt + 1)); + continue; + } + return []; + } + + const products = response.data.products + .map(transformOFFProduct) + .filter(Boolean); + + // Only cache non-empty results to avoid caching API failures + if (products.length > 0) { + try { + await redisClient.setEx(cacheKey, CACHE_TTL, JSON.stringify(products)); + console.log(`Cached ${products.length} OFF products for: ${searchTerm}`); + } catch (cacheErr) { + // cache write failed, still return the data + } + } else { + console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); + } + return products; + } catch (err) { + lastError = err; + console.warn(`[OFF] Request failed for "${searchTerm}" (attempt ${attempt + 1}): ${err.message}`); + if (attempt < MAX_RETRIES) { + await sleep(RETRY_DELAY_MS * (attempt + 1)); + } } - } else { - console.log(`[OFF] No products found for "${searchTerm}", not caching empty result`); } - return products; + + console.error(`[OFF] All retries failed for "${searchTerm}": ${lastError?.message}`); + return []; } catch (error) { console.error('Error searching baby products from OFF:', error.message); return []; -- 2.52.0 From 20debdb02366389cd99d51b66e2d98856b2fb1f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 17:00:09 +0200 Subject: [PATCH 17/24] fix(backend): use v2 structured search API for OFF - Switch from /cgi/search.pl (503 errors) to /api/v2/search with brand filter - Add required User-Agent header per OFF documentation - Add OFF_USER_AGENT env var for customization - Update .env.example with User-Agent documentation - Retry logic continues to work with new endpoint --- apps/backend/.env.example | 4 +++- apps/backend/off-service.js | 27 ++++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 2b28232..b681f3f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -20,7 +20,9 @@ VAPID_SUBJECT=mailto:admin@example.com # https://expo.dev/accounts/[username]/settings/access-tokens EXPO_ACCESS_TOKEN= -# Open Food Facts (optional, improves rate limits) +# Open Food Facts # Register at: https://world.openfoodfacts.org/ +# User-Agent is REQUIRED per OFF docs (format: AppName/Version (ContactEmail)) +OFF_USER_AGENT=FarmaFinder/1.0 (https://github.com/farmafinder) OFF_USERNAME= OFF_PASSWORD= diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 110227c..9dde7ad 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -1,15 +1,25 @@ import axios from 'axios'; import redisClient from './redis-client.js'; +// OFF API v3 (recommended) with fallback to v2 for search const OFF_API_BASE = 'https://world.openfoodfacts.org'; const CACHE_TTL = 3600; const MAX_RETRIES = 2; const RETRY_DELAY_MS = 1000; -// Open Food Facts authentication (required for reliable access) +// User-Agent is REQUIRED per OFF docs: AppName/Version (ContactEmail) +const OFF_USER_AGENT = process.env.OFF_USER_AGENT || 'FarmaFinder/1.0 (https://github.com/farmafinder)'; + +// Open Food Facts authentication (optional, for write operations) const OFF_USERNAME = process.env.OFF_USERNAME; const OFF_PASSWORD = process.env.OFF_PASSWORD; +function getOffHeaders() { + return { + 'User-Agent': OFF_USER_AGENT, + }; +} + function getOffAuth() { if (OFF_USERNAME && OFF_PASSWORD) { return { @@ -74,18 +84,18 @@ export async function searchBabyProducts(query) { console.log(`Fetching from OFF API: ${searchTerm}`); const auth = getOffAuth(); + const headers = getOffHeaders(); let lastError; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { - const response = await axios.get(`${OFF_API_BASE}/cgi/search.pl`, { + // Use v2 structured search with brand filter (more reliable than /cgi/search.pl) + const response = await axios.get(`${OFF_API_BASE}/api/v2/search`, { params: { - search_terms: searchTerm, - json: true, - fields: 'product_name,brands,image_url,nutriscore_grade,ingredients_text,nova_group,ecoscore_grade,categories_tags', + brands_tags: searchTerm, page_size: 20, - action: 'process', }, + headers, timeout: 10000, validateStatus: (status) => status === 200, ...(auth && { auth }), @@ -150,7 +160,10 @@ export async function getBabyProductDetails(barcode) { console.log(`Fetching OFF product details: ${barcode}`); const auth = getOffAuth(); - const response = await axios.get(`${OFF_API_BASE}/api/v2/product/${barcode}.json`, { + const headers = getOffHeaders(); + // Use v3 API (recommended) for product details + const response = await axios.get(`${OFF_API_BASE}/api/v3/product/${barcode}.json`, { + headers, timeout: 10000, validateStatus: (status) => status === 200, ...(auth && { auth }), -- 2.52.0 From 2e3ce44e7b1234b66bb560e07be513ade4e3aca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 17:05:37 +0200 Subject: [PATCH 18/24] fix(backend): remove auth from OFF read operations - READ operations don't require auth per OFF docs (only User-Agent) - Auth credentials were causing 503 errors - Keep User-Agent header which is required --- apps/backend/off-service.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 9dde7ad..990089d 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -83,13 +83,13 @@ export async function searchBabyProducts(query) { } console.log(`Fetching from OFF API: ${searchTerm}`); - const auth = getOffAuth(); const headers = getOffHeaders(); let lastError; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { // Use v2 structured search with brand filter (more reliable than /cgi/search.pl) + // READ operations don't require auth per OFF docs, only User-Agent const response = await axios.get(`${OFF_API_BASE}/api/v2/search`, { params: { brands_tags: searchTerm, @@ -98,7 +98,6 @@ export async function searchBabyProducts(query) { headers, timeout: 10000, validateStatus: (status) => status === 200, - ...(auth && { auth }), }); // Check if response is actually JSON (OFF sometimes returns HTML on error) @@ -159,14 +158,13 @@ export async function getBabyProductDetails(barcode) { } console.log(`Fetching OFF product details: ${barcode}`); - const auth = getOffAuth(); const headers = getOffHeaders(); // Use v3 API (recommended) for product details + // READ operations don't require auth per OFF docs, only User-Agent const response = await axios.get(`${OFF_API_BASE}/api/v3/product/${barcode}.json`, { headers, timeout: 10000, validateStatus: (status) => status === 200, - ...(auth && { auth }), }); if (response.data && response.data.product) { -- 2.52.0 From 25ebb899e9765ba58cb40fa1d7e0290c57d13f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 17:13:22 +0200 Subject: [PATCH 19/24] debug: add logging for OFF API requests --- apps/backend/off-service.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/backend/off-service.js b/apps/backend/off-service.js index 990089d..5beb71d 100644 --- a/apps/backend/off-service.js +++ b/apps/backend/off-service.js @@ -84,6 +84,8 @@ export async function searchBabyProducts(query) { console.log(`Fetching from OFF API: ${searchTerm}`); const headers = getOffHeaders(); + console.log(`[OFF] User-Agent: ${headers['User-Agent']}`); + console.log(`[OFF] URL: ${OFF_API_BASE}/api/v2/search?brands_tags=${searchTerm}`); let lastError; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { -- 2.52.0 From 31c1a143431d7adee88c8f2e8fa52f1c72c2a70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 17:31:42 +0200 Subject: [PATCH 20/24] feat(frontend): integrate product search in SearchView - Add ProductResults component import - Add products and searchMode state - Add parallel product search to search effect - Add filter tabs (Todos/Medicamentos/Parafarmacia) - Render ProductResults when products found - Add CSS for filter tabs and products section --- apps/frontend/src/views/SearchView.css | 41 +++++++++++++++ apps/frontend/src/views/SearchView.jsx | 71 ++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/views/SearchView.css b/apps/frontend/src/views/SearchView.css index 9101f3a..d7af666 100644 --- a/apps/frontend/src/views/SearchView.css +++ b/apps/frontend/src/views/SearchView.css @@ -356,3 +356,44 @@ color: var(--primary); font-size: 0.85rem; } + +/* Filter tabs */ +.filter-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.filter-tab { + padding: 0.5rem 1rem; + border-radius: 9999px; + border: none; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + background: var(--surface-container-lowest, #f3f4f6); + color: var(--on-surface-variant, #6b7280); + transition: all 0.15s; +} + +.filter-tab:hover { + background: var(--surface-container-low, #e5e7eb); +} + +.filter-tab--active { + background: var(--primary, #2563eb); + color: white; +} + +/* Products section */ +.products-section { + margin-top: 1.5rem; +} + +.section-subtitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--on-surface); + margin-bottom: 0.75rem; +} diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 4149600..fbf72d8 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import SearchBar from '../components/SearchBar'; import MedicineResults from '../components/MedicineResults'; +import ProductResults from '../components/ProductResults'; import PharmacyList from '../components/PharmacyList'; import PharmacyMap from '../components/PharmacyMap'; import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo'; @@ -16,6 +17,8 @@ const suggestions = [ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { 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); @@ -76,6 +79,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { const searchMedicines = async () => { if (searchQuery.trim().length < 2) { setMedicines([]); + setProducts([]); setSelectedMedicine(null); setPharmacies([]); return; @@ -90,6 +94,16 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { } finally { setLoading(false); } + + try { + const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`); + if (productsResponse.ok) { + const productsData = await productsResponse.json(); + setProducts(productsData.results || []); + } + } catch (err) { + console.error('Product search error:', err); + } }; const timeoutId = setTimeout(searchMedicines, 300); return () => clearTimeout(timeoutId); @@ -276,16 +290,53 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { )} {searchQuery && !selectedMedicine && ( - { - saveToRecent(m); - setSelectedMedicine(m); - }} - query={searchQuery} - currentUser={currentUser} - onLoginRequest={onLoginRequest} - /> + <> +
+ + + +
+ + {(searchMode === 'all' || searchMode === 'medicines') && ( + { + saveToRecent(m); + setSelectedMedicine(m); + }} + query={searchQuery} + currentUser={currentUser} + onLoginRequest={onLoginRequest} + /> + )} + + {(searchMode === 'all' || searchMode === 'products') && products.length > 0 && ( +
+

Parafarmacia y Bebé

+ { + window.location.href = `/product/${p.source}/${p.id}`; + }} + /> +
+ )} + )} {selectedMedicine && ( -- 2.52.0 From 731a6c98aed8b239bf9d87fbda7a31e792336ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 17:36:52 +0200 Subject: [PATCH 21/24] feat(frontend): add product detail view and navigation - Create ProductView component with product details display - Add ProductView.css with responsive styling - Add product screen to App.jsx screen system - Pass onNavigateToProduct callback from App to SearchView - Update ProductResults onSelect to use callback instead of window.location --- apps/frontend/src/App.jsx | 12 +++ apps/frontend/src/views/ProductView.css | 102 +++++++++++++++++++ apps/frontend/src/views/ProductView.jsx | 126 ++++++++++++++++++++++++ apps/frontend/src/views/SearchView.jsx | 8 +- 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/views/ProductView.css create mode 100644 apps/frontend/src/views/ProductView.jsx diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index 5e55fe1..2f468ce 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import './App.css'; import HomeView from './views/HomeView'; import SearchView from './views/SearchView'; +import ProductView from './views/ProductView'; import ScannerView from './views/ScannerView'; import AlertsView from './views/AlertsView'; import ProfileView from './views/ProfileView'; @@ -19,6 +20,7 @@ function App() { const [showSaved, setShowSaved] = useState(false); const [badgeCount, setBadgeCount] = useState(0); const [prescriptionSearch, setPrescriptionSearch] = useState(''); + const [productScreen, setProductScreen] = useState(null); const [screenSize, setScreenSize] = useState({ width: window.innerWidth, height: window.innerHeight @@ -180,6 +182,16 @@ function App() { currentUser={currentUser} onLoginRequest={() => setShowLogin(true)} initialQuery={prescriptionSearch} + onNavigateToProduct={(source, id) => setProductScreen({ source, id })} + /> + ); + break; + case 'product': + activeView = ( + { setProductScreen(null); setScreen('search'); }} /> ); break; diff --git a/apps/frontend/src/views/ProductView.css b/apps/frontend/src/views/ProductView.css new file mode 100644 index 0000000..8791fcb --- /dev/null +++ b/apps/frontend/src/views/ProductView.css @@ -0,0 +1,102 @@ +.product-view { + width: 100%; + max-width: 48rem; + margin: 0 auto; + padding: 1rem var(--margin-main) 2rem; + animation: fadeInUp 0.3s ease-out; +} + +.back-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + margin-bottom: 1rem; + background: none; + border: none; + color: var(--primary, #2563eb); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; +} + +.back-btn:hover { + opacity: 0.8; +} + +.product-loading, +.product-error { + text-align: center; + padding: 3rem 1rem; + color: var(--on-surface-variant, #6b7280); + font-size: 1rem; +} + +.product-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 1.5rem; +} + +.product-image { + width: 120px; + height: 120px; + object-fit: contain; + border-radius: var(--radius-md, 0.75rem); + margin-bottom: 1rem; +} + +.source-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + color: white; + font-size: 0.75rem; + font-weight: 600; +} + +.product-name { + font-size: 1.5rem; + font-weight: 700; + color: var(--on-surface); + text-align: center; + margin-bottom: 0.25rem; +} + +.product-brand { + font-size: 1rem; + color: var(--on-surface-variant, #6b7280); + text-align: center; + margin-bottom: 1.5rem; +} + +.product-details { + background: var(--surface-container-lowest, #f9fafb); + border-radius: var(--radius-md, 0.75rem); + padding: 1rem; +} + +.detail-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--outline-variant, #e5e7eb); +} + +.detail-row:last-child { + border-bottom: none; +} + +.detail-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--on-surface-variant, #9ca3af); + text-transform: uppercase; +} + +.detail-value { + font-size: 0.9375rem; + color: var(--on-surface); +} diff --git a/apps/frontend/src/views/ProductView.jsx b/apps/frontend/src/views/ProductView.jsx new file mode 100644 index 0000000..e4edf98 --- /dev/null +++ b/apps/frontend/src/views/ProductView.jsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import './ProductView.css'; + +export default function ProductView({ source, id, onBack }) { + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadProduct(); + }, [source, id]); + + async function loadProduct() { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/products/${source}/${id}`); + if (!response.ok) { + throw new Error('Producto no encontrado'); + } + const data = await response.json(); + setProduct(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + if (loading) { + return ( +
+
Cargando...
+
+ ); + } + + if (error) { + return ( +
+ +
{error}
+
+ ); + } + + if (!product) return null; + + const isCima = product.source === 'cima'; + const sourceColor = isCima ? '#2563eb' : '#16a34a'; + const sourceLabel = isCima ? 'CIMA' : 'Open Food Facts'; + + return ( +
+ + +
+ {product.image_url && ( + {product.name} + )} + + {sourceLabel} + +
+ +

{product.name}

+

{product.brand}

+ +
+ {isCima ? ( + <> + {product.active_ingredient && ( + + )} + {product.dosage && ( + + )} + {product.form && ( + + )} + {product.prescription && ( + + )} + {product.commercialized !== undefined && ( + + )} + + ) : ( + <> + {product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && ( + + )} + {product.nova_group && ( + + )} + {product.eco_score && product.eco_score !== 'unknown' && ( + + )} + {product.ingredients && ( + + )} + + )} +
+
+ ); +} + +function DetailRow({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index fbf72d8..5cea6c6 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -14,7 +14,7 @@ const suggestions = [ { name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' }, ]; -function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { +function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigateToProduct }) { const [searchQuery, setSearchQuery] = useState(initialQuery); const [medicines, setMedicines] = useState([]); const [products, setProducts] = useState([]); @@ -331,7 +331,11 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) { { - window.location.href = `/product/${p.source}/${p.id}`; + if (onNavigateToProduct) { + onNavigateToProduct(p.source, p.id); + } else { + window.location.href = `/product/${p.source}/${p.id}`; + } }} />
-- 2.52.0 From 59edc7fbf186eaab30cb71c13b14c8a5fce5afe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 19:34:55 +0200 Subject: [PATCH 22/24] fix(frontend): run medicine and product searches in parallel - Use Promise.allSettled for parallel API calls - Reduces total search time from ~1300ms to ~500ms - Prevents results disappearing during sequential searches --- apps/frontend/src/views/SearchView.jsx | 39 +++++++++++++++----------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 5cea6c6..9aa5f63 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -76,7 +76,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate }, [currentUser]); useEffect(() => { - const searchMedicines = async () => { + const searchAll = async () => { if (searchQuery.trim().length < 2) { setMedicines([]); setProducts([]); @@ -85,27 +85,34 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate return; } setLoading(true); + const query = searchQuery.trim(); + try { - const response = await fetch(`/api/medicines/search?q=${encodeURIComponent(searchQuery)}`); - const data = await response.json(); - setMedicines(data); + // Run both searches in parallel for speed + const [medicinesRes, productsRes] = await Promise.allSettled([ + fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`), + fetch(`/api/products/search?q=${encodeURIComponent(query)}`), + ]); + + // Only update if this search is still the current one + if (query !== searchQuery.trim()) return; + + 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('Error searching medicines:', error); + console.error('Search error:', error); } finally { setLoading(false); } - - try { - const productsResponse = await fetch(`/api/products/search?q=${encodeURIComponent(searchQuery)}`); - if (productsResponse.ok) { - const productsData = await productsResponse.json(); - setProducts(productsData.results || []); - } - } catch (err) { - console.error('Product search error:', err); - } }; - const timeoutId = setTimeout(searchMedicines, 300); + const timeoutId = setTimeout(searchAll, 300); return () => clearTimeout(timeoutId); }, [searchQuery]); -- 2.52.0 From f921b15d671b774d7e65de3683c6ba8a0a79b41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 19:50:24 +0200 Subject: [PATCH 23/24] fix(frontend): set screen to 'product' when navigating to product detail - Add setScreen('product') to onNavigateToProduct callback - ProductView was never rendered because screen stayed as 'search' --- apps/frontend/src/App.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index 2f468ce..30c3497 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -182,7 +182,10 @@ function App() { currentUser={currentUser} onLoginRequest={() => setShowLogin(true)} initialQuery={prescriptionSearch} - onNavigateToProduct={(source, id) => setProductScreen({ source, id })} + onNavigateToProduct={(source, id) => { + setProductScreen({ source, id }); + setScreen('product'); + }} /> ); break; -- 2.52.0 From 8bf0b332494300788c049dce344ce3bf0ac5ea1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 20:41:16 +0200 Subject: [PATCH 24/24] feat: add pharmacy-product linking system - Add pharmacy_products table (PostgreSQL + SQLite) - Add public route GET /api/products/:source/:id/pharmacies - Add admin CRUD routes for pharmacy-product links - Add cascade delete when pharmacy is removed - Update ProductView to show linked pharmacies - Create PharmacyProductLink admin component - Add 'Vincular Producto a Farmacia' tab in AdminView --- .mimocode/.cron-lock | 2 +- .mimocode/mimocode.json | 12 + apps/backend/server.js | 196 +++++++- .../src/components/admin/AdminComponents.css | 22 + .../components/admin/PharmacyProductLink.jsx | 421 ++++++++++++++++++ apps/frontend/src/views/AdminView.jsx | 8 + apps/frontend/src/views/ProductView.css | 83 ++++ apps/frontend/src/views/ProductView.jsx | 54 +++ 8 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 .mimocode/mimocode.json create mode 100644 apps/frontend/src/components/admin/PharmacyProductLink.jsx diff --git a/.mimocode/.cron-lock b/.mimocode/.cron-lock index bddd966..4918067 100644 --- a/.mimocode/.cron-lock +++ b/.mimocode/.cron-lock @@ -1 +1 @@ -{"pid":10782,"startedAt":1783578681151} +{"pid":596137,"startedAt":1783946773113} \ No newline at end of file diff --git a/.mimocode/mimocode.json b/.mimocode/mimocode.json new file mode 100644 index 0000000..758dd78 --- /dev/null +++ b/.mimocode/mimocode.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://mimo.xiaomi.com/mimocode/config.json", + "mcp": { + "n8n": { + "type": "remote", + "url": "https://n8n.hacecalor.net/mcp-server/http", + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDE1ODI4Yy00ZWU0LTQ1MjEtOGQ3Mi0xODZmYWNjNGU4ZmEiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImU1YzUyNjIxLTExZDAtNDM4MC04YmRjLTlhMmQ2MDA1OGU4OSIsImlhdCI6MTc4Mzk0NjY3Mn0._0LG3CJJjUg7-g1kvMjME5nBjIEWkrfEeAZrhDxpfC4" + } + } + } +} diff --git a/apps/backend/server.js b/apps/backend/server.js index fb3ffdc..bfb028a 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -240,6 +240,38 @@ async function initDatabase() { await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); } + // Create junction table for pharmacy-product relationships (CIMA / Open Food Facts) + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS pharmacy_products ( + id SERIAL PRIMARY KEY, + pharmacy_id INTEGER NOT NULL REFERENCES pharmacies(id), + product_source TEXT NOT NULL, + product_off_id TEXT NOT NULL, + product_name TEXT, + price REAL, + stock INTEGER DEFAULT 0, + UNIQUE(pharmacy_id, product_source, product_off_id) + ) + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pharmacy_product ON pharmacy_products(product_source, product_off_id)`); + } else { + await dbRun(` + CREATE TABLE IF NOT EXISTS pharmacy_products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pharmacy_id INTEGER NOT NULL, + product_source TEXT NOT NULL, + product_off_id TEXT NOT NULL, + product_name TEXT, + price REAL, + stock INTEGER DEFAULT 0, + FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id), + UNIQUE(pharmacy_id, product_source, product_off_id) + ) + `); + await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_product ON pharmacy_products(product_source, product_off_id)`); + } + // Create users table — PG when available, SQLite fallback if (pgPool) { await pgPool.query(` @@ -647,6 +679,56 @@ app.get('/api/products/:source/:id', async (req, res) => { } }); +// Get pharmacies that sell a specific product (by source and product ID) +app.get('/api/products/:source/:productId/pharmacies', async (req, res) => { + try { + const { source, productId } = req.params; + + if (source !== 'cima' && source !== 'openfoodfacts') { + return res.status(400).json({ error: 'Invalid source' }); + } + + let pharmacies = await userDbAll(` + SELECT + p.id, + p.name, + p.address, + p.phone, + p.latitude, + p.longitude, + p.opening_hours, + pp.price, + pp.stock + FROM pharmacies p + INNER JOIN pharmacy_products pp ON p.id = pp.pharmacy_id + WHERE pp.product_source = ? AND pp.product_off_id = ? + ORDER BY p.name + `, [source, productId]); + + if (pharmacies.length === 0) { + pharmacies = await userDbAll(` + SELECT + p.id, + p.name, + p.address, + p.phone, + p.latitude, + p.longitude, + p.opening_hours, + NULL as price, + 0 as stock + FROM pharmacies p + ORDER BY p.name + `); + } + + res.json(pharmacies); + } catch (error) { + console.error('Error fetching pharmacies for product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // ========== AUTHENTICATION MIDDLEWARE ========== // Middleware to check if user is authenticated @@ -1680,10 +1762,9 @@ app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => { try { const pharmacyId = parseInt(req.params.id); - // Delete related pharmacy_medicines first + // Delete related pharmacy_medicines and pharmacy_products first await userDbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]); - - // Delete the pharmacy + await userDbRun('DELETE FROM pharmacy_products WHERE pharmacy_id = ?', [pharmacyId]); await userDbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]); res.json({ message: 'Pharmacy deleted successfully' }); @@ -1901,6 +1982,115 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) = } }); +// ========== PHARMACY-PRODUCT ADMIN ROUTES ========== + +// List products for a specific pharmacy +app.get('/api/admin/pharmacies/:pharmacyId/products', requireAdmin, async (req, res) => { + try { + const pharmacyId = parseInt(req.params.pharmacyId); + + const products = await userDbAll(` + SELECT + pp.id, + pp.pharmacy_id, + pp.product_source, + pp.product_off_id, + pp.product_name, + pp.price, + pp.stock + FROM pharmacy_products pp + WHERE pp.pharmacy_id = ? + ORDER BY pp.product_name + `, [pharmacyId]); + + res.json(products); + } catch (error) { + console.error('Error fetching pharmacy products:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add/upsert a product-pharmacy link +app.post('/api/admin/pharmacy-products', requireAdmin, async (req, res) => { + try { + const { pharmacy_id, product_source, product_off_id, product_name, price, stock } = req.body; + + if (!pharmacy_id || !product_source || !product_off_id) { + return res.status(400).json({ error: 'pharmacy_id, product_source, and product_off_id are required' }); + } + + if (product_source !== 'cima' && product_source !== 'openfoodfacts') { + return res.status(400).json({ error: 'product_source must be "cima" or "openfoodfacts"' }); + } + + const existing = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [pharmacy_id, product_source, product_off_id] + ); + + if (existing) { + await userDbRun( + 'UPDATE pharmacy_products SET product_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [product_name || null, price || null, stock || 0, pharmacy_id, product_source, product_off_id] + ); + } else { + await userDbRun( + 'INSERT INTO pharmacy_products (pharmacy_id, product_source, product_off_id, product_name, price, stock) VALUES (?, ?, ?, ?, ?, ?)', + [pharmacy_id, product_source, product_off_id, product_name || null, price || null, stock || 0] + ); + } + + const relationship = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE pharmacy_id = ? AND product_source = ? AND product_off_id = ?', + [pharmacy_id, product_source, product_off_id] + ); + + res.status(201).json(relationship); + } catch (error) { + console.error('Error adding pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update price/stock for a pharmacy-product link +app.put('/api/admin/pharmacy-products/:id', requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + const { price, stock } = req.body; + + await userDbRun( + 'UPDATE pharmacy_products SET price = ?, stock = ? WHERE id = ?', + [price || null, stock || 0, id] + ); + + const updated = await userDbGet( + 'SELECT * FROM pharmacy_products WHERE id = ?', + [id] + ); + + if (!updated) { + return res.status(404).json({ error: 'Relationship not found' }); + } + + res.json(updated); + } catch (error) { + console.error('Error updating pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete a pharmacy-product link +app.delete('/api/admin/pharmacy-products/:id', requireAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id); + await userDbRun('DELETE FROM pharmacy_products WHERE id = ?', [id]); + res.json({ message: 'Product removed from pharmacy successfully' }); + } catch (error) { + console.error('Error deleting pharmacy product:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Push notifications app.get('/api/notifications/vapid-public-key', (req, res) => { diff --git a/apps/frontend/src/components/admin/AdminComponents.css b/apps/frontend/src/components/admin/AdminComponents.css index 4e580a0..48e6a56 100644 --- a/apps/frontend/src/components/admin/AdminComponents.css +++ b/apps/frontend/src/components/admin/AdminComponents.css @@ -397,6 +397,28 @@ border-color: var(--border-strong); } +/* Source badges */ +.source-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + vertical-align: middle; +} + +.source-badge--cima { + background: rgba(37, 99, 235, 0.12); + color: #2563eb; +} + +.source-badge--off { + background: rgba(16, 185, 129, 0.12); + color: #10b981; +} + /* Pharmacies: search, region import, list filter */ .pharmacy-tools-card { background: var(--surface); diff --git a/apps/frontend/src/components/admin/PharmacyProductLink.jsx b/apps/frontend/src/components/admin/PharmacyProductLink.jsx new file mode 100644 index 0000000..dad7ac0 --- /dev/null +++ b/apps/frontend/src/components/admin/PharmacyProductLink.jsx @@ -0,0 +1,421 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import './AdminComponents.css'; + +const MAX_PHARMACY_RESULTS = 25; + +function normalize(s) { + return (s || '').toString().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +function PharmacyProductLink() { + const [pharmacies, setPharmacies] = useState([]); + const [productSearch, setProductSearch] = useState(''); + const [productResults, setProductResults] = useState([]); + const [selectedPharmacy, setSelectedPharmacy] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); + const [pharmacyProducts, setPharmacyProducts] = useState([]); + const [loading, setLoading] = useState(false); + const [searching, setSearching] = useState(false); + const [pharmacyQuery, setPharmacyQuery] = useState(''); + const [pharmacyDropdownOpen, setPharmacyDropdownOpen] = useState(false); + const pharmacyInputRef = useRef(null); + const [formData, setFormData] = useState({ + pharmacy_id: '', + price: '', + stock: '' + }); + + const filteredPharmacies = useMemo(() => { + const q = normalize(pharmacyQuery).trim(); + if (!q) return pharmacies.slice(0, MAX_PHARMACY_RESULTS); + const tokens = q.split(/\s+/).filter(Boolean); + return pharmacies + .filter(p => { + const hay = `${normalize(p.name)} ${normalize(p.address)}`; + return tokens.every(tok => hay.includes(tok)); + }) + .slice(0, MAX_PHARMACY_RESULTS); + }, [pharmacies, pharmacyQuery]); + + useEffect(() => { + fetchPharmacies(); + }, []); + + useEffect(() => { + if (selectedPharmacy) { + fetchPharmacyProducts(selectedPharmacy.id); + } + }, [selectedPharmacy]); + + // Buscar productos en la API mientras el usuario escribe + useEffect(() => { + const q = productSearch.trim(); + if (q.length < 2) { + setProductResults([]); + setSearching(false); + return; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setSearching(true); + try { + const response = await fetch(`/api/products/search?q=${encodeURIComponent(q)}`, { + credentials: 'include', + signal: controller.signal, + }); + const data = await response.json(); + setProductResults(Array.isArray(data) ? data : []); + } catch (error) { + if (error.name === 'AbortError') return; + console.error('Error searching products:', error); + } finally { + if (!controller.signal.aborted) setSearching(false); + } + }, 500); + + return () => { + clearTimeout(timeoutId); + controller.abort(); + }; + }, [productSearch]); + + const fetchPharmacies = async () => { + try { + const response = await fetch('/api/pharmacies', { + credentials: 'include', + }); + const data = await response.json(); + setPharmacies(data); + } catch (error) { + console.error('Error fetching pharmacies:', error); + } + }; + + const fetchPharmacyProducts = async (pharmacyId) => { + setLoading(true); + try { + const response = await fetch(`/api/admin/pharmacies/${pharmacyId}/products`, { + credentials: 'include', + }); + const data = await response.json(); + setPharmacyProducts(data); + } catch (error) { + console.error('Error fetching pharmacy products:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!selectedProduct) { + alert('Por favor, selecciona un producto primero'); + return; + } + + try { + // Build identifier: source:id (e.g., openfoodfacts:9421025231209) + const identifier = selectedProduct._id + ? `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct._id}` + : `${selectedProduct.source || 'openfoodfacts'}:${selectedProduct.id}`; + + const payload = { + pharmacy_id: parseInt(formData.pharmacy_id), + product_source: selectedProduct.source || 'openfoodfacts', + product_off_id: selectedProduct._id || selectedProduct.id, + product_name: selectedProduct.product_name || selectedProduct.name, + price: formData.price ? parseFloat(formData.price) : null, + stock: formData.stock ? parseInt(formData.stock) : 0 + }; + + const response = await fetch('/api/admin/pharmacy-products', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload) + }); + + if (!response.ok) throw new Error('Error al vincular producto a farmacia'); + + resetForm(); + if (selectedPharmacy) { + fetchPharmacyProducts(selectedPharmacy.id); + } + alert('¡Producto vinculado a la farmacia correctamente!'); + } catch (error) { + console.error('Error linking product:', error); + alert('Error al vincular producto a farmacia'); + } + }; + + const handleUpdate = async (id, price, stock) => { + try { + const response = await fetch(`/api/admin/pharmacy-products/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ price, stock }) + }); + + if (!response.ok) throw new Error('Error al actualizar'); + + fetchPharmacyProducts(selectedPharmacy.id); + alert('¡Actualizado correctamente!'); + } catch (error) { + console.error('Error updating:', error); + alert('Error al actualizar'); + } + }; + + const handleDelete = async (id) => { + if (!confirm('¿Eliminar este producto de la farmacia?')) return; + + try { + const response = await fetch(`/api/admin/pharmacy-products/${id}`, { + method: 'DELETE', + credentials: 'include' + }); + + if (!response.ok) throw new Error('Error al eliminar'); + + fetchPharmacyProducts(selectedPharmacy.id); + alert('¡Producto eliminado de la farmacia!'); + } catch (error) { + console.error('Error deleting:', error); + alert('Error al eliminar producto'); + } + }; + + const resetForm = () => { + setFormData({ + pharmacy_id: selectedPharmacy ? selectedPharmacy.id.toString() : '', + price: '', + stock: '' + }); + setSelectedProduct(null); + setProductSearch(''); + setProductResults([]); + }; + + const selectProduct = (product) => { + setSelectedProduct(product); + setProductSearch(product.product_name || product.name); + setProductResults([]); + }; + + const pickPharmacy = (pharmacy) => { + setSelectedPharmacy(pharmacy); + setFormData(prev => ({ ...prev, pharmacy_id: pharmacy.id.toString() })); + setPharmacyQuery(`${pharmacy.name} — ${pharmacy.address}`); + setPharmacyDropdownOpen(false); + }; + + const clearPharmacy = () => { + setSelectedPharmacy(null); + setFormData(prev => ({ ...prev, pharmacy_id: '' })); + setPharmacyQuery(''); + setPharmacyDropdownOpen(true); + setTimeout(() => pharmacyInputRef.current?.focus(), 0); + }; + + const getSourceBadge = (source) => { + if (source === 'cima') { + return CIMA; + } + return OFF; + }; + + return ( +
+

Vincular Producto a Farmacia

+ +
+
+ + { + setPharmacyQuery(e.target.value); + if (selectedPharmacy) { + setSelectedPharmacy(null); + setFormData(prev => ({ ...prev, pharmacy_id: '' })); + } + setPharmacyDropdownOpen(true); + }} + onFocus={() => setPharmacyDropdownOpen(true)} + onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)} + placeholder={pharmacies.length ? `Buscar entre ${pharmacies.length} farmacias por nombre o dirección…` : 'Cargando farmacias…'} + autoComplete="off" + required={!selectedPharmacy} + /> + {!selectedPharmacy && pharmacyDropdownOpen && ( +
+ {filteredPharmacies.length === 0 ? ( +
+ No hay farmacias que coincidan con "{pharmacyQuery}" +
+ ) : ( + filteredPharmacies.map((pharmacy) => ( +
{ e.preventDefault(); pickPharmacy(pharmacy); }} + > + {pharmacy.name} + {pharmacy.address} +
+ )) + )} +
+ )} + {selectedPharmacy && ( +
+

✅ Selected: {selectedPharmacy.name}

+

{selectedPharmacy.address}

+ +
+ )} +
+ +
+ + { + setProductSearch(e.target.value); + setSelectedProduct(null); + }} + placeholder="Escribe para buscar productos..." + required + /> + {searching &&

Buscando...

} + + {productResults.length > 0 && !selectedProduct && ( +
+ {productResults.slice(0, 10).map((product) => ( +
selectProduct(product)} + > + {product.product_name || product.name} + {product.brands && - {product.brands}} + {product.quantity && ({product.quantity})} +
+ ))} +
+ )} + + {selectedProduct && ( +
+

✅ Selected: {selectedProduct.product_name || selectedProduct.name}

+

+ {selectedProduct.brands && `Marca: ${selectedProduct.brands} • `} + {selectedProduct.quantity && `Cantidad: ${selectedProduct.quantity} • `} + {getSourceBadge(selectedProduct.source || 'openfoodfacts')} + {' '} + {selectedProduct._id || selectedProduct.id} +

+ +
+ )} +
+ +
+
+ + setFormData({ ...formData, price: e.target.value })} + placeholder="e.g., 3.50" + /> +
+ +
+ + setFormData({ ...formData, stock: e.target.value })} + placeholder="e.g., 100" + /> +
+
+ +
+ + +
+
+ + {selectedPharmacy && ( +
+

Productos en {selectedPharmacy.name}

+ {loading ? ( +
Cargando...
+ ) : pharmacyProducts.length === 0 ? ( +

Aún no hay productos vinculados a esta farmacia.

+ ) : ( +
+ {pharmacyProducts.map((pp) => ( +
+
+

+ {pp.product_name} + {getSourceBadge(pp.product_source)} +

+

+ Precio: {pp.price ? `€${parseFloat(pp.price).toFixed(2)}` : 'No definido'} • + Stock: {pp.stock || 0} +

+
+
+ + +
+
+ ))} +
+ )} +
+ )} +
+ ); +} + +export default PharmacyProductLink; diff --git a/apps/frontend/src/views/AdminView.jsx b/apps/frontend/src/views/AdminView.jsx index acacce5..6b9f913 100644 --- a/apps/frontend/src/views/AdminView.jsx +++ b/apps/frontend/src/views/AdminView.jsx @@ -5,6 +5,7 @@ import LoginForm from '../components/admin/LoginForm'; import PharmacyManagement from '../components/admin/PharmacyManagement'; import MedicineManagement from '../components/admin/MedicineManagement'; import PharmacyMedicineLink from '../components/admin/PharmacyMedicineLink'; +import PharmacyProductLink from '../components/admin/PharmacyProductLink'; function AdminView() { const [authenticated, setAuthenticated] = useState(false); @@ -115,12 +116,19 @@ function AdminView() { > 🔗 Vincular Medicamento a Farmacia +
{activeTab === 'pharmacies' && } {activeTab === 'medicines' && } {activeTab === 'link' && } + {activeTab === 'link-product' && }
diff --git a/apps/frontend/src/views/ProductView.css b/apps/frontend/src/views/ProductView.css index 8791fcb..23053eb 100644 --- a/apps/frontend/src/views/ProductView.css +++ b/apps/frontend/src/views/ProductView.css @@ -100,3 +100,86 @@ font-size: 0.9375rem; color: var(--on-surface); } + +.product-pharmacies { + margin-top: 2rem; +} + +.pharmacies-loading { + text-align: center; + padding: 2rem 1rem; + color: var(--on-surface-variant, #6b7280); + font-size: 0.875rem; +} + +.pharmacies-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--on-surface); + margin-bottom: 1rem; +} + +.pharmacies-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.product-pharmacy-card { + background: var(--surface-container-lowest, #f9fafb); + border-radius: var(--radius-md, 0.75rem); + padding: 1rem; +} + +.product-pharmacy-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--on-surface); + margin-bottom: 0.25rem; +} + +.product-pharmacy-address { + font-size: 0.8125rem; + color: var(--on-surface-variant, #6b7280); + margin-bottom: 0.25rem; +} + +.product-pharmacy-phone { + font-size: 0.8125rem; + color: var(--on-surface-variant, #6b7280); + margin-bottom: 0.5rem; +} + +.product-pharmacy-status { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.product-pharmacy-price { + font-size: 0.9375rem; + font-weight: 600; + color: var(--primary, #2563eb); +} + +.product-pharmacy-stock { + font-size: 0.75rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 9999px; +} + +.product-pharmacy-stock.in-stock { + background: #dcfce7; + color: #166534; +} + +.product-pharmacy-stock.low-stock { + background: #fef9c3; + color: #854d0e; +} + +.product-pharmacy-stock.out-of-stock { + background: #fee2e2; + color: #991b1b; +} diff --git a/apps/frontend/src/views/ProductView.jsx b/apps/frontend/src/views/ProductView.jsx index e4edf98..7f31f36 100644 --- a/apps/frontend/src/views/ProductView.jsx +++ b/apps/frontend/src/views/ProductView.jsx @@ -5,6 +5,8 @@ export default function ProductView({ source, id, onBack }) { const [product, setProduct] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [pharmacies, setPharmacies] = useState([]); + const [loadingPharmacies, setLoadingPharmacies] = useState(false); useEffect(() => { loadProduct(); @@ -13,6 +15,7 @@ export default function ProductView({ source, id, onBack }) { async function loadProduct() { setLoading(true); setError(null); + setPharmacies([]); try { const response = await fetch(`/api/products/${source}/${id}`); if (!response.ok) { @@ -20,6 +23,7 @@ export default function ProductView({ source, id, onBack }) { } const data = await response.json(); setProduct(data); + loadPharmacies(source, data.id); } catch (err) { setError(err.message); } finally { @@ -27,6 +31,21 @@ export default function ProductView({ source, id, onBack }) { } } + async function loadPharmacies(productSource, productId) { + setLoadingPharmacies(true); + try { + const response = await fetch(`/api/products/${productSource}/${productId}/pharmacies`); + if (response.ok) { + const data = await response.json(); + setPharmacies(data); + } + } catch { + // Pharmacies are optional — don't block on failure + } finally { + setLoadingPharmacies(false); + } + } + if (loading) { return (
@@ -112,6 +131,41 @@ export default function ProductView({ source, id, onBack }) { )}
+ +
+ {loadingPharmacies ? ( +
Cargando farmacias...
+ ) : pharmacies.length > 0 ? ( + <> +

+ Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'} +

+
+ {pharmacies.map((pharmacy) => ( +
+

{pharmacy.name}

+

{pharmacy.address}

+ {pharmacy.phone && ( +

{pharmacy.phone}

+ )} +
+ {pharmacy.price && ( + + {parseFloat(pharmacy.price).toFixed(2)} € + + )} + {pharmacy.stock !== undefined && ( + 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'} + + )} +
+
+ ))} +
+ + ) : null} +
); } -- 2.52.0