feat(fullstack): implement observability and redesign frontend UI
Build & Push Docker Images / test-backend (push) Failing after 36s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / build-backend (push) Has been skipped
Build & Push Docker Images / build-frontend (push) Has been skipped
Build & Push Docker Images / deploy (push) Successful in 7s

This commit introduces comprehensive observability for both backend and frontend, alongside a major UI/UX overhaul to align with modern design standards (Material 3 inspired) and improve localization.

Backend changes:
- Integrated OpenTelemetry SDK for distributed tracing.
- Added Pino for structured JSON logging with OTel instrumentation for trace/span correlation.
- Configured OTLP exporters to route traces and logs to Grafana Alloy.
- Updated docker-compose to include necessary environment variables for observability.

Frontend changes:
- Integrated Grafana Faro for Real User Monitoring (RUM), capturing Web Vitals, JS errors, and user interactions.
- Redesigned the entire UI using a new color palette and Material 3 design principles.
- Refactored component architecture (App, Home, Search, Scanner, Profile, Alerts views) for better state management and navigation.
- Improved mobile UX with a redesigned Bottom Navigation bar and Top Bar.
- Localized the interface to Spanish (es).
- Updated assets, icons, and PWA configuration (manifest, icons).
- Refactored CSS to use a centralized design token system (CSS variables).

Observability enables better debugging and performance monitoring across the entire stack.
This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 11:48:27 +02:00
parent 9316f6c54f
commit db0935eb16
46 changed files with 5633 additions and 1867 deletions
+2596 -19
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -18,6 +18,16 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.52.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.55.0",
"@opentelemetry/instrumentation-pino": "^0.45.0",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-logs": "^0.55.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/sdk-trace-base": "^1.28.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
@@ -27,6 +37,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"web-push": "^3.6.7" "web-push": "^3.6.7"
+13
View File
@@ -1,3 +1,6 @@
// OpenTelemetry SDK — must be imported first to instrument http, express, pg, etc.
import './src/tracing.js';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
@@ -11,6 +14,8 @@ import pg from 'pg';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import webpush from 'web-push'; import webpush from 'web-push';
import pino from 'pino';
import pinoHttp from 'pino-http';
import { searchMedicines, getMedicineDetails } from './cima-service.js'; import { searchMedicines, getMedicineDetails } from './cima-service.js';
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { fetchPharmaciesExternal } from '../API/index.js'; import { fetchPharmaciesExternal } from '../API/index.js';
@@ -21,6 +26,13 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Structured JSON logger. The Pino OTel instrumentation attaches trace_id /
// span_id to every log line so they can be correlated in Grafana.
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: process.env.OTEL_SERVICE_NAME || 'farmafinder-backend' },
});
// Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so // Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so
// req.ip and X-Forwarded-For are honored. Configurable via TRUST_PROXY: // req.ip and X-Forwarded-For are honored. Configurable via TRUST_PROXY:
// - unset/"1" → trust exactly one hop (typical single reverse proxy) // - unset/"1" → trust exactly one hop (typical single reverse proxy)
@@ -39,6 +51,7 @@ app.use(cors({
credentials: true credentials: true
})); }));
app.use(express.json()); app.use(express.json());
app.use(pinoHttp({ logger }));
const PG_URL = process.env.PG_URL; const PG_URL = process.env.PG_URL;
let pgPool = null; let pgPool = null;
+49
View File
@@ -0,0 +1,49 @@
// OpenTelemetry Node SDK bootstrap for FarmaFinder backend.
// Started as a side-effect import from server.js (ESM).
//
// Env vars (set by docker-compose):
// OTEL_SERVICE_NAME — default: farmafinder-backend
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
//
// Exports traces to the shared Grafana Alloy collector, where they are
// routed to Tempo.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmafinder-backend';
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
}),
traceExporter: new OTLPTraceExporter({ url: otlpEndpoint }),
instrumentations: [
getNodeAutoInstrumentations({
// Disable fs by default — it is noisy and rarely useful.
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
}),
new PinoInstrumentation(),
],
});
sdk.start();
const shutdown = async () => {
try {
await sdk.shutdown();
} catch (err) {
// eslint-disable-next-line no-console
console.error('OpenTelemetry shutdown failed', err);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+13 -1
View File
@@ -32,6 +32,12 @@ services:
DATABASE_PATH: /app/data/database.sqlite DATABASE_PATH: /app/data/database.sqlite
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-} FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
OTEL_SERVICE_NAME: farmafinder-backend
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
depends_on: depends_on:
@@ -40,7 +46,13 @@ services:
frontend: frontend:
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
build: ./frontend build:
context: ./frontend
args:
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:80" - "3000:80"
+18
View File
@@ -0,0 +1,18 @@
# Grafana Faro (browser RUM) — Vite build-time env vars.
# These are inlined into the production bundle at build time.
# Copy to .env and adjust as needed for local dev.
# OTLP HTTP endpoint of the Grafana Alloy collector.
# In Docker on the same host as Alloy, use http://localhost:4318 (the
# container's perspective of the host). For external deploys, use the
# Alloy's public URL, e.g. https://alloy.example.com.
VITE_FARO_ENDPOINT=http://localhost:4318
# App name shown in Grafana Explore / Faro data source.
VITE_FARO_APP_NAME=farmafinder-frontend
# Environment label (production | staging | development).
VITE_FARO_ENV=production
# App version (set by CI from the package version or git tag).
VITE_FARO_APP_VERSION=1.0.0
+8
View File
@@ -1,4 +1,12 @@
FROM node:20-alpine AS build FROM node:20-alpine AS build
ARG VITE_FARO_ENDPOINT
ARG VITE_FARO_APP_NAME=farmafinder-frontend
ARG VITE_FARO_ENV=production
ARG VITE_FARO_APP_VERSION=1.0.0
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
ENV VITE_FARO_APP_NAME=$VITE_FARO_APP_NAME
ENV VITE_FARO_ENV=$VITE_FARO_ENV
ENV VITE_FARO_APP_VERSION=$VITE_FARO_APP_VERSION
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
+7 -4
View File
@@ -1,13 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f766e" /> <meta name="theme-color" content="#00450d" />
<title>FarmaFinder | Find Your Medicine</title> <title>FarmaFinder</title>
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+700 -10
View File
@@ -13,6 +13,10 @@
"@capacitor/core": "^8.4.1", "@capacitor/core": "^8.4.1",
"@capacitor/splash-screen": "^8.0.1", "@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2", "@capacitor/status-bar": "^8.0.2",
"@grafana/faro-react": "^1.7.0",
"@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^1.7.0",
"@grafana/faro-web-tracing": "^1.7.0",
"barcode-detector": "^3.2.0", "barcode-detector": "^3.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
@@ -2205,6 +2209,75 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@grafana/faro-core": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-1.19.0.tgz",
"integrity": "sha512-Juo5G/aviSh3XqSGGr6D61noAC8sb+oCawBsv545ILEeOQdINyzRaoQdRpnXEY3DLS9LYtL0PYhvHZiP3rlscQ==",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/otlp-transformer": "^0.202.0"
}
},
"node_modules/@grafana/faro-react": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-react/-/faro-react-1.19.0.tgz",
"integrity": "sha512-3rrqxgDefvlaZ8753Wen8AxUmCKxOLYPspJMw60u4WvRSj/ki7XN3RjuRAc2AkuHm342jTs7EU0jwDBXAUWTgg==",
"dependencies": {
"@grafana/faro-web-sdk": "^1.19.0",
"@grafana/faro-web-tracing": "^1.19.0",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-router-dom": "^4.0.0 || ^5.0.0 || ^6.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-router-dom": {
"optional": true
}
}
},
"node_modules/@grafana/faro-transport-otlp-http": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-transport-otlp-http/-/faro-transport-otlp-http-1.19.0.tgz",
"integrity": "sha512-tON0nEvg1LqJGKIFrPzMnuOOuQBpWw83QgLn1SfCGr3G6CJBsfSdUAraTDtNoDAHL0/rCnYjV35hlU2/7e6a5w==",
"dependencies": {
"@opentelemetry/otlp-transformer": "^0.202.0",
"@opentelemetry/semantic-conventions": "^1.28.0"
}
},
"node_modules/@grafana/faro-web-sdk": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-1.19.0.tgz",
"integrity": "sha512-3u74mV2uBWqoF6WBx71p0vtkaS1Z0QbGoZ8tuX5yiYnIybqnhKdGkApFUi7q5se0tMPIeJdMVoRFdLU8f9hfAw==",
"dependencies": {
"@grafana/faro-core": "^1.19.0",
"ua-parser-js": "^1.0.32",
"web-vitals": "^4.0.1"
}
},
"node_modules/@grafana/faro-web-tracing": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-web-tracing/-/faro-web-tracing-1.19.0.tgz",
"integrity": "sha512-p37kQ/n8vU53ISCMvUn42upbGKqiRZI/QspHGx6kI7nDD5CJIl/2Byzg9vGDuw9BgMEOCIS8Oz6b5+U05kfKdQ==",
"dependencies": {
"@grafana/faro-web-sdk": "^1.19.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
"@opentelemetry/instrumentation": "^0.202.0",
"@opentelemetry/instrumentation-fetch": "^0.202.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.202.0",
"@opentelemetry/otlp-transformer": "^0.202.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.32.0"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
@@ -2289,6 +2362,508 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.202.0.tgz",
"integrity": "sha512-fTBjMqKCfotFWfLzaKyhjLvyEyq5vDKTTFfBmx21btv3gvy8Lq6N5Dh2OzqeuN4DjtpSvNT1uNVfg08eD2Rfxw==",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.202.0.tgz",
"integrity": "sha512-/hKE8DaFCJuaQqE1IxpgkcjOolUIwgi3TgHElPVKGdGRBSmJMTmN/cr6vWa55pCJIXPyhKvcMrbrya7DZ3VmzA==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/otlp-exporter-base": "0.202.0",
"@opentelemetry/otlp-transformer": "0.202.0",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-trace-base": "2.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.202.0.tgz",
"integrity": "sha512-Uz3BxZWPgDwgHM2+vCKEQRh0R8WKrd/q6Tus1vThRClhlPO39Dyz7mDrOr6KuqGXAlBQ1e5Tnymzri4RMZNaWA==",
"dependencies": {
"@opentelemetry/api-logs": "0.202.0",
"import-in-the-middle": "^1.8.1",
"require-in-the-middle": "^7.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-fetch": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.202.0.tgz",
"integrity": "sha512-RlLgOJAKs9cQIRXPoLnS6YG8CeQt1gR+WJpzthQlqt4hdgNmfnyB7zZrg1yddECF0K2lPGBqF4s+IqjA4dy3JQ==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/instrumentation": "0.202.0",
"@opentelemetry/sdk-trace-web": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/sdk-trace-web": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.0.1.tgz",
"integrity": "sha512-R4/i0rISvAujG4Zwk3s6ySyrWG+Db3SerZVM4jZ2lEzjrNylF7nRAy1hVvWe8gTbwIxX+6w6ZvZwdtl2C7UQHQ==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/sdk-trace-base": "2.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation-xml-http-request": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.202.0.tgz",
"integrity": "sha512-N0wZyWpdUviscnhNKbRr2mEEfTtVi0ki7v6Lr9ZsK5mUtg12e9Mf/LsT2Msl7tvyGDGGi8Tmm8ssFrfLOADqDw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/instrumentation": "0.202.0",
"@opentelemetry/sdk-trace-web": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/sdk-trace-web": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.0.1.tgz",
"integrity": "sha512-R4/i0rISvAujG4Zwk3s6ySyrWG+Db3SerZVM4jZ2lEzjrNylF7nRAy1hVvWe8gTbwIxX+6w6ZvZwdtl2C7UQHQ==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/sdk-trace-base": "2.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.202.0.tgz",
"integrity": "sha512-nMEOzel+pUFYuBJg2znGmHJWbmvMbdX5/RhoKNKowguMbURhz0fwik5tUKplLcUtl8wKPL1y9zPnPxeBn65N0Q==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/otlp-transformer": "0.202.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.202.0.tgz",
"integrity": "sha512-5XO77QFzs9WkexvJQL9ksxL8oVFb/dfi9NWQSq7Sv0Efr9x3N+nb1iklP1TeVgxqJ7m1xWiC/Uv3wupiQGevMw==",
"dependencies": {
"@opentelemetry/api-logs": "0.202.0",
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-logs": "0.202.0",
"@opentelemetry/sdk-metrics": "2.0.1",
"@opentelemetry/sdk-trace-base": "2.0.1",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.202.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.202.0.tgz",
"integrity": "sha512-pv8QiQLQzk4X909YKm0lnW4hpuQg4zHwJ4XBd5bZiXcd9urvrJNoNVKnxGHPiDVX/GiLFvr5DMYsDBQbZCypRQ==",
"dependencies": {
"@opentelemetry/api-logs": "0.202.0",
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"dependencies": {
"@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-web": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.8.0.tgz",
"integrity": "sha512-P3ZM8BGJ5mwjtyfAxRyxsCyWHvaj+xahdhLoS3YiPsEyTHcWTVzx2691C8SrGkpvro3tNFCsWuNNrvM+spKODg==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="
},
"node_modules/@react-leaflet/core": { "node_modules/@react-leaflet/core": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
@@ -2904,6 +3479,14 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3117,7 +3700,6 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -3126,6 +3708,14 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/acorn-import-attributes": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
"peerDependencies": {
"acorn": "^8"
}
},
"node_modules/acorn-walk": { "node_modules/acorn-walk": {
"version": "8.3.5", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
@@ -3555,6 +4145,11 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/cjs-module-lexer": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3766,7 +4361,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -4040,7 +4634,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4404,7 +4997,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -4695,7 +5287,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -4704,6 +5295,19 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -4775,6 +5379,17 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/import-in-the-middle": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.15.0.tgz",
"integrity": "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==",
"dependencies": {
"acorn": "^8.14.0",
"acorn-import-attributes": "^1.9.5",
"cjs-module-lexer": "^1.2.2",
"module-details-from-path": "^1.0.3"
}
},
"node_modules/indent-string": { "node_modules/indent-string": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@@ -4905,7 +5520,6 @@
"version": "2.16.2", "version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hasown": "^2.0.3" "hasown": "^2.0.3"
@@ -5430,6 +6044,11 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5591,11 +6210,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@@ -5805,7 +6428,6 @@
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
@@ -5971,6 +6593,28 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/protobufjs": {
"version": "7.6.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
"hasInstallScript": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/psl": { "node_modules/psl": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -6182,6 +6826,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-in-the-middle": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz",
"integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3",
"resolve": "^1.22.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/requires-port": { "node_modules/requires-port": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -6193,7 +6850,6 @@
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -6819,7 +7475,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -7087,6 +7742,31 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.3", "version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
@@ -7113,6 +7793,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@@ -7426,6 +8111,11 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+4
View File
@@ -14,6 +14,10 @@
"@capacitor/core": "^8.4.1", "@capacitor/core": "^8.4.1",
"@capacitor/splash-screen": "^8.0.1", "@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2", "@capacitor/status-bar": "^8.0.2",
"@grafana/faro-react": "^1.7.0",
"@grafana/faro-transport-otlp-http": "^1.7.0",
"@grafana/faro-web-sdk": "^1.7.0",
"@grafana/faro-web-tracing": "^1.7.0",
"barcode-detector": "^3.2.0", "barcode-detector": "^3.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18.2.0", "react": "^18.2.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

+11 -401
View File
@@ -1,419 +1,29 @@
.app { .app {
height: 100dvh; height: 100dvh;
overflow-y: auto; max-width: 48rem;
overscroll-behavior: contain;
padding: 3rem 1.5rem;
max-width: 1000px;
margin: 0 auto; margin: 0 auto;
}
.view-switcher {
display: flex;
justify-content: center;
background: var(--surface);
padding: 5px;
border-radius: 999px;
border: 1px solid var(--border);
box-shadow: var(--glass-shadow);
width: fit-content;
margin: 0 auto 3rem auto;
}
.view-switcher button {
background: transparent;
border: none;
color: var(--text-muted);
padding: 0.8rem 2rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
transition: color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.view-switcher button:hover {
color: var(--text-main);
}
.view-switcher button.active {
background: var(--surface-muted);
color: var(--primary);
box-shadow: inset 0 0 0 1px var(--border);
}
.auth-btn {
background: transparent;
border: none;
color: var(--primary);
padding: 0.55rem 1rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
transition: background 0.2s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.auth-btn:hover {
background: var(--surface-muted);
}
.app-header {
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 {
font-size: 3.5rem;
font-weight: 800;
margin-bottom: 0.75rem;
color: var(--text-main);
letter-spacing: -0.03em;
line-height: 1.1;
}
.app-header h1::after {
content: "";
display: block;
width: 3rem;
height: 4px;
margin: 1rem auto 0;
background: var(--primary);
border-radius: 2px;
}
.app-header p {
font-size: 1.2rem;
color: var(--text-muted);
font-weight: 400;
max-width: 28rem;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
}
.app-main {
width: 100%;
}
.glass-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.loading {
text-align: center;
color: var(--text-muted);
font-size: 1.05rem;
font-weight: 500;
margin: 3rem 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; overflow: hidden;
align-items: center;
} }
.loading::after { .app-content {
content: ""; flex: 1;
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
.selected-medicine-section {
margin-top: 2rem;
max-height: 60vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0.5rem 0.5rem 0.5rem 0;
animation: fadeInUp 0.6s ease-out;
} }
.medicine-info { @media (max-width: 768px) {
background: var(--surface); .app-content {
border-radius: var(--radius); padding-bottom: calc(6rem + env(safe-area-inset-bottom));
padding: 2.5rem; }
margin-bottom: 2rem;
box-shadow: var(--glass-shadow);
border: 1px solid var(--border);
} }
.medicine-info h2 { @keyframes fadeInUp {
color: var(--text-main); from { opacity: 0; transform: translateY(14px); }
margin-bottom: 1.2rem; to { opacity: 1; transform: translateY(0); }
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.medicine-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--surface-muted);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.medicine-details span {
font-size: 1rem;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.medicine-details strong {
color: var(--text-main);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.back-button {
background: var(--surface-muted);
color: var(--text-main);
border: 1px solid var(--border);
padding: 0.8rem 1.8rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.back-button:hover {
background: var(--surface-card);
border-color: var(--border-strong);
transform: translateX(-3px);
}
.pharmacy-controls {
display: flex;
align-items: center;
gap: 0.85rem;
margin: 1.5rem 0 0.5rem;
flex-wrap: wrap;
}
.sort-distance-button {
background: var(--surface);
color: var(--text-main);
border: 1px solid var(--border);
padding: 0.65rem 1.2rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.sort-distance-button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.sort-distance-button.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.sort-distance-button:disabled {
opacity: 0.6;
cursor: progress;
}
.location-error {
color: #b91c1c;
font-size: 0.85rem;
}
.location-source {
color: var(--primary);
font-size: 0.85rem;
font-weight: 500;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.app {
padding: calc(3.55rem + env(safe-area-inset-top)) 1rem calc(7rem + env(safe-area-inset-bottom));
}
.app-header h1 {
font-size: 2.35rem;
}
.app-header p {
font-size: 1rem;
}
.medicine-info {
padding: 1.5rem;
}
.medicine-info h2 {
font-size: 1.65rem;
}
.view-switcher {
display: none; /* ponytail: replaced by BottomNav on mobile */
}
.view-switcher button {
flex: 1;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
padding: 0.4rem 0.3rem 0.45rem;
border-radius: 14px;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-muted);
background: transparent;
position: relative;
overflow: visible;
}
.view-switcher button:hover {
background: transparent;
}
.view-switcher button .nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65rem;
height: 1.65rem;
border-radius: 999px;
font-size: 1.05rem;
line-height: 1;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.2s ease,
box-shadow 0.2s ease,
width 0.2s ease,
height 0.2s ease;
}
.view-switcher button .nav-label {
font-size: 0.7rem;
line-height: 1;
max-width: none;
color: inherit;
transition: color 0.2s ease, font-weight 0.2s ease;
}
.view-switcher button.active {
background: transparent;
box-shadow: none;
color: var(--text-main);
}
.view-switcher button.active .nav-icon {
width: 2.7rem;
height: 2.7rem;
background: var(--surface);
color: var(--primary);
font-size: 1.3rem;
border: 1px solid var(--border);
box-shadow:
0 0 0 5px var(--surface),
0 10px 22px rgba(15, 118, 110, 0.22),
0 4px 8px rgba(28, 25, 23, 0.08);
transform: translateY(-1.1rem);
margin-bottom: -0.6rem;
}
.view-switcher button.active .nav-label {
font-weight: 700;
color: var(--text-main);
}
.auth-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
padding: 0.4rem 0.3rem 0.45rem;
border-radius: 14px;
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 500;
background: transparent;
}
.auth-btn:hover {
background: transparent;
}
.auth-btn .nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65rem;
height: 1.65rem;
font-size: 1.05rem;
line-height: 1;
}
.auth-btn .nav-label {
font-size: 0.7rem;
line-height: 1;
}
}
+86 -88
View File
@@ -1,16 +1,18 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './App.css'; import './App.css';
import PublicView from './views/PublicView'; import HomeView from './views/HomeView';
import AdminView from './views/AdminView'; import SearchView from './views/SearchView';
import ScannerView from './views/ScannerView';
import AlertsView from './views/AlertsView';
import ProfileView from './views/ProfileView'; import ProfileView from './views/ProfileView';
import AdminView from './views/AdminView';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import SavedNotifications from './components/SavedNotifications'; import SavedNotifications from './components/SavedNotifications';
import TopBar from './components/TopBar'; import TopBar from './components/TopBar';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
function App() { function App() {
const [view, setView] = useState('public'); const [screen, setScreen] = useState('home');
const [publicScreen, setPublicScreen] = useState('home'); // 'home' | 'scan' | 'search'
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false); const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
@@ -32,114 +34,110 @@ function App() {
async function handleLogout() { async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
setCurrentUser(null); setCurrentUser(null);
setView('public'); setScreen('home');
setPublicScreen('home');
} }
function handleProfileSaved(updated) { function handleProfileSaved(updated) {
setCurrentUser(prev => ({ ...prev, ...updated })); setCurrentUser(prev => ({ ...prev, ...updated }));
} }
const isAdmin = Boolean(currentUser?.is_admin); const isLoggedIn = Boolean(currentUser);
let activeView;
if (view === 'profile' && currentUser) {
activeView = (
<ProfileView
currentUser={currentUser}
onProfileSaved={handleProfileSaved}
onShowSaved={() => setShowSaved(true)}
onLogout={handleLogout}
/>
);
} else if (view === 'admin' && isAdmin) {
activeView = <AdminView />;
} else {
activeView = (
<PublicView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
screen={publicScreen}
onScreenChange={setPublicScreen}
/>
);
}
// ponytail: BottomNav active tab derived from view + publicScreen. Hidden on desktop via CSS.
function navActiveTab() {
if (view === 'profile') return 'profile';
if (view === 'admin') return 'profile'; // no admin tab in bottom nav; profile still highlighted
if (publicScreen === 'scan') return 'scan';
if (publicScreen === 'search') return 'search';
return 'search'; // home → search is the default landing active
}
function handleNavChange(tab) { function handleNavChange(tab) {
if (tab === 'search') { setView('public'); setPublicScreen('search'); return; } if (tab === 'home') { setScreen('home'); return; }
if (tab === 'scan') { setView('public'); setPublicScreen('scan'); return; } if (tab === 'search') { setScreen('search'); return; }
if (tab === 'saved') { if (tab === 'scan') { setScreen('scan'); return; }
if (currentUser) { setShowSaved(true); setView('public'); setPublicScreen('home'); } if (tab === 'alerts') {
if (currentUser) setScreen('alerts');
else setShowLogin(true); else setShowLogin(true);
return; return;
} }
if (tab === 'profile') { if (tab === 'profile') {
if (currentUser) setView('profile'); if (currentUser) setScreen('profile');
else setShowLogin(true); else setShowLogin(true);
return; return;
} }
} }
function handleBack() {
setScreen('home');
}
let activeView;
let topBarTitle = 'FarmaFinder';
let showBack = false;
switch (screen) {
case 'profile':
activeView = (
<ProfileView
currentUser={currentUser}
onProfileSaved={handleProfileSaved}
onShowSaved={() => setShowSaved(true)}
onLogout={handleLogout}
/>
);
showBack = true;
break;
case 'alerts':
activeView = <AlertsView />;
showBack = true;
break;
case 'search':
activeView = (
<SearchView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
/>
);
showBack = true;
break;
case 'scan':
activeView = (
<ScannerView
onClose={() => setScreen('home')}
onSelectMedicine={(name) => {
setScreen('home');
}}
/>
);
showBack = false;
break;
case 'admin':
activeView = <AdminView />;
topBarTitle = 'Admin';
showBack = true;
break;
default:
activeView = (
<HomeView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
onScanClick={() => setScreen('scan')}
onSearchClick={() => setScreen('search')}
/>
);
showBack = false;
break;
}
return ( return (
<div className="app"> <div className="app">
<TopBar <TopBar
currentUser={currentUser} title={topBarTitle}
isAdmin={isAdmin} showBack={showBack}
authChecked={authChecked} onBack={handleBack}
onShowSaved={() => setShowSaved(true)}
onLoginRequest={() => setShowLogin(true)}
onAdminClick={() => setView(view === 'admin' ? 'public' : 'admin')}
/> />
<nav className="view-switcher" aria-label="Primary"> <div className="app-content">
<button {activeView}
className={view === 'public' ? 'active' : ''} </div>
onClick={() => setView('public')}
>
<span className="nav-icon">🔍</span>
<span className="nav-label">Search</span>
</button>
{isAdmin && (
<button
className={view === 'admin' ? 'active' : ''}
onClick={() => setView('admin')}
>
<span className="nav-icon"></span>
<span className="nav-label">Admin</span>
</button>
)}
{authChecked && currentUser && (
<button
className={view === 'profile' ? 'active' : ''}
onClick={() => setView('profile')}
>
<span className="nav-icon">👤</span>
<span className="nav-label">Profile</span>
</button>
)}
{authChecked && !currentUser && (
<button className="auth-btn" onClick={() => setShowLogin(true)}>
<span className="nav-icon"></span>
<span className="nav-label">Login</span>
</button>
)}
</nav>
{activeView}
<BottomNav <BottomNav
activeTab={navActiveTab()} activeTab={screen}
onChange={handleNavChange} onChange={handleNavChange}
isLoggedIn={Boolean(currentUser)} isLoggedIn={isLoggedIn}
badgeCount={currentUser ? 2 : 0}
/> />
{showLogin && ( {showLogin && (
@@ -156,4 +154,4 @@ function App() {
); );
} }
export default App; export default App;
+22 -29
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react' import { render, screen, fireEvent, act } from '@testing-library/react'
import PublicView from './views/PublicView.jsx' import HomeView from './views/HomeView.jsx'
import SearchView from './views/SearchView.jsx'
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers() vi.useFakeTimers()
@@ -11,39 +12,34 @@ afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
}) })
// Helper: navigate from HomeView to the search screen
async function navigateToSearch() {
fireEvent.click(screen.getByRole('button', { name: /manual search/i }))
}
describe('HomeView', () => { describe('HomeView', () => {
it('renders two action buttons on the home screen', () => { it('renders two action buttons on the home screen', () => {
render(<PublicView />) const onSearch = vi.fn()
expect(screen.getByRole('button', { name: /scan tsi/i })).toBeInTheDocument() const onScan = vi.fn()
expect(screen.getByRole('button', { name: /manual search/i })).toBeInTheDocument() render(<HomeView onSearchClick={onSearch} onScanClick={onScan} />)
expect(screen.getByRole('button', { name: /buscar medicamento/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /escanear tsi/i })).toBeInTheDocument()
}) })
it('navigates to search screen when Manual Search is clicked', async () => { it('calls onSearchClick when Buscar Medicamento is clicked', async () => {
render(<PublicView />) const onSearch = vi.fn()
await navigateToSearch() render(<HomeView onSearchClick={onSearch} onScanClick={vi.fn()} />)
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /buscar medicamento/i }))
expect(onSearch).toHaveBeenCalledTimes(1)
}) })
}) })
describe('PublicView (search screen)', () => { describe('SearchView', () => {
it('renders search bar on load with no results', async () => { it('renders search bar with placeholder', () => {
render(<PublicView />) render(<SearchView />)
await navigateToSearch() expect(screen.getByPlaceholderText(/escriba el nombre del medicamento/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
}) })
it('does not fetch for queries shorter than 2 chars', async () => { it('does not fetch for queries shorter than 2 chars', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch') const fetchMock = vi.spyOn(globalThis, 'fetch')
render(<PublicView />) render(<SearchView />)
await navigateToSearch()
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), { fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
target: { value: 'a' }, target: { value: 'a' },
}) })
@@ -62,10 +58,9 @@ describe('PublicView (search screen)', () => {
json: async () => medicines, json: async () => medicines,
}) })
render(<PublicView />) render(<SearchView />)
await navigateToSearch()
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), { fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
target: { value: 'ibu' }, target: { value: 'ibu' },
}) })
@@ -84,9 +79,8 @@ describe('PublicView (search screen)', () => {
json: async () => medicines, json: async () => medicines,
}) })
render(<PublicView />) render(<SearchView />)
await navigateToSearch() const input = screen.getByPlaceholderText(/escriba el nombre del medicamento/i)
const input = screen.getByPlaceholderText(/search for a medicine/i)
fireEvent.change(input, { target: { value: 'ibu' } }) fireEvent.change(input, { target: { value: 'ibu' } })
await act(async () => { await vi.runAllTimersAsync() }) await act(async () => { await vi.runAllTimersAsync() })
@@ -98,4 +92,3 @@ describe('PublicView (search screen)', () => {
expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument() expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument()
}) })
}) })
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+72 -38
View File
@@ -1,23 +1,22 @@
/* BottomNav — mobile-only fixed bottom tab bar. Desktop hides via display:none above 768px. */
.bottom-nav { .bottom-nav {
display: none; display: none;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.bottom-nav { .bottom-nav {
display: grid; display: flex;
grid-template-columns: repeat(4, 1fr); justify-content: space-around;
align-items: flex-end;
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 50; z-index: 50;
padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom)); padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
background: var(--glass-bg); background: var(--surface-container-lowest);
-webkit-backdrop-filter: saturate(180%) blur(20px); box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
backdrop-filter: saturate(180%) blur(20px); border-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-top: 1px solid var(--border); height: 5.5rem;
} }
.bottom-nav-item { .bottom-nav-item {
@@ -25,19 +24,21 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0.25rem; gap: 0.15rem;
padding: 0.25rem 0.25rem 0.1rem; padding: 0.25rem 0.5rem;
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--text-muted); color: var(--on-surface-variant);
font: inherit; font: inherit;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: color 0.15s; transition: color 0.15s;
position: relative;
min-width: 3.5rem;
} }
.bottom-nav-item.disabled { .bottom-nav-item.disabled {
opacity: 0.5; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -45,45 +46,78 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2.5rem; width: 2.75rem;
height: 2.5rem; height: 2.75rem;
border-radius: 999px; border-radius: var(--radius-full);
color: inherit; color: inherit;
transition: background 0.2s, color 0.2s, transform 0.2s; position: relative;
} }
/* Elevated center tab (Scan) — sits above the bar like iOS center FAB */ .nav-fab {
.bottom-nav-item:nth-child(2) { display: inline-flex;
margin-top: -1.1rem; align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: var(--radius-full);
background: var(--tertiary-container);
color: var(--on-tertiary);
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
margin-top: -1.5rem;
transition: transform 0.15s;
} }
.nav-icon-wrap--elevated { .nav-fab:active {
background: var(--primary); transform: scale(0.92);
color: #fff;
width: 3.25rem;
height: 3.25rem;
box-shadow:
0 0 0 4px var(--glass-bg),
0 8px 18px rgba(15, 118, 110, 0.4),
0 2px 6px rgba(15, 118, 110, 0.25);
} }
.bottom-nav-item.active .nav-label { .nav-badge {
position: absolute;
top: 0;
right: -2px;
width: 1.125rem;
height: 1.125rem;
background: var(--error);
color: var(--on-error);
font-size: 0.625rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
border: 2px solid var(--surface-container-lowest);
line-height: 1;
}
.nav-label {
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
color: var(--on-surface-variant);
}
.nav-label--active {
color: var(--primary); color: var(--primary);
font-weight: 700; font-weight: 700;
} }
.bottom-nav-item.active .nav-icon-wrap:not(.nav-icon-wrap--elevated) { .nav-indicator {
width: 0.375rem;
height: 0.375rem;
background: var(--primary);
border-radius: var(--radius-full);
margin-top: 0.125rem;
}
.bottom-nav-item.active .nav-icon-wrap {
color: var(--primary); color: var(--primary);
} }
.bottom-nav-item:active .nav-icon-wrap--elevated { .bottom-nav-item.active .nav-icon-wrap svg {
transform: scale(0.94); stroke-width: 2.5;
} }
.nav-label { .nav-elevated.active .nav-label {
font-size: 0.68rem; color: var(--tertiary);
line-height: 1;
letter-spacing: 0.01em;
} }
} }
+29 -15
View File
@@ -1,19 +1,18 @@
import { IconSearch, IconScan, IconBell, IconUser } from './icons'; import { IconHome, IconSearch, IconScan, IconBell, IconUser } from './icons';
import './BottomNav.css'; import './BottomNav.css';
// ponytail: 4 tabs. Scan is the 2nd item (center) and gets the elevated teal pill. function BottomNav({ activeTab, onChange, isLoggedIn, badgeCount }) {
// ponytail: when logged out, "saved" slot morphs into Login. Profile is disabled until login.
function BottomNav({ activeTab, onChange, isLoggedIn }) {
const tabs = [ const tabs = [
{ id: 'search', label: 'Search', Icon: IconSearch }, { id: 'home', label: 'Inicio', Icon: IconHome },
{ id: 'scan', label: 'Scan', Icon: IconScan, elevated: true }, { id: 'search', label: 'Buscar', Icon: IconSearch },
{ id: 'saved', label: 'Saved', Icon: IconBell, requiresAuth: true }, { id: 'scan', label: 'Escanear', Icon: IconScan, elevated: true },
{ id: 'profile', label: 'Profile', Icon: IconUser, requiresAuth: true }, { id: 'alerts', label: 'Avisos', Icon: IconBell, badge: badgeCount > 0, badgeCount },
{ id: 'profile', label: 'Usuario', Icon: IconUser },
]; ];
return ( return (
<nav className="bottom-nav" aria-label="Primary"> <nav className="bottom-nav" aria-label="Navegación principal">
{tabs.map(({ id, label, Icon, elevated, requiresAuth }) => { {tabs.map(({ id, label, Icon, elevated, badge, badgeCount: count, requiresAuth }) => {
const disabled = requiresAuth && !isLoggedIn; const disabled = requiresAuth && !isLoggedIn;
const isActive = activeTab === id; const isActive = activeTab === id;
return ( return (
@@ -24,16 +23,31 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
'bottom-nav-item', 'bottom-nav-item',
isActive ? 'active' : '', isActive ? 'active' : '',
disabled ? 'disabled' : '', disabled ? 'disabled' : '',
elevated ? 'nav-elevated' : '',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={() => onChange(id)} onClick={() => {
if (!disabled) onChange(id);
}}
disabled={disabled} disabled={disabled}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
aria-label={label} aria-label={label}
> >
<span className={`nav-icon-wrap${elevated ? ' nav-icon-wrap--elevated' : ''}`}> {elevated ? (
<Icon size={elevated ? 24 : 22} /> <div className="nav-fab">
<Icon size={28} />
</div>
) : (
<span className="nav-icon-wrap">
<Icon size={26} />
{badge && (
<span className="nav-badge">{count}</span>
)}
</span>
)}
<span className={`nav-label${isActive && !elevated ? ' nav-label--active' : ''}`}>
{label}
</span> </span>
<span className="nav-label">{label}</span> {isActive && !elevated && <span className="nav-indicator" />}
</button> </button>
); );
})} })}
@@ -41,4 +55,4 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
); );
} }
export default BottomNav; export default BottomNav;
+38 -57
View File
@@ -1,34 +1,31 @@
.medicine-results { .medicine-results {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem; gap: 1rem;
margin-top: 1rem; margin-top: 0.5rem;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0.5rem 0.5rem 0.5rem 0; animation: fadeInUp 0.5s ease-out;
animation: fadeInUp 0.8s ease-out 0.4s backwards;
} }
.medicine-card { .medicine-card {
background: var(--surface); background: var(--surface-container-lowest);
border-radius: var(--radius); border-radius: var(--radius-md);
padding: 1.65rem; padding: 1.25rem;
cursor: pointer; cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; transition: transform 0.15s, box-shadow 0.15s;
border: 1px solid var(--border); border: 1px solid var(--outline-variant);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
box-shadow: 0 1px 3px rgba(28, 25, 23, 0.04); box-shadow: var(--shadow-soft);
} }
@media (hover: hover) { .medicine-card:hover {
.medicine-card:hover { transform: translateY(-2px);
transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08); border-color: var(--primary);
border-color: var(--primary);
}
} }
.medicine-card-header { .medicine-card-header {
@@ -40,19 +37,18 @@
} }
.medicine-card h3 { .medicine-card h3 {
color: var(--text-main); color: var(--on-surface);
font-size: 1.2rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
transition: color 0.2s;
flex: 1; flex: 1;
margin: 0; margin: 0;
} }
.notify-bell { .notify-bell {
background: var(--surface-muted, #f5f5f4); background: var(--surface-container-low);
border: 1px solid var(--border, #e7e5e4); border: 1px solid var(--outline-variant);
border-radius: 999px; border-radius: var(--radius-full);
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
cursor: pointer; cursor: pointer;
@@ -69,11 +65,9 @@
transition: background 0.15s, border-color 0.15s, transform 0.15s; transition: background 0.15s, border-color 0.15s, transform 0.15s;
} }
@media (hover: hover) { .notify-bell:hover:not(:disabled) {
.notify-bell:hover:not(:disabled) { border-color: var(--primary);
border-color: var(--primary, #2563eb); transform: scale(1.05);
transform: scale(1.05);
}
} }
.notify-bell:active:not(:disabled) { .notify-bell:active:not(:disabled) {
@@ -86,56 +80,39 @@
} }
.notify-bell--on { .notify-bell--on {
background: var(--primary, #2563eb); background: var(--primary);
border-color: var(--primary, #2563eb); border-color: var(--primary);
} }
.notify-bell--locked { .notify-bell--locked {
opacity: 0.55; opacity: 0.55;
} }
@media (hover: hover) {
.notify-bell--locked:hover:not(:disabled) {
border-color: var(--text-muted, #78716c);
transform: scale(1.05);
}
}
.notify-error { .notify-error {
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.78rem; font-size: 0.78rem;
color: #b91c1c; color: var(--error);
}
@media (hover: hover) {
.medicine-card:hover h3 {
color: var(--primary);
}
.medicine-card:hover .view-pharmacies::after {
transform: translateX(4px);
}
} }
.medicine-card-body { .medicine-card-body {
margin-bottom: 1.35rem; margin-bottom: 1.15rem;
} }
.medicine-card-body p { .medicine-card-body p {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--on-surface-variant);
line-height: 1.55; line-height: 1.55;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.medicine-card-body strong { .medicine-card-body strong {
color: var(--text-main); color: var(--on-surface);
font-weight: 600; font-weight: 600;
} }
.medicine-card-footer { .medicine-card-footer {
padding-top: 1.15rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--outline-variant);
} }
.view-pharmacies { .view-pharmacies {
@@ -152,11 +129,15 @@
transition: transform 0.2s; transition: transform 0.2s;
} }
.medicine-card:hover .view-pharmacies::after {
transform: translateX(4px);
}
.no-results { .no-results {
text-align: center; text-align: center;
padding: 2.75rem 1.5rem; padding: 2.5rem 1.5rem;
background: var(--surface-muted); background: var(--surface-container-low);
border-radius: var(--radius); border-radius: var(--radius-md);
color: var(--text-muted); color: var(--on-surface-variant);
border: 1px dashed var(--border-strong); border: 1px dashed var(--outline-variant);
} }
+41 -108
View File
@@ -3,37 +3,34 @@
} }
.pharmacy-list-title { .pharmacy-list-title {
font-size: 1.4rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
margin-bottom: 1.35rem; margin-bottom: 1rem;
color: var(--text-main); color: var(--on-surface);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.5rem;
letter-spacing: -0.02em;
} }
.pharmacy-grid { .pharmacy-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem; gap: 1rem;
} }
.pharmacy-card { .pharmacy-card {
background: var(--surface); background: var(--surface-container-lowest);
border-radius: var(--radius); border-radius: var(--radius-md);
padding: 1.65rem; padding: 1.25rem;
border: 1px solid var(--border); border: 1px solid var(--outline-variant);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; transition: transform 0.15s, box-shadow 0.15s;
box-shadow: 0 1px 3px rgba(28, 25, 23, 0.04); box-shadow: var(--shadow-soft);
} }
@media (hover: hover) { .pharmacy-card:hover {
.pharmacy-card:hover { transform: translateY(-2px);
transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08); border-color: var(--primary);
border-color: var(--primary);
}
} }
.pharmacy-header { .pharmacy-header {
@@ -41,15 +38,14 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 0.75rem;
} }
.pharmacy-header h4 { .pharmacy-header h4 {
color: var(--text-main); color: var(--on-surface);
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.05rem;
transition: color 0.2s;
flex: 1; flex: 1;
} }
@@ -60,80 +56,18 @@
flex-shrink: 0; flex-shrink: 0;
} }
.notify-bell {
background: var(--surface-muted, #f5f5f4);
border: 1px solid var(--border, #e7e5e4);
border-radius: 999px;
width: 2.75rem;
height: 2.75rem;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 1;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
}
@media (hover: hover) {
.notify-bell:hover:not(:disabled) {
border-color: var(--primary, #2563eb);
transform: scale(1.05);
}
}
.notify-bell:active:not(:disabled) {
transform: scale(0.95);
}
.notify-bell:disabled {
opacity: 0.55;
cursor: progress;
}
.notify-bell--on {
background: var(--primary, #2563eb);
border-color: var(--primary, #2563eb);
}
.notify-bell--locked {
opacity: 0.55;
}
@media (hover: hover) {
.notify-bell--locked:hover:not(:disabled) {
border-color: var(--text-muted, #78716c);
transform: scale(1.05);
}
}
.notify-error {
margin-top: 0.5rem;
font-size: 0.78rem;
color: #b91c1c;
}
.pharmacy-distance { .pharmacy-distance {
flex-shrink: 0; flex-shrink: 0;
background: rgba(37, 99, 235, 0.1); background: rgba(0, 69, 13, 0.08);
color: var(--primary); color: var(--primary);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
padding: 0.3rem 0.7rem; padding: 0.3rem 0.7rem;
border-radius: 999px; border-radius: var(--radius-full);
letter-spacing: 0.02em;
white-space: nowrap;
} }
@media (hover: hover) { .pharmacy-card:hover .pharmacy-header h4 {
.pharmacy-card:hover .pharmacy-header h4 { color: var(--primary);
color: var(--primary);
}
} }
.pharmacy-details { .pharmacy-details {
@@ -144,7 +78,7 @@
.pharmacy-address, .pharmacy-address,
.pharmacy-phone { .pharmacy-phone {
color: var(--text-muted); color: var(--on-surface-variant);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0; margin: 0;
line-height: 1.45; line-height: 1.45;
@@ -164,29 +98,29 @@
display: inline-block; display: inline-block;
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
border-radius: 999px; border-radius: var(--radius-full);
background: currentColor; background: currentColor;
} }
.pharmacy-hours--open { .pharmacy-hours--open {
color: var(--accent, #047857); color: var(--primary);
} }
.pharmacy-hours--closed { .pharmacy-hours--closed {
color: var(--text-muted); color: var(--on-surface-variant);
} }
.pharmacy-pricing { .pharmacy-pricing {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 1rem; margin-top: 0.75rem;
padding-top: 1rem; padding-top: 0.75rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--outline-variant);
} }
.price { .price {
font-size: 1.2rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
color: var(--primary); color: var(--primary);
} }
@@ -194,35 +128,34 @@
.stock { .stock {
font-size: 0.72rem; font-size: 0.72rem;
padding: 0.35rem 0.8rem; padding: 0.35rem 0.8rem;
border-radius: 999px; border-radius: var(--radius-full);
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em;
} }
.stock.in-stock { .stock.in-stock {
background: rgba(4, 120, 87, 0.12); background: rgba(0, 69, 13, 0.1);
color: var(--accent); color: var(--primary);
} }
.stock.low-stock { .stock.low-stock {
background: rgba(180, 83, 9, 0.12); background: rgba(180, 83, 9, 0.12);
color: var(--accent-warm); color: #b45309;
} }
.stock.out-of-stock { .stock.out-of-stock {
background: rgba(185, 28, 28, 0.1); background: rgba(186, 26, 26, 0.1);
color: #b91c1c; color: var(--error);
} }
.loading-pharmacies, .loading-pharmacies,
.no-pharmacies { .no-pharmacies {
text-align: center; text-align: center;
padding: 2.75rem 1.5rem; padding: 2.5rem 1.5rem;
background: var(--surface-muted); background: var(--surface-container-low);
border-radius: var(--radius); border-radius: var(--radius-md);
color: var(--text-muted); color: var(--on-surface-variant);
border: 1px solid var(--border); border: 1px solid var(--outline-variant);
margin-top: 1rem; margin-top: 1rem;
} }
+26 -28
View File
@@ -1,30 +1,29 @@
.search-bar-container { .search-bar-container {
margin-bottom: 2.5rem; margin-bottom: 1.5rem;
width: 100%;
animation: fadeInUp 0.8s ease-out 0.2s backwards;
} }
.search-bar { .search-bar {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--surface); background: var(--surface-container-lowest);
border-radius: var(--radius); border-radius: var(--radius-md);
padding: 0.45rem 1.25rem; padding: 0.25rem 1rem;
box-shadow: var(--glass-shadow); border: 2px solid var(--outline-variant);
border: 1px solid var(--border); transition: border-color 0.2s;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
position: relative; position: relative;
} }
.search-bar:focus-within { .search-bar:focus-within {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring), var(--glass-shadow);
} }
.search-icon { .search-icon {
font-size: 1.2rem; color: var(--primary);
margin-right: 0.85rem; font-size: 1.5rem;
opacity: 0.45; margin-right: 0.75rem;
display: flex;
align-items: center;
flex-shrink: 0;
} }
.search-input { .search-input {
@@ -32,35 +31,34 @@
border: none; border: none;
background: transparent; background: transparent;
padding: 1rem 0; padding: 1rem 0;
font-size: 1.1rem; font-size: 1.125rem;
font-family: inherit; font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
color: var(--text-main); color: var(--on-surface);
outline: none; outline: none;
} }
.search-input::placeholder { .search-input::placeholder {
color: var(--text-muted); color: var(--on-surface-variant);
opacity: 0.65; opacity: 0.6;
} }
.clear-button { .clear-button {
background: var(--surface-muted); background: var(--surface-container-low);
border: 1px solid var(--border); border: 1px solid var(--outline-variant);
color: var(--text-muted); color: var(--on-surface-variant);
width: 30px; width: 2rem;
height: 30px; height: 2rem;
border-radius: 50%; border-radius: var(--radius-full);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.75rem; font-size: 0.75rem;
transition: background 0.2s, color 0.2s, transform 0.2s; transition: background 0.15s;
margin-left: 0.5rem; margin-left: 0.5rem;
flex-shrink: 0;
} }
.clear-button:hover { .clear-button:hover {
background: var(--surface-card); background: var(--surface-container);
color: var(--text-main);
transform: rotate(90deg);
} }
+7 -3
View File
@@ -5,7 +5,12 @@ function SearchBar({ value, onChange, placeholder }) {
return ( return (
<div className="search-bar-container"> <div className="search-bar-container">
<div className="search-bar"> <div className="search-bar">
<span className="search-icon">🔍</span> <span className="search-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</span>
<input <input
type="text" type="text"
value={value} value={value}
@@ -18,7 +23,7 @@ function SearchBar({ value, onChange, placeholder }) {
<button <button
className="clear-button" className="clear-button"
onClick={() => onChange('')} onClick={() => onChange('')}
aria-label="Clear search" aria-label="Limpiar búsqueda"
> >
</button> </button>
@@ -29,4 +34,3 @@ function SearchBar({ value, onChange, placeholder }) {
} }
export default SearchBar; export default SearchBar;
+35 -60
View File
@@ -1,88 +1,63 @@
/* TopBar — mobile-only sticky header. Desktop hides via display:none above 768px. */
.top-bar { .top-bar {
display: none; display: none;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.top-bar { .top-bar {
display: block;
position: sticky;
top: 0;
z-index: 40;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.topbar-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
position: fixed; height: var(--touch-target-min);
top: 0; padding: 0 var(--margin-main);
left: 0; max-width: 48rem;
right: 0; margin: 0 auto;
z-index: 50;
padding: calc(0.65rem + env(safe-area-inset-top)) 1rem 0.65rem;
background: var(--glass-bg);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
} }
.topbar-brand { .topbar-back {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--text-main);
}
.topbar-brand-icon {
color: var(--primary);
flex-shrink: 0;
}
.topbar-brand-text {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.topbar-actions {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
}
.topbar-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.4rem; width: 2.5rem;
min-height: 2.25rem; height: 2.5rem;
padding: 0.45rem 0.7rem; border-radius: var(--radius-full);
border: 1px solid transparent; border: none;
border-radius: 999px;
background: transparent; background: transparent;
color: var(--text-main); color: var(--primary);
cursor: pointer; cursor: pointer;
font-size: 0.85rem; transition: background 0.15s;
font-weight: 600;
transition: background 0.15s, color 0.15s, border-color 0.15s;
} }
.topbar-action:hover { .topbar-back:hover {
background: var(--surface-muted); background: var(--surface-container-high);
} }
.topbar-action--admin { .topbar-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--primary); color: var(--primary);
border-color: rgba(15, 118, 110, 0.25); letter-spacing: -0.01em;
background: rgba(15, 118, 110, 0.08);
} }
.topbar-action--admin:hover { .topbar-logo-img {
background: rgba(15, 118, 110, 0.15); height: 2rem;
width: auto;
} }
.topbar-action--login { .topbar-spacer {
color: var(--primary); width: 2.5rem;
border-color: rgba(15, 118, 110, 0.25);
} }
.topbar-action--placeholder { .topbar-right {
width: 2.25rem; display: flex;
align-items: center;
} }
} }
+19 -57
View File
@@ -1,66 +1,28 @@
import { IconBell, IconCog, IconUser, IconLogo } from './icons';
import './TopBar.css'; import './TopBar.css';
function TopBar({ function TopBar({ title, showBack, onBack, rightAction }) {
currentUser,
isAdmin,
onShowSaved,
onLoginRequest,
onAdminClick,
authChecked,
}) {
// ponytail: action slot priority — admin pill > bell (logged-in) > login button (logged-out).
// ponytail: hidden on desktop via .top-bar { display: none } above 768px.
function renderAction() {
if (isAdmin) {
return (
<button
type="button"
className="topbar-action topbar-action--admin"
onClick={onAdminClick}
aria-label="Admin panel"
>
<IconCog size={18} />
<span>Admin</span>
</button>
);
}
if (currentUser) {
return (
<button
type="button"
className="topbar-action"
onClick={onShowSaved}
aria-label="Saved notifications"
>
<IconBell size={22} />
</button>
);
}
if (authChecked) {
return (
<button
type="button"
className="topbar-action topbar-action--login"
onClick={onLoginRequest}
>
<IconUser size={18} />
<span>Login</span>
</button>
);
}
return <span className="topbar-action topbar-action--placeholder" />;
}
return ( return (
<header className="top-bar"> <header className="top-bar">
<div className="topbar-brand"> <div className="topbar-inner">
<IconLogo size={22} className="topbar-brand-icon" /> {showBack ? (
<span className="topbar-brand-text">FarmaFinder</span> <button className="topbar-back" onClick={onBack} aria-label="Volver">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5" />
<polyline points="12 19 5 12 12 5" />
</svg>
</button>
) : (
<div className="topbar-spacer" />
)}
{showBack ? (
<h1 className="topbar-title">{title}</h1>
) : (
<img src="/logo.png" alt="FarmaFinder" className="topbar-logo-img" />
)}
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
</div> </div>
<div className="topbar-actions">{renderAction()}</div>
</header> </header>
); );
} }
export default TopBar; export default TopBar;
+15 -8
View File
@@ -1,10 +1,17 @@
// ponytail: stroke-based line icons, 24x24, currentColor. Emojis stay for in-content use. export function IconHome({ size = 24, ...props }) {
// ponytail: add when a filled variant is needed for active states — swap stroke="currentColor" for fill. return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconSearch({ size = 24, ...props }) { export function IconSearch({ size = 24, ...props }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}> strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" /> <path d="m20 20-3.5-3.5" />
</svg> </svg>
@@ -14,7 +21,7 @@ export function IconSearch({ size = 24, ...props }) {
export function IconScan({ size = 24, ...props }) { export function IconScan({ size = 24, ...props }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}> strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M3 7V5a2 2 0 0 1 2-2h2" /> <path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" /> <path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" /> <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
@@ -27,7 +34,7 @@ export function IconScan({ size = 24, ...props }) {
export function IconBell({ size = 24, ...props }) { export function IconBell({ size = 24, ...props }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}> strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg> </svg>
@@ -37,7 +44,7 @@ export function IconBell({ size = 24, ...props }) {
export function IconUser({ size = 24, ...props }) { export function IconUser({ size = 24, ...props }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}> strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" /> <circle cx="12" cy="7" r="4" />
</svg> </svg>
@@ -47,7 +54,7 @@ export function IconUser({ size = 24, ...props }) {
export function IconCog({ size = 24, ...props }) { export function IconCog({ size = 24, ...props }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}> strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
@@ -60,4 +67,4 @@ export function IconLogo({ size = 24, ...props }) {
<path d="M19 8h-1V6a3 3 0 0 0-3-3H9a3 3 0 0 0-3 3v2H5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a3 3 0 0 0-3-3zM8 6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2H8V6zm4 11a3 3 0 1 1 3-3 3 3 0 0 1-3 3z" /> <path d="M19 8h-1V6a3 3 0 0 0-3-3H9a3 3 0 0 0-3 3v2H5a3 3 0 0 0-3 3v6a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a3 3 0 0 0-3-3zM8 6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2H8V6zm4 11a3 3 0 1 1 3-3 3 3 0 0 1-3 3z" />
</svg> </svg>
); );
} }
+87 -34
View File
@@ -1,31 +1,75 @@
:root { :root {
/* Brand — teal (trust / health), no purple */ --primary: #00450d;
--primary: #0f766e; --on-primary: #ffffff;
--primary-hover: #0d9488; --primary-container: #1b5e20;
--primary-light: #ccfbf1; --on-primary-container: #90d689;
--primary-faint: rgba(15, 118, 110, 0.08); --secondary: #2b5bb5;
--primary-ring: rgba(15, 118, 110, 0.22); --on-secondary: #ffffff;
--primary-shadow: rgba(15, 118, 110, 0.18); --secondary-container: #759efd;
--on-secondary-container: #00337c;
--tertiary: #4600ad;
--on-tertiary: #ffffff;
--tertiary-container: #6100e8;
--on-tertiary-container: #cebbff;
--surface: #f8fafb;
--on-surface: #191c1d;
--on-surface-variant: #41493e;
--surface-container-lowest: #ffffff;
--surface-container-low: #f2f4f5;
--surface-container: #eceeef;
--surface-container-high: #e6e8e9;
--surface-container-highest: #e1e3e4;
--error: #ba1a1a;
--on-error: #ffffff;
--error-container: #ffdad6;
--on-error-container: #93000a;
--outline: #717a6d;
--outline-variant: #c0c9bb;
--on-background: #191c1d;
--inverse-surface: #2e3132;
--inverse-on-surface: #eff1f2;
--inverse-primary: #91d78a;
--surface-tint: #2a6b2c;
--surface-dim: #d8dadb;
--surface-bright: #f8fafb;
--surface-variant: #e1e3e4;
--primary-fixed: #acf4a4;
--primary-fixed-dim: #91d78a;
--secondary-fixed: #d9e2ff;
--secondary-fixed-dim: #b0c6ff;
--tertiary-fixed: #e9ddff;
--tertiary-fixed-dim: #cfbcff;
/* Warm secondary for emphasis (badges, subtle highlights) */ --radius-sm: 0.25rem;
--accent: #047857; --radius: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.5rem;
--radius-full: 9999px;
--margin-main: 1.5rem;
--card-padding: 1.25rem;
--gutter: 1rem;
--touch-target-min: 3rem;
--shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.08);
--text-main: var(--on-surface);
--text-muted: var(--on-surface-variant);
--surface-muted: var(--surface-container-low);
--surface-card: var(--surface-container-lowest);
--border: var(--outline-variant);
--border-strong: var(--outline);
--glass-bg: var(--surface-container-lowest);
--glass-border: var(--outline-variant);
--glass-shadow: var(--shadow-soft);
--accent: var(--primary);
--accent-warm: #b45309; --accent-warm: #b45309;
--primary-hover: #1a6b2e;
/* Neutrals */ --primary-ring: rgba(0, 69, 13, 0.22);
--text-main: #1c1917; --primary-shadow: rgba(0, 69, 13, 0.18);
--text-muted: #57534e; --primary-light: var(--primary-fixed);
--surface: #ffffff; --primary-faint: rgba(0, 69, 13, 0.08);
--surface-muted: #f5f5f4;
--surface-card: #fafaf9;
--border: #e7e5e4;
--border-strong: #d6d3d1;
--glass-bg: rgba(255, 255, 255, 0.92);
--glass-border: rgba(231, 229, 228, 0.9);
--glass-shadow: 0 1px 3px rgba(28, 25, 23, 0.06), 0 8px 24px rgba(28, 25, 23, 0.04);
--radius: 14px;
--radius-sm: 10px;
} }
* { * {
@@ -39,22 +83,31 @@ html, body {
overflow: hidden; overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
} }
html { html {
overflow-x: clip; overflow-x: clip;
} }
body { body {
font-family: 'Outfit', system-ui, sans-serif; font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
color: var(--text-main); color: var(--on-surface);
background-color: var(--surface-muted); background-color: var(--surface-container-lowest);
background-image:
linear-gradient(rgba(255, 255, 255, 0.85), rgba(255, 255, 255, 0.85)),
url('./assets/bg.png');
background-size: cover;
background-position: center;
background-attachment: fixed;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
body::before {
content: "";
position: fixed;
inset: 0;
background-image: url('./assets/bg-SHmHDvQz.png');
background-size: cover;
background-position: center;
background-attachment: fixed;
opacity: 0.5;
z-index: -1;
}
#root { #root {
height: 100dvh; height: 100dvh;
overflow: hidden; overflow: hidden;
+5
View File
@@ -3,6 +3,11 @@ import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { initNativeShell } from './utils/native'; import { initNativeShell } from './utils/native';
import { initFaro } from './utils/faro';
// Initialize Grafana Faro (browser RUM) before rendering.
// No-op if VITE_FARO_ENDPOINT is not configured.
initFaro();
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
+69
View File
@@ -0,0 +1,69 @@
// Grafana Faro — browser RUM SDK initializer.
// Sends Web Vitals, JS errors, console messages, fetch/XHR, navigation,
// and React component lifecycle events to Grafana Alloy via OTLP HTTP.
//
// Required env vars (Vite injects VITE_* at build time):
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
// VITE_FARO_APP_NAME — e.g. farmafinder-frontend
// VITE_FARO_ENV — e.g. production | staging | development
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
import {
getWebInstrumentations,
ReactIntegration,
initializeFaro,
} from '@grafana/faro-react';
import { TracingInstrumentation } from '@grafana/faro-web-tracing';
import { OtlpHttpTransport } from '@grafana/faro-transport-otlp-http';
let initialized = false;
export function initFaro() {
if (initialized) return;
initialized = true;
const endpoint = import.meta.env.VITE_FARO_ENDPOINT;
if (!endpoint) {
// Faro is optional. If the endpoint is not configured (e.g. local dev
// without an Alloy collector), do nothing so the app still runs.
return;
}
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmafinder-frontend';
const environment = import.meta.env.VITE_FARO_ENV || 'production';
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
try {
initializeFaro({
url: `${endpoint.replace(/\/$/, '')}/v1/traces`,
app: {
name: appName,
environment,
version,
},
instrumentations: [
...getWebInstrumentations({
captureConsole: true,
captureConsoleDisabledLevels: ['debug'],
captureException: true,
captureMessage: true,
captureResource: true,
captureUserInteraction: true,
captureXHRInstrumentation: true,
captureFetchInstrumentation: true,
captureWebVitals: true,
captureNavigation: true,
}),
new ReactIntegration(),
new TracingInstrumentation(),
],
transport: new OtlpHttpTransport({
url: `${endpoint.replace(/\/$/, '')}/v1/traces`,
}),
});
} catch (err) {
// Never let Faro init failure break the app.
// eslint-disable-next-line no-console
console.warn('[faro] init failed', err);
}
}
+178
View File
@@ -0,0 +1,178 @@
.alerts-view {
width: 100%;
max-width: 48rem;
margin: 0 auto;
}
.alerts-main {
padding: 1.5rem var(--margin-main) 2rem;
}
.alerts-header {
margin-bottom: 2rem;
}
.alerts-title {
font-size: 2rem;
font-weight: 700;
color: var(--on-surface);
line-height: 1.2;
margin-bottom: 0.25rem;
}
.alerts-subtitle {
font-size: 1.125rem;
color: var(--on-surface-variant);
font-weight: 400;
}
.alerts-list {
display: flex;
flex-direction: column;
gap: var(--gutter);
}
.alert-card {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
padding: var(--card-padding);
display: flex;
flex-direction: column;
gap: 1rem;
border: 2px solid transparent;
}
.alert-card--primary { border-color: rgba(0, 69, 13, 0.1); }
.alert-card--secondary { border-color: rgba(43, 91, 181, 0.1); }
.alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); }
.alert-card-top {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.alert-icon {
width: 3.25rem;
height: 3.25rem;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.alert-icon--primary {
background: var(--primary-container);
color: var(--on-primary-container);
}
.alert-icon--secondary {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.alert-icon--tertiary {
background: var(--tertiary-container);
color: var(--on-tertiary);
}
.alert-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.alert-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--on-surface);
line-height: 1.3;
}
.alert-badge {
display: inline-block;
width: fit-content;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 700;
}
.alert-badge--primary {
background: var(--error-container);
color: var(--on-error-container);
}
.alert-badge--secondary {
background: var(--secondary-fixed);
color: var(--on-secondary-fixed-variant);
}
.alert-badge--tertiary {
background: var(--tertiary-fixed);
color: var(--on-tertiary-fixed-variant);
}
.alert-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.alert-btn {
flex: 1;
height: var(--touch-target-min);
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s;
}
.alert-btn:active {
transform: scale(0.97);
}
.alert-btn--primary {
background: var(--primary);
color: var(--on-primary);
}
.alert-btn--secondary {
background: var(--secondary);
color: var(--on-secondary);
}
.alert-btn--tertiary {
background: var(--tertiary-container);
color: var(--on-tertiary);
}
.alert-delete {
width: var(--touch-target-min);
height: var(--touch-target-min);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--outline);
border-radius: var(--radius-md);
background: transparent;
color: var(--outline);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s;
}
.alert-delete:hover {
background: var(--surface-container-high);
}
@media (max-width: 400px) {
.alerts-title {
font-size: 1.5rem;
}
}
+99
View File
@@ -0,0 +1,99 @@
import React from 'react';
import './AlertsView.css';
const alerts = [
{
id: 1,
type: 'reminder',
title: 'Recordatorio: Comprar Paracetamol',
detail: 'Quedan 2 cajas',
color: 'primary',
icon: 'bell',
actions: [{ label: 'Ver Detalles', variant: 'secondary' }],
},
{
id: 2,
type: 'availability',
title: 'Disponibilidad: Aspirina',
detail: 'Ya disponible en Farmacia Sol',
color: 'secondary',
icon: 'store',
actions: [{ label: 'Ir a Farmacia', variant: 'primary' }],
},
{
id: 3,
type: 'dose',
title: 'Próxima Toma',
detail: '12:00 PM',
color: 'tertiary',
icon: 'schedule',
actions: [{ label: 'Confirmar Toma', variant: 'tertiary' }],
},
];
const iconMap = {
bell: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 22c1.1 0 2-.9 2-2h-4a2 2 0 0 0 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
</svg>
),
store: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" />
</svg>
),
schedule: (
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
),
};
function AlertsView({ onBack }) {
return (
<div className="alerts-view">
<main className="alerts-main">
<div className="alerts-header">
<h2 className="alerts-title">Mis Avisos</h2>
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
</div>
<div className="alerts-list">
{alerts.map((alert) => (
<div key={alert.id} className={`alert-card alert-card--${alert.color}`}>
<div className="alert-card-top">
<div className={`alert-icon alert-icon--${alert.color}`}>
{iconMap[alert.icon]}
</div>
<div className="alert-info">
<h3 className="alert-title">{alert.title}</h3>
<span className={`alert-badge alert-badge--${alert.color}`}>
{alert.detail}
</span>
</div>
</div>
<div className="alert-actions">
{alert.actions.map((action, i) => (
<button
key={i}
className={`alert-btn alert-btn--${action.variant}`}
>
{action.label}
</button>
))}
<button className="alert-delete" aria-label="Eliminar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
))}
</div>
</main>
</div>
);
}
export default AlertsView;
+155 -254
View File
@@ -1,291 +1,192 @@
/* ============================================================ .home-view {
HomeView.css — Premium home page styles
============================================================ */
.home-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
padding: 2rem 1.5rem; justify-content: center;
gap: 2.5rem; height: 100%;
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; overflow: hidden;
transition: padding: 1rem var(--margin-main);
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), gap: 1.5rem;
box-shadow 0.25s ease, animation: fadeInUp 0.6s ease-out;
border-color 0.25s ease;
} }
.home-card::before { .home-hero {
content: ""; display: flex;
position: absolute; flex-direction: column;
inset: 0; align-items: center;
opacity: 0; text-align: center;
transition: opacity 0.3s ease; gap: 0.75rem;
border-radius: inherit;
pointer-events: none;
} }
.home-card:hover { .home-logo-wrapper {
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; display: flex;
align-items: center; align-items: center;
justify-content: 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; margin-bottom: 0.5rem;
} }
.card-content p { .home-logo {
font-size: 0.95rem; width: min(16rem, 70vw);
color: var(--text-muted); height: auto;
line-height: 1.55;
} }
/* ── Card Action Footer ─────────────────────────────────── */ @media (max-height: 700px) {
.card-action { .home-logo {
width: min(12rem, 60vw);
}
}
@media (max-height: 600px) {
.home-logo {
width: min(10rem, 50vw);
}
}
.home-desc {
font-size: 1.125rem;
color: var(--on-surface-variant);
font-weight: 400;
max-width: 20rem;
line-height: 1.5;
}
.home-cards {
display: flex;
flex-direction: column;
gap: var(--gutter);
width: 100%;
max-width: 28rem;
}
.home-card {
display: flex;
align-items: center;
gap: 1.25rem;
width: 100%;
min-height: 5rem;
padding: 1rem 1.5rem;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: transform 0.15s, box-shadow 0.15s;
}
.home-card:active {
transform: scale(0.98);
}
.home-card--search {
background: var(--primary);
color: var(--on-primary);
box-shadow: 0 4px 16px rgba(0, 69, 13, 0.3);
}
.home-card--scan {
background: var(--secondary);
color: var(--on-secondary);
box-shadow: 0 4px 16px rgba(43, 91, 181, 0.3);
}
.home-card-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.home-card-text {
flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: auto; gap: 0.5rem;
padding-top: 0.8rem;
border-top: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 600;
color: var(--primary);
} }
.scan-card .card-action { .home-card-label {
color: var(--primary); font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
} }
.search-card .card-action { .home-card-arrow {
color: var(--text-muted); opacity: 0.7;
flex-shrink: 0;
display: flex;
} }
.search-card:hover .card-action { @media (max-width: 400px) {
color: var(--text-main); .home-card {
} min-height: 4.25rem;
padding: 0.75rem 1.25rem;
.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;
gap: 1.75rem;
justify-content: flex-start;
padding-top: 0.5rem;
} }
.home-grid { .home-card-label {
grid-template-columns: 1fr; font-size: 1.1rem;
}
}
@media (max-height: 700px) {
.home-view {
gap: 1rem; gap: 1rem;
padding: 0.5rem var(--margin-main);
} }
.home-card { .home-card {
padding: 1.5rem 1.4rem 1.3rem; min-height: 4rem;
gap: 0.9rem; padding: 0.625rem 1rem;
} }
.card-content h3 { .home-card-icon {
font-size: 1.25rem; width: 2.75rem;
height: 2.75rem;
} }
.card-content p { .home-card-icon svg {
font-size: 0.88rem; width: 24px;
height: 24px;
}
.home-card-label {
font-size: 1.05rem;
}
.home-desc {
font-size: 0.95rem;
}
}
@media (max-height: 600px) {
.home-view {
gap: 0.5rem;
padding: 0.25rem var(--margin-main);
}
.home-card {
min-height: 3.5rem;
padding: 0.5rem 0.875rem;
gap: 0.75rem;
}
.home-card-icon {
width: 2.25rem;
height: 2.25rem;
}
.home-card-icon svg {
width: 20px;
height: 20px;
}
.home-card-label {
font-size: 0.95rem;
}
.home-desc {
font-size: 0.85rem;
} }
} }
+37 -33
View File
@@ -3,48 +3,52 @@ import './HomeView.css';
function HomeView({ onScanClick, onSearchClick }) { function HomeView({ onScanClick, onSearchClick }) {
return ( return (
<div className="home-container"> <div className="home-view">
<header className="home-header"> <div className="home-hero">
<h1 className="home-logo">💊 FarmaFinder</h1> <div className="home-logo-wrapper">
<p className="home-subtitle">Find your medicine at nearby pharmacies quickly and easily.</p> <img src="/logo.png" alt="FarmaFinder" className="home-logo" />
</header> </div>
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
</div>
<div className="home-grid"> <div className="home-cards">
<button className="home-card scan-card" onClick={onScanClick} aria-label="Scan TSI Card"> <button className="home-card home-card--search" onClick={onSearchClick}>
<div className="card-badge">Fast Access</div> <div className="home-card-icon">
<div className="card-icon-container"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<span className="card-icon">📷</span> <circle cx="11" cy="11" r="7" />
<div className="pulse-ring"></div> <path d="m20 20-3.5-3.5" />
<div className="pulse-ring-outer"></div> </svg>
</div> </div>
<div className="card-content"> <div className="home-card-text">
<h3>Scan TSI Card</h3> <span className="home-card-label">Buscar Medicamento</span>
<p>Scan your health card (Tarjeta Sanitaria Individual) barcode or QR code with your camera to instantly view your active prescriptions.</p> <span className="home-card-arrow">
</div> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<div className="card-action"> <polyline points="9 18 15 12 9 6" />
<span>Scan now</span> </svg>
<span className="action-arrow"></span> </span>
</div> </div>
</button> </button>
<button className="home-card search-card" onClick={onSearchClick} aria-label="Manual Search"> <button className="home-card home-card--scan" onClick={onScanClick}>
<div className="card-icon-container"> <div className="home-card-icon">
<span className="card-icon">🔍</span> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
<path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
</svg>
</div> </div>
<div className="card-content"> <div className="home-card-text">
<h3>Manual Search</h3> <span className="home-card-label">Escanear TSI</span>
<p>Search for a specific medicine by typing its name, active ingredient, or register number to check availability in real-time.</p> <span className="home-card-arrow">
</div> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<div className="card-action"> <polyline points="9 18 15 12 9 6" />
<span>Search catalogue</span> </svg>
<span className="action-arrow"></span> </span>
</div> </div>
</button> </button>
</div> </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> </div>
); );
} }
+182 -129
View File
@@ -1,114 +1,196 @@
.profile-view { .profile-view {
width: 100%; width: 100%;
max-width: 640px; max-width: 48rem;
margin: 0 auto; margin: 0 auto;
display: flex; padding: 2rem var(--margin-main) 2rem;
flex-direction: column;
gap: 1.5rem;
animation: fadeInUp 0.5s ease-out; animation: fadeInUp 0.5s ease-out;
} }
.profile-header { .profile-avatar-section {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 1.1rem; margin-bottom: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.4rem 1.5rem;
box-shadow: var(--glass-shadow);
} }
.profile-avatar { .profile-avatar-circle {
width: 3.5rem; width: 8rem;
height: 3.5rem; height: 8rem;
border-radius: 50%; border-radius: var(--radius-full);
background: var(--primary-faint); background: var(--secondary-container);
color: var(--primary); color: var(--on-secondary-container);
font-size: 1.8rem; display: flex;
align-items: center;
justify-content: center;
border: 4px solid var(--surface-container-lowest);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
margin-bottom: 0.75rem;
}
.profile-name {
font-size: 1.75rem;
font-weight: 700;
color: var(--on-surface);
text-align: center;
}
.profile-info-cards {
display: flex;
flex-direction: column;
gap: var(--gutter);
margin-bottom: 2rem;
}
.profile-info-card {
background: var(--surface-container-lowest);
padding: var(--card-padding);
border-radius: var(--radius-md);
border: 2px solid var(--surface-container-high);
box-shadow: var(--shadow-soft);
}
.info-card-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.info-card-value {
font-size: 1.25rem;
font-weight: 500;
color: var(--on-surface);
}
.profile-menu {
display: flex;
flex-direction: column;
gap: var(--gutter);
margin-bottom: 3rem;
}
.profile-menu-item {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
height: 5rem;
padding: 0 1.5rem;
background: var(--surface-container-lowest);
border: 2px solid var(--surface-container-high);
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
box-shadow: var(--shadow-soft);
transition: background 0.15s;
}
.profile-menu-item:active {
background: var(--surface-container);
}
.menu-item-icon {
width: 3rem;
height: 3rem;
border-radius: var(--radius);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.profile-info h2 { .menu-item-icon--primary {
margin: 0; background: rgba(0, 69, 13, 0.08);
font-size: 1.4rem;
font-weight: 700;
color: var(--text-main);
letter-spacing: -0.01em;
}
.profile-role {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.profile-pill {
color: var(--primary); color: var(--primary);
font-weight: 600;
} }
.profile-section { .menu-item-icon--error {
background: var(--surface); background: rgba(186, 26, 26, 0.08);
border: 1px solid var(--border); color: var(--error);
border-radius: var(--radius);
padding: 1.4rem 1.5rem;
box-shadow: var(--glass-shadow);
} }
.profile-section h3 { .menu-item-icon--secondary {
margin: 0 0 0.35rem; background: rgba(43, 91, 181, 0.08);
font-size: 1.05rem; color: var(--secondary);
}
.menu-item-icon--error-outline {
background: rgba(186, 26, 26, 0.08);
color: var(--error);
}
.menu-item-label {
flex: 1;
font-size: 1.125rem;
font-weight: 500;
color: var(--on-surface);
}
.menu-item-chevron {
color: var(--on-surface-variant);
flex-shrink: 0;
}
.profile-location-section {
background: var(--surface-container-lowest);
border: 1px solid var(--outline-variant);
border-radius: var(--radius-md);
padding: var(--card-padding);
margin-bottom: 1.5rem;
}
.profile-location-section h3 {
font-size: 1.1rem;
font-weight: 700; font-weight: 700;
color: var(--text-main); color: var(--on-surface);
margin-bottom: 0.25rem;
} }
.profile-section-sub { .profile-section-sub {
margin: 0 0 1.1rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--on-surface-variant);
font-size: 0.88rem; margin-bottom: 1rem;
line-height: 1.45; line-height: 1.45;
} }
.profile-form { .profile-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.95rem; gap: 0.85rem;
} }
.profile-field { .profile-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.3rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.profile-field label { .profile-field label {
font-size: 0.78rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--on-surface-variant);
letter-spacing: 0.02em; letter-spacing: 0.03em;
} }
.profile-field input { .profile-field input {
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
border: 1px solid var(--border); border: 2px solid var(--outline-variant);
border-radius: var(--radius-sm); border-radius: var(--radius);
background: var(--surface-muted); background: var(--surface);
color: var(--text-main); color: var(--on-surface);
font-size: 0.95rem; font-size: 0.95rem;
outline: none; outline: none;
transition: border-color 0.15s ease, background 0.15s ease; transition: border-color 0.15s;
width: 100%; width: 100%;
font-family: inherit;
} }
.profile-field input:focus { .profile-field input:focus {
border-color: var(--primary); border-color: var(--primary);
background: var(--surface);
} }
.profile-field input:disabled { .profile-field input:disabled {
@@ -127,20 +209,20 @@
} }
.profile-btn-secondary { .profile-btn-secondary {
background: var(--surface-muted); background: var(--surface-container-low);
border: 1px solid var(--border); border: 1px solid var(--outline-variant);
color: var(--text-main); color: var(--on-surface);
padding: 0.55rem 1rem; padding: 0.5rem 1rem;
border-radius: 999px; border-radius: var(--radius-full);
cursor: pointer; cursor: pointer;
font-size: 0.83rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
transition: background 0.15s, border-color 0.15s, color 0.15s; font-family: inherit;
transition: background 0.15s;
} }
.profile-btn-secondary:hover:not(:disabled) { .profile-btn-secondary:hover:not(:disabled) {
border-color: var(--primary); background: var(--surface-container);
color: var(--primary);
} }
.profile-btn-secondary:disabled { .profile-btn-secondary:disabled {
@@ -151,110 +233,81 @@
.profile-feedback { .profile-feedback {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.55rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm); border-radius: var(--radius);
line-height: 1.4; line-height: 1.4;
} }
.profile-feedback--ok { .profile-feedback--ok {
background: var(--primary-faint); background: rgba(0, 69, 13, 0.08);
color: var(--primary); color: var(--primary);
border: 1px solid var(--primary-ring); border: 1px solid rgba(0, 69, 13, 0.2);
} }
.profile-feedback--err { .profile-feedback--err {
background: rgba(185, 28, 28, 0.08); background: rgba(186, 26, 26, 0.08);
color: #b91c1c; color: var(--error);
border: 1px solid rgba(185, 28, 28, 0.2); border: 1px solid rgba(186, 26, 26, 0.2);
} }
.profile-actions { .profile-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 0.2rem; margin-top: 0.25rem;
} }
.profile-btn-primary { .profile-btn-primary {
background: var(--primary); background: var(--primary);
color: #fff; color: var(--on-primary);
border: none; border: none;
padding: 0.65rem 1.6rem; padding: 0.65rem 1.5rem;
border-radius: 999px; border-radius: var(--radius-full);
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
transition: background 0.15s, opacity 0.15s; font-family: inherit;
transition: opacity 0.15s;
} }
.profile-btn-primary:hover:not(:disabled) { .profile-btn-primary:hover:not(:disabled) {
background: var(--primary-hover); opacity: 0.9;
} }
.profile-btn-primary:disabled { .profile-btn-primary:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed;
} }
.profile-link-row { .profile-logout-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.85rem; gap: 1rem;
width: 100%; width: 100%;
padding: 0.8rem 0.5rem; height: 5rem;
background: transparent; padding: 0 1.5rem;
border: none; background: rgba(186, 26, 26, 0.06);
border-bottom: 1px solid var(--border); border: 2px solid rgba(186, 26, 26, 0.2);
border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
font-size: 0.95rem; color: var(--error);
color: var(--text-main); font-size: 1.125rem;
transition: background 0.12s ease; font-weight: 700;
font-family: inherit;
transition: background 0.15s;
} }
.profile-link-row:last-child { .profile-logout-btn:active {
border-bottom: none; background: rgba(186, 26, 26, 0.12);
} }
.profile-link-row:hover { @media (max-width: 400px) {
background: var(--surface-muted);
}
.profile-link-row-icon {
font-size: 1.1rem;
width: 1.5rem;
text-align: center;
}
.profile-link-row-label {
flex: 1;
font-weight: 500;
}
.profile-link-row-chevron {
color: var(--text-muted);
font-size: 1.2rem;
line-height: 1;
}
.profile-link-row--danger {
color: #b91c1c;
}
.profile-link-row--danger:hover {
background: rgba(185, 28, 28, 0.06);
}
@media (max-width: 540px) {
.profile-field-row { .profile-field-row {
flex-direction: column; flex-direction: column;
gap: 0.95rem; gap: 0.85rem;
} }
.profile-header { .profile-avatar-circle {
padding: 1.1rem 1.1rem; width: 6rem;
} height: 6rem;
.profile-section {
padding: 1.1rem 1.1rem;
} }
} }
+86 -72
View File
@@ -39,13 +39,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Save failed (HTTP ${res.status})`); throw new Error(err.error || `Error al guardar (HTTP ${res.status})`);
} }
const updated = await res.json(); const updated = await res.json();
onProfileSaved?.(updated); onProfileSaved?.(updated);
setFeedback({ type: 'ok', text: 'Profile saved.' }); setFeedback({ type: 'ok', text: 'Perfil guardado.' });
} catch (err) { } catch (err) {
setFeedback({ type: 'err', text: err.message || 'Save failed' }); setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -58,13 +58,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
const pos = await getUserPosition(); const pos = await getUserPosition();
setLatitude(String(pos.lat)); setLatitude(String(pos.lat));
setLongitude(String(pos.lon)); setLongitude(String(pos.lon));
setFeedback({ type: 'ok', text: 'Got your current location — remember to Save.' }); setFeedback({ type: 'ok', text: 'Ubicación obtenida — recuerda Guardar.' });
} catch (err) { } catch (err) {
let msg = err && err.message ? err.message : 'Could not get location'; let msg = 'No se pudo obtener la ubicación';
if (err && typeof err.code === 'number') { if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings.'; if (err.code === 1) msg = 'Permiso de ubicación denegado';
else if (err.code === 2) msg = 'Location unavailable — check OS location services.'; else if (err.code === 2) msg = 'Ubicación no disponible';
else if (err.code === 3) msg = 'Location request timed out — try again.'; else if (err.code === 3) msg = 'La solicitud expiró';
} }
setFeedback({ type: 'err', text: msg }); setFeedback({ type: 'err', text: msg });
} finally { } finally {
@@ -75,7 +75,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
async function handleGeocode() { async function handleGeocode() {
const q = address.trim(); const q = address.trim();
if (!q) { if (!q) {
setFeedback({ type: 'err', text: 'Enter an address first.' }); setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' });
return; return;
} }
setGeocoding(true); setGeocoding(true);
@@ -86,61 +86,99 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Lookup failed (HTTP ${res.status})`); throw new Error(err.error || `Error en la búsqueda (HTTP ${res.status})`);
} }
const data = await res.json(); const data = await res.json();
setLatitude(String(data.lat)); setLatitude(String(data.lat));
setLongitude(String(data.lon)); setLongitude(String(data.lon));
setFeedback({ setFeedback({
type: 'ok', type: 'ok',
text: `Located: ${data.displayName} — remember to Save.`, text: `Ubicado: ${data.displayName} — recuerda Guardar.`,
}); });
} catch (err) { } catch (err) {
setFeedback({ type: 'err', text: err.message || 'Lookup failed' }); setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' });
} finally { } finally {
setGeocoding(false); setGeocoding(false);
} }
} }
const hasSavedCoords = const username = currentUser?.username || 'Usuario';
currentUser?.latitude != null && currentUser?.longitude != null;
return ( return (
<div className="profile-view"> <div className="profile-view">
<header className="profile-header"> <div className="profile-avatar-section">
<div className="profile-avatar" aria-hidden="true">👤</div> <div className="profile-avatar-circle">
<div className="profile-info"> <svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor">
<h2>{currentUser?.username}</h2> <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
<p className="profile-role"> </svg>
{currentUser?.is_admin ? 'Administrator' : 'Member'}
{hasSavedCoords && <span className="profile-pill"> · Location saved</span>}
</p>
</div> </div>
</header> <h2 className="profile-name">Mi Perfil</h2>
</div>
<section className="profile-section"> <div className="profile-info-cards">
<h3>Your location</h3> <div className="profile-info-card">
<p className="profile-section-sub"> <p className="info-card-label">Nombre</p>
Save your address so "Sort by distance" uses it without asking the browser every time. <p className="info-card-value">{username}</p>
</p> </div>
</div>
<div className="profile-menu">
<button className="profile-menu-item" onClick={onShowSaved}>
<div className="menu-item-icon menu-item-icon--primary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
</svg>
</div>
<span className="menu-item-label">Mis Direcciones</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button className="profile-menu-item">
<div className="menu-item-icon menu-item-icon--error">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 3H2C.9 3 0 3.9 0 5v14c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm6 12H2v-1c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1zm8-5h-5V9h5v4z" />
</svg>
</div>
<span className="menu-item-label">Contactos de Emergencia</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button className="profile-menu-item">
<div className="menu-item-icon menu-item-icon--secondary">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 2h2.5v12H13V6zm-2 12H8.5V6H11v12zM4 6h2.5v12H4V6zm16 12h-2.5V6H20v12z" />
</svg>
</div>
<span className="menu-item-label">Configuración de Texto</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
<div className="profile-location-section">
<h3>Tu Ubicación</h3>
<p className="profile-section-sub">Guarda tu dirección para ordenar por distancia sin pedir permiso cada vez.</p>
<form onSubmit={handleSave} className="profile-form"> <form onSubmit={handleSave} className="profile-form">
<div className="profile-field"> <div className="profile-field">
<label htmlFor="profile-address">Address</label> <label htmlFor="profile-address">Dirección</label>
<input <input
id="profile-address" id="profile-address"
type="text" type="text"
placeholder="123 Main St, Madrid, Spain" placeholder="Calle Mayor 1, Madrid"
value={address} value={address}
onChange={(e) => setAddress(e.target.value)} onChange={(e) => setAddress(e.target.value)}
autoComplete="street-address" autoComplete="street-address"
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="profile-field-row"> <div className="profile-field-row">
<div className="profile-field"> <div className="profile-field">
<label htmlFor="profile-lat">Latitude</label> <label htmlFor="profile-lat">Latitud</label>
<input <input
id="profile-lat" id="profile-lat"
type="number" type="number"
@@ -152,7 +190,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
/> />
</div> </div>
<div className="profile-field"> <div className="profile-field">
<label htmlFor="profile-lon">Longitude</label> <label htmlFor="profile-lon">Longitud</label>
<input <input
id="profile-lon" id="profile-lon"
type="number" type="number"
@@ -164,57 +202,33 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
/> />
</div> </div>
</div> </div>
<div className="profile-helper-actions"> <div className="profile-helper-actions">
<button <button type="button" className="profile-btn-secondary" onClick={handleGeocode} disabled={geocoding || saving}>
type="button" {geocoding ? 'Buscando…' : '📍 Buscar desde dirección'}
className="profile-btn-secondary"
onClick={handleGeocode}
disabled={geocoding || saving}
>
{geocoding ? 'Finding…' : '📍 Find from address'}
</button> </button>
<button <button type="button" className="profile-btn-secondary" onClick={handleUseLocation} disabled={locating || saving}>
type="button" {locating ? 'Localizando…' : '🛰️ Usar mi ubicación'}
className="profile-btn-secondary"
onClick={handleUseLocation}
disabled={locating || saving}
>
{locating ? 'Locating…' : '🛰️ Use my current location'}
</button> </button>
</div> </div>
{feedback && ( {feedback && (
<p className={`profile-feedback profile-feedback--${feedback.type}`}> <p className={`profile-feedback profile-feedback--${feedback.type}`}>{feedback.text}</p>
{feedback.text}
</p>
)} )}
<div className="profile-actions"> <div className="profile-actions">
<button type="submit" className="profile-btn-primary" disabled={saving}> <button type="submit" className="profile-btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save profile'} {saving ? 'Guardando…' : 'Guardar'}
</button> </button>
</div> </div>
</form> </form>
</section> </div>
<section className="profile-section"> <button className="profile-logout-btn" onClick={onLogout}>
<h3>Account</h3> <div className="menu-item-icon menu-item-icon--error-outline">
<button type="button" className="profile-link-row" onClick={onShowSaved}> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<span className="profile-link-row-icon">🔔</span> <path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z" />
<span className="profile-link-row-label">Saved notifications</span> </svg>
<span className="profile-link-row-chevron" aria-hidden="true"></span> </div>
</button> <span>Cerrar Sesión</span>
<button </button>
type="button"
className="profile-link-row profile-link-row--danger"
onClick={onLogout}
>
<span className="profile-link-row-icon"></span>
<span className="profile-link-row-label">Logout</span>
<span className="profile-link-row-chevron" aria-hidden="true"></span>
</button>
</section>
</div> </div>
); );
} }
+200 -280
View File
@@ -1,245 +1,124 @@
/* ============================================================ .scanner-view {
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%; width: 100%;
max-width: 42rem; max-width: 48rem;
margin: 0 auto; margin: 0 auto;
} }
/* ── Start Scan Button ───────────────────────────────────── */ .scanner-content {
.scan-start-btn { padding: 2rem var(--margin-main) 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: fadeInUp 0.5s ease-out;
}
.scanner-hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
}
.scanner-icon-circle {
width: 4.5rem;
height: 4.5rem;
border-radius: var(--radius-full);
background: var(--tertiary-container);
color: var(--on-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; margin-bottom: 0.25rem;
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 { .scanner-heading {
font-size: 1.5rem; 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; font-weight: 700;
cursor: pointer; color: var(--on-surface);
white-space: nowrap; line-height: 1.2;
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 ──────────────────────────────────────────── */ .scanner-desc {
.pwa-notice { font-size: 1rem;
display: flex; color: var(--on-surface-variant);
align-items: flex-start; font-weight: 400;
gap: 0.75rem; max-width: 22rem;
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; line-height: 1.5;
} }
/* ── Error Panel ─────────────────────────────────────────── */ .scan-error-card {
.scan-error-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
background: rgba(239, 68, 68, 0.06); background: var(--error-container);
border: 1px solid rgba(239, 68, 68, 0.2); border-radius: var(--radius-md);
border-radius: 16px; padding: 1.5rem 1.25rem;
padding: 1.5rem 1.2rem;
text-align: center; text-align: center;
} }
.scan-error-icon { .scan-error-icon {
font-size: 2.5rem; color: var(--error);
width: 2.5rem;
height: 2.5rem;
} }
.scan-error-msg { .scan-error-text {
color: #fca5a5; color: var(--on-error-container);
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
} }
.scan-retry-btn { .scan-btn {
background: rgba(239, 68, 68, 0.15); height: var(--touch-target-min);
border: 1px solid rgba(239, 68, 68, 0.3); border: none;
color: #fca5a5; border-radius: var(--radius-md);
border-radius: 10px; font-size: 1rem;
padding: 0.6rem 1.2rem; font-weight: 700;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.2s; font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
} display: inline-flex;
.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; align-items: center;
gap: 1rem; justify-content: center;
padding: 2rem; gap: 0.5rem;
} transition: opacity 0.15s, transform 0.1s;
.scan-active-panel p { }
color: #94a3b8;
font-size: 0.95rem; .scan-btn:active {
margin: 0; transform: scale(0.97);
}
.scan-btn--primary {
background: var(--primary);
color: var(--on-primary);
}
.scan-btn--outline {
background: transparent;
border: 2px solid var(--error);
color: var(--on-error-container);
}
.scan-btn--ghost {
background: var(--surface-container-low);
border: 1px solid var(--outline-variant);
color: var(--on-surface-variant);
width: 100%;
}
.scan-btn--start {
width: 100%;
padding: 0 1.5rem;
height: 3.75rem;
font-size: 1.125rem;
} }
/* ── Camera Viewport (web PWA scanning) ──────────────────── */
.scanner-camera-container { .scanner-camera-container {
position: relative; position: relative;
width: 100%; width: 100%;
border-radius: 16px; border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
background: #000; background: #000;
aspect-ratio: 4/3; aspect-ratio: 4/3;
@@ -262,10 +141,11 @@
position: absolute; position: absolute;
width: 22px; width: 22px;
height: 22px; height: 22px;
border-color: #10b981; border-color: var(--tertiary-container);
border-style: solid; border-style: solid;
border-width: 0; border-width: 0;
} }
.corner.tl { top: 0; left: 0; border-top-width: 3px; border-left-width: 3px; border-radius: 4px 0 0 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.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.bl { bottom: 0; left: 0; border-bottom-width: 3px; border-left-width: 3px; border-radius: 0 0 0 4px; }
@@ -282,82 +162,131 @@
background: rgba(0,0,0,0.55); background: rgba(0,0,0,0.55);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
padding: 0.3rem 0.75rem; padding: 0.3rem 0.75rem;
border-radius: 999px; border-radius: var(--radius-full);
}
.scanning-active {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.scanning-active p {
color: var(--on-surface-variant);
font-size: 0.95rem;
margin: 0;
} }
/* ── Spinner ─────────────────────────────────────────────── */
.scanner-spinner { .scanner-spinner {
width: 36px; width: 36px;
height: 36px; height: 36px;
border: 3px solid rgba(255,255,255,0.12); border: 3px solid var(--outline-variant);
border-top-color: #10b981; border-top-color: var(--primary);
border-radius: 50%; border-radius: 50%;
animation: spin 0.75s linear infinite; animation: spin 0.75s linear infinite;
} }
@keyframes spin { .cip-form {
to { transform: rotate(360deg); }
}
/* ── Prescriptions Panel ─────────────────────────────────── */
.scanner-prescriptions {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.1rem; gap: 0.5rem;
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 { .cip-label {
from { opacity: 0; transform: translateY(14px); } font-size: 0.8rem;
to { opacity: 1; transform: translateY(0); } color: var(--on-surface-variant);
font-weight: 600;
letter-spacing: 0.04em;
} }
.scanned-card-info { .cip-row {
display: flex;
gap: 0.5rem;
}
.cip-input {
flex: 1;
background: var(--surface-container-lowest);
border: 2px solid var(--outline-variant);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
font-size: 1rem;
font-family: 'Courier New', monospace;
color: var(--on-surface);
letter-spacing: 0.08em;
outline: none;
transition: border-color 0.15s;
}
.cip-input::placeholder {
color: var(--on-surface-variant);
letter-spacing: 0.04em;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
opacity: 0.6;
}
.cip-input:focus {
border-color: var(--primary);
}
/* Prescriptions panel */
.prescriptions-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.cip-badge {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
background: rgba(16, 185, 129, 0.1); background: rgba(0, 69, 13, 0.08);
border: 1px solid rgba(16, 185, 129, 0.25); border: 1px solid rgba(0, 69, 13, 0.2);
border-radius: 14px; border-radius: var(--radius-md);
padding: 1rem 1.2rem; padding: 1rem 1.25rem;
color: var(--primary);
} }
.scanned-icon { font-size: 2rem; }
.scanned-cip { .cip-badge-label {
font-size: 0.85rem; font-size: 0.85rem;
color: #64748b; font-weight: 600;
font-family: 'Courier New', monospace;
margin: 0; margin: 0;
} }
.rx-heading { .cip-badge-value {
font-size: 1.1rem; font-size: 0.85rem;
font-weight: 800; font-family: 'Courier New', monospace;
color: #f1f5f9; color: var(--on-surface-variant);
letter-spacing: -0.02em; margin: 0.1rem 0 0;
}
.rx-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--on-surface);
margin: 0; margin: 0;
} }
.rx-subheading {
font-size: 0.85rem; .rx-subtitle {
color: #64748b; font-size: 0.9rem;
margin: -0.5rem 0 0; color: var(--on-surface-variant);
margin: -0.75rem 0 0;
} }
.rx-loading { .rx-loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.8rem; gap: 0.75rem;
padding: 2rem 0; padding: 2rem 0;
color: #64748b; color: var(--on-surface-variant);
font-size: 0.9rem; font-size: 0.9rem;
} }
.rx-empty { .rx-empty {
color: #64748b; color: var(--on-surface-variant);
text-align: center; text-align: center;
padding: 1.5rem 0; padding: 1.5rem 0;
font-size: 0.9rem; font-size: 0.9rem;
@@ -377,64 +306,55 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
background: rgba(255,255,255,0.04); background: var(--surface-container-lowest);
border: 1px solid rgba(255,255,255,0.09); border: 1px solid var(--outline-variant);
border-radius: 14px; border-radius: var(--radius-md);
padding: 1rem 1.1rem; padding: 1rem 1.25rem;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
text-align: left; text-align: left;
transition: background 0.2s, border-color 0.2s, transform 0.2s; font-family: inherit;
transition: background 0.15s, border-color 0.15s;
} }
.rx-item:hover { .rx-item:hover {
background: rgba(255,255,255,0.09); background: var(--surface-container);
border-color: rgba(16, 185, 129, 0.35); border-color: var(--primary);
transform: translateX(3px);
} }
.rx-item-info { .rx-item-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.18rem; gap: 0.15rem;
} }
.rx-name { .rx-name {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: #e2e8f0; color: var(--on-surface);
} }
.rx-detail { .rx-detail {
font-size: 0.82rem; font-size: 0.85rem;
color: #64748b; color: var(--on-surface-variant);
} }
.rx-ingredient { .rx-ingredient {
font-size: 0.78rem; font-size: 0.8rem;
color: #10b981; color: var(--primary);
font-weight: 500; font-weight: 500;
} }
.rx-arrow { .rx-arrow {
color: #10b981; color: var(--primary);
font-size: 1.2rem;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.2s;
}
.rx-item:hover .rx-arrow {
transform: translateX(4px);
} }
.scan-again-btn { @keyframes fadeInUp {
background: rgba(255,255,255,0.06); from { opacity: 0; transform: translateY(14px); }
border: 1px solid rgba(255,255,255,0.1); to { opacity: 1; transform: translateY(0); }
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); @keyframes spin {
color: #e2e8f0; to { transform: rotate(360deg); }
} }
+151 -133
View File
@@ -20,7 +20,7 @@ function playBeep() {
gain.connect(ctx.destination); gain.connect(ctx.destination);
osc.start(); osc.start();
osc.stop(ctx.currentTime + 0.38); osc.stop(ctx.currentTime + 0.38);
} catch (_) { /* No audio context available */ } } catch (_) { }
} }
function ScannerView({ onClose, onSelectMedicine }) { function ScannerView({ onClose, onSelectMedicine }) {
@@ -65,7 +65,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
try { try {
const supported = await BarcodeScanner.isSupported(); const supported = await BarcodeScanner.isSupported();
if (!supported) { if (!supported) {
setErrorMsg('Barcode scanning is not supported on this device.'); setErrorMsg('El escáner no está disponible en este dispositivo.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -74,7 +74,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
if (permission.camera !== 'granted') { if (permission.camera !== 'granted') {
const request = await BarcodeScanner.requestPermissions(); const request = await BarcodeScanner.requestPermissions();
if (request.camera !== 'granted') { if (request.camera !== 'granted') {
setErrorMsg('Camera permission denied. Please enable it in settings, or enter your CIP code manually.'); setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -91,7 +91,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
const rawValue = barcode?.rawValue; const rawValue = barcode?.rawValue;
if (!rawValue || !CIP_REGEX.test(rawValue)) { if (!rawValue || !CIP_REGEX.test(rawValue)) {
setErrorMsg('Invalid card barcode. Please try again or enter your CIP code manually.'); setErrorMsg('Código de barras inválido. Intenta de nuevo o introduce el CIP manualmente.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -105,7 +105,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
setPhase('idle'); setPhase('idle');
return; return;
} }
setErrorMsg(`Scan failed: ${err.message || 'Unknown error'}`); setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`);
setPhase('error'); setPhase('error');
} }
} }
@@ -114,7 +114,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
setErrorMsg(''); setErrorMsg('');
try { try {
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
setErrorMsg('Camera not available in this browser.'); setErrorMsg('Cámara no disponible en este navegador.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -123,7 +123,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
try { try {
detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] }); detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] });
} catch { } catch {
setErrorMsg('Barcode detection is not supported in this browser.'); setErrorMsg('La detección de códigos no está soportada en este navegador.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -164,7 +164,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
return; return;
} }
} }
} catch { /* detector not ready */ } } catch { }
if (!found) { if (!found) {
rafRef.current = requestAnimationFrame(scanFrame); rafRef.current = requestAnimationFrame(scanFrame);
} }
@@ -173,11 +173,11 @@ function ScannerView({ onClose, onSelectMedicine }) {
} catch (err) { } catch (err) {
stopCamera(); stopCamera();
if (err.name === 'NotAllowedError') { if (err.name === 'NotAllowedError') {
setErrorMsg('Camera permission denied. Please allow camera access and try again.'); setErrorMsg('Permiso de cámara denegado. Permite el acceso e intenta de nuevo.');
} else if (err.name === 'NotFoundError') { } else if (err.name === 'NotFoundError') {
setErrorMsg('No camera detected. Enter your CIP code manually.'); setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.');
} else { } else {
setErrorMsg(`Camera error: ${err.message || 'Unknown error'}`); setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`);
} }
setPhase('error'); setPhase('error');
} }
@@ -195,12 +195,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
e.preventDefault(); e.preventDefault();
const cip = manualCip.trim(); const cip = manualCip.trim();
if (!cip) { if (!cip) {
setErrorMsg('Please enter a CIP code.'); setErrorMsg('Introduce un código CIP.');
setPhase('error'); setPhase('error');
return; return;
} }
if (!CIP_REGEX.test(cip)) { if (!CIP_REGEX.test(cip)) {
setErrorMsg('Invalid CIP format. Must be 16 alphanumeric characters.'); setErrorMsg('Formato CIP inválido. Debe tener 16 caracteres alfanuméricos.');
setPhase('error'); setPhase('error');
return; return;
} }
@@ -227,128 +227,146 @@ function ScannerView({ onClose, onSelectMedicine }) {
} }
return ( return (
<div className="scanner-overlay"> <div className="scanner-view">
<div className="scanner-topbar"> <div className="scanner-content">
<button className="scanner-back-btn" onClick={handleBack} aria-label="Back"> <div className="scanner-hero">
Back <div className="scanner-icon-circle">
</button> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<h2 className="scanner-title"> <path d="M3 7V5a2 2 0 0 1 2-2h2" />
{phase === 'prescriptions' ? '✅ TSI Scanned' : '📷 Scan TSI Card'} <path d="M17 3h2a2 2 0 0 1 2 2v2" />
</h2> <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
<span /> <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
</div> <path d="M7 8v8M11 8v8M15 8v8M19 8v8" />
</svg>
{/* ── 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> </div>
<h2 className="scanner-heading">Escanear TSI</h2>
<h3 className="rx-heading">Active Prescriptions</h3> <p className="scanner-desc">Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas</p>
<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>
)}
{phase !== 'prescriptions' && (
<>
{phase === 'error' && (
<div className="scan-error-card">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="scan-error-icon">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<p className="scan-error-text">{errorMsg}</p>
<button className="scan-btn scan-btn--outline" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
Intentar de nuevo
</button>
</div>
)}
{phase === 'idle' && (
<button className="scan-btn scan-btn--primary scan-btn--start" onClick={handleStartScan}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
Abrir cámara
</button>
)}
{phase === 'scanning' && !isNative && (
<div className="scanner-camera-container">
<video ref={videoRef} className="scanner-video" autoPlay muted playsInline />
<div className="scanner-frame">
<div className="corner tl" />
<div className="corner tr" />
<div className="corner bl" />
<div className="corner br" />
</div>
<p className="scanner-hint">Apunta al código de barras</p>
</div>
)}
{phase === 'scanning' && isNative && (
<div className="scanning-active">
<div className="scanner-spinner" />
<p>Abriendo cámara</p>
</div>
)}
<form className="cip-form" onSubmit={handleManualSubmit}>
<label className="cip-label" htmlFor="cip-input">O introduce el código CIP manualmente</label>
<div className="cip-row">
<input
id="cip-input"
className="cip-input"
type="text"
placeholder="Código CIP de 16 dígitos"
value={manualCip}
onChange={(e) => setManualCip(e.target.value)}
maxLength={16}
autoComplete="off"
spellCheck={false}
/>
<button type="submit" className="scan-btn scan-btn--primary">Buscar</button>
</div>
</form>
</>
)}
{phase === 'prescriptions' && (
<div className="prescriptions-panel">
<div className="cip-badge">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm3 10c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" />
</svg>
<div>
<p className="cip-badge-label">TSI Escaneada</p>
<p className="cip-badge-value">{scannedCip}</p>
</div>
</div>
<h3 className="rx-title">Recetas Activas</h3>
<p className="rx-subtitle">Toca un medicamento para ver disponibilidad en farmacias cercanas.</p>
{loadingPrescriptions && (
<div className="rx-loading">
<div className="scanner-spinner" />
<p>Cargando recetas</p>
</div>
)}
{!loadingPrescriptions && prescriptions.length === 0 && (
<p className="rx-empty">No se encontraron recetas activas para esta tarjeta.</p>
)}
<ul className="rx-list">
{prescriptions.map((rx, i) => (
<li key={i}>
<button className="rx-item" onClick={() => handlePickPrescription(rx)}>
<div className="rx-item-info">
<span className="rx-name">{rx.name}</span>
<span className="rx-detail">{rx.dosage} · {rx.form}</span>
{rx.active_ingredient && (
<span className="rx-ingredient">{rx.active_ingredient}</span>
)}
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="rx-arrow">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</li>
))}
</ul>
<button className="scan-btn scan-btn--ghost" onClick={() => {
stopCamera();
setPhase('idle');
setPrescriptions([]);
setScannedCip(null);
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
Escanear otra tarjeta
</button>
</div>
)}
</div>
</div> </div>
); );
} }
+295
View File
@@ -0,0 +1,295 @@
.search-view {
width: 100%;
max-width: 48rem;
margin: 0 auto;
}
.search-content {
padding: 1rem var(--margin-main) 2rem;
animation: fadeInUp 0.5s ease-out;
}
.section-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--on-surface);
margin-bottom: 1.25rem;
line-height: 1.2;
}
.suggestions-section {
margin-bottom: 2rem;
}
.suggestions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gutter);
}
.suggestion-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding: var(--card-padding);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
transition: all 0.15s;
}
.suggestion-btn:active {
transform: scale(0.96);
}
.suggestion-btn--primary {
background: var(--primary);
color: var(--on-primary);
}
.suggestion-btn--secondary {
background: var(--secondary);
color: var(--on-secondary);
}
.suggestion-btn--tertiary {
background: var(--tertiary-container);
color: var(--on-tertiary);
}
.suggestion-btn--tint {
background: var(--surface-tint);
color: var(--on-primary);
}
.suggestion-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.suggestion-name {
font-size: 1.125rem;
font-weight: 700;
line-height: 1.2;
}
.recent-section {
margin-bottom: 2rem;
}
.recent-list {
display: flex;
flex-direction: column;
gap: var(--gutter);
}
.recent-card {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
padding: var(--card-padding);
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid var(--outline-variant);
}
.recent-card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.recent-name {
font-size: 1.25rem;
font-weight: 700;
color: var(--primary);
line-height: 1.2;
}
.recent-detail {
font-size: 1rem;
color: var(--on-surface-variant);
margin-top: 0.125rem;
}
.recent-tag {
padding: 0.25rem 0.75rem;
background: var(--secondary-fixed);
color: var(--on-secondary-fixed-variant);
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 600;
white-space: nowrap;
}
.recent-distance {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--primary);
font-weight: 700;
font-size: 0.875rem;
}
.recent-btn {
width: 100%;
height: var(--touch-target-min);
background: var(--primary-container);
color: var(--on-primary-container);
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: opacity 0.15s;
}
.recent-btn:active {
opacity: 0.85;
}
.loading {
text-align: center;
color: var(--on-surface-variant);
font-size: 1rem;
font-weight: 500;
margin: 3rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.loading::after {
content: "";
width: 36px;
height: 36px;
border: 3px solid var(--outline-variant);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
.selected-medicine-section {
margin-top: 1.5rem;
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain;
}
.medicine-info {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft);
border: 1px solid var(--outline-variant);
}
.medicine-info h2 {
color: var(--on-surface);
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.medicine-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--surface-container-low);
border-radius: var(--radius);
border: 1px solid var(--outline-variant);
}
.medicine-details span {
font-size: 0.95rem;
color: var(--on-surface-variant);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.medicine-details strong {
color: var(--on-surface);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.back-button {
background: var(--surface-container-low);
color: var(--on-surface);
border: 1px solid var(--outline-variant);
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: background 0.15s;
}
.back-button:hover {
background: var(--surface-container);
}
.pharmacy-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0 0.5rem;
flex-wrap: wrap;
}
.sort-distance-button {
background: var(--surface-container-lowest);
color: var(--on-surface);
border: 1px solid var(--outline-variant);
padding: 0.6rem 1.1rem;
border-radius: var(--radius-full);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.15s;
}
.sort-distance-button:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.sort-distance-button.active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.sort-distance-button:disabled {
opacity: 0.6;
cursor: progress;
}
.location-error {
color: var(--error);
font-size: 0.85rem;
}
.location-source {
color: var(--primary);
font-size: 0.85rem;
}
+263
View File
@@ -0,0 +1,263 @@
import React, { useState, useEffect, useMemo } from 'react';
import SearchBar from '../components/SearchBar';
import MedicineResults from '../components/MedicineResults';
import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition } from '../utils/geo';
import './SearchView.css';
const suggestions = [
{ name: 'Paracetamol', icon: 'medication', color: 'primary' },
{ name: 'Ibuprofeno', icon: 'pill', color: 'secondary' },
{ name: 'Aspirina', icon: 'vaccines', color: 'tertiary' },
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
];
const recentResults = [
{ name: 'Amoxicilina', detail: '500mg • 24 Cápsulas', tag: 'Antibiótico', distance: '300m - Farmacia Central' },
{ name: 'Losartán', detail: '50mg • 30 Comprimidos', tag: 'Hipertensión', distance: '1.2km - Farmacia del Sol' },
];
function SearchView({ currentUser, onLoginRequest }) {
const [searchQuery, setSearchQuery] = useState('');
const [medicines, setMedicines] = useState([]);
const [selectedMedicine, setSelectedMedicine] = useState(null);
const [pharmacies, setPharmacies] = useState([]);
const [loading, setLoading] = useState(false);
const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null);
const [sortByDistance, setSortByDistance] = useState(false);
const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState('');
useEffect(() => {
const searchMedicines = async () => {
if (searchQuery.trim().length < 2) {
setMedicines([]);
setSelectedMedicine(null);
setPharmacies([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/medicines/search?q=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
setMedicines(data);
} catch (error) {
console.error('Error searching medicines:', error);
} finally {
setLoading(false);
}
};
const timeoutId = setTimeout(searchMedicines, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
useEffect(() => {
const fetchPharmacies = async () => {
if (!selectedMedicine) {
setPharmacies([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/medicines/${selectedMedicine.id}/pharmacies`);
const data = await response.json();
setPharmacies(data);
} catch (error) {
console.error('Error fetching pharmacies:', error);
} finally {
setLoading(false);
}
};
fetchPharmacies();
}, [selectedMedicine]);
const savedLat = currentUser?.latitude;
const savedLon = currentUser?.longitude;
const hasSavedCoords = savedLat != null && savedLon != null;
const handleSortByDistance = async () => {
if (sortByDistance) {
setSortByDistance(false);
return;
}
setLocationError('');
if (hasSavedCoords) {
setUserPosition({ lat: savedLat, lon: savedLon });
setPositionSource('profile');
setSortByDistance(true);
return;
}
if (userPosition) {
setSortByDistance(true);
return;
}
setLocating(true);
try {
const pos = await getUserPosition();
setUserPosition(pos);
setPositionSource('browser');
setSortByDistance(true);
} catch (err) {
let msg = 'No se pudo obtener tu ubicación';
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Permiso de ubicación denegado';
else if (err.code === 2) msg = 'Ubicación no disponible';
else if (err.code === 3) msg = 'La solicitud de ubicación expiró';
}
setLocationError(msg);
} finally {
setLocating(false);
}
};
const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies;
return [...pharmacies].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1;
return (
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
);
});
}, [pharmacies, sortByDistance, userPosition]);
return (
<div className="search-view">
<div className="search-content">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Escriba el nombre del medicamento"
/>
{loading && <div className="loading">Buscando...</div>}
{!searchQuery && !selectedMedicine && (
<>
<section className="suggestions-section">
<h2 className="section-title">Sugerencias</h2>
<div className="suggestions-grid">
{suggestions.map((s, i) => (
<button
key={i}
className={`suggestion-btn suggestion-btn--${s.color}`}
onClick={() => setSearchQuery(s.name)}
>
<div className="suggestion-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z" />
</svg>
</div>
<span className="suggestion-name">{s.name}</span>
</button>
))}
</div>
</section>
<section className="recent-section">
<h2 className="section-title">Resultados Recientes</h2>
<div className="recent-list">
{recentResults.map((r, i) => (
<div key={i} className="recent-card">
<div className="recent-card-top">
<div>
<h3 className="recent-name">{r.name}</h3>
<p className="recent-detail">{r.detail}</p>
</div>
<span className="recent-tag">{r.tag}</span>
</div>
<div className="recent-distance">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{r.distance}</span>
</div>
<button className="recent-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
Encontrar cerca
</button>
</div>
))}
</div>
</section>
</>
)}
{searchQuery && !selectedMedicine && (
<MedicineResults
medicines={medicines}
onSelect={setSelectedMedicine}
query={searchQuery}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
/>
)}
{selectedMedicine && (
<div className="selected-medicine-section">
<div className="medicine-info">
<h2>{selectedMedicine.name}</h2>
<div className="medicine-details">
<span><strong>Ingrediente Activo:</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
</div>
<button
className="back-button"
onClick={() => {
setSelectedMedicine(null);
setPharmacies([]);
}}
>
Volver a búsqueda
</button>
</div>
{pharmacies.length > 0 && (
<div className="pharmacy-controls">
<button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance}
disabled={locating}
>
{locating
? '📍 Localizando…'
: sortByDistance
? '📍 Ordenado por distancia · Reset'
: hasSavedCoords
? '📍 Ordenar por ubicación guardada'
: '📍 Ordenar por distancia'}
</button>
{sortByDistance && positionSource === 'profile' && (
<span className="location-source">Usando tu dirección guardada</span>
)}
{locationError && (
<span className="location-error">{locationError}</span>
)}
</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList
pharmacies={displayedPharmacies}
loading={loading}
userPosition={sortByDistance ? userPosition : null}
medicine={selectedMedicine}
currentUser={currentUser}
onLoginRequest={onLoginRequest}
/>
</div>
)}
</div>
</div>
);
}
export default SearchView;
+5 -5
View File
@@ -20,14 +20,14 @@ export default defineConfig({
name: 'FarmaFinder', name: 'FarmaFinder',
short_name: 'FarmaFinder', short_name: 'FarmaFinder',
description: 'Find pharmacies that stock the medicine you need', description: 'Find pharmacies that stock the medicine you need',
theme_color: '#0f766e', theme_color: '#00450d',
background_color: '#ffffff', background_color: '#f8fafb',
display: 'standalone', display: 'standalone',
start_url: '/', start_url: '/',
icons: [ icons: [
{ src: '/icon.svg', sizes: '192x192', type: 'image/svg+xml', purpose: 'any' }, { src: '/logo-square-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icon.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any' }, { src: '/logo-square-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: '/icon-mask.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'maskable' }, { src: '/logo.png', sizes: '512x279', type: 'image/png', purpose: 'any' },
], ],
}, },
}), }),
-1
View File
@@ -35,7 +35,6 @@
"url": "https://opencollective.com/capawesome" "url": "https://opencollective.com/capawesome"
} }
], ],
"license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"@capacitor/core": ">=8.0.0" "@capacitor/core": ">=8.0.0"
} }