33 KiB
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
// 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
// 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
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:
// 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:
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
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:
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:
// 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:
// 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:
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
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
// 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 (
<div className="text-center py-8 text-gray-500">
No se encontraron productos
</div>
);
}
return (
<div className="space-y-3">
{products.map((product) => (
<div
key={`${product.source}-${product.id}`}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => onSelect(product)}
>
<div className="flex items-start gap-4">
{/* Product Image */}
<div className="w-16 h-16 flex-shrink-0 bg-gray-100 rounded-lg overflow-hidden">
{product.image_url ? (
<img
src={product.image_url}
alt={product.name}
className="w-full h-full object-cover"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
Sin imagen
</div>
)}
</div>
{/* Product Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs font-medium px-2 py-0.5 rounded-full text-white"
style={{ backgroundColor: sourceColors[product.source] }}
>
{sourceLabels[product.source]}
</span>
<span className="text-xs text-gray-500 px-2 py-0.5 bg-gray-100 rounded-full">
{categoryLabels[product.category] || product.category}
</span>
</div>
<h3 className="font-medium text-gray-900 truncate">
{product.name}
</h3>
<p className="text-sm text-gray-600 truncate">
{product.brand}
</p>
{/* CIMA-specific fields */}
{product.source === 'cima' && product.active_ingredient && (
<p className="text-xs text-gray-500 mt-1">
Principio activo: {product.active_ingredient}
{product.dosage && ` - ${product.dosage}`}
</p>
)}
{/* OFF-specific fields */}
{product.source === 'openfoodfacts' && product.nutriscore && (
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">Nutriscore:</span>
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
product.nutriscore === 'a' ? 'bg-green-100 text-green-700' :
product.nutriscore === 'b' ? 'bg-lime-100 text-lime-700' :
product.nutriscore === 'c' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>
{product.nutriscore.toUpperCase()}
</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}
- Step 2: Commit
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:
import ProductResults from '../components/ProductResults';
- Step 2: Add state for products
Find the existing search state variables and add:
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:
// 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:
{/* Add before the results section */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setSearchMode('all')}
className={`px-3 py-1.5 text-sm rounded-full ${
searchMode === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
Todos ({medicines.length + products.length})
</button>
<button
onClick={() => setSearchMode('medicines')}
className={`px-3 py-1.5 text-sm rounded-full ${
searchMode === 'medicines' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
Medicamentos ({medicines.length})
</button>
<button
onClick={() => setSearchMode('products')}
className={`px-3 py-1.5 text-sm rounded-full ${
searchMode === 'products' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
Parafarmacia ({products.length})
</button>
</div>
- Step 5: Render ProductResults conditionally
In the results section, add:
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Parafarmacia y Bebé</h3>
<ProductResults
products={products}
onSelect={(product) => {
window.location.href = `/product/${product.source}/${product.id}`;
}}
/>
</div>
)}
- Step 6: Test in browser
Start the frontend dev server and verify:
- Search "leche" — should show both CIMA OTC and OFF baby products
- Search "paracetamol" — should show CIMA OTC results
- Filter tabs work correctly
- Step 7: Commit
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
// 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<Product[]> {
if (!query || query.trim().length < 2) return [];
try {
const { data } = await api.get<ProductSearchResponse>('/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<Product | null> {
try {
const { data } = await api.get<Product>(`/products/${source}/${id}`);
return data;
} catch (error) {
console.error('[Products] Detail error:', error);
return null;
}
}
- Step 2: Commit
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:
import { searchProducts, Product } from '../../services/products';
- Step 2: Add state for products
Find existing state variables and add:
const [products, setProducts] = useState<Product[]>([]);
- Step 3: Add product search to search effect
Find the existing search debounced effect and add product search:
// 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:
{/* Filter tabs */}
<View style={styles.filterContainer}>
<TouchableOpacity
style={[styles.filterTab, searchMode === 'all' && styles.filterTabActive]}
onPress={() => setSearchMode('all')}
>
<Text style={[styles.filterText, searchMode === 'all' && styles.filterTextActive]}>
Todos
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, searchMode === 'medicines' && styles.filterTabActive]}
onPress={() => setSearchMode('medicines')}
>
<Text style={[styles.filterText, searchMode === 'medicines' && styles.filterTextActive]}>
Medicamentos
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterTab, searchMode === 'products' && styles.filterTabActive]}
onPress={() => setSearchMode('products')}
>
<Text style={[styles.filterText, searchMode === 'products' && styles.filterTextActive]}>
Parafarmacia
</Text>
</TouchableOpacity>
</View>
- Step 5: Render product cards
Add product cards below the medicine results:
{(searchMode === 'all' || searchMode === 'products') && products.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Parafarmacia y Bebé</Text>
{products.map((product) => (
<TouchableOpacity
key={`${product.source}-${product.id}`}
style={styles.productCard}
onPress={() => router.push(`/product/${product.source}/${product.id}`)}
>
<View style={styles.productInfo}>
<View style={styles.badges}>
<View style={[styles.sourceBadge, { backgroundColor: product.source === 'cima' ? '#2563eb' : '#16a34a' }]}>
<Text style={styles.badgeText}>
{product.source === 'cima' ? 'CIMA' : 'OFF'}
</Text>
</View>
</View>
<Text style={styles.productName} numberOfLines={1}>{product.name}</Text>
<Text style={styles.productBrand} numberOfLines={1}>{product.brand}</Text>
</View>
</TouchableOpacity>
))}
</View>
)}
- Step 6: Add styles
Add to the StyleSheet:
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
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
// 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<Product | null>(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 (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
if (!product) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>Producto no encontrado</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
{/* Header */}
<View style={styles.header}>
{product.image_url && (
<Image source={{ uri: product.image_url }} style={styles.image} />
)}
<View style={[styles.sourceBadge, { backgroundColor: product.source === 'cima' ? '#2563eb' : '#16a34a' }]}>
<Text style={styles.badgeText}>
{product.source === 'cima' ? 'CIMA' : 'Open Food Facts'}
</Text>
</View>
</View>
{/* Product Info */}
<View style={styles.infoSection}>
<Text style={styles.name}>{product.name}</Text>
<Text style={styles.brand}>{product.brand}</Text>
{/* CIMA Details */}
{product.source === 'cima' && (
<View style={styles.details}>
{product.active_ingredient && (
<DetailRow label="Principio activo" value={product.active_ingredient} />
)}
{product.dosage && (
<DetailRow label="Dosis" value={product.dosage} />
)}
{product.form && (
<DetailRow label="Forma" value={product.form} />
)}
{product.prescription && (
<DetailRow label="Prescripción" value={product.prescription} />
)}
</View>
)}
{/* OFF Details */}
{product.source === 'openfoodfacts' && (
<View style={styles.details}>
{product.nutriscore && (
<DetailRow label="Nutriscore" value={product.nutriscore.toUpperCase()} />
)}
{product.ingredients && (
<DetailRow label="Ingredientes" value={product.ingredients} />
)}
{product.nova_group && (
<DetailRow label="NOVA" value={`Grupo ${product.nova_group}`} />
)}
</View>
)}
</View>
</ScrollView>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>{label}</Text>
<Text style={styles.detailValue}>{value}</Text>
</View>
);
}
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
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:
import ProductResults from '../ProductResults';
- Step 2: Accept products prop
Modify the component to accept products:
export default function MedicineResults({ medicines, products = [], onSelectMedicine, onSelectProduct }) {
- Step 3: Add products section below medicines
After the medicines list, add:
{products.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3">Parafarmacia y Bebé</h3>
<ProductResults products={products} onSelect={onSelectProduct} />
</div>
)}
- Step 4: Commit
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
# 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
cd apps/frontend && npm run dev
Open browser, test:
- Search "leche" — verify both CIMA OTC and OFF baby products appear
- Search "paracetamol" — verify CIMA OTC results
- Click filter tabs — verify filtering works
- Click a product — verify detail page loads
- Step 3: Test mobile app
cd apps/frontend-mobile && npm start
Test on simulator/device:
- Search "leche" — verify products appear
- Use filter tabs
- 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
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 | - |