Documentos + Homepage rediseñada
Build & Push Docker Images / test-backend (push) Successful in 24s
Build & Push Docker Images / test-frontend (push) Successful in 23s
Build & Push Docker Images / build-backend (push) Successful in 2m22s
Build & Push Docker Images / build-frontend (push) Successful in 57s

This commit is contained in:
Antoni Nuñez Romeu
2026-06-26 16:59:20 +02:00
parent 14e9c16310
commit 2c1b3cfca6
36 changed files with 4665 additions and 372 deletions
+356
View File
@@ -0,0 +1,356 @@
import React, { useState, useCallback, useRef } from 'react';
import { BarcodeScanner, BarcodeFormat } from '@capacitor-mlkit/barcode-scanning';
import { Capacitor } from '@capacitor/core';
import 'barcode-detector/polyfill';
import './ScannerView.css';
const CIP_REGEX = /^[A-Z0-9]{16}$/i;
function playBeep() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1046, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1318, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.28, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.38);
} catch (_) { /* No audio context available */ }
}
function ScannerView({ onClose, onSelectMedicine }) {
const videoRef = useRef(null);
const streamRef = useRef(null);
const rafRef = useRef(null);
const detectorRef = useRef(null);
const [phase, setPhase] = useState('idle');
const [manualCip, setManualCip] = useState('');
const [prescriptions, setPrescriptions] = useState([]);
const [scannedCip, setScannedCip] = useState(null);
const [loadingPrescriptions, setLoadingPrescriptions] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const isNative = Capacitor.isNativePlatform();
const fetchPrescriptions = useCallback(async (cip) => {
setLoadingPrescriptions(true);
try {
const res = await fetch(`/api/tsi/${encodeURIComponent(cip)}/prescriptions`);
const data = await res.json();
setPrescriptions(data);
} catch {
setPrescriptions([]);
} finally {
setLoadingPrescriptions(false);
}
}, []);
function stopCamera() {
streamRef.current?.getTracks().forEach(t => t.stop());
streamRef.current = null;
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}
async function handleNativeScan() {
setErrorMsg('');
try {
const supported = await BarcodeScanner.isSupported();
if (!supported) {
setErrorMsg('Barcode scanning is not supported on this device.');
setPhase('error');
return;
}
const permission = await BarcodeScanner.checkPermissions();
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.');
setPhase('error');
return;
}
}
setPhase('scanning');
const result = await BarcodeScanner.scan({
formats: [BarcodeFormat.Code128, BarcodeFormat.QrCode],
autoZoom: true,
});
const barcode = result.barcodes[0];
const rawValue = barcode?.rawValue;
if (!rawValue || !CIP_REGEX.test(rawValue)) {
setErrorMsg('Invalid card barcode. Please try again or enter your CIP code manually.');
setPhase('error');
return;
}
playBeep();
setScannedCip(rawValue);
setPhase('prescriptions');
fetchPrescriptions(rawValue);
} catch (err) {
if (err?.message?.includes('cancel') || err?.message?.includes('User')) {
setPhase('idle');
return;
}
setErrorMsg(`Scan failed: ${err.message || 'Unknown error'}`);
setPhase('error');
}
}
async function handleWebScan() {
setErrorMsg('');
try {
if (!navigator.mediaDevices?.getUserMedia) {
setErrorMsg('Camera not available in this browser.');
setPhase('error');
return;
}
let detector;
try {
detector = new BarcodeDetector({ formats: ['code_128', 'qr_code'] });
} catch {
setErrorMsg('Barcode detection is not supported in this browser.');
setPhase('error');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
});
streamRef.current = stream;
detectorRef.current = detector;
setPhase('scanning');
await new Promise((resolve) => {
requestAnimationFrame(() => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play().then(resolve).catch(resolve);
} else {
resolve();
}
});
});
let found = false;
async function scanFrame() {
if (found || !videoRef.current || !detectorRef.current) return;
try {
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0) {
const rawValue = barcodes[0].rawValue;
if (rawValue && CIP_REGEX.test(rawValue)) {
found = true;
stopCamera();
playBeep();
setScannedCip(rawValue);
setPhase('prescriptions');
fetchPrescriptions(rawValue);
return;
}
}
} catch { /* detector not ready */ }
if (!found) {
rafRef.current = requestAnimationFrame(scanFrame);
}
}
scanFrame();
} catch (err) {
stopCamera();
if (err.name === 'NotAllowedError') {
setErrorMsg('Camera permission denied. Please allow camera access and try again.');
} else if (err.name === 'NotFoundError') {
setErrorMsg('No camera detected. Enter your CIP code manually.');
} else {
setErrorMsg(`Camera error: ${err.message || 'Unknown error'}`);
}
setPhase('error');
}
}
function handleStartScan() {
if (isNative) {
handleNativeScan();
} else {
handleWebScan();
}
}
function handleManualSubmit(e) {
e.preventDefault();
const cip = manualCip.trim();
if (!cip) {
setErrorMsg('Please enter a CIP code.');
setPhase('error');
return;
}
if (!CIP_REGEX.test(cip)) {
setErrorMsg('Invalid CIP format. Must be 16 alphanumeric characters.');
setPhase('error');
return;
}
playBeep();
setScannedCip(cip);
setPhase('prescriptions');
fetchPrescriptions(cip);
}
function handlePickPrescription(rx) {
stopCamera();
onSelectMedicine(rx.name);
}
function handleBack() {
stopCamera();
if (phase === 'prescriptions') {
setPhase('idle');
setPrescriptions([]);
setScannedCip(null);
} else {
onClose();
}
}
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>
<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>
</div>
)}
</div>
);
}
export default ScannerView;