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 ? (
+

{
+ 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