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.
@@ -18,6 +18,16 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"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",
|
||||
"bcrypt": "^5.1.1",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
@@ -27,6 +37,8 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"redis": "^4.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"web-push": "^3.6.7"
|
||||
|
||||
@@ -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 cors from 'cors';
|
||||
import sqlite3 from 'sqlite3';
|
||||
@@ -11,6 +14,8 @@ import pg from 'pg';
|
||||
import bcrypt from 'bcrypt';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import webpush from 'web-push';
|
||||
import pino from 'pino';
|
||||
import pinoHttp from 'pino-http';
|
||||
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||
@@ -21,6 +26,13 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
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
|
||||
// req.ip and X-Forwarded-For are honored. Configurable via TRUST_PROXY:
|
||||
// - unset/"1" → trust exactly one hop (typical single reverse proxy)
|
||||
@@ -39,6 +51,7 @@ app.use(cors({
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(pinoHttp({ logger }));
|
||||
|
||||
const PG_URL = process.env.PG_URL;
|
||||
let pgPool = null;
|
||||
|
||||
@@ -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);
|
||||
@@ -32,6 +32,12 @@ services:
|
||||
DATABASE_PATH: /app/data/database.sqlite
|
||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||
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:
|
||||
- backend_data:/app/data
|
||||
depends_on:
|
||||
@@ -40,7 +46,13 @@ services:
|
||||
|
||||
frontend:
|
||||
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
|
||||
ports:
|
||||
- "3000:80"
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,12 @@
|
||||
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
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f766e" />
|
||||
<title>FarmaFinder | Find Your Medicine</title>
|
||||
<meta name="theme-color" content="#00450d" />
|
||||
<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.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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
"@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",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
@@ -2205,6 +2209,75 @@
|
||||
"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": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
@@ -2289,6 +2362,508 @@
|
||||
"@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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
@@ -2904,6 +3479,14 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -3117,7 +3700,6 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -3126,6 +3708,14 @@
|
||||
"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": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
@@ -3555,6 +4145,11 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3766,7 +4361,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -4040,7 +4634,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -4404,7 +4997,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -4695,7 +5287,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4704,6 +5295,19 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
@@ -4775,6 +5379,17 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -4905,7 +5520,6 @@
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.3"
|
||||
@@ -5430,6 +6044,11 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -5591,11 +6210,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@@ -5805,7 +6428,6 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
@@ -5971,6 +6593,28 @@
|
||||
"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": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
@@ -6182,6 +6826,19 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -6193,7 +6850,6 @@
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -6819,7 +7475,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -7087,6 +7742,31 @@
|
||||
"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": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
@@ -7113,6 +7793,11 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"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_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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/splash-screen": "^8.0.1",
|
||||
"@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",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 172 KiB |
@@ -1,419 +1,29 @@
|
||||
.app {
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 3rem 1.5rem;
|
||||
max-width: 1000px;
|
||||
max-width: 48rem;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
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;
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.medicine-info {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
border: 1px solid var(--border);
|
||||
@media (max-width: 768px) {
|
||||
.app-content {
|
||||
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.medicine-info h2 {
|
||||
color: var(--text-main);
|
||||
margin-bottom: 1.2rem;
|
||||
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 fadeInUp {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './App.css';
|
||||
import PublicView from './views/PublicView';
|
||||
import AdminView from './views/AdminView';
|
||||
import HomeView from './views/HomeView';
|
||||
import SearchView from './views/SearchView';
|
||||
import ScannerView from './views/ScannerView';
|
||||
import AlertsView from './views/AlertsView';
|
||||
import ProfileView from './views/ProfileView';
|
||||
import AdminView from './views/AdminView';
|
||||
import LoginModal from './components/LoginModal';
|
||||
import SavedNotifications from './components/SavedNotifications';
|
||||
import TopBar from './components/TopBar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState('public');
|
||||
const [publicScreen, setPublicScreen] = useState('home'); // 'home' | 'scan' | 'search'
|
||||
const [screen, setScreen] = useState('home');
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
@@ -32,114 +34,110 @@ function App() {
|
||||
async function handleLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setCurrentUser(null);
|
||||
setView('public');
|
||||
setPublicScreen('home');
|
||||
setScreen('home');
|
||||
}
|
||||
|
||||
function handleProfileSaved(updated) {
|
||||
setCurrentUser(prev => ({ ...prev, ...updated }));
|
||||
}
|
||||
|
||||
const isAdmin = Boolean(currentUser?.is_admin);
|
||||
|
||||
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
|
||||
}
|
||||
const isLoggedIn = Boolean(currentUser);
|
||||
|
||||
function handleNavChange(tab) {
|
||||
if (tab === 'search') { setView('public'); setPublicScreen('search'); return; }
|
||||
if (tab === 'scan') { setView('public'); setPublicScreen('scan'); return; }
|
||||
if (tab === 'saved') {
|
||||
if (currentUser) { setShowSaved(true); setView('public'); setPublicScreen('home'); }
|
||||
if (tab === 'home') { setScreen('home'); return; }
|
||||
if (tab === 'search') { setScreen('search'); return; }
|
||||
if (tab === 'scan') { setScreen('scan'); return; }
|
||||
if (tab === 'alerts') {
|
||||
if (currentUser) setScreen('alerts');
|
||||
else setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (tab === 'profile') {
|
||||
if (currentUser) setView('profile');
|
||||
if (currentUser) setScreen('profile');
|
||||
else setShowLogin(true);
|
||||
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 (
|
||||
<div className="app">
|
||||
<TopBar
|
||||
currentUser={currentUser}
|
||||
isAdmin={isAdmin}
|
||||
authChecked={authChecked}
|
||||
onShowSaved={() => setShowSaved(true)}
|
||||
onLoginRequest={() => setShowLogin(true)}
|
||||
onAdminClick={() => setView(view === 'admin' ? 'public' : 'admin')}
|
||||
title={topBarTitle}
|
||||
showBack={showBack}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<nav className="view-switcher" aria-label="Primary">
|
||||
<button
|
||||
className={view === 'public' ? 'active' : ''}
|
||||
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}
|
||||
<div className="app-content">
|
||||
{activeView}
|
||||
</div>
|
||||
|
||||
<BottomNav
|
||||
activeTab={navActiveTab()}
|
||||
activeTab={screen}
|
||||
onChange={handleNavChange}
|
||||
isLoggedIn={Boolean(currentUser)}
|
||||
isLoggedIn={isLoggedIn}
|
||||
badgeCount={currentUser ? 2 : 0}
|
||||
/>
|
||||
|
||||
{showLogin && (
|
||||
@@ -156,4 +154,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
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(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -11,39 +12,34 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Helper: navigate from HomeView to the search screen
|
||||
async function navigateToSearch() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /manual search/i }))
|
||||
}
|
||||
|
||||
describe('HomeView', () => {
|
||||
it('renders two action buttons on the home screen', () => {
|
||||
render(<PublicView />)
|
||||
expect(screen.getByRole('button', { name: /scan tsi/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /manual search/i })).toBeInTheDocument()
|
||||
const onSearch = vi.fn()
|
||||
const onScan = vi.fn()
|
||||
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 () => {
|
||||
render(<PublicView />)
|
||||
await navigateToSearch()
|
||||
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
|
||||
it('calls onSearchClick when Buscar Medicamento is clicked', async () => {
|
||||
const onSearch = vi.fn()
|
||||
render(<HomeView onSearchClick={onSearch} onScanClick={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /buscar medicamento/i }))
|
||||
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PublicView (search screen)', () => {
|
||||
it('renders search bar on load with no results', async () => {
|
||||
render(<PublicView />)
|
||||
await navigateToSearch()
|
||||
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
||||
describe('SearchView', () => {
|
||||
it('renders search bar with placeholder', () => {
|
||||
render(<SearchView />)
|
||||
expect(screen.getByPlaceholderText(/escriba el nombre del medicamento/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not fetch for queries shorter than 2 chars', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
render(<PublicView />)
|
||||
await navigateToSearch()
|
||||
render(<SearchView />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
|
||||
target: { value: 'a' },
|
||||
})
|
||||
|
||||
@@ -62,10 +58,9 @@ describe('PublicView (search screen)', () => {
|
||||
json: async () => medicines,
|
||||
})
|
||||
|
||||
render(<PublicView />)
|
||||
await navigateToSearch()
|
||||
render(<SearchView />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/escriba el nombre del medicamento/i), {
|
||||
target: { value: 'ibu' },
|
||||
})
|
||||
|
||||
@@ -84,9 +79,8 @@ describe('PublicView (search screen)', () => {
|
||||
json: async () => medicines,
|
||||
})
|
||||
|
||||
render(<PublicView />)
|
||||
await navigateToSearch()
|
||||
const input = screen.getByPlaceholderText(/search for a medicine/i)
|
||||
render(<SearchView />)
|
||||
const input = screen.getByPlaceholderText(/escriba el nombre del medicamento/i)
|
||||
|
||||
fireEvent.change(input, { target: { value: 'ibu' } })
|
||||
await act(async () => { await vi.runAllTimersAsync() })
|
||||
@@ -98,4 +92,3 @@ describe('PublicView (search screen)', () => {
|
||||
expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -1,23 +1,22 @@
|
||||
/* BottomNav — mobile-only fixed bottom tab bar. Desktop hides via display:none above 768px. */
|
||||
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: flex-end;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
padding: 0.4rem 0.5rem calc(0.4rem + env(safe-area-inset-bottom));
|
||||
background: var(--glass-bg);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||
background: var(--surface-container-lowest);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
height: 5.5rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
@@ -25,19 +24,21 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.25rem 0.1rem;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-surface-variant);
|
||||
font: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.15s;
|
||||
position: relative;
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.bottom-nav-item.disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -45,45 +46,78 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 999px;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--radius-full);
|
||||
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 */
|
||||
.bottom-nav-item:nth-child(2) {
|
||||
margin-top: -1.1rem;
|
||||
.nav-fab {
|
||||
display: inline-flex;
|
||||
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 {
|
||||
background: var(--primary);
|
||||
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);
|
||||
.nav-fab:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.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);
|
||||
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);
|
||||
}
|
||||
|
||||
.bottom-nav-item:active .nav-icon-wrap--elevated {
|
||||
transform: scale(0.94);
|
||||
.bottom-nav-item.active .nav-icon-wrap svg {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.01em;
|
||||
.nav-elevated.active .nav-label {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { IconSearch, IconScan, IconBell, IconUser } from './icons';
|
||||
import { IconHome, IconSearch, IconScan, IconBell, IconUser } from './icons';
|
||||
import './BottomNav.css';
|
||||
|
||||
// ponytail: 4 tabs. Scan is the 2nd item (center) and gets the elevated teal pill.
|
||||
// ponytail: when logged out, "saved" slot morphs into Login. Profile is disabled until login.
|
||||
function BottomNav({ activeTab, onChange, isLoggedIn }) {
|
||||
function BottomNav({ activeTab, onChange, isLoggedIn, badgeCount }) {
|
||||
const tabs = [
|
||||
{ id: 'search', label: 'Search', Icon: IconSearch },
|
||||
{ id: 'scan', label: 'Scan', Icon: IconScan, elevated: true },
|
||||
{ id: 'saved', label: 'Saved', Icon: IconBell, requiresAuth: true },
|
||||
{ id: 'profile', label: 'Profile', Icon: IconUser, requiresAuth: true },
|
||||
{ id: 'home', label: 'Inicio', Icon: IconHome },
|
||||
{ id: 'search', label: 'Buscar', Icon: IconSearch },
|
||||
{ id: 'scan', label: 'Escanear', Icon: IconScan, elevated: true },
|
||||
{ id: 'alerts', label: 'Avisos', Icon: IconBell, badge: badgeCount > 0, badgeCount },
|
||||
{ id: 'profile', label: 'Usuario', Icon: IconUser },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bottom-nav" aria-label="Primary">
|
||||
{tabs.map(({ id, label, Icon, elevated, requiresAuth }) => {
|
||||
<nav className="bottom-nav" aria-label="Navegación principal">
|
||||
{tabs.map(({ id, label, Icon, elevated, badge, badgeCount: count, requiresAuth }) => {
|
||||
const disabled = requiresAuth && !isLoggedIn;
|
||||
const isActive = activeTab === id;
|
||||
return (
|
||||
@@ -24,16 +23,31 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
|
||||
'bottom-nav-item',
|
||||
isActive ? 'active' : '',
|
||||
disabled ? 'disabled' : '',
|
||||
elevated ? 'nav-elevated' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onChange(id)}
|
||||
onClick={() => {
|
||||
if (!disabled) onChange(id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<span className={`nav-icon-wrap${elevated ? ' nav-icon-wrap--elevated' : ''}`}>
|
||||
<Icon size={elevated ? 24 : 22} />
|
||||
{elevated ? (
|
||||
<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 className="nav-label">{label}</span>
|
||||
{isActive && !elevated && <span className="nav-indicator" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -41,4 +55,4 @@ function BottomNav({ activeTab, onChange, isLoggedIn }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomNav;
|
||||
export default BottomNav;
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
.medicine-results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||
animation: fadeInUp 0.8s ease-out 0.4s backwards;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.medicine-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.65rem;
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
border: 1px solid var(--outline-variant);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.medicine-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.medicine-card-header {
|
||||
@@ -40,19 +37,18 @@
|
||||
}
|
||||
|
||||
.medicine-card h3 {
|
||||
color: var(--text-main);
|
||||
font-size: 1.2rem;
|
||||
color: var(--on-surface);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 0.2s;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notify-bell {
|
||||
background: var(--surface-muted, #f5f5f4);
|
||||
border: 1px solid var(--border, #e7e5e4);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--radius-full);
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
cursor: pointer;
|
||||
@@ -69,11 +65,9 @@
|
||||
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:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.notify-bell:active:not(:disabled) {
|
||||
@@ -86,56 +80,39 @@
|
||||
}
|
||||
|
||||
.notify-bell--on {
|
||||
background: var(--primary, #2563eb);
|
||||
border-color: var(--primary, #2563eb);
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.medicine-card:hover h3 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.medicine-card:hover .view-pharmacies::after {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.medicine-card-body {
|
||||
margin-bottom: 1.35rem;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.medicine-card-body p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-surface-variant);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.medicine-card-body strong {
|
||||
color: var(--text-main);
|
||||
color: var(--on-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.medicine-card-footer {
|
||||
padding-top: 1.15rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.view-pharmacies {
|
||||
@@ -152,11 +129,15 @@
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.medicine-card:hover .view-pharmacies::after {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 2.75rem 1.5rem;
|
||||
background: var(--surface-muted);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
border: 1px dashed var(--border-strong);
|
||||
padding: 2.5rem 1.5rem;
|
||||
background: var(--surface-container-low);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--on-surface-variant);
|
||||
border: 1px dashed var(--outline-variant);
|
||||
}
|
||||
|
||||
@@ -3,37 +3,34 @@
|
||||
}
|
||||
|
||||
.pharmacy-list-title {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.35rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 1rem;
|
||||
color: var(--on-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
letter-spacing: -0.02em;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pharmacy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pharmacy-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.65rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
box-shadow: 0 1px 3px rgba(28, 25, 23, 0.04);
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--outline-variant);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.pharmacy-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(28, 25, 23, 0.08);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.pharmacy-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.pharmacy-header {
|
||||
@@ -41,15 +38,14 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pharmacy-header h4 {
|
||||
color: var(--text-main);
|
||||
color: var(--on-surface);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
transition: color 0.2s;
|
||||
font-size: 1.05rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -60,80 +56,18 @@
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
background: rgba(0, 69, 13, 0.08);
|
||||
color: var(--primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.pharmacy-card:hover .pharmacy-header h4 {
|
||||
color: var(--primary);
|
||||
}
|
||||
.pharmacy-card:hover .pharmacy-header h4 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pharmacy-details {
|
||||
@@ -144,7 +78,7 @@
|
||||
|
||||
.pharmacy-address,
|
||||
.pharmacy-phone {
|
||||
color: var(--text-muted);
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
@@ -164,29 +98,29 @@
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.pharmacy-hours--open {
|
||||
color: var(--accent, #047857);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pharmacy-hours--closed {
|
||||
color: var(--text-muted);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.pharmacy-pricing {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -194,35 +128,34 @@
|
||||
.stock {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.stock.in-stock {
|
||||
background: rgba(4, 120, 87, 0.12);
|
||||
color: var(--accent);
|
||||
background: rgba(0, 69, 13, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stock.low-stock {
|
||||
background: rgba(180, 83, 9, 0.12);
|
||||
color: var(--accent-warm);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.stock.out-of-stock {
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
color: #b91c1c;
|
||||
background: rgba(186, 26, 26, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.loading-pharmacies,
|
||||
.no-pharmacies {
|
||||
text-align: center;
|
||||
padding: 2.75rem 1.5rem;
|
||||
background: var(--surface-muted);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2.5rem 1.5rem;
|
||||
background: var(--surface-container-low);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--on-surface-variant);
|
||||
border: 1px solid var(--outline-variant);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
.search-bar-container {
|
||||
margin-bottom: 2.5rem;
|
||||
width: 100%;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 1.25rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
background: var(--surface-container-lowest);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.25rem 1rem;
|
||||
border: 2px solid var(--outline-variant);
|
||||
transition: border-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-ring), var(--glass-shadow);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.85rem;
|
||||
opacity: 0.45;
|
||||
color: var(--primary);
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -32,35 +31,34 @@
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-main);
|
||||
font-size: 1.125rem;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
color: var(--on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.65;
|
||||
color: var(--on-surface-variant);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid var(--outline-variant);
|
||||
color: var(--on-surface-variant);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.2s, color 0.2s, transform 0.2s;
|
||||
transition: background 0.15s;
|
||||
margin-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: var(--surface-card);
|
||||
color: var(--text-main);
|
||||
transform: rotate(90deg);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ function SearchBar({ value, onChange, placeholder }) {
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<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
|
||||
type="text"
|
||||
value={value}
|
||||
@@ -18,7 +23,7 @@ function SearchBar({ value, onChange, placeholder }) {
|
||||
<button
|
||||
className="clear-button"
|
||||
onClick={() => onChange('')}
|
||||
aria-label="Clear search"
|
||||
aria-label="Limpiar búsqueda"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -29,4 +34,3 @@ function SearchBar({ value, onChange, placeholder }) {
|
||||
}
|
||||
|
||||
export default SearchBar;
|
||||
|
||||
|
||||
@@ -1,88 +1,63 @@
|
||||
/* TopBar — mobile-only sticky header. Desktop hides via display:none above 768px. */
|
||||
|
||||
.top-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
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);
|
||||
height: var(--touch-target-min);
|
||||
padding: 0 var(--margin-main);
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
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 {
|
||||
.topbar-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.topbar-action:hover {
|
||||
background: var(--surface-muted);
|
||||
.topbar-back:hover {
|
||||
background: var(--surface-container-high);
|
||||
}
|
||||
|
||||
.topbar-action--admin {
|
||||
.topbar-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
border-color: rgba(15, 118, 110, 0.25);
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.topbar-action--admin:hover {
|
||||
background: rgba(15, 118, 110, 0.15);
|
||||
.topbar-logo-img {
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.topbar-action--login {
|
||||
color: var(--primary);
|
||||
border-color: rgba(15, 118, 110, 0.25);
|
||||
.topbar-spacer {
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.topbar-action--placeholder {
|
||||
width: 2.25rem;
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,28 @@
|
||||
import { IconBell, IconCog, IconUser, IconLogo } from './icons';
|
||||
import './TopBar.css';
|
||||
|
||||
function TopBar({
|
||||
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" />;
|
||||
}
|
||||
|
||||
function TopBar({ title, showBack, onBack, rightAction }) {
|
||||
return (
|
||||
<header className="top-bar">
|
||||
<div className="topbar-brand">
|
||||
<IconLogo size={22} className="topbar-brand-icon" />
|
||||
<span className="topbar-brand-text">FarmaFinder</span>
|
||||
<div className="topbar-inner">
|
||||
{showBack ? (
|
||||
<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 className="topbar-actions">{renderAction()}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopBar;
|
||||
export default TopBar;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// ponytail: stroke-based line icons, 24x24, currentColor. Emojis stay for in-content use.
|
||||
// ponytail: add when a filled variant is needed for active states — swap stroke="currentColor" for fill.
|
||||
export function IconHome({ size = 24, ...props }) {
|
||||
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 }) {
|
||||
return (
|
||||
<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" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
@@ -14,7 +21,7 @@ export function IconSearch({ size = 24, ...props }) {
|
||||
export function IconScan({ size = 24, ...props }) {
|
||||
return (
|
||||
<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="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<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 }) {
|
||||
return (
|
||||
<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="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
@@ -37,7 +44,7 @@ export function IconBell({ size = 24, ...props }) {
|
||||
export function IconUser({ size = 24, ...props }) {
|
||||
return (
|
||||
<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" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
@@ -47,7 +54,7 @@ export function IconUser({ size = 24, ...props }) {
|
||||
export function IconCog({ size = 24, ...props }) {
|
||||
return (
|
||||
<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" />
|
||||
<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>
|
||||
@@ -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" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,75 @@
|
||||
:root {
|
||||
/* Brand — teal (trust / health), no purple */
|
||||
--primary: #0f766e;
|
||||
--primary-hover: #0d9488;
|
||||
--primary-light: #ccfbf1;
|
||||
--primary-faint: rgba(15, 118, 110, 0.08);
|
||||
--primary-ring: rgba(15, 118, 110, 0.22);
|
||||
--primary-shadow: rgba(15, 118, 110, 0.18);
|
||||
--primary: #00450d;
|
||||
--on-primary: #ffffff;
|
||||
--primary-container: #1b5e20;
|
||||
--on-primary-container: #90d689;
|
||||
--secondary: #2b5bb5;
|
||||
--on-secondary: #ffffff;
|
||||
--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) */
|
||||
--accent: #047857;
|
||||
--radius-sm: 0.25rem;
|
||||
--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;
|
||||
|
||||
/* Neutrals */
|
||||
--text-main: #1c1917;
|
||||
--text-muted: #57534e;
|
||||
--surface: #ffffff;
|
||||
--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;
|
||||
--primary-hover: #1a6b2e;
|
||||
--primary-ring: rgba(0, 69, 13, 0.22);
|
||||
--primary-shadow: rgba(0, 69, 13, 0.18);
|
||||
--primary-light: var(--primary-fixed);
|
||||
--primary-faint: rgba(0, 69, 13, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -39,22 +83,31 @@ html, body {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
color: var(--text-main);
|
||||
background-color: var(--surface-muted);
|
||||
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;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
color: var(--on-surface);
|
||||
background-color: var(--surface-container-lowest);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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 {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -3,6 +3,11 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,291 +1,192 @@
|
||||
/* ============================================================
|
||||
HomeView.css — Premium home page styles
|
||||
============================================================ */
|
||||
|
||||
.home-container {
|
||||
.home-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem 1.5rem;
|
||||
gap: 2.5rem;
|
||||
animation: fadeInUp 0.7s ease-out;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
.home-header {
|
||||
text-align: center;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
font-size: clamp(2.6rem, 7vw, 4rem);
|
||||
font-weight: 900;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.home-logo::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 3rem;
|
||||
height: 4px;
|
||||
margin: 0.85rem auto 0;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-hover));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.home-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ── Card Grid ──────────────────────────────────────────── */
|
||||
.home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 54rem;
|
||||
}
|
||||
|
||||
/* ── Base Card ──────────────────────────────────────────── */
|
||||
.home-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
padding: 2rem 1.75rem 1.6rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--glass-bg);
|
||||
box-shadow: var(--glass-shadow);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
padding: 1rem var(--margin-main);
|
||||
gap: 1.5rem;
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.home-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
.home-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.home-card:hover {
|
||||
transform: translateY(-5px) scale(1.015);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(28, 25, 23, 0.14),
|
||||
0 4px 12px rgba(28, 25, 23, 0.08);
|
||||
}
|
||||
|
||||
.home-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-card:active {
|
||||
transform: translateY(-2px) scale(1.005);
|
||||
}
|
||||
|
||||
/* ── Scan Card Accent ───────────────────────────────────── */
|
||||
.scan-card {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.97) 0%,
|
||||
rgba(240, 253, 250, 0.97) 100%
|
||||
);
|
||||
border-color: rgba(15, 118, 110, 0.22);
|
||||
}
|
||||
|
||||
.scan-card::before {
|
||||
background: radial-gradient(
|
||||
ellipse at top left,
|
||||
rgba(15, 118, 110, 0.08) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.scan-card:hover {
|
||||
border-color: rgba(15, 118, 110, 0.45);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(15, 118, 110, 0.18),
|
||||
0 4px 12px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
/* ── Search Card Accent ─────────────────────────────────── */
|
||||
.search-card {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.97) 0%,
|
||||
rgba(245, 245, 244, 0.97) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.search-card:hover {
|
||||
border-color: rgba(71, 85, 105, 0.35);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(28, 25, 23, 0.12),
|
||||
0 4px 12px rgba(28, 25, 23, 0.06);
|
||||
}
|
||||
|
||||
/* ── Badge ──────────────────────────────────────────────── */
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: 1.1rem;
|
||||
right: 1.1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 8px rgba(15, 118, 110, 0.35);
|
||||
}
|
||||
|
||||
/* ── Icon Container ─────────────────────────────────────── */
|
||||
.card-icon-container {
|
||||
position: relative;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
.home-logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2.4rem;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.12));
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.home-card:hover .card-icon {
|
||||
transform: scale(1.18) rotate(-4deg);
|
||||
}
|
||||
|
||||
/* Pulse rings on the Scan card */
|
||||
.pulse-ring,
|
||||
.pulse-ring-outer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(15, 118, 110, 0.4);
|
||||
animation: pulse-ring 2.2s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pulse-ring-outer {
|
||||
animation-delay: 0.7s;
|
||||
border-color: rgba(15, 118, 110, 0.2);
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.85); opacity: 0.7; }
|
||||
70% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Card Content ───────────────────────────────────────── */
|
||||
.card-content h3 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
.home-logo {
|
||||
width: min(16rem, 70vw);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ── Card Action Footer ─────────────────────────────────── */
|
||||
.card-action {
|
||||
@media (max-height: 700px) {
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scan-card .card-action {
|
||||
color: var(--primary);
|
||||
.home-card-label {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.search-card .card-action {
|
||||
color: var(--text-muted);
|
||||
.home-card-arrow {
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-card:hover .card-action {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.action-arrow {
|
||||
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.home-card:hover .action-arrow {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────── */
|
||||
.home-footer {
|
||||
max-width: 40rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-footer p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.home-container {
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 1.75rem;
|
||||
justify-content: flex-start;
|
||||
padding-top: 0.5rem;
|
||||
@media (max-width: 400px) {
|
||||
.home-card {
|
||||
min-height: 4.25rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.home-card-label {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.home-view {
|
||||
gap: 1rem;
|
||||
padding: 0.5rem var(--margin-main);
|
||||
}
|
||||
|
||||
.home-card {
|
||||
padding: 1.5rem 1.4rem 1.3rem;
|
||||
gap: 0.9rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.625rem 1rem;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 1.25rem;
|
||||
.home-card-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
font-size: 0.88rem;
|
||||
.home-card-icon svg {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,48 +3,52 @@ import './HomeView.css';
|
||||
|
||||
function HomeView({ onScanClick, onSearchClick }) {
|
||||
return (
|
||||
<div className="home-container">
|
||||
<header className="home-header">
|
||||
<h1 className="home-logo">💊 FarmaFinder</h1>
|
||||
<p className="home-subtitle">Find your medicine at nearby pharmacies quickly and easily.</p>
|
||||
</header>
|
||||
<div className="home-view">
|
||||
<div className="home-hero">
|
||||
<div className="home-logo-wrapper">
|
||||
<img src="/logo.png" alt="FarmaFinder" className="home-logo" />
|
||||
</div>
|
||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||
</div>
|
||||
|
||||
<div className="home-grid">
|
||||
<button className="home-card scan-card" onClick={onScanClick} aria-label="Scan TSI Card">
|
||||
<div className="card-badge">Fast Access</div>
|
||||
<div className="card-icon-container">
|
||||
<span className="card-icon">📷</span>
|
||||
<div className="pulse-ring"></div>
|
||||
<div className="pulse-ring-outer"></div>
|
||||
<div className="home-cards">
|
||||
<button className="home-card home-card--search" onClick={onSearchClick}>
|
||||
<div className="home-card-icon">
|
||||
<svg width="32" height="32" 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>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<h3>Scan TSI Card</h3>
|
||||
<p>Scan your health card (Tarjeta Sanitaria Individual) barcode or QR code with your camera to instantly view your active prescriptions.</p>
|
||||
</div>
|
||||
<div className="card-action">
|
||||
<span>Scan now</span>
|
||||
<span className="action-arrow">→</span>
|
||||
<div className="home-card-text">
|
||||
<span className="home-card-label">Buscar Medicamento</span>
|
||||
<span className="home-card-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="home-card search-card" onClick={onSearchClick} aria-label="Manual Search">
|
||||
<div className="card-icon-container">
|
||||
<span className="card-icon">🔍</span>
|
||||
<button className="home-card home-card--scan" onClick={onScanClick}>
|
||||
<div className="home-card-icon">
|
||||
<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 className="card-content">
|
||||
<h3>Manual Search</h3>
|
||||
<p>Search for a specific medicine by typing its name, active ingredient, or register number to check availability in real-time.</p>
|
||||
</div>
|
||||
<div className="card-action">
|
||||
<span>Search catalogue</span>
|
||||
<span className="action-arrow">→</span>
|
||||
<div className="home-card-text">
|
||||
<span className="home-card-label">Escanear TSI</span>
|
||||
<span className="home-card-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer className="home-footer">
|
||||
<p>🔒 Your medical and personal data is secure. FarmaFinder complies with data protection regulations and does not store sensitive card information.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,196 @@
|
||||
.profile-view {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem var(--margin-main) 2rem;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
.profile-avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.5rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-faint);
|
||||
color: var(--primary);
|
||||
font-size: 1.8rem;
|
||||
.profile-avatar-circle {
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--secondary-container);
|
||||
color: var(--on-secondary-container);
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info h2 {
|
||||
margin: 0;
|
||||
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 {
|
||||
.menu-item-icon--primary {
|
||||
background: rgba(0, 69, 13, 0.08);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.5rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
.menu-item-icon--error {
|
||||
background: rgba(186, 26, 26, 0.08);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.profile-section h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
.menu-item-icon--secondary {
|
||||
background: rgba(43, 91, 181, 0.08);
|
||||
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;
|
||||
color: var(--text-main);
|
||||
color: var(--on-surface);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-section-sub {
|
||||
margin: 0 0 1.1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--on-surface-variant);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-field label {
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--on-surface-variant);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.profile-field input {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-main);
|
||||
border: 2px solid var(--outline-variant);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.profile-field input:focus {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile-field input:disabled {
|
||||
@@ -127,20 +209,20 @@
|
||||
}
|
||||
|
||||
.profile-btn-secondary {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-container-low);
|
||||
border: 1px solid var(--outline-variant);
|
||||
color: var(--on-surface);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
font-size: 0.83rem;
|
||||
font-size: 0.8rem;
|
||||
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) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
.profile-btn-secondary:disabled {
|
||||
@@ -151,110 +233,81 @@
|
||||
.profile-feedback {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-feedback--ok {
|
||||
background: var(--primary-faint);
|
||||
background: rgba(0, 69, 13, 0.08);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary-ring);
|
||||
border: 1px solid rgba(0, 69, 13, 0.2);
|
||||
}
|
||||
|
||||
.profile-feedback--err {
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
color: #b91c1c;
|
||||
border: 1px solid rgba(185, 28, 28, 0.2);
|
||||
background: rgba(186, 26, 26, 0.08);
|
||||
color: var(--error);
|
||||
border: 1px solid rgba(186, 26, 26, 0.2);
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
color: var(--on-primary);
|
||||
border: none;
|
||||
padding: 0.65rem 1.6rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.65rem 1.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.profile-btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.profile-btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-link-row {
|
||||
.profile-logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.8rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 5rem;
|
||||
padding: 0 1.5rem;
|
||||
background: rgba(186, 26, 26, 0.06);
|
||||
border: 2px solid rgba(186, 26, 26, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-main);
|
||||
transition: background 0.12s ease;
|
||||
color: var(--error);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.profile-link-row:last-child {
|
||||
border-bottom: none;
|
||||
.profile-logout-btn:active {
|
||||
background: rgba(186, 26, 26, 0.12);
|
||||
}
|
||||
|
||||
.profile-link-row:hover {
|
||||
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) {
|
||||
@media (max-width: 400px) {
|
||||
.profile-field-row {
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 1.1rem 1.1rem;
|
||||
.profile-avatar-circle {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
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();
|
||||
onProfileSaved?.(updated);
|
||||
setFeedback({ type: 'ok', text: 'Profile saved.' });
|
||||
setFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Save failed' });
|
||||
setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -58,13 +58,13 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
const pos = await getUserPosition();
|
||||
setLatitude(String(pos.lat));
|
||||
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) {
|
||||
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.code === 1) msg = 'Location permission denied — allow it in your browser settings.';
|
||||
else if (err.code === 2) msg = 'Location unavailable — check OS location services.';
|
||||
else if (err.code === 3) msg = 'Location request timed out — try again.';
|
||||
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 expiró';
|
||||
}
|
||||
setFeedback({ type: 'err', text: msg });
|
||||
} finally {
|
||||
@@ -75,7 +75,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
async function handleGeocode() {
|
||||
const q = address.trim();
|
||||
if (!q) {
|
||||
setFeedback({ type: 'err', text: 'Enter an address first.' });
|
||||
setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' });
|
||||
return;
|
||||
}
|
||||
setGeocoding(true);
|
||||
@@ -86,61 +86,99 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
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();
|
||||
setLatitude(String(data.lat));
|
||||
setLongitude(String(data.lon));
|
||||
setFeedback({
|
||||
type: 'ok',
|
||||
text: `Located: ${data.displayName} — remember to Save.`,
|
||||
text: `Ubicado: ${data.displayName} — recuerda Guardar.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Lookup failed' });
|
||||
setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' });
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasSavedCoords =
|
||||
currentUser?.latitude != null && currentUser?.longitude != null;
|
||||
const username = currentUser?.username || 'Usuario';
|
||||
|
||||
return (
|
||||
<div className="profile-view">
|
||||
<header className="profile-header">
|
||||
<div className="profile-avatar" aria-hidden="true">👤</div>
|
||||
<div className="profile-info">
|
||||
<h2>{currentUser?.username}</h2>
|
||||
<p className="profile-role">
|
||||
{currentUser?.is_admin ? 'Administrator' : 'Member'}
|
||||
{hasSavedCoords && <span className="profile-pill"> · Location saved</span>}
|
||||
</p>
|
||||
<div className="profile-avatar-section">
|
||||
<div className="profile-avatar-circle">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
</header>
|
||||
<h2 className="profile-name">Mi Perfil</h2>
|
||||
</div>
|
||||
|
||||
<section className="profile-section">
|
||||
<h3>Your location</h3>
|
||||
<p className="profile-section-sub">
|
||||
Save your address so "Sort by distance" uses it without asking the browser every time.
|
||||
</p>
|
||||
<div className="profile-info-cards">
|
||||
<div className="profile-info-card">
|
||||
<p className="info-card-label">Nombre</p>
|
||||
<p className="info-card-value">{username}</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">
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-address">Address</label>
|
||||
<label htmlFor="profile-address">Dirección</label>
|
||||
<input
|
||||
id="profile-address"
|
||||
type="text"
|
||||
placeholder="123 Main St, Madrid, Spain"
|
||||
placeholder="Calle Mayor 1, Madrid"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
autoComplete="street-address"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profile-field-row">
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-lat">Latitude</label>
|
||||
<label htmlFor="profile-lat">Latitud</label>
|
||||
<input
|
||||
id="profile-lat"
|
||||
type="number"
|
||||
@@ -152,7 +190,7 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-lon">Longitude</label>
|
||||
<label htmlFor="profile-lon">Longitud</label>
|
||||
<input
|
||||
id="profile-lon"
|
||||
type="number"
|
||||
@@ -164,57 +202,33 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-helper-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="profile-btn-secondary"
|
||||
onClick={handleGeocode}
|
||||
disabled={geocoding || saving}
|
||||
>
|
||||
{geocoding ? 'Finding…' : '📍 Find from address'}
|
||||
<button type="button" className="profile-btn-secondary" onClick={handleGeocode} disabled={geocoding || saving}>
|
||||
{geocoding ? 'Buscando…' : '📍 Buscar desde dirección'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-btn-secondary"
|
||||
onClick={handleUseLocation}
|
||||
disabled={locating || saving}
|
||||
>
|
||||
{locating ? 'Locating…' : '🛰️ Use my current location'}
|
||||
<button type="button" className="profile-btn-secondary" onClick={handleUseLocation} disabled={locating || saving}>
|
||||
{locating ? 'Localizando…' : '🛰️ Usar mi ubicación'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<p className={`profile-feedback profile-feedback--${feedback.type}`}>
|
||||
{feedback.text}
|
||||
</p>
|
||||
<p className={`profile-feedback profile-feedback--${feedback.type}`}>{feedback.text}</p>
|
||||
)}
|
||||
|
||||
<div className="profile-actions">
|
||||
<button type="submit" className="profile-btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save profile'}
|
||||
{saving ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="profile-section">
|
||||
<h3>Account</h3>
|
||||
<button type="button" className="profile-link-row" onClick={onShowSaved}>
|
||||
<span className="profile-link-row-icon">🔔</span>
|
||||
<span className="profile-link-row-label">Saved notifications</span>
|
||||
<span className="profile-link-row-chevron" aria-hidden="true">›</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>
|
||||
<button className="profile-logout-btn" onClick={onLogout}>
|
||||
<div className="menu-item-icon menu-item-icon--error-outline">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<span>Cerrar Sesión</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,245 +1,124 @@
|
||||
/* ============================================================
|
||||
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));
|
||||
.scanner-view {
|
||||
width: 100%;
|
||||
max-width: 42rem;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Start Scan Button ───────────────────────────────────── */
|
||||
.scan-start-btn {
|
||||
.scanner-content {
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 1.1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
letter-spacing: -0.01em;
|
||||
box-shadow: 0 4px 20px rgba(15, 118, 110, 0.45);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.scan-start-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(15, 118, 110, 0.55);
|
||||
}
|
||||
.scan-start-btn:active {
|
||||
transform: translateY(0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.scan-start-icon {
|
||||
.scanner-heading {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Manual CIP Input ────────────────────────────────────── */
|
||||
.manual-cip-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.cip-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.cip-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cip-input {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #e2e8f0;
|
||||
letter-spacing: 0.08em;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.cip-input::placeholder {
|
||||
color: #475569;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.cip-input:focus {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.cip-submit-btn {
|
||||
background: linear-gradient(135deg, #0f766e, #0d9488);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.cip-submit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(15, 118, 110, 0.4);
|
||||
color: var(--on-surface);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── PWA Notice ──────────────────────────────────────────── */
|
||||
.pwa-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
.pwa-notice-icon {
|
||||
font-size: 1.3rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.pwa-notice p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #93c5fd;
|
||||
.scanner-desc {
|
||||
font-size: 1rem;
|
||||
color: var(--on-surface-variant);
|
||||
font-weight: 400;
|
||||
max-width: 22rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Error Panel ─────────────────────────────────────────── */
|
||||
.scan-error-panel {
|
||||
.scan-error-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem 1.2rem;
|
||||
background: var(--error-container);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scan-error-icon {
|
||||
font-size: 2.5rem;
|
||||
color: var(--error);
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.scan-error-msg {
|
||||
color: #fca5a5;
|
||||
.scan-error-text {
|
||||
color: var(--on-error-container);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scan-retry-btn {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
.scan-btn {
|
||||
height: var(--touch-target-min);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.scan-retry-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
/* ── Scanning Active Panel (native) ──────────────────────── */
|
||||
.scan-active-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
.scan-active-panel p {
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.scan-btn:active {
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
aspect-ratio: 4/3;
|
||||
@@ -262,10 +141,11 @@
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-color: #10b981;
|
||||
border-color: var(--tertiary-container);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.corner.tl { top: 0; left: 0; border-top-width: 3px; border-left-width: 3px; border-radius: 4px 0 0 0; }
|
||||
.corner.tr { top: 0; right: 0; border-top-width: 3px; border-right-width: 3px; border-radius: 0 4px 0 0; }
|
||||
.corner.bl { bottom: 0; left: 0; border-bottom-width: 3px; border-left-width: 3px; border-radius: 0 0 0 4px; }
|
||||
@@ -282,82 +162,131 @@
|
||||
background: rgba(0,0,0,0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
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 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid rgba(255,255,255,0.12);
|
||||
border-top-color: #10b981;
|
||||
border: 3px solid var(--outline-variant);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Prescriptions Panel ─────────────────────────────────── */
|
||||
.scanner-prescriptions {
|
||||
flex: 1;
|
||||
.cip-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1.5rem 1.25rem calc(2rem + env(safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp 0.4s ease-out;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
.cip-label {
|
||||
font-size: 0.8rem;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.2rem;
|
||||
background: rgba(0, 69, 13, 0.08);
|
||||
border: 1px solid rgba(0, 69, 13, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
.scanned-icon { font-size: 2rem; }
|
||||
.scanned-cip {
|
||||
|
||||
.cip-badge-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rx-heading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #f1f5f9;
|
||||
letter-spacing: -0.02em;
|
||||
.cip-badge-value {
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.rx-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--on-surface);
|
||||
margin: 0;
|
||||
}
|
||||
.rx-subheading {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin: -0.5rem 0 0;
|
||||
|
||||
.rx-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--on-surface-variant);
|
||||
margin: -0.75rem 0 0;
|
||||
}
|
||||
|
||||
.rx-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 0;
|
||||
color: #64748b;
|
||||
color: var(--on-surface-variant);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rx-empty {
|
||||
color: #64748b;
|
||||
color: var(--on-surface-variant);
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
@@ -377,64 +306,55 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
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 {
|
||||
background: rgba(255,255,255,0.09);
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
transform: translateX(3px);
|
||||
background: var(--surface-container);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.rx-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.rx-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.rx-detail {
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.rx-ingredient {
|
||||
font-size: 0.78rem;
|
||||
color: #10b981;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rx-arrow {
|
||||
color: #10b981;
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.rx-item:hover .rx-arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.scan-again-btn {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #94a3b8;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-top: 0.5rem;
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.scan-again-btn:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #e2e8f0;
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function playBeep() {
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.38);
|
||||
} catch (_) { /* No audio context available */ }
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function ScannerView({ onClose, onSelectMedicine }) {
|
||||
@@ -65,7 +65,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
try {
|
||||
const supported = await BarcodeScanner.isSupported();
|
||||
if (!supported) {
|
||||
setErrorMsg('Barcode scanning is not supported on this device.');
|
||||
setErrorMsg('El escáner no está disponible en este dispositivo.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
if (permission.camera !== 'granted') {
|
||||
const request = await BarcodeScanner.requestPermissions();
|
||||
if (request.camera !== 'granted') {
|
||||
setErrorMsg('Camera permission denied. Please enable it in settings, or enter your CIP code manually.');
|
||||
setErrorMsg('Permiso de cámara denegado. Actívalo en ajustes o introduce el código CIP manualmente.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -91,7 +91,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const rawValue = barcode?.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');
|
||||
return;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
setErrorMsg(`Scan failed: ${err.message || 'Unknown error'}`);
|
||||
setErrorMsg(`Error al escanear: ${err.message || 'Error desconocido'}`);
|
||||
setPhase('error');
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
setErrorMsg('');
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setErrorMsg('Camera not available in this browser.');
|
||||
setErrorMsg('Cámara no disponible en este navegador.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
try {
|
||||
detector = new BarcodeDetector({ formats: ['pdf417', 'code_128', 'qr_code'] });
|
||||
} 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');
|
||||
return;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* detector not ready */ }
|
||||
} catch { }
|
||||
if (!found) {
|
||||
rafRef.current = requestAnimationFrame(scanFrame);
|
||||
}
|
||||
@@ -173,11 +173,11 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
} catch (err) {
|
||||
stopCamera();
|
||||
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') {
|
||||
setErrorMsg('No camera detected. Enter your CIP code manually.');
|
||||
setErrorMsg('No se detectó ninguna cámara. Introduce el código CIP manualmente.');
|
||||
} else {
|
||||
setErrorMsg(`Camera error: ${err.message || 'Unknown error'}`);
|
||||
setErrorMsg(`Error de cámara: ${err.message || 'Error desconocido'}`);
|
||||
}
|
||||
setPhase('error');
|
||||
}
|
||||
@@ -195,12 +195,12 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
e.preventDefault();
|
||||
const cip = manualCip.trim();
|
||||
if (!cip) {
|
||||
setErrorMsg('Please enter a CIP code.');
|
||||
setErrorMsg('Introduce un código CIP.');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -227,128 +227,146 @@ function ScannerView({ onClose, onSelectMedicine }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scanner-overlay">
|
||||
<div className="scanner-topbar">
|
||||
<button className="scanner-back-btn" onClick={handleBack} aria-label="Back">
|
||||
← Back
|
||||
</button>
|
||||
<h2 className="scanner-title">
|
||||
{phase === 'prescriptions' ? '✅ TSI Scanned' : '📷 Scan TSI Card'}
|
||||
</h2>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* ── Idle / Error / Scanning state ────────────────── */}
|
||||
{phase !== 'prescriptions' && (
|
||||
<div className="scanner-content">
|
||||
{phase === 'error' && (
|
||||
<div className="scan-error-panel">
|
||||
<span className="scan-error-icon">🚫</span>
|
||||
<p className="scan-error-msg">{errorMsg}</p>
|
||||
<button className="scan-retry-btn" onClick={() => { setErrorMsg(''); setPhase('idle'); }}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && !isNative && (
|
||||
<div className="scanner-camera-container">
|
||||
<video ref={videoRef} className="scanner-video" autoPlay muted playsInline />
|
||||
<div className="scanner-frame">
|
||||
<div className="corner tl" />
|
||||
<div className="corner tr" />
|
||||
<div className="corner bl" />
|
||||
<div className="corner br" />
|
||||
</div>
|
||||
<p className="scanner-hint">Point at barcode — hold steady</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'scanning' && isNative && (
|
||||
<div className="scan-active-panel">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Opening camera… Point at barcode</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'idle' && (
|
||||
<button className="scan-start-btn" onClick={handleStartScan}>
|
||||
<span className="scan-start-icon">📷</span>
|
||||
{isNative ? 'Start Scanning' : 'Open Camera to Scan'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<form className="manual-cip-section" onSubmit={handleManualSubmit}>
|
||||
<label className="cip-label" htmlFor="cip-input">Or enter CIP manually</label>
|
||||
<div className="cip-input-group">
|
||||
<input
|
||||
id="cip-input"
|
||||
className="cip-input"
|
||||
type="text"
|
||||
placeholder="Enter CIP code manually"
|
||||
value={manualCip}
|
||||
onChange={(e) => setManualCip(e.target.value)}
|
||||
maxLength={16}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="cip-submit-btn">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Prescriptions result panel ───────────────────── */}
|
||||
{phase === 'prescriptions' && (
|
||||
<div className="scanner-prescriptions">
|
||||
<div className="scanned-card-info">
|
||||
<span className="scanned-icon">💳</span>
|
||||
<div>
|
||||
<p className="scanned-cip">{scannedCip}</p>
|
||||
</div>
|
||||
<div className="scanner-view">
|
||||
<div className="scanner-content">
|
||||
<div className="scanner-hero">
|
||||
<div className="scanner-icon-circle">
|
||||
<svg width="40" height="40" 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>
|
||||
|
||||
<h3 className="rx-heading">Active Prescriptions</h3>
|
||||
<p className="rx-subheading">Tap a medicine to check availability at nearby pharmacies.</p>
|
||||
|
||||
{loadingPrescriptions && (
|
||||
<div className="rx-loading">
|
||||
<div className="scanner-spinner" />
|
||||
<p>Loading prescriptions…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingPrescriptions && prescriptions.length === 0 && (
|
||||
<p className="rx-empty">No active prescriptions found for this card.</p>
|
||||
)}
|
||||
|
||||
<ul className="rx-list">
|
||||
{prescriptions.map((rx, i) => (
|
||||
<li key={i}>
|
||||
<button className="rx-item" onClick={() => handlePickPrescription(rx)}>
|
||||
<div className="rx-item-info">
|
||||
<span className="rx-name">{rx.name}</span>
|
||||
<span className="rx-detail">{rx.dosage} · {rx.form}</span>
|
||||
{rx.active_ingredient && (
|
||||
<span className="rx-ingredient">{rx.active_ingredient}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rx-arrow">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button className="scan-again-btn" onClick={() => {
|
||||
stopCamera();
|
||||
setPhase('idle');
|
||||
setPrescriptions([]);
|
||||
setScannedCip(null);
|
||||
}}>
|
||||
🔄 Scan Again
|
||||
</button>
|
||||
<h2 className="scanner-heading">Escanear TSI</h2>
|
||||
<p className="scanner-desc">Escanea el código de barras de tu tarjeta sanitaria para ver tus recetas activas</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -20,14 +20,14 @@ export default defineConfig({
|
||||
name: 'FarmaFinder',
|
||||
short_name: 'FarmaFinder',
|
||||
description: 'Find pharmacies that stock the medicine you need',
|
||||
theme_color: '#0f766e',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#00450d',
|
||||
background_color: '#f8fafb',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icon.svg', sizes: '192x192', type: 'image/svg+xml', purpose: 'any' },
|
||||
{ src: '/icon.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any' },
|
||||
{ src: '/icon-mask.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'maskable' },
|
||||
{ src: '/logo-square-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/logo-square-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/logo.png', sizes: '512x279', type: 'image/png', purpose: 'any' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"url": "https://opencollective.com/capawesome"
|
||||
}
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
|
||||