# 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 && (
)}
```
- [ ] **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 | - |