feat(fullstack): implement observability and redesign frontend UI
Build & Push Docker Images / test-backend (push) Failing after 36s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / build-backend (push) Has been skipped
Build & Push Docker Images / build-frontend (push) Has been skipped
Build & Push Docker Images / deploy (push) Successful in 7s

This commit introduces comprehensive observability for both backend and frontend, alongside a major UI/UX overhaul to align with modern design standards (Material 3 inspired) and improve localization.

Backend changes:
- Integrated OpenTelemetry SDK for distributed tracing.
- Added Pino for structured JSON logging with OTel instrumentation for trace/span correlation.
- Configured OTLP exporters to route traces and logs to Grafana Alloy.
- Updated docker-compose to include necessary environment variables for observability.

Frontend changes:
- Integrated Grafana Faro for Real User Monitoring (RUM), capturing Web Vitals, JS errors, and user interactions.
- Redesigned the entire UI using a new color palette and Material 3 design principles.
- Refactored component architecture (App, Home, Search, Scanner, Profile, Alerts views) for better state management and navigation.
- Improved mobile UX with a redesigned Bottom Navigation bar and Top Bar.
- Localized the interface to Spanish (es).
- Updated assets, icons, and PWA configuration (manifest, icons).
- Refactored CSS to use a centralized design token system (CSS variables).

Observability enables better debugging and performance monitoring across the entire stack.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 11:48:27 +02:00
parent 9316f6c54f
commit db0935eb16
46 changed files with 5633 additions and 1867 deletions
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useMemo } from 'react';
import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition } from '../utils/geo';
import './SearchView.css';
const suggestions = [
{ name: 'Paracetamol', icon: 'medication', color: 'primary' },
{ name: 'Ibuprofeno', icon: 'pill', color: 'secondary' },
{ name: 'Aspirina', icon: 'vaccines', color: 'tertiary' },
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
];
const recentResults = [
{ name: 'Amoxicilina', detail: '500mg • 24 Cápsulas', tag: 'Antibiótico', distance: '300m - Farmacia Central' },
{ name: 'Losartán', detail: '50mg • 30 Comprimidos', tag: 'Hipertensión', distance: '1.2km - Farmacia del Sol' },
];
function SearchView({ currentUser, onLoginRequest }) {
const [searchQuery, setSearchQuery] = useState('');
const [medicines, setMedicines] = useState([]);
const [selectedMedicine, setSelectedMedicine] = useState(null);
const [pharmacies, setPharmacies] = useState([]);
const [loading, setLoading] = useState(false);
const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null);
const [sortByDistance, setSortByDistance] = useState(false);
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
useEffect(() => {
const searchMedicines = async () => {
if (searchQuery.trim().length < 2) {
setMedicines([]);
setSelectedMedicine(null);
setPharmacies([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/medicines/search?q=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
setMedicines(data);
} catch (error) {
console.error('Error searching medicines:', error);
} finally {
setLoading(false);
}
};
const timeoutId = setTimeout(searchMedicines, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
useEffect(() => {
const fetchPharmacies = async () => {
if (!selectedMedicine) {
setPharmacies([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/medicines/${selectedMedicine.id}/pharmacies`);
const data = await response.json();
setPharmacies(data);
} catch (error) {
console.error('Error fetching pharmacies:', error);
} finally {
setLoading(false);
}
};
fetchPharmacies();
}, [selectedMedicine]);
const savedLat = currentUser?.latitude;
const savedLon = currentUser?.longitude;
const hasSavedCoords = savedLat != null && savedLon != null;
const handleSortByDistance = async () => {
if (sortByDistance) {
setSortByDistance(false);
return;
}
setLocationError('');
if (hasSavedCoords) {
setUserPosition({ lat: savedLat, lon: savedLon });
setPositionSource('profile');
setSortByDistance(true);
return;
}
if (userPosition) {
setSortByDistance(true);
return;
}
setLocating(true);
try {
const pos = await getUserPosition();
setUserPosition(pos);
setPositionSource('browser');
setSortByDistance(true);
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado';
else if (err.code === 2) msg = 'Ubicación no disponible';
else if (err.code === 3) msg = 'La solicitud de ubicación expiró';
}
setLocationError(msg);
} finally {
setLocating(false);
}
};
const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies;
return [...pharmacies].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1;
return (
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
);
});
}, [pharmacies, sortByDistance, userPosition]);
return (
<div className="search-view">
<div className="search-content">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Escriba el nombre del medicamento"
/>
{loading && <div className="loading">Buscando...</div>}
{!searchQuery && !selectedMedicine && (
<>
<section className="suggestions-section">
<h2 className="section-title">Sugerencias</h2>
<div className="suggestions-grid">
{suggestions.map((s, i) => (
<button
key={i}
className={`suggestion-btn suggestion-btn--${s.color}`}
onClick={() => setSearchQuery(s.name)}
>
<div className="suggestion-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z" />
</svg>
</div>
<span className="suggestion-name">{s.name}</span>
</button>
))}
</div>
</section>
<section className="recent-section">
<h2 className="section-title">Resultados Recientes</h2>
<div className="recent-list">
{recentResults.map((r, i) => (
<div key={i} className="recent-card">
<div className="recent-card-top">
<div>
<h3 className="recent-name">{r.name}</h3>
<p className="recent-detail">{r.detail}</p>
</div>
<span className="recent-tag">{r.tag}</span>
</div>
<div className="recent-distance">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{r.distance}</span>
</div>
<button className="recent-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Encontrar cerca
</button>
</div>
))}
</div>
</section>
</>
)}
{searchQuery && !selectedMedicine && (
<MedicineResults
medicines={medicines}
onSelect={setSelectedMedicine}
query={searchQuery}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
/>
)}
{selectedMedicine && (
<div className="selected-medicine-section">
<div className="medicine-info">
<h2>{selectedMedicine.name}</h2>
<div className="medicine-details">
<span><strong>Ingrediente Activo:</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
</div>
<button
className="back-button"
onClick={() => {
setSelectedMedicine(null);
setPharmacies([]);
}}
>
Volver a búsqueda
</button>
</div>
{pharmacies.length > 0 && (
<div className="pharmacy-controls">
<button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance}
disabled={locating}
>
{locating
? '📍 Localizando…'
: sortByDistance
? '📍 Ordenado por distancia · Reset'
: hasSavedCoords
? '📍 Ordenar por ubicación guardada'
: '📍 Ordenar por distancia'}
</button>
{sortByDistance && positionSource === 'profile' && (
<span className="location-source">Usando tu dirección guardada</span>
)}
{locationError && (
<span className="location-error">{locationError}</span>
)}
</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList
pharmacies={displayedPharmacies}
loading={loading}
userPosition={sortByDistance ? userPosition : null}
medicine={selectedMedicine}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
/>
</div>
)}
</div>
</div>
);
}
export default SearchView;