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
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:
@@ -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;
|
||||
Reference in New Issue
Block a user