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:
Generated
+90
-24
@@ -8,10 +8,12 @@
|
||||
"name": "farma-finder-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^6.0.3",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/splash-screen": "^6.0.4",
|
||||
"@capacitor/status-bar": "^6.0.3",
|
||||
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.2",
|
||||
"barcode-detector": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -1646,40 +1648,54 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/app": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-6.0.3.tgz",
|
||||
"integrity": "sha512-4gFUCbcVz0N/YYN32OBFerocWXslIv3Nc90gDiRsBkJc0plwK6kIUT6PKa5WtW2kfhteUeCVXQbvArH2fH+0Ug==",
|
||||
"license": "MIT",
|
||||
"node_modules/@capacitor-mlkit/barcode-scanning": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-mlkit/barcode-scanning/-/barcode-scanning-8.1.0.tgz",
|
||||
"integrity": "sha512-lhOYHZINLOCT0i5YSbSMkouik3zh0BncJouumNTgXCT0/533Z4733jAX9zv+nEd++bE8QHeUl2g0NrewreumnQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/capawesome-team/"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/capawesome"
|
||||
}
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/app": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz",
|
||||
"integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-6.2.1.tgz",
|
||||
"integrity": "sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==",
|
||||
"license": "MIT",
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.1.tgz",
|
||||
"integrity": "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/splash-screen": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-6.0.4.tgz",
|
||||
"integrity": "sha512-uJXR+28cdaie7zIIUBvgkWgHim6Gr1itJym9voIMTmrjXkOaPtejwxYJsdQWPJz9zgGnSbXuC1mNNibLgv3OpQ==",
|
||||
"license": "MIT",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz",
|
||||
"integrity": "sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-6.0.3.tgz",
|
||||
"integrity": "sha512-nFlgSmtx6Zwaw0tEvZgQsWHBeOfWWB/AvEoCApopLT4mHkBVoSrwkLvy2PjZs5wxCbsmqvQczr3XCyTwaDZVQg==",
|
||||
"license": "MIT",
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz",
|
||||
"integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^6.0.0"
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
@@ -2875,6 +2891,11 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q=="
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3331,6 +3352,14 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/barcode-detector": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
|
||||
"integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
|
||||
"dependencies": {
|
||||
"zxing-wasm": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
@@ -6805,6 +6834,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||
@@ -7833,6 +7873,32 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
|
||||
"integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "^1.41.5",
|
||||
"type-fest": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/emscripten": ">=1.39.6"
|
||||
}
|
||||
},
|
||||
"node_modules/zxing-wasm/node_modules/type-fest": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
|
||||
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^6.0.3",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/splash-screen": "^6.0.4",
|
||||
"@capacitor/status-bar": "^6.0.3",
|
||||
"@capacitor-mlkit/barcode-scanning": "^8.1.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.2",
|
||||
"barcode-detector": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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