Files
FarmaFinder/docs/superpowers/plans/2026-07-13-parapharmacy-baby-products.md
Antoni Nuñez Romeu 4df1594b3b
Run Tests on Branches / Detect Changes (push) Successful in 8s
Run Tests on Branches / Frontend Tests (push) Has been cancelled
Run Tests on Branches / Frontend Mobile Tests (push) Has been cancelled
Run Tests on Branches / Backend Tests (push) Has been cancelled
docs: add design spec and implementation plan for parapharmacy + baby products
2026-07-13 15:56:00 +02:00

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:

  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
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:

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

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 -