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:
+151
-133
@@ -20,7 +20,7 @@ function playBeep() {
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.38);
|
||||
} catch (_) { /* No audio context available */ }
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function ScannerView({ onClose, onSelectMedicine }) {
|
||||
@@ -65,7 +65,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
try {
|
||||
const supported = await BarcodeScanner.isSupported();
|
||||
if (!supported) {
|
||||
setErrorMsg('Barcode scanning is not supported on this device.');
|
||||
setErrorMsg('El escáner no está disponible en este dispositivo.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
if (permission.camera !== 'granted') {
|
||||
const request = await BarcodeScanner.requestPermissions();
|
||||
if (request.camera !== 'granted') {
|
||||
setErrorMsg('Camera permission denied. Please enable it in settings, or enter your CIP code manually.');
|
||||
setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const rawValue = barcode?.rawValue;
|
||||
|
||||
if (!rawValue || !CIP_REGEX.test(rawValue)) {
|
||||
setErrorMsg('Invalid card barcode. Please try again or enter your CIP code manually.');
|
||||
setErrorMsg('Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
setErrorMsg(`Scan failed: ${err.message || 'Unknown error'}`);
|
||||
setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`);
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setErrorMsg('');
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setErrorMsg('Camera not available in this browser.');
|
||||
setErrorMsg('Cámara no disponible en este navegador.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] });
|
||||
} catch {
|
||||
setErrorMsg('Barcode detection is not supported in this browser.');
|
||||
setErrorMsg('La detección de códigos no está soportada en este navegador.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* detector not ready */ }
|
||||
} catch { }
|
||||
if (!found) {
|
||||
rafRef.current = requestAnimationFrame(scanFrame);
|
||||
}
|
||||
@@ -173,11 +173,11 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
} catch (err) {
|
||||
stopCamera();
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setErrorMsg('Camera permission denied. Please allow camera access and try again.');
|
||||
setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
setErrorMsg('No camera detected. Enter your CIP code manually.');
|
||||
setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.');
|
||||
} else {
|
||||
setErrorMsg(`Camera error: ${err.message || 'Unknown error'}`);
|
||||
setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`);
|
||||
}
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -195,12 +195,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
e.preventDefault();
|
||||
const cip = manualCip.trim();
|
||||
if (!cip) {
|
||||
setErrorMsg('Please enter a CIP code.');
|
||||
setErrorMsg('Introduce un código CIP.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
if (!CIP_REGEX.test(cip)) {
|
||||
setErrorMsg('Invalid CIP format. Must be 16 alphanumeric characters.');
|
||||
setErrorMsg('Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -227,128 +227,146 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scanner-overlay">
|
||||
<div className="scanner-topbar">
|
||||
<button className="scanner-back-btn" onClick={handleBack} aria-label="Back">
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="scanner-title">
|
||||
{phase === 'prescriptions' ? '✅ TSI Scanned' : '📷 Scan TSI Card'}
|
||||
</h2>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* ── Idle / Error / Scanning state ────────────────── */}
|
||||
{phase !== 'prescriptions' && (
|
||||
<div className="scanner-content">
|
||||
{phase === 'error' && (
|
||||
<div className="scan-error-panel">
|
||||
<span className="scan-error-icon">🚫</span>
|
||||
<p className="scan-error-msg">{errorMsg}</p>
|
||||
<button className="scan-retry-btn" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && !isNative && (
|
||||
<div className="scanner-camera-container">
|
||||
<video ref={videoRef} className="scanner-video" autoPlay muted playsInline />
|
||||
<div className="scanner-frame">
|
||||
<div className="corner tl" />
|
||||
<div className="corner tr" />
|
||||
<div className="corner bl" />
|
||||
<div className="corner br" />
|
||||
</div>
|
||||
<p className="scanner-hint">Point at barcode — hold steady</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && isNative && (
|
||||
<div className="scan-active-panel">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Opening camera… Point at barcode</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'idle' && (
|
||||
<button className="scan-start-btn" onClick={handleStartScan}>
|
||||
<span className="scan-start-icon">📷</span>
|
||||
{isNative ? 'Start Scanning' : 'Open Camera to Scan'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<form className="manual-cip-section" onSubmit={handleManualSubmit}>
|
||||
<label className="cip-label" htmlFor="cip-input">Or enter CIP manually</label>
|
||||
<div className="cip-input-group">
|
||||
<input
|
||||
id="cip-input"
|
||||
className="cip-input"
|
||||
type="text"
|
||||
placeholder="Enter CIP code manually"
|
||||
value={manualCip}
|
||||
onChange={(e) => setManualCip(e.target.value)}
|
||||
maxLength={16}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="cip-submit-btn">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Prescriptions result panel ───────────────────── */}
|
||||
{phase === 'prescriptions' && (
|
||||
<div className="scanner-prescriptions">
|
||||
<div className="scanned-card-info">
|
||||
<span className="scanned-icon">💳</span>
|
||||
<div>
|
||||
<p className="scanned-cip">{scannedCip}</p>
|
||||
</div>
|
||||
<div className="scanner-view">
|
||||
<div className="scanner-content">
|
||||
<div className="scanner-hero">
|
||||
<div className="scanner-icon-circle">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="rx-heading">Active Prescriptions</h3>
|
||||
<p className="rx-subheading">Tap a medicine to check availability at nearby pharmacies.</p>
|
||||
|
||||
{loadingPrescriptions && (
|
||||
<div className="rx-loading">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Loading prescriptions…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingPrescriptions && prescriptions.length === 0 && (
|
||||
<p className="rx-empty">No active prescriptions found for this card.</p>
|
||||
)}
|
||||
|
||||
<ul className="rx-list">
|
||||
{prescriptions.map((rx, i) => (
|
||||
<li key={i}>
|
||||
<button className="rx-item" onClick={() => handlePickPrescription(rx)}>
|
||||
<div className="rx-item-info">
|
||||
<span className="rx-name">{rx.name}</span>
|
||||
<span className="rx-detail">{rx.dosage} · {rx.form}</span>
|
||||
{rx.active_ingredient && (
|
||||
<span className="rx-ingredient">{rx.active_ingredient}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rx-arrow">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button className="scan-again-btn" onClick={() => {
|
||||
stopCamera();
|
||||
setPhase('idle');
|
||||
setPrescriptions([]);
|
||||
setScannedCip(null);
|
||||
}}>
|
||||
🔄 Scan Again
|
||||
</button>
|
||||
<h2 className="scanner-heading">Escanear TSI</h2>
|
||||
<p className="scanner-desc">Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase !== 'prescriptions' && (
|
||||
<>
|
||||
{phase === 'error' && (
|
||||
<div className="scan-error-card">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="scan-error-icon">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<p className="scan-error-text">{errorMsg}</p>
|
||||
<button className="scan-btn scan-btn--outline" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'idle' && (
|
||||
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
Abrir cámara
|
||||
</button>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && !isNative && (
|
||||
<div className="scanner-camera-container">
|
||||
<video ref={videoRef} className="scanner-video" autoPlay muted playsInline />
|
||||
<div className="scanner-frame">
|
||||
<div className="corner tl" />
|
||||
<div className="corner tr" />
|
||||
<div className="corner bl" />
|
||||
<div className="corner br" />
|
||||
</div>
|
||||
<p className="scanner-hint">Apunta al código de barras</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && isNative && (
|
||||
<div className="scanning-active">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Abriendo cámara…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="cip-form" onSubmit={handleManualSubmit}>
|
||||
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
|
||||
<div className="cip-row">
|
||||
<input
|
||||
id="cip-input"
|
||||
className="cip-input"
|
||||
type="text"
|
||||
placeholder="Código CIP de 16 dígitos"
|
||||
value={manualCip}
|
||||
onChange={(e) => setManualCip(e.target.value)}
|
||||
maxLength={16}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="scan-btn scan-btn--primary">Buscar</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'prescriptions' && (
|
||||
<div className="prescriptions-panel">
|
||||
<div className="cip-badge">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm3 10c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="cip-badge-label">TSI Escaneada</p>
|
||||
<p className="cip-badge-value">{scannedCip}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="rx-title">Recetas Activas</h3>
|
||||
<p className="rx-subtitle">Toca un medicamento para ver disponibilidad en farmacias cercanas.</p>
|
||||
|
||||
{loadingPrescriptions && (
|
||||
<div className="rx-loading">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Cargando recetas…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingPrescriptions && prescriptions.length === 0 && (
|
||||
<p className="rx-empty">No se encontraron recetas activas para esta tarjeta.</p>
|
||||
)}
|
||||
|
||||
<ul className="rx-list">
|
||||
{prescriptions.map((rx, i) => (
|
||||
<li key={i}>
|
||||
<button className="rx-item" onClick={() => handlePickPrescription(rx)}>
|
||||
<div className="rx-item-info">
|
||||
<span className="rx-name">{rx.name}</span>
|
||||
<span className="rx-detail">{rx.dosage} · {rx.form}</span>
|
||||
{rx.active_ingredient && (
|
||||
<span className="rx-ingredient">{rx.active_ingredient}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="rx-arrow">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button className="scan-btn scan-btn--ghost" onClick={() => {
|
||||
stopCamera();
|
||||
setPhase('idle');
|
||||
setPrescriptions([]);
|
||||
setScannedCip(null);
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1 4 1 10 7 10" />
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||
</svg>
|
||||
Escanear otra tarjeta
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user