feat(frontend): add product detail view and navigation
Run Tests on Branches / Detect Changes (push) Successful in 12s
Run Tests on Branches / Backend Tests (push) Has been cancelled
Run Tests on Branches / Frontend Tests (push) Has been cancelled
Run Tests on Branches / Frontend Mobile Tests (push) Has been cancelled

- Create ProductView component with product details display
- Add ProductView.css with responsive styling
- Add product screen to App.jsx screen system
- Pass onNavigateToProduct callback from App to SearchView
- Update ProductResults onSelect to use callback instead of window.location
This commit is contained in:
Antoni Nuñez Romeu
2026-07-13 17:36:52 +02:00
parent 31c1a14343
commit 731a6c98ae
4 changed files with 246 additions and 2 deletions
+12
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import './App.css';
import HomeView from './views/HomeView';
import SearchView from './views/SearchView';
import ProductView from './views/ProductView';
import ScannerView from './views/ScannerView';
import AlertsView from './views/AlertsView';
import ProfileView from './views/ProfileView';
@@ -19,6 +20,7 @@ function App() {
const [showSaved, setShowSaved] = useState(false);
const [badgeCount, setBadgeCount] = useState(0);
const [prescriptionSearch, setPrescriptionSearch] = useState('');
const [productScreen, setProductScreen] = useState(null);
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight
@@ -180,6 +182,16 @@ function App() {
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
initialQuery={prescriptionSearch}
onNavigateToProduct={(source, id) => setProductScreen({ source, id })}
/>
);
break;
case 'product':
activeView = (
<ProductView
source={productScreen?.source}
id={productScreen?.id}
onBack={() => { setProductScreen(null); setScreen('search'); }}
/>
);
break;
+102
View File
@@ -0,0 +1,102 @@
.product-view {
width: 100%;
max-width: 48rem;
margin: 0 auto;
padding: 1rem var(--margin-main) 2rem;
animation: fadeInUp 0.3s ease-out;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
margin-bottom: 1rem;
background: none;
border: none;
color: var(--primary, #2563eb);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.back-btn:hover {
opacity: 0.8;
}
.product-loading,
.product-error {
text-align: center;
padding: 3rem 1rem;
color: var(--on-surface-variant, #6b7280);
font-size: 1rem;
}
.product-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1.5rem;
}
.product-image {
width: 120px;
height: 120px;
object-fit: contain;
border-radius: var(--radius-md, 0.75rem);
margin-bottom: 1rem;
}
.source-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.product-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--on-surface);
text-align: center;
margin-bottom: 0.25rem;
}
.product-brand {
font-size: 1rem;
color: var(--on-surface-variant, #6b7280);
text-align: center;
margin-bottom: 1.5rem;
}
.product-details {
background: var(--surface-container-lowest, #f9fafb);
border-radius: var(--radius-md, 0.75rem);
padding: 1rem;
}
.detail-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--outline-variant, #e5e7eb);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--on-surface-variant, #9ca3af);
text-transform: uppercase;
}
.detail-value {
font-size: 0.9375rem;
color: var(--on-surface);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import './ProductView.css';
export default function ProductView({ source, id, onBack }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
loadProduct();
}, [source, id]);
async function loadProduct() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/products/${source}/${id}`);
if (!response.ok) {
throw new Error('Producto no encontrado');
}
const data = await response.json();
setProduct(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
if (loading) {
return (
<div className="product-view">
<div className="product-loading">Cargando...</div>
</div>
);
}
if (error) {
return (
<div className="product-view">
<button className="back-btn" onClick={onBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Volver
</button>
<div className="product-error">{error}</div>
</div>
);
}
if (!product) return null;
const isCima = product.source === 'cima';
const sourceColor = isCima ? '#2563eb' : '#16a34a';
const sourceLabel = isCima ? 'CIMA' : 'Open Food Facts';
return (
<div className="product-view">
<button className="back-btn" onClick={onBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Volver
</button>
<div className="product-header">
{product.image_url && (
<img src={product.image_url} alt={product.name} className="product-image" />
)}
<span className="source-badge" style={{ backgroundColor: sourceColor }}>
{sourceLabel}
</span>
</div>
<h1 className="product-name">{product.name}</h1>
<p className="product-brand">{product.brand}</p>
<div className="product-details">
{isCima ? (
<>
{product.active_ingredient && (
<DetailRow label="Principio activo" value={product.active_ingredient} />
)}
{product.dosage && (
<DetailRow label="Dosis" value={product.dosage} />
)}
{product.form && (
<DetailRow label="Forma farmacéutica" value={product.form} />
)}
{product.prescription && (
<DetailRow label="Prescripción" value={product.prescription} />
)}
{product.commercialized !== undefined && (
<DetailRow label="Comercializado" value={product.commercialized ? 'Sí' : 'No'} />
)}
</>
) : (
<>
{product.nutriscore && product.nutriscore !== 'not-applicable' && product.nutriscore !== 'unknown' && (
<DetailRow label="Nutri-Score" value={product.nutriscore.toUpperCase()} />
)}
{product.nova_group && (
<DetailRow label="NOVA" value={`Grupo ${product.nova_group}`} />
)}
{product.eco_score && product.eco_score !== 'unknown' && (
<DetailRow label="Eco-Score" value={product.eco_score.toUpperCase()} />
)}
{product.ingredients && (
<DetailRow label="Ingredientes" value={product.ingredients} />
)}
</>
)}
</div>
</div>
);
}
function DetailRow({ label, value }) {
return (
<div className="detail-row">
<span className="detail-label">{label}</span>
<span className="detail-value">{value}</span>
</div>
);
}
+5 -1
View File
@@ -14,7 +14,7 @@ const suggestions = [
{ name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
];
function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigateToProduct }) {
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [medicines, setMedicines] = useState([]);
const [products, setProducts] = useState([]);
@@ -331,7 +331,11 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
<ProductResults
products={products}
onSelect={(p) => {
if (onNavigateToProduct) {
onNavigateToProduct(p.source, p.id);
} else {
window.location.href = `/product/${p.source}/${p.id}`;
}
}}
/>
</div>