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
+22
View File
@@ -66,6 +66,28 @@
text-align: center;
margin-bottom: 4rem;
animation: fadeInDown 0.8s ease-out;
position: relative;
}
.back-to-home-btn {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
background: var(--surface-muted);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.88rem;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.back-to-home-btn:hover {
color: var(--text-main);
background: var(--surface-card);
}
.app-header h1 {
+26 -2
View File
@@ -11,9 +11,29 @@ afterEach(() => {
vi.useRealTimers()
})
describe('PublicView', () => {
it('renders search bar on load with no results', () => {
// Helper: navigate from HomeView to the search screen
async function navigateToSearch() {
fireEvent.click(screen.getByRole('button', { name: /manual search/i }))
}
describe('HomeView', () => {
it('renders two action buttons on the home screen', () => {
render(<PublicView />)
expect(screen.getByRole('button', { name: /scan tsi/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /manual search/i })).toBeInTheDocument()
})
it('navigates to search screen when Manual Search is clicked', async () => {
render(<PublicView />)
await navigateToSearch()
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
})
})
describe('PublicView (search screen)', () => {
it('renders search bar on load with no results', async () => {
render(<PublicView />)
await navigateToSearch()
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
@@ -21,6 +41,7 @@ describe('PublicView', () => {
it('does not fetch for queries shorter than 2 chars', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
render(<PublicView />)
await navigateToSearch()
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), {
target: { value: 'a' },
@@ -42,6 +63,7 @@ describe('PublicView', () => {
})
render(<PublicView />)
await navigateToSearch()
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), {
target: { value: 'ibu' },
@@ -63,6 +85,7 @@ describe('PublicView', () => {
})
render(<PublicView />)
await navigateToSearch()
const input = screen.getByPlaceholderText(/search for a medicine/i)
fireEvent.change(input, { target: { value: 'ibu' } })
@@ -75,3 +98,4 @@ describe('PublicView', () => {
expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument()
})
})
+292
View File
@@ -0,0 +1,292 @@
/* ============================================================
HomeView.css — Premium home page styles
============================================================ */
.home-container {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem 1.5rem calc(5rem + env(safe-area-inset-bottom));
gap: 2.5rem;
animation: fadeInUp 0.7s ease-out;
}
/* ── Header ─────────────────────────────────────────────── */
.home-header {
text-align: center;
max-width: 34rem;
}
.home-logo {
font-size: clamp(2.6rem, 7vw, 4rem);
font-weight: 900;
color: var(--text-main);
letter-spacing: -0.04em;
line-height: 1;
margin-bottom: 0.9rem;
}
.home-logo::after {
content: "";
display: block;
width: 3rem;
height: 4px;
margin: 0.85rem auto 0;
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
border-radius: 2px;
}
.home-subtitle {
font-size: 1.1rem;
color: var(--text-muted);
font-weight: 400;
line-height: 1.55;
}
/* ── Card Grid ──────────────────────────────────────────── */
.home-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
width: 100%;
max-width: 54rem;
}
/* ── Base Card ──────────────────────────────────────────── */
.home-card {
position: relative;
display: flex;
flex-direction: column;
gap: 1.2rem;
padding: 2rem 1.75rem 1.6rem;
border-radius: 20px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
box-shadow: var(--glass-shadow);
cursor: pointer;
text-align: left;
overflow: hidden;
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.home-card::before {
content: "";
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: inherit;
pointer-events: none;
}
.home-card:hover {
transform: translateY(-5px) scale(1.015);
box-shadow:
0 16px 40px rgba(28, 25, 23, 0.14),
0 4px 12px rgba(28, 25, 23, 0.08);
}
.home-card:hover::before {
opacity: 1;
}
.home-card:active {
transform: translateY(-2px) scale(1.005);
}
/* ── Scan Card Accent ───────────────────────────────────── */
.scan-card {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.97) 0%,
rgba(240, 253, 250, 0.97) 100%
);
border-color: rgba(15, 118, 110, 0.22);
}
.scan-card::before {
background: radial-gradient(
ellipse at top left,
rgba(15, 118, 110, 0.08) 0%,
transparent 70%
);
}
.scan-card:hover {
border-color: rgba(15, 118, 110, 0.45);
box-shadow:
0 16px 40px rgba(15, 118, 110, 0.18),
0 4px 12px rgba(15, 118, 110, 0.12);
}
/* ── Search Card Accent ─────────────────────────────────── */
.search-card {
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.97) 0%,
rgba(245, 245, 244, 0.97) 100%
);
}
.search-card:hover {
border-color: rgba(71, 85, 105, 0.35);
box-shadow:
0 16px 40px rgba(28, 25, 23, 0.12),
0 4px 12px rgba(28, 25, 23, 0.06);
}
/* ── Badge ──────────────────────────────────────────────── */
.card-badge {
position: absolute;
top: 1.1rem;
right: 1.1rem;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: #fff;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.28rem 0.65rem;
border-radius: 999px;
box-shadow: 0 2px 8px rgba(15, 118, 110, 0.35);
}
/* ── Icon Container ─────────────────────────────────────── */
.card-icon-container {
position: relative;
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
}
.card-icon {
font-size: 2.4rem;
line-height: 1;
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.12));
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.home-card:hover .card-icon {
transform: scale(1.18) rotate(-4deg);
}
/* Pulse rings on the Scan card */
.pulse-ring,
.pulse-ring-outer {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgba(15, 118, 110, 0.4);
animation: pulse-ring 2.2s ease-out infinite;
pointer-events: none;
}
.pulse-ring-outer {
animation-delay: 0.7s;
border-color: rgba(15, 118, 110, 0.2);
}
@keyframes pulse-ring {
0% { transform: scale(0.85); opacity: 0.7; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
/* ── Card Content ───────────────────────────────────────── */
.card-content h3 {
font-size: 1.4rem;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.card-content p {
font-size: 0.95rem;
color: var(--text-muted);
line-height: 1.55;
}
/* ── Card Action Footer ─────────────────────────────────── */
.card-action {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
padding-top: 0.8rem;
border-top: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 600;
color: var(--primary);
}
.scan-card .card-action {
color: var(--primary);
}
.search-card .card-action {
color: var(--text-muted);
}
.search-card:hover .card-action {
color: var(--text-main);
}
.action-arrow {
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
font-size: 1.1rem;
}
.home-card:hover .action-arrow {
transform: translateX(5px);
}
/* ── Footer ─────────────────────────────────────────────── */
.home-footer {
max-width: 40rem;
text-align: center;
}
.home-footer p {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.55;
opacity: 0.8;
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 640px) {
.home-container {
padding: 1.5rem 1rem calc(6rem + env(safe-area-inset-bottom));
gap: 1.75rem;
justify-content: flex-start;
padding-top: 2.5rem;
}
.home-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.home-card {
padding: 1.5rem 1.4rem 1.3rem;
gap: 0.9rem;
}
.card-content h3 {
font-size: 1.25rem;
}
.card-content p {
font-size: 0.88rem;
}
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import './HomeView.css';
function HomeView({ onScanClick, onSearchClick }) {
return (
<div className="home-container">
<header className="home-header">
<h1 className="home-logo">💊 FarmaFinder</h1>
<p className="home-subtitle">Find your medicine at nearby pharmacies quickly and easily.</p>
</header>
<div className="home-grid">
<button className="home-card scan-card" onClick={onScanClick} aria-label="Scan TSI Card">
<div className="card-badge">Fast Access</div>
<div className="card-icon-container">
<span className="card-icon">📷</span>
<div className="pulse-ring"></div>
<div className="pulse-ring-outer"></div>
</div>
<div className="card-content">
<h3>Scan TSI Card</h3>
<p>Scan your health card (Tarjeta Sanitaria Individual) barcode or QR code with your camera to instantly view your active prescriptions.</p>
</div>
<div className="card-action">
<span>Scan now</span>
<span className="action-arrow"></span>
</div>
</button>
<button className="home-card search-card" onClick={onSearchClick} aria-label="Manual Search">
<div className="card-icon-container">
<span className="card-icon">🔍</span>
</div>
<div className="card-content">
<h3>Manual Search</h3>
<p>Search for a specific medicine by typing its name, active ingredient, or register number to check availability in real-time.</p>
</div>
<div className="card-action">
<span>Search catalogue</span>
<span className="action-arrow"></span>
</div>
</button>
</div>
<footer className="home-footer">
<p>🔒 Your medical and personal data is secure. FarmaFinder complies with data protection regulations and does not store sensitive card information.</p>
</footer>
</div>
);
}
export default HomeView;
+45
View File
@@ -4,9 +4,14 @@ import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap';
import HomeView from './HomeView';
import ScannerView from './ScannerView';
import { haversineKm, getUserPosition } from '../utils/geo';
function PublicView({ currentUser, onLoginRequest }) {
// 'home' | 'scan' | 'search'
const [screen, setScreen] = useState('home');
const [searchQuery, setSearchQuery] = useState('');
const [medicines, setMedicines] = useState([]);
const [selectedMedicine, setSelectedMedicine] = useState(null);
@@ -120,9 +125,49 @@ function PublicView({ currentUser, onLoginRequest }) {
});
}, [pharmacies, sortByDistance, userPosition]);
/* ── Scanner → Search handoff ──────────────────────────── */
function handleScanSelectMedicine(medicineName) {
setSearchQuery(medicineName);
setSelectedMedicine(null);
setPharmacies([]);
setScreen('search');
}
/* ── Screens ─────────────────────────────────────────────── */
if (screen === 'home') {
return (
<HomeView
onScanClick={() => setScreen('scan')}
onSearchClick={() => setScreen('search')}
/>
);
}
if (screen === 'scan') {
return (
<ScannerView
onClose={() => setScreen('home')}
onSelectMedicine={handleScanSelectMedicine}
/>
);
}
// screen === 'search'
return (
<>
<header className="app-header">
<button
className="back-to-home-btn"
onClick={() => {
setSearchQuery('');
setSelectedMedicine(null);
setPharmacies([]);
setScreen('home');
}}
aria-label="Back to home"
>
Home
</button>
<h1>💊 FarmaFinder</h1>
<p>Find your medicine at nearby pharmacies</p>
</header>
+440
View File
@@ -0,0 +1,440 @@
/* ============================================================
ScannerView.css — Barcode scanning interface styles
============================================================ */
/* ── Full-screen Overlay ─────────────────────────────────── */
.scanner-overlay {
position: fixed;
inset: 0;
background: #0a0e17;
z-index: 200;
display: flex;
flex-direction: column;
overflow-y: auto;
overscroll-behavior: contain;
animation: fadeInFast 0.3s ease-out;
}
@keyframes fadeInFast {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Top Bar ─────────────────────────────────────────────── */
.scanner-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.2rem calc(1rem + env(safe-area-inset-top));
padding-top: calc(1rem + env(safe-area-inset-top));
background: rgba(10, 14, 23, 0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.07);
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
}
.scanner-back-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #e2e8f0;
font-size: 0.9rem;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s;
}
.scanner-back-btn:hover {
background: rgba(255,255,255,0.14);
}
.scanner-title {
font-size: 1.05rem;
font-weight: 700;
color: #f1f5f9;
letter-spacing: -0.01em;
}
/* ── Content Area ────────────────────────────────────────── */
.scanner-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem 1.25rem calc(2rem + env(safe-area-inset-bottom));
width: 100%;
max-width: 42rem;
margin: 0 auto;
}
/* ── Start Scan Button ───────────────────────────────────── */
.scan-start-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
background: linear-gradient(135deg, #0f766e, #0d9488);
color: #fff;
border: none;
border-radius: 16px;
padding: 1.1rem 1.5rem;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
letter-spacing: -0.01em;
box-shadow: 0 4px 20px rgba(15, 118, 110, 0.45);
transition: transform 0.2s, box-shadow 0.2s;
}
.scan-start-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(15, 118, 110, 0.55);
}
.scan-start-btn:active {
transform: translateY(0);
}
.scan-start-icon {
font-size: 1.5rem;
}
/* ── Manual CIP Input ────────────────────────────────────── */
.manual-cip-section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.cip-label {
font-size: 0.8rem;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.cip-input-group {
display: flex;
gap: 0.5rem;
}
.cip-input {
flex: 1;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
padding: 0.85rem 1rem;
font-size: 1rem;
font-family: 'Courier New', monospace;
color: #e2e8f0;
letter-spacing: 0.08em;
outline: none;
transition: border-color 0.2s;
}
.cip-input::placeholder {
color: #475569;
letter-spacing: 0.04em;
font-family: system-ui, sans-serif;
}
.cip-input:focus {
border-color: rgba(16, 185, 129, 0.5);
}
.cip-submit-btn {
background: linear-gradient(135deg, #0f766e, #0d9488);
color: #fff;
border: none;
border-radius: 12px;
padding: 0.85rem 1.4rem;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: transform 0.2s, box-shadow 0.2s;
}
.cip-submit-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(15, 118, 110, 0.4);
}
/* ── PWA Notice ──────────────────────────────────────────── */
.pwa-notice {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 14px;
padding: 1rem 1.2rem;
}
.pwa-notice-icon {
font-size: 1.3rem;
flex-shrink: 0;
margin-top: 0.1rem;
}
.pwa-notice p {
margin: 0;
font-size: 0.9rem;
color: #93c5fd;
line-height: 1.5;
}
/* ── Error Panel ─────────────────────────────────────────── */
.scan-error-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 16px;
padding: 1.5rem 1.2rem;
text-align: center;
}
.scan-error-icon {
font-size: 2.5rem;
}
.scan-error-msg {
color: #fca5a5;
font-size: 0.95rem;
line-height: 1.5;
margin: 0;
}
.scan-retry-btn {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 10px;
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.scan-retry-btn:hover {
background: rgba(239, 68, 68, 0.25);
}
/* ── Scanning Active Panel (native) ──────────────────────── */
.scan-active-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.scan-active-panel p {
color: #94a3b8;
font-size: 0.95rem;
margin: 0;
}
/* ── Camera Viewport (web PWA scanning) ──────────────────── */
.scanner-camera-container {
position: relative;
width: 100%;
border-radius: 16px;
overflow: hidden;
background: #000;
aspect-ratio: 4/3;
}
.scanner-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.scanner-frame {
position: absolute;
inset: 10% 12%;
pointer-events: none;
}
.corner {
position: absolute;
width: 22px;
height: 22px;
border-color: #10b981;
border-style: solid;
border-width: 0;
}
.corner.tl { top: 0; left: 0; border-top-width: 3px; border-left-width: 3px; border-radius: 4px 0 0 0; }
.corner.tr { top: 0; right: 0; border-top-width: 3px; border-right-width: 3px; border-radius: 0 4px 0 0; }
.corner.bl { bottom: 0; left: 0; border-bottom-width: 3px; border-left-width: 3px; border-radius: 0 0 0 4px; }
.corner.br { bottom: 0; right: 0; border-bottom-width: 3px; border-right-width: 3px; border-radius: 0 0 4px 0; }
.scanner-hint {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 0.78rem;
color: rgba(255,255,255,0.75);
background: rgba(0,0,0,0.55);
backdrop-filter: blur(6px);
padding: 0.3rem 0.75rem;
border-radius: 999px;
}
/* ── Spinner ─────────────────────────────────────────────── */
.scanner-spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,0.12);
border-top-color: #10b981;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Prescriptions Panel ─────────────────────────────────── */
.scanner-prescriptions {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1.5rem 1.25rem calc(2rem + env(safe-area-inset-bottom));
width: 100%;
max-width: 42rem;
margin: 0 auto;
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.scanned-card-info {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.25);
border-radius: 14px;
padding: 1rem 1.2rem;
}
.scanned-icon { font-size: 2rem; }
.scanned-cip {
font-size: 0.85rem;
color: #64748b;
font-family: 'Courier New', monospace;
margin: 0;
}
.rx-heading {
font-size: 1.1rem;
font-weight: 800;
color: #f1f5f9;
letter-spacing: -0.02em;
margin: 0;
}
.rx-subheading {
font-size: 0.85rem;
color: #64748b;
margin: -0.5rem 0 0;
}
.rx-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
padding: 2rem 0;
color: #64748b;
font-size: 0.9rem;
}
.rx-empty {
color: #64748b;
text-align: center;
padding: 1.5rem 0;
font-size: 0.9rem;
}
.rx-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rx-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 14px;
padding: 1rem 1.1rem;
cursor: pointer;
width: 100%;
text-align: left;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.rx-item:hover {
background: rgba(255,255,255,0.09);
border-color: rgba(16, 185, 129, 0.35);
transform: translateX(3px);
}
.rx-item-info {
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.rx-name {
font-size: 1rem;
font-weight: 700;
color: #e2e8f0;
}
.rx-detail {
font-size: 0.82rem;
color: #64748b;
}
.rx-ingredient {
font-size: 0.78rem;
color: #10b981;
font-weight: 500;
}
.rx-arrow {
color: #10b981;
font-size: 1.2rem;
flex-shrink: 0;
transition: transform 0.2s;
}
.rx-item:hover .rx-arrow {
transform: translateX(4px);
}
.scan-again-btn {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: #94a3b8;
border-radius: 12px;
padding: 0.75rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-top: 0.5rem;
}
.scan-again-btn:hover {
background: rgba(255,255,255,0.1);
color: #e2e8f0;
}
+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;