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