Rebranding app and naming #1
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FarmaFinder external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
|
* FarmaClic external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
|
||||||
* Used by the backend admin import; OSM does not require n8n.
|
* Used by the backend admin import; OSM does not require n8n.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
|
|||||||
const res = await fetch(u, {
|
const res = await fetch(u, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': 'FarmaFinder/1.0 (open-data pharmacy import)',
|
'User-Agent': 'FarmaClic/1.0 (open-data pharmacy import)',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Parse a subset of the OSM opening_hours format into FarmaFinder's internal
|
* Parse a subset of the OSM opening_hours format into FarmaClic's internal
|
||||||
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
|
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
|
||||||
*
|
*
|
||||||
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ async function fetchOverpass(endpoint, body) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': 'FarmaFinder/1.0 (OSM pharmacy import; local admin)',
|
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-api-sources",
|
"name": "farma-clic-api-sources",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
|
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
|
||||||
PG_URL=postgresql://farmafinder:change-me@localhost:5432/farmafinder
|
PG_URL=postgresql://farmaclic:change-me@localhost:5432/farmaclic
|
||||||
PG_PASSWORD=change-me
|
PG_PASSWORD=change-me
|
||||||
|
|
||||||
# Web Push (VAPID). Generate with:
|
# Web Push (VAPID). Generate with:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaFinder rows.
|
* Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaClic rows.
|
||||||
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
|
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-backend",
|
"name": "farma-clic-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend API for FarmaFinder",
|
"description": "Backend API for FarmaClic",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "🔄 FarmaFinder - Quick Database Reset"
|
echo "🔄 FarmaClic - Quick Database Reset"
|
||||||
echo "===================================="
|
echo "===================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Este script eliminará la base de datos actual y creará una nueva."
|
echo "Este script eliminará la base de datos actual y creará una nueva."
|
||||||
|
|||||||
+3
-3
@@ -30,7 +30,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
// span_id to every log line so they can be correlated in Grafana.
|
// span_id to every log line so they can be correlated in Grafana.
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
base: { service: process.env.OTEL_SERVICE_NAME || 'farmafinder-backend' },
|
base: { service: process.env.OTEL_SERVICE_NAME || 'farmaclic-backend' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so
|
// Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so
|
||||||
@@ -61,7 +61,7 @@ if (PG_URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionConfig = {
|
const sessionConfig = {
|
||||||
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
|
secret: process.env.SESSION_SECRET || 'farma-clic-secret-key-change-in-production',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
@@ -703,7 +703,7 @@ async function nominatimSearchFirstHit(originalQuery) {
|
|||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Accept-Language': 'es,en',
|
'Accept-Language': 'es,en',
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'FarmaFinder/1.0 (pharmacy admin; geocoding; https://github.com/)',
|
'FarmaClic/1.0 (pharmacy admin; geocoding; https://github.com/)',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const text = await nomRes.text();
|
const text = await nomRes.text();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// OpenTelemetry Node SDK bootstrap for FarmaFinder backend.
|
// OpenTelemetry Node SDK bootstrap for FarmaClic backend.
|
||||||
// Started as a side-effect import from server.js (ESM).
|
// Started as a side-effect import from server.js (ESM).
|
||||||
//
|
//
|
||||||
// Env vars (set by docker-compose):
|
// Env vars (set by docker-compose):
|
||||||
// OTEL_SERVICE_NAME — default: farmafinder-backend
|
// OTEL_SERVICE_NAME — default: farmaclic-backend
|
||||||
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
|
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
|
||||||
//
|
//
|
||||||
// Exports traces to the shared Grafana Alloy collector, where they are
|
// Exports traces to the shared Grafana Alloy collector, where they are
|
||||||
@@ -15,17 +15,17 @@ import * as resources from '@opentelemetry/resources';
|
|||||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
|
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
|
||||||
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
|
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
|
||||||
|
|
||||||
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmafinder-backend';
|
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmaclic-backend';
|
||||||
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
|
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
|
||||||
|
|
||||||
const resource = typeof resources.resourceFromAttributes === 'function'
|
const resource = typeof resources.resourceFromAttributes === 'function'
|
||||||
? resources.resourceFromAttributes({
|
? resources.resourceFromAttributes({
|
||||||
[ATTR_SERVICE_NAME]: serviceName,
|
[ATTR_SERVICE_NAME]: serviceName,
|
||||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||||
})
|
})
|
||||||
: new resources.Resource({
|
: new resources.Resource({
|
||||||
[ATTR_SERVICE_NAME]: serviceName,
|
[ATTR_SERVICE_NAME]: serviceName,
|
||||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||||
});
|
});
|
||||||
|
|
||||||
const sdk = new NodeSDK({
|
const sdk = new NodeSDK({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "net.hacecalor.farmafinder",
|
"appId": "net.hacecalor.farmaclic",
|
||||||
"appName": "FarmaFinder",
|
"appName": "FarmaClic",
|
||||||
"webDir": "frontend/dist",
|
"webDir": "frontend/dist",
|
||||||
"server": {
|
"server": {
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
|
|||||||
+10
-10
@@ -7,14 +7,14 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: farmafinder
|
POSTGRES_DB: farmaclic
|
||||||
POSTGRES_USER: farmafinder
|
POSTGRES_USER: farmaclic
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
@@ -25,19 +25,19 @@ services:
|
|||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||||
CORS_ORIGIN: http://localhost:3000
|
CORS_ORIGIN: http://localhost:4000
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
DATABASE_PATH: /app/data/database.sqlite
|
DATABASE_PATH: /app/data/database.sqlite
|
||||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||||
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
|
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic
|
||||||
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
||||||
OTEL_SERVICE_NAME: farmafinder-backend
|
OTEL_SERVICE_NAME: farmaclic-backend
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
OTEL_TRACES_EXPORTER: otlp
|
OTEL_TRACES_EXPORTER: otlp
|
||||||
OTEL_LOGS_EXPORTER: otlp
|
OTEL_LOGS_EXPORTER: otlp
|
||||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
|
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,17 +45,17 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
||||||
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
|
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-frontend}
|
||||||
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
||||||
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "4000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
VITE_FARO_ENDPOINT=http://localhost:4318
|
VITE_FARO_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
# App name shown in Grafana Explore / Faro data source.
|
# App name shown in Grafana Explore / Faro data source.
|
||||||
VITE_FARO_APP_NAME=farmafinder-frontend
|
VITE_FARO_APP_NAME=farmaclic-frontend
|
||||||
|
|
||||||
# Environment label (production | staging | development).
|
# Environment label (production | staging | development).
|
||||||
VITE_FARO_ENV=production
|
VITE_FARO_ENV=production
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
ARG VITE_FARO_ENDPOINT
|
ARG VITE_FARO_ENDPOINT
|
||||||
ARG VITE_FARO_APP_NAME=farmafinder-frontend
|
ARG VITE_FARO_APP_NAME=farmaclic-frontend
|
||||||
ARG VITE_FARO_ENV=production
|
ARG VITE_FARO_ENV=production
|
||||||
ARG VITE_FARO_APP_VERSION=1.0.0
|
ARG VITE_FARO_APP_VERSION=1.0.0
|
||||||
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
|
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#00450d" />
|
<meta name="theme-color" content="#00450d" />
|
||||||
<title>FarmaFinder</title>
|
<title>FarmaClic</title>
|
||||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-frontend",
|
"name": "farma-clic-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeView;
|
let activeView;
|
||||||
let topBarTitle = 'FarmaFinder';
|
let topBarTitle = 'FarmaClic';
|
||||||
let showBack = false;
|
let showBack = false;
|
||||||
|
|
||||||
switch (screen) {
|
switch (screen) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
const u = username.trim();
|
const u = username.trim();
|
||||||
if (!u || !password) return;
|
if (!u || !password) return;
|
||||||
if (mode === 'register' && password.length < 8) {
|
if (mode === 'register' && password.length < 8) {
|
||||||
setError('Password must be at least 8 characters');
|
setError('La contraseña debe tener al menos 8 caracteres');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -40,12 +40,12 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || (mode === 'register' ? 'Could not create account' : 'Login failed'));
|
setError(data.error || (mode === 'register' ? 'No se pudo crear la cuenta' : 'Inicio de sesión fallido'));
|
||||||
} else {
|
} else {
|
||||||
onLogin(data.user);
|
onLogin(data.user);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error — try again');
|
setError('Error de red — inténtalo de nuevo');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={() => setMode('login')}
|
onClick={() => setMode('login')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Sign in
|
Iniciar sesión
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,20 +81,20 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={() => setMode('register')}
|
onClick={() => setMode('register')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Create account
|
Crear cuenta
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 id="modal-title">{isRegister ? 'Create your account' : 'Welcome back'}</h2>
|
<h2 id="modal-title">{isRegister ? 'Crea tu cuenta' : 'Bienvenido de nuevo'}</h2>
|
||||||
<p className="modal-sub">
|
<p className="modal-sub">
|
||||||
{isRegister
|
{isRegister
|
||||||
? 'Save your address and get notified when medicines arrive.'
|
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
|
||||||
: 'Sign in to manage your profile and notifications.'}
|
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<div className="modal-field">
|
<div className="modal-field">
|
||||||
<label htmlFor="modal-username">Username</label>
|
<label htmlFor="modal-username">Usuario</label>
|
||||||
<input
|
<input
|
||||||
id="modal-username"
|
id="modal-username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -107,11 +107,11 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
maxLength={isRegister ? 32 : undefined}
|
maxLength={isRegister ? 32 : undefined}
|
||||||
/>
|
/>
|
||||||
{isRegister && (
|
{isRegister && (
|
||||||
<p className="modal-hint">3–32 characters: letters, digits, or underscore.</p>
|
<p className="modal-hint">3–32 caracteres: letras, dígitos o guión bajo.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-field">
|
<div className="modal-field">
|
||||||
<label htmlFor="modal-password">Password</label>
|
<label htmlFor="modal-password">Contraseña</label>
|
||||||
<input
|
<input
|
||||||
id="modal-password"
|
id="modal-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -122,7 +122,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
minLength={isRegister ? 8 : undefined}
|
minLength={isRegister ? 8 : undefined}
|
||||||
/>
|
/>
|
||||||
{isRegister && (
|
{isRegister && (
|
||||||
<p className="modal-hint">At least 8 characters.</p>
|
<p className="modal-hint">Al menos 8 caracteres.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="modal-error">{error}</p>}
|
{error && <p className="modal-error">{error}</p>}
|
||||||
@@ -133,7 +133,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -141,8 +141,8 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
disabled={loading || !username.trim() || !password}
|
disabled={loading || !username.trim() || !password}
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? (isRegister ? 'Creating…' : 'Signing in…')
|
? (isRegister ? 'Creando…' : 'Iniciando sesión…')
|
||||||
: (isRegister ? 'Create account' : 'Sign in')}
|
: (isRegister ? 'Crear cuenta' : 'Iniciar sesión')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque
|
|||||||
if (medicines.length === 0 && query.length >= 2) {
|
if (medicines.length === 0 && query.length >= 2) {
|
||||||
return (
|
return (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<p>No medicines found matching "{query}"</p>
|
<p>No se encontraron medicamentos para "{query}"</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!supported) {
|
if (!supported) {
|
||||||
setError('Notifications need iOS 16.4+ and this site installed as an app (Share → Add to Home Screen).');
|
setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -65,7 +65,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[notify] toggle failed:', err);
|
console.error('[notify] toggle failed:', err);
|
||||||
setError(err.message || 'Could not update subscription');
|
setError(err.message || 'No se pudo actualizar la suscripción');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -83,29 +83,29 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
aria-pressed={subscribed && !!currentUser}
|
aria-pressed={subscribed && !!currentUser}
|
||||||
aria-label={
|
aria-label={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Stop notifications for this medicine'
|
? 'Desactivar notificaciones para este medicamento'
|
||||||
: 'Notify me when this medicine becomes available'
|
: 'Notificarme cuando esté disponible'
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Notifications on — click to turn off'
|
? 'Notificaciones activadas — clic para desactivar'
|
||||||
: 'Notify me when this medicine is added to a pharmacy'
|
: 'Notificarme cuando este medicamento esté en una farmacia'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="medicine-card-body">
|
<div className="medicine-card-body">
|
||||||
<p><strong>Active Ingredient:</strong> {medicine.active_ingredient}</p>
|
<p><strong>Principio Activo:</strong> {medicine.active_ingredient}</p>
|
||||||
<p><strong>Dosage:</strong> {medicine.dosage} • <strong>Form:</strong> {medicine.form}</p>
|
<p><strong>Dosis:</strong> {medicine.dosage} • <strong>Forma:</strong> {medicine.form}</p>
|
||||||
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
|
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="medicine-card-footer">
|
<div className="medicine-card-footer">
|
||||||
<span className="view-pharmacies">View pharmacies →</span>
|
<span className="view-pharmacies">Ver farmacias →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-pharmacies">
|
<div className="loading-pharmacies">
|
||||||
<p>Loading pharmacies...</p>
|
<p>Cargando farmacias...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
if (pharmacies.length === 0) {
|
if (pharmacies.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="no-pharmacies">
|
<div className="no-pharmacies">
|
||||||
<p>No pharmacies found selling this medicine</p>
|
<p>No se encontraron farmacias con este medicamento</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
return (
|
return (
|
||||||
<div className="pharmacy-list">
|
<div className="pharmacy-list">
|
||||||
<h3 className="pharmacy-list-title">
|
<h3 className="pharmacy-list-title">
|
||||||
Available at {pharmacies.length} {pharmacies.length === 1 ? 'pharmacy' : 'pharmacies'}
|
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="pharmacy-grid">
|
<div className="pharmacy-grid">
|
||||||
{pharmacies.map((pharmacy) => {
|
{pharmacies.map((pharmacy) => {
|
||||||
@@ -76,7 +76,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!supported) {
|
if (!supported) {
|
||||||
setError('Notifications need iOS 16.4+ and this site installed as an app (Share → Add to Home Screen).');
|
setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -92,7 +92,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[notify] pharmacy toggle failed:', err);
|
console.error('[notify] pharmacy toggle failed:', err);
|
||||||
setError(err.message || 'Could not update subscription');
|
setError(err.message || 'No se pudo actualizar la suscripción');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -117,17 +117,17 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
aria-pressed={subscribed && !!currentUser}
|
aria-pressed={subscribed && !!currentUser}
|
||||||
aria-label={
|
aria-label={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Stop notifications for this pharmacy'
|
? 'Desactivar notificaciones para esta farmacia'
|
||||||
: 'Notify me when this medicine arrives at this pharmacy'
|
: 'Notificarme cuando llegue a esta farmacia'
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Notifications on for this pharmacy — click to turn off'
|
? 'Notificaciones activadas para esta farmacia — clic para desactivar'
|
||||||
: 'Notify me when this medicine arrives at this pharmacy'
|
: 'Notificarme cuando llegue a esta farmacia'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||||
@@ -152,7 +152,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
)}
|
)}
|
||||||
{pharmacy.stock !== undefined && (
|
{pharmacy.stock !== undefined && (
|
||||||
<span className={`stock ${pharmacy.stock > 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}>
|
<span className={`stock ${pharmacy.stock > 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}>
|
||||||
{pharmacy.stock > 20 ? '✓ In Stock' : pharmacy.stock > 0 ? `⚠ Low Stock (${pharmacy.stock})` : '✗ Out of Stock'}
|
{pharmacy.stock > 20 ? '✓ En Stock' : pharmacy.stock > 0 ? `⚠ Stock Bajo (${pharmacy.stock})` : '✗ Sin Stock'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||||
setItems(merged);
|
setItems(merged);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err.message || 'Could not load saved notifications');
|
if (!cancelled) setError(err.message || 'No se pudieron cargar las notificaciones guardadas');
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
||||||
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
|
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not remove notification');
|
setError(err.message || 'No se pudo eliminar la notificación');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -52,15 +52,15 @@ function SavedNotifications({ onClose }) {
|
|||||||
<div className="saved-notifications-backdrop" onClick={onClose}>
|
<div className="saved-notifications-backdrop" onClick={onClose}>
|
||||||
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
|
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="saved-notifications-header">
|
<div className="saved-notifications-header">
|
||||||
<h2>🔔 Saved Notifications</h2>
|
<h2>🔔 Notificaciones Guardadas</h2>
|
||||||
<button className="saved-notifications-close" onClick={onClose} aria-label="Close">×</button>
|
<button className="saved-notifications-close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="saved-notifications-body">
|
<div className="saved-notifications-body">
|
||||||
{loading && <p className="saved-notifications-status">Loading…</p>}
|
{loading && <p className="saved-notifications-status">Cargando…</p>}
|
||||||
{!loading && error && <p className="saved-notifications-error">{error}</p>}
|
{!loading && error && <p className="saved-notifications-error">{error}</p>}
|
||||||
{!loading && !error && items.length === 0 && (
|
{!loading && !error && items.length === 0 && (
|
||||||
<p className="saved-notifications-empty">
|
<p className="saved-notifications-empty">
|
||||||
No saved notifications yet. Tap the 🔕 bell on an out-of-stock pharmacy to get notified when it's restocked.
|
Aún no hay notificaciones guardadas. Toca la campana 🔕 en una farmacia sin stock para recibir notificaciones cuando se reponga.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && items.length > 0 && (
|
{!loading && !error && items.length > 0 && (
|
||||||
@@ -77,7 +77,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
{item.scope === 'pharmacy' ? (
|
{item.scope === 'pharmacy' ? (
|
||||||
<>
|
<>
|
||||||
<span className="saved-notifications-chip saved-notifications-chip--pharmacy">
|
<span className="saved-notifications-chip saved-notifications-chip--pharmacy">
|
||||||
🏥 {item.pharmacy_name || `Pharmacy #${item.pharmacy_id}`}
|
🏥 {item.pharmacy_name || `Farmacia #${item.pharmacy_id}`}
|
||||||
</span>
|
</span>
|
||||||
{item.pharmacy_address && (
|
{item.pharmacy_address && (
|
||||||
<span className="saved-notifications-address">{item.pharmacy_address}</span>
|
<span className="saved-notifications-address">{item.pharmacy_address}</span>
|
||||||
@@ -85,7 +85,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="saved-notifications-chip">
|
<span className="saved-notifications-chip">
|
||||||
Any pharmacy
|
Cualquier farmacia
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +95,9 @@ function SavedNotifications({ onClose }) {
|
|||||||
className="saved-notifications-remove"
|
className="saved-notifications-remove"
|
||||||
onClick={() => handleDelete(item)}
|
onClick={() => handleDelete(item)}
|
||||||
disabled={busyId === key}
|
disabled={busyId === key}
|
||||||
aria-label="Remove notification"
|
aria-label="Eliminar notificación"
|
||||||
>
|
>
|
||||||
{busyId === key ? '…' : 'Remove'}
|
{busyId === key ? '…' : 'Eliminar'}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function TopBar({ title, showBack, onBack, rightAction }) {
|
|||||||
{showBack ? (
|
{showBack ? (
|
||||||
<h1 className="topbar-title">{title}</h1>
|
<h1 className="topbar-title">{title}</h1>
|
||||||
) : (
|
) : (
|
||||||
<img src="/logo.png" alt="FarmaFinder" className="topbar-logo-img" />
|
<img src="/logo.png" alt="FarmaClic" className="topbar-logo-img" />
|
||||||
)}
|
)}
|
||||||
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
|
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ function LoginForm({ onLogin }) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Login failed');
|
throw new Error(data.error || 'Inicio de sesión fallido');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - notify parent component
|
// Success - notify parent component
|
||||||
onLogin(data.user);
|
onLogin(data.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
setError(error.message || 'Invalid username or password');
|
setError(error.message || 'Usuario o contraseña inválidos');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ function LoginForm({ onLogin }) {
|
|||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-box">
|
<div className="login-box">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
<h2>🔐 Admin Login</h2>
|
<h2>🔐 Acceso Administración</h2>
|
||||||
<p>Please enter your credentials to access the admin panel</p>
|
<p>Introduce tus credenciales para acceder al panel de administración</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="login-form">
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
@@ -54,13 +54,13 @@ function LoginForm({ onLogin }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="username">Username</label>
|
<label htmlFor="username">Usuario</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Enter username"
|
placeholder="Introduce usuario"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -68,13 +68,13 @@ function LoginForm({ onLogin }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">Contraseña</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter password"
|
placeholder="Introduce contraseña"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -85,16 +85,16 @@ function LoginForm({ onLogin }) {
|
|||||||
className="login-button"
|
className="login-button"
|
||||||
disabled={loading || !username || !password}
|
disabled={loading || !username || !password}
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<p className="help-text">
|
<p className="help-text">
|
||||||
Default credentials: <code>admin</code> / <code>admin123</code>
|
Credenciales por defecto: <code>admin</code> / <code>admin123</code>
|
||||||
</p>
|
</p>
|
||||||
<p className="warning-text">
|
<p className="warning-text">
|
||||||
⚠️ Change the default password after first login!
|
⚠️ ¡Cambia la contraseña por defecto tras el primer inicio de sesión!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function MedicineManagement() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error('Error searching medicines:', error);
|
console.error('Error searching medicines:', error);
|
||||||
alert('Error searching medicines from CIMA API');
|
alert('Error al buscar medicamentos en la API CIMA');
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ function MedicineManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Search Medicines (CIMA API)</h2>
|
<h2>Buscar Medicamentos (API CIMA)</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-box">
|
<div className="info-box">
|
||||||
@@ -54,7 +54,7 @@ function MedicineManagement() {
|
|||||||
|
|
||||||
<div className="admin-form">
|
<div className="admin-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Search for medicines</label>
|
<label>Buscar medicamentos</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -64,11 +64,11 @@ function MedicineManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="loading">Searching CIMA API...</div>}
|
{loading && <div className="loading">Buscando en API CIMA...</div>}
|
||||||
|
|
||||||
{!loading && medicines.length > 0 && (
|
{!loading && medicines.length > 0 && (
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
<p className="info-text">Found {medicines.length} medicines</p>
|
<p className="info-text">Se encontraron {medicines.length} medicamentos</p>
|
||||||
{medicines.map((medicine) => (
|
{medicines.map((medicine) => (
|
||||||
<div key={medicine.nregistro} className="admin-item">
|
<div key={medicine.nregistro} className="admin-item">
|
||||||
<div className="item-content">
|
<div className="item-content">
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ function haversineMeters(lat1, lon1, lat2, lon2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REGION_PRESETS = [
|
const REGION_PRESETS = [
|
||||||
{ id: 'custom', label: 'Custom coordinates', lat: '', lon: '', radio: '' },
|
{ id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' },
|
||||||
{
|
{
|
||||||
id: 'rubi',
|
id: 'rubi',
|
||||||
label: 'Example: Rubí area (1.5 km)',
|
label: 'Ejemplo: Área de Rubí (1.5 km)',
|
||||||
lat: '41.5631',
|
lat: '41.5631',
|
||||||
lon: '2.0038',
|
lon: '2.0038',
|
||||||
radio: '1500',
|
radio: '1500',
|
||||||
@@ -76,16 +76,16 @@ async function geocodeErrorMessage(response) {
|
|||||||
}
|
}
|
||||||
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
return 'Session expired or not logged in. Sign in again on Admin, then retry.';
|
return 'Sesión expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.';
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
|
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
|
||||||
if (looksLikeHtml) {
|
if (looksLikeHtml) {
|
||||||
return 'The app could not reach the API (404). Use http://localhost:3000 with both frontend and backend running, or configure your server to proxy /api to the backend.';
|
return 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.';
|
||||||
}
|
}
|
||||||
return 'Geocode service not found. Update the backend and restart it.';
|
return 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.';
|
||||||
}
|
}
|
||||||
return `Lookup failed (HTTP ${response.status}).`;
|
return `Búsqueda fallida (HTTP ${response.status}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PharmacyManagement() {
|
function PharmacyManagement() {
|
||||||
@@ -132,7 +132,7 @@ function PharmacyManagement() {
|
|||||||
setPharmacies(data);
|
setPharmacies(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching pharmacies:', error);
|
console.error('Error fetching pharmacies:', error);
|
||||||
alert('Error loading pharmacies');
|
alert('Error al cargar farmacias');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ function PharmacyManagement() {
|
|||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const q = cityQuery.trim();
|
const q = cityQuery.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setCityLookupMessage({ type: 'err', text: 'Enter a city or place name.' });
|
setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCityLookupLoading(true);
|
setCityLookupLoading(true);
|
||||||
@@ -253,7 +253,7 @@ function PharmacyManagement() {
|
|||||||
const lon = parseFloat(regionLon);
|
const lon = parseFloat(regionLon);
|
||||||
const radio = parseFloat(regionRadio);
|
const radio = parseFloat(regionRadio);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) {
|
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) {
|
||||||
throw new Error('Set latitude, longitude and radius (use Find city or a preset).');
|
throw new Error('Establece latitud, longitud y radio (usa Buscar ciudad o un preset).');
|
||||||
}
|
}
|
||||||
const response = await fetch('/api/admin/pharmacies/import-external', {
|
const response = await fetch('/api/admin/pharmacies/import-external', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -307,7 +307,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to update pharmacy');
|
throw new Error(error.error || 'Error al actualizar farmacia');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const response = await fetch('/api/admin/pharmacies', {
|
const response = await fetch('/api/admin/pharmacies', {
|
||||||
@@ -319,16 +319,16 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to create pharmacy');
|
throw new Error(error.error || 'Error al crear farmacia');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchPharmacies();
|
fetchPharmacies();
|
||||||
alert(editingPharmacy ? 'Pharmacy updated successfully!' : 'Pharmacy added successfully!');
|
alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving pharmacy:', error);
|
console.error('Error saving pharmacy:', error);
|
||||||
alert(`Error saving pharmacy: ${error.message}`);
|
alert(`Error al guardar farmacia: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -348,7 +348,7 @@ function PharmacyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm('Are you sure you want to delete this pharmacy?')) return;
|
if (!confirm('¿Estás seguro de que quieres eliminar esta farmacia?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/pharmacies/${id}`, {
|
const response = await fetch(`/api/admin/pharmacies/${id}`, {
|
||||||
@@ -356,13 +356,13 @@ function PharmacyManagement() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete pharmacy');
|
if (!response.ok) throw new Error('Error al eliminar farmacia');
|
||||||
|
|
||||||
fetchPharmacies();
|
fetchPharmacies();
|
||||||
alert('Pharmacy deleted successfully!');
|
alert('¡Farmacia eliminada!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting pharmacy:', error);
|
console.error('Error deleting pharmacy:', error);
|
||||||
alert('Error deleting pharmacy');
|
alert('Error al eliminar farmacia');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ function PharmacyManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Manage Pharmacies</h2>
|
<h2>Gestionar Farmacias</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -400,16 +400,16 @@ function PharmacyManagement() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ Add New Pharmacy
|
+ Añadir Nueva Farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pharmacy-tools-card">
|
<div className="pharmacy-tools-card">
|
||||||
<h3>City, region & import</h3>
|
<h3>Ciudad, región e importación</h3>
|
||||||
<p className="pharmacy-tools-hint">
|
<p className="pharmacy-tools-hint">
|
||||||
<strong>Find city</strong> sets latitude, longitude and radius for the map filter and for imports.
|
<strong>Buscar ciudad</strong> establece latitud, longitud y radio para el filtro de mapa y las importaciones.
|
||||||
Choose a <strong>data source</strong> below: <strong>OpenStreetMap</strong> is free (no key);{' '}
|
Elige una <strong>fuente de datos</strong>: <strong>OpenStreetMap</strong> es gratuita (sin clave);{' '}
|
||||||
<strong>Open data URL</strong> loads JSON you host (array or GeoJSON). Geocoding uses{' '}
|
<strong>URL de datos abiertos</strong> carga JSON alojado por ti. Geocodificación usa{' '}
|
||||||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
|
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
@@ -418,11 +418,11 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<form className="city-lookup-form" onSubmit={handleCityLookup}>
|
<form className="city-lookup-form" onSubmit={handleCityLookup}>
|
||||||
<div className="form-group city-lookup-input-wrap">
|
<div className="form-group city-lookup-input-wrap">
|
||||||
<label htmlFor="city-finder">Find city</label>
|
<label htmlFor="city-finder">Buscar ciudad</label>
|
||||||
<input
|
<input
|
||||||
id="city-finder"
|
id="city-finder"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="e.g. Rubí, Spain — or Madrid, Valencia…"
|
placeholder="Ej: Rubí, Madrid, Valencia…"
|
||||||
value={cityQuery}
|
value={cityQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCityQuery(e.target.value);
|
setCityQuery(e.target.value);
|
||||||
@@ -436,7 +436,7 @@ function PharmacyManagement() {
|
|||||||
className="btn-secondary city-lookup-submit"
|
className="btn-secondary city-lookup-submit"
|
||||||
disabled={cityLookupLoading}
|
disabled={cityLookupLoading}
|
||||||
>
|
>
|
||||||
{cityLookupLoading ? 'Looking up…' : 'Look up city'}
|
{cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{cityLookupMessage && (
|
{cityLookupMessage && (
|
||||||
@@ -449,7 +449,7 @@ function PharmacyManagement() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="region-presets">
|
<div className="region-presets">
|
||||||
<label htmlFor="region-preset">Area preset</label>
|
<label htmlFor="region-preset">Preset de área</label>
|
||||||
<select
|
<select
|
||||||
id="region-preset"
|
id="region-preset"
|
||||||
value={regionPreset}
|
value={regionPreset}
|
||||||
@@ -465,7 +465,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="region-grid">
|
<div className="region-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-lat">Latitude</label>
|
<label htmlFor="region-lat">Latitud</label>
|
||||||
<input
|
<input
|
||||||
id="region-lat"
|
id="region-lat"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -476,7 +476,7 @@ function PharmacyManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-lon">Longitude</label>
|
<label htmlFor="region-lon">Longitud</label>
|
||||||
<input
|
<input
|
||||||
id="region-lon"
|
id="region-lon"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -487,7 +487,7 @@ function PharmacyManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-radio">Radius (m)</label>
|
<label htmlFor="region-radio">Radio (m)</label>
|
||||||
<input
|
<input
|
||||||
id="region-radio"
|
id="region-radio"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -501,7 +501,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="import-mode-row">
|
<div className="import-mode-row">
|
||||||
<div className="form-group import-mode-select-wrap">
|
<div className="form-group import-mode-select-wrap">
|
||||||
<label htmlFor="import-mode">Data source</label>
|
<label htmlFor="import-mode">Fuente de datos</label>
|
||||||
<select
|
<select
|
||||||
id="import-mode"
|
id="import-mode"
|
||||||
value={importMode}
|
value={importMode}
|
||||||
@@ -510,16 +510,16 @@ function PharmacyManagement() {
|
|||||||
setImportFeedback(null);
|
setImportFeedback(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="osm">OpenStreetMap (Overpass, free)</option>
|
<option value="osm">OpenStreetMap (Overpass, gratuito)</option>
|
||||||
<option value="webhook">n8n webhook (legacy)</option>
|
<option value="webhook">n8n webhook (heredado)</option>
|
||||||
<option value="openData">Open data JSON URL</option>
|
<option value="openData">URL de datos abiertos JSON</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{importMode === 'openData' && (
|
{importMode === 'openData' && (
|
||||||
<div className="form-group open-data-url-row">
|
<div className="form-group open-data-url-row">
|
||||||
<label htmlFor="open-data-url">JSON URL</label>
|
<label htmlFor="open-data-url">URL JSON</label>
|
||||||
<input
|
<input
|
||||||
id="open-data-url"
|
id="open-data-url"
|
||||||
type="url"
|
type="url"
|
||||||
@@ -539,12 +539,12 @@ function PharmacyManagement() {
|
|||||||
disabled={importing}
|
disabled={importing}
|
||||||
>
|
>
|
||||||
{importing
|
{importing
|
||||||
? 'Importing…'
|
? 'Importando…'
|
||||||
: importMode === 'webhook'
|
: importMode === 'webhook'
|
||||||
? 'Import from webhook'
|
? 'Importar desde webhook'
|
||||||
: importMode === 'openData'
|
: importMode === 'openData'
|
||||||
? 'Import from URL'
|
? 'Importar desde URL'
|
||||||
: `Import from ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
: `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
||||||
</button>
|
</button>
|
||||||
<label className="filter-region-toggle">
|
<label className="filter-region-toggle">
|
||||||
<input
|
<input
|
||||||
@@ -552,7 +552,7 @@ function PharmacyManagement() {
|
|||||||
checked={filterByRegion}
|
checked={filterByRegion}
|
||||||
onChange={(e) => setFilterByRegion(e.target.checked)}
|
onChange={(e) => setFilterByRegion(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Show only pharmacies inside radius
|
Mostrar solo farmacias dentro del radio
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -568,10 +568,10 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form className="admin-form" onSubmit={handleSubmit}>
|
<form className="admin-form" onSubmit={handleSubmit}>
|
||||||
<h3>{editingPharmacy ? 'Edit Pharmacy' : 'Add New Pharmacy'}</h3>
|
<h3>{editingPharmacy ? 'Editar Farmacia' : 'Añadir Nueva Farmacia'}</h3>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Name *</label>
|
<label>Nombre *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
@@ -581,7 +581,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Address *</label>
|
<label>Dirección *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
@@ -591,7 +591,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Phone</label>
|
<label>Teléfono</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
@@ -601,7 +601,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Latitude</label>
|
<label>Latitud</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -611,7 +611,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Longitude</label>
|
<label>Longitud</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -622,8 +622,8 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="hours-editor">
|
<fieldset className="hours-editor">
|
||||||
<legend>Opening hours</legend>
|
<legend>Horario de apertura</legend>
|
||||||
<p className="hours-editor-hint">Leave a day checked as <em>Closed</em> if the pharmacy doesn't open that day.</p>
|
<p className="hours-editor-hint">Marca un día como <em>Cerrado</em> si la farmacia no abre ese día.</p>
|
||||||
{DAY_KEYS.map((day) => {
|
{DAY_KEYS.map((day) => {
|
||||||
const d = hoursDraft[day];
|
const d = hoursDraft[day];
|
||||||
return (
|
return (
|
||||||
@@ -635,14 +635,14 @@ function PharmacyManagement() {
|
|||||||
checked={d.closed}
|
checked={d.closed}
|
||||||
onChange={(e) => updateDay(day, { closed: e.target.checked })}
|
onChange={(e) => updateDay(day, { closed: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Closed
|
Cerrado
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={d.open}
|
value={d.open}
|
||||||
disabled={d.closed}
|
disabled={d.closed}
|
||||||
onChange={(e) => updateDay(day, { open: e.target.value })}
|
onChange={(e) => updateDay(day, { open: e.target.value })}
|
||||||
aria-label={`${DAY_LABEL[day]} opens at`}
|
aria-label={`${DAY_LABEL[day]} abre a las`}
|
||||||
/>
|
/>
|
||||||
<span className="hours-sep">–</span>
|
<span className="hours-sep">–</span>
|
||||||
<input
|
<input
|
||||||
@@ -650,7 +650,7 @@ function PharmacyManagement() {
|
|||||||
value={d.close}
|
value={d.close}
|
||||||
disabled={d.closed}
|
disabled={d.closed}
|
||||||
onChange={(e) => updateDay(day, { close: e.target.value })}
|
onChange={(e) => updateDay(day, { close: e.target.value })}
|
||||||
aria-label={`${DAY_LABEL[day]} closes at`}
|
aria-label={`${DAY_LABEL[day]} cierra a las`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -659,28 +659,28 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn-primary" disabled={saving}>
|
<button type="submit" className="btn-primary" disabled={saving}>
|
||||||
{saving ? 'Saving...' : editingPharmacy ? 'Update' : 'Add'} Pharmacy
|
{saving ? 'Guardando...' : editingPharmacy ? 'Actualizar' : 'Añadir'} Farmacia
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
|
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading pharmacies...</div>
|
<div className="loading">Cargando farmacias...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
<p className="list-meta">
|
<p className="list-meta">
|
||||||
Showing {displayedPharmacies.length} of {pharmacies.length} pharmacies
|
Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias
|
||||||
{filterByRegion && ' (inside radius)'}
|
{filterByRegion && ' (dentro del radio)'}
|
||||||
</p>
|
</p>
|
||||||
{displayedPharmacies.length === 0 ? (
|
{displayedPharmacies.length === 0 ? (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
{pharmacies.length === 0
|
{pharmacies.length === 0
|
||||||
? 'No pharmacies yet. Import from webhook or add one manually.'
|
? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.'
|
||||||
: 'No pharmacies in this radius with coordinates. Widen the radius, look up a different city, or turn off the region filter.'}
|
: 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
displayedPharmacies.map((pharmacy) => (
|
displayedPharmacies.map((pharmacy) => (
|
||||||
@@ -697,10 +697,10 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<div className="item-actions">
|
||||||
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
|
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
|
||||||
Edit
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
|
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
|
||||||
Delete
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function PharmacyMedicineLink() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedMedicine) {
|
if (!selectedMedicine) {
|
||||||
alert('Please select a medicine first');
|
alert('Por favor, selecciona un medicamento primero');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,16 +131,16 @@ function PharmacyMedicineLink() {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to link medicine to pharmacy');
|
if (!response.ok) throw new Error('Error al vincular medicamento a farmacia');
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
if (selectedPharmacy) {
|
if (selectedPharmacy) {
|
||||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
}
|
}
|
||||||
alert('Medicine linked to pharmacy successfully!');
|
alert('¡Medicamento vinculado a la farmacia correctamente!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error linking medicine:', error);
|
console.error('Error linking medicine:', error);
|
||||||
alert('Error linking medicine to pharmacy');
|
alert('Error al vincular medicamento a farmacia');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,18 +153,18 @@ function PharmacyMedicineLink() {
|
|||||||
body: JSON.stringify({ price, stock })
|
body: JSON.stringify({ price, stock })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to update');
|
if (!response.ok) throw new Error('Error al actualizar');
|
||||||
|
|
||||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
alert('Updated successfully!');
|
alert('¡Actualizado correctamente!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating:', error);
|
console.error('Error updating:', error);
|
||||||
alert('Error updating');
|
alert('Error al actualizar');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm('Remove this medicine from the pharmacy?')) return;
|
if (!confirm('¿Eliminar este medicamento de la farmacia?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
||||||
@@ -172,13 +172,13 @@ function PharmacyMedicineLink() {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete');
|
if (!response.ok) throw new Error('Error al eliminar');
|
||||||
|
|
||||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
alert('Medicine removed from pharmacy!');
|
alert('¡Medicamento eliminado de la farmacia!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting:', error);
|
console.error('Error deleting:', error);
|
||||||
alert('Error removing medicine');
|
alert('Error al eliminar medicamento');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<h2>Link Medicine to Pharmacy</h2>
|
<h2>Vincular Medicamento a Farmacia</h2>
|
||||||
|
|
||||||
<form className="admin-form" onSubmit={handleSubmit}>
|
<form className="admin-form" onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Pharmacy *</label>
|
<label>Farmacia *</label>
|
||||||
<input
|
<input
|
||||||
ref={pharmacyInputRef}
|
ref={pharmacyInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -235,7 +235,7 @@ function PharmacyMedicineLink() {
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setPharmacyDropdownOpen(true)}
|
onFocus={() => setPharmacyDropdownOpen(true)}
|
||||||
onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)}
|
onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)}
|
||||||
placeholder={pharmacies.length ? `Search ${pharmacies.length} pharmacies by name or address…` : 'Loading pharmacies…'}
|
placeholder={pharmacies.length ? `Buscar entre ${pharmacies.length} farmacias por nombre o dirección…` : 'Cargando farmacias…'}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
required={!selectedPharmacy}
|
required={!selectedPharmacy}
|
||||||
/>
|
/>
|
||||||
@@ -243,7 +243,7 @@ function PharmacyMedicineLink() {
|
|||||||
<div className="medicine-search-results">
|
<div className="medicine-search-results">
|
||||||
{filteredPharmacies.length === 0 ? (
|
{filteredPharmacies.length === 0 ? (
|
||||||
<div className="search-result-item search-result-item--empty">
|
<div className="search-result-item search-result-item--empty">
|
||||||
<span>No pharmacies match "{pharmacyQuery}"</span>
|
<span>No hay farmacias que coincidan con "{pharmacyQuery}"</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredPharmacies.map((pharmacy) => (
|
filteredPharmacies.map((pharmacy) => (
|
||||||
@@ -264,14 +264,14 @@ function PharmacyMedicineLink() {
|
|||||||
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
|
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
|
||||||
<p className="medicine-details">{selectedPharmacy.address}</p>
|
<p className="medicine-details">{selectedPharmacy.address}</p>
|
||||||
<button type="button" className="btn-small" onClick={clearPharmacy}>
|
<button type="button" className="btn-small" onClick={clearPharmacy}>
|
||||||
Change pharmacy
|
Cambiar farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Search Medicine (CIMA API) *</label>
|
<label>Buscar Medicamento (API CIMA) *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={medicineSearch}
|
value={medicineSearch}
|
||||||
@@ -279,10 +279,10 @@ function PharmacyMedicineLink() {
|
|||||||
setMedicineSearch(e.target.value);
|
setMedicineSearch(e.target.value);
|
||||||
setSelectedMedicine(null);
|
setSelectedMedicine(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Type to search medicines from CIMA..."
|
placeholder="Escribe para buscar medicamentos en CIMA..."
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{searching && <p className="loading-text">Searching...</p>}
|
{searching && <p className="loading-text">Buscando...</p>}
|
||||||
|
|
||||||
{medicineResults.length > 0 && !selectedMedicine && (
|
{medicineResults.length > 0 && !selectedMedicine && (
|
||||||
<div className="medicine-search-results">
|
<div className="medicine-search-results">
|
||||||
@@ -316,7 +316,7 @@ function PharmacyMedicineLink() {
|
|||||||
setMedicineSearch('');
|
setMedicineSearch('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Change medicine
|
Cambiar medicamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -324,7 +324,7 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Price (€)</label>
|
<label>Precio (€)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -347,21 +347,21 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn-primary">
|
<button type="submit" className="btn-primary">
|
||||||
Link Medicine
|
Vincular Medicamento
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-secondary" onClick={resetForm}>
|
<button type="button" className="btn-secondary" onClick={resetForm}>
|
||||||
Reset
|
Reiniciar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{selectedPharmacy && (
|
{selectedPharmacy && (
|
||||||
<div className="pharmacy-medicines-section">
|
<div className="pharmacy-medicines-section">
|
||||||
<h3>Medicines at {selectedPharmacy.name}</h3>
|
<h3>Medicamentos en {selectedPharmacy.name}</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading...</div>
|
<div className="loading">Cargando...</div>
|
||||||
) : pharmacyMedicines.length === 0 ? (
|
) : pharmacyMedicines.length === 0 ? (
|
||||||
<p className="empty-state">No medicines linked to this pharmacy yet.</p>
|
<p className="empty-state">Aún no hay medicamentos vinculados a esta farmacia.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
{pharmacyMedicines.map((pm) => (
|
{pharmacyMedicines.map((pm) => (
|
||||||
@@ -369,7 +369,7 @@ function PharmacyMedicineLink() {
|
|||||||
<div className="item-content">
|
<div className="item-content">
|
||||||
<h4>{pm.medicine_name}</h4>
|
<h4>{pm.medicine_name}</h4>
|
||||||
<p>
|
<p>
|
||||||
<strong>Price:</strong> {pm.price ? `€${parseFloat(pm.price).toFixed(2)}` : 'Not set'} •
|
<strong>Precio:</strong> {pm.price ? `€${parseFloat(pm.price).toFixed(2)}` : 'No definido'} •
|
||||||
<strong> Stock:</strong> {pm.stock || 0}
|
<strong> Stock:</strong> {pm.stock || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,17 +377,17 @@ function PharmacyMedicineLink() {
|
|||||||
<button
|
<button
|
||||||
className="btn-edit"
|
className="btn-edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPrice = prompt('Enter new price:', pm.price || '');
|
const newPrice = prompt('Introduce nuevo precio:', pm.price || '');
|
||||||
const newStock = prompt('Enter new stock:', pm.stock || '0');
|
const newStock = prompt('Introduce nuevo stock:', pm.stock || '0');
|
||||||
if (newPrice !== null && newStock !== null) {
|
if (newPrice !== null && newStock !== null) {
|
||||||
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Update
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
||||||
Remove
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -14,10 +14,10 @@ self.addEventListener('push', (event) => {
|
|||||||
try {
|
try {
|
||||||
data = event.data.json();
|
data = event.data.json();
|
||||||
} catch {
|
} catch {
|
||||||
data = { title: 'FarmaFinder', body: event.data.text() };
|
data = { title: 'FarmaClic', body: event.data.text() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const title = data.title || 'FarmaFinder';
|
const title = data.title || 'FarmaClic';
|
||||||
const options = {
|
const options = {
|
||||||
body: data.body || '',
|
body: data.body || '',
|
||||||
icon: '/icon.svg',
|
icon: '/icon.svg',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Required env vars (Vite injects VITE_* at build time):
|
// Required env vars (Vite injects VITE_* at build time):
|
||||||
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
|
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
|
||||||
// VITE_FARO_APP_NAME — e.g. farmafinder-frontend
|
// VITE_FARO_APP_NAME — e.g. farmaclic-frontend
|
||||||
// VITE_FARO_ENV — e.g. production | staging | development
|
// VITE_FARO_ENV — e.g. production | staging | development
|
||||||
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
|
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function initFaro() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmafinder-frontend';
|
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmaclic-frontend';
|
||||||
const environment = import.meta.env.VITE_FARO_ENV || 'production';
|
const environment = import.meta.env.VITE_FARO_ENV || 'production';
|
||||||
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
|
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
|||||||
export function getUserPosition() {
|
export function getUserPosition() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject(new Error('Geolocation not supported by this browser'));
|
reject(new Error('Geolocalización no compatible con este navegador'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
||||||
reject(new Error('Geolocation requires HTTPS (or localhost)'));
|
reject(new Error('La geolocalización requiere HTTPS (o localhost)'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
|||||||
+13
-13
@@ -1,12 +1,12 @@
|
|||||||
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||||
const DAY_LABELS = {
|
const DAY_LABELS = {
|
||||||
sun: 'Sunday',
|
sun: 'Domingo',
|
||||||
mon: 'Monday',
|
mon: 'Lunes',
|
||||||
tue: 'Tuesday',
|
tue: 'Martes',
|
||||||
wed: 'Wednesday',
|
wed: 'Miércoles',
|
||||||
thu: 'Thursday',
|
thu: 'Jueves',
|
||||||
fri: 'Friday',
|
fri: 'Viernes',
|
||||||
sat: 'Saturday',
|
sat: 'Sábado',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DAY_KEYS = DAYS;
|
export const DAY_KEYS = DAYS;
|
||||||
@@ -33,8 +33,8 @@ function findNextOpen(hours, now) {
|
|||||||
const range = hours[day];
|
const range = hours[day];
|
||||||
if (Array.isArray(range) && range.length === 2) {
|
if (Array.isArray(range) && range.length === 2) {
|
||||||
const openStr = range[0];
|
const openStr = range[0];
|
||||||
if (offset === 1) return `tomorrow at ${openStr}`;
|
if (offset === 1) return `mañana a las ${openStr}`;
|
||||||
return `${DAY_LABELS[day]} at ${openStr}`;
|
return `${DAY_LABELS[day]} a las ${openStr}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -49,7 +49,7 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
|||||||
|
|
||||||
if (!Array.isArray(range) || range.length !== 2) {
|
if (!Array.isArray(range) || range.length !== 2) {
|
||||||
const next = findNextOpen(hours, now);
|
const next = findNextOpen(hours, now);
|
||||||
return { status: 'closed', label: next ? `Closed · Opens ${next}` : 'Closed' };
|
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMins = parseHM(range[0]);
|
const openMins = parseHM(range[0]);
|
||||||
@@ -59,13 +59,13 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
|||||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
if (nowMins < openMins) {
|
if (nowMins < openMins) {
|
||||||
return { status: 'closed', label: `Closed · Opens at ${range[0]}` };
|
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}` };
|
||||||
}
|
}
|
||||||
if (nowMins >= closeMins) {
|
if (nowMins >= closeMins) {
|
||||||
const next = findNextOpen(hours, now);
|
const next = findNextOpen(hours, now);
|
||||||
return { status: 'closed', label: next ? `Closed · Opens ${next}` : 'Closed' };
|
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
|
||||||
}
|
}
|
||||||
return { status: 'open', label: `Open · Closes at ${range[1]}` };
|
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyHours() {
|
export function emptyHours() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STORAGE_KEY = 'farmafinder.subscriptions.v1';
|
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
|
||||||
|
|
||||||
export function getStoredSubscriptions() {
|
export function getStoredSubscriptions() {
|
||||||
try {
|
try {
|
||||||
@@ -38,12 +38,12 @@ export async function getVapidPublicKey() {
|
|||||||
const res = await fetch('/api/notifications/vapid-public-key');
|
const res = await fetch('/api/notifications/vapid-public-key');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = res.status === 503
|
const message = res.status === 503
|
||||||
? 'Push notifications are not configured on the server yet'
|
? 'Las notificaciones push no están configuradas en el servidor'
|
||||||
: `Could not fetch VAPID key (HTTP ${res.status})`;
|
: `No se pudo obtener la clave VAPID (HTTP ${res.status})`;
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
const { publicKey } = await res.json();
|
const { publicKey } = await res.json();
|
||||||
if (!publicKey) throw new Error('Server returned an empty VAPID key');
|
if (!publicKey) throw new Error('El servidor devolvió una clave VAPID vacía');
|
||||||
return publicKey;
|
return publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,17 +58,17 @@ export function urlBase64ToUint8Array(base64String) {
|
|||||||
|
|
||||||
async function getOrCreateSubscription() {
|
async function getOrCreateSubscription() {
|
||||||
if (!pushSupported()) {
|
if (!pushSupported()) {
|
||||||
throw new Error('Push notifications are not supported in this browser');
|
throw new Error('Las notificaciones push no son compatibles con este navegador');
|
||||||
}
|
}
|
||||||
if (window.isSecureContext === false) {
|
if (window.isSecureContext === false) {
|
||||||
throw new Error('Push notifications require HTTPS (or localhost)');
|
throw new Error('Las notificaciones push requieren HTTPS (o localhost)');
|
||||||
}
|
}
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
let sub = await reg.pushManager.getSubscription();
|
let sub = await reg.pushManager.getSubscription();
|
||||||
if (sub) return sub;
|
if (sub) return sub;
|
||||||
|
|
||||||
if (Notification.permission === 'denied') {
|
if (Notification.permission === 'denied') {
|
||||||
throw new Error('Notification permission was denied — enable it in browser settings');
|
throw new Error('Permiso de notificaciones denegado — actívalo en la configuración del navegador');
|
||||||
}
|
}
|
||||||
if (Notification.permission === 'default') {
|
if (Notification.permission === 'default') {
|
||||||
const result = await Notification.requestPermission();
|
const result = await Notification.requestPermission();
|
||||||
@@ -98,7 +98,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '');
|
const detail = await res.text().catch(() => '');
|
||||||
throw new Error(`Subscribe failed (HTTP ${res.status}) ${detail}`);
|
throw new Error(`Suscripción fallida (HTTP ${res.status}) ${detail}`);
|
||||||
}
|
}
|
||||||
setStoredSubscriptions([...getStoredSubscriptions(), storageKey(medicineNregistro, pharmacyId)]);
|
setStoredSubscriptions([...getStoredSubscriptions(), storageKey(medicineNregistro, pharmacyId)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function AdminView() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<div className="loading">Checking authentication...</div>
|
<div className="loading">Comprobando autenticación...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,8 +68,8 @@ function AdminView() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>⚙️ Admin Panel</h1>
|
<h1>⚙️ Panel de Administración</h1>
|
||||||
<p>Authentication required</p>
|
<p>Autenticación requerida</p>
|
||||||
</header>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<LoginForm onLogin={handleLogin} />
|
<LoginForm onLogin={handleLogin} />
|
||||||
@@ -83,13 +83,13 @@ function AdminView() {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="admin-header-content">
|
<div className="admin-header-content">
|
||||||
<div>
|
<div>
|
||||||
<h1>⚙️ Admin Panel</h1>
|
<h1>⚙️ Panel de Administración</h1>
|
||||||
<p>Manage pharmacies and medicines</p>
|
<p>Gestiona farmacias y medicamentos</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-user-info">
|
<div className="admin-user-info">
|
||||||
<span>👤 {user?.username}</span>
|
<span>👤 {user?.username}</span>
|
||||||
<button className="logout-button" onClick={handleLogout}>
|
<button className="logout-button" onClick={handleLogout}>
|
||||||
Logout
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,19 +101,19 @@ function AdminView() {
|
|||||||
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('pharmacies')}
|
onClick={() => setActiveTab('pharmacies')}
|
||||||
>
|
>
|
||||||
🏥 Pharmacies
|
🏥 Farmacias
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('medicines')}
|
onClick={() => setActiveTab('medicines')}
|
||||||
>
|
>
|
||||||
💊 Medicines
|
💊 Medicamentos
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('link')}
|
onClick={() => setActiveTab('link')}
|
||||||
>
|
>
|
||||||
🔗 Link Medicine to Pharmacy
|
🔗 Vincular Medicamento a Farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,26 @@
|
|||||||
|
|
||||||
.home-logo-wrapper {
|
.home-logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-logo {
|
.home-logo {
|
||||||
width: min(16rem, 70vw);
|
width: min(10rem, 50vw);
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-brand-name {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-height: 700px) {
|
@media (max-height: 700px) {
|
||||||
.home-logo {
|
.home-logo {
|
||||||
width: min(12rem, 60vw);
|
width: min(12rem, 60vw);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
|
|||||||
<div className="home-view">
|
<div className="home-view">
|
||||||
<div className="home-hero">
|
<div className="home-hero">
|
||||||
<div className="home-logo-wrapper">
|
<div className="home-logo-wrapper">
|
||||||
<img src="/logo.png" alt="FarmaFinder" className="home-logo" />
|
<img src="/logo.png" alt="FarmaClic" className="home-logo" />
|
||||||
|
<h1 className="home-brand-name">FarmaClic</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ function PublicView({
|
|||||||
console.error('[location] error', err);
|
console.error('[location] error', err);
|
||||||
let msg;
|
let msg;
|
||||||
if (err && typeof err.code === 'number') {
|
if (err && typeof err.code === 'number') {
|
||||||
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings';
|
if (err.code === 1) msg = 'Permiso de ubicación denegado — actívalo en la configuración del navegador';
|
||||||
else if (err.code === 2) msg = 'Location unavailable — check OS location services / WiFi';
|
else if (err.code === 2) msg = 'Ubicación no disponible — comprueba los servicios de ubicación/WiFi';
|
||||||
else if (err.code === 3) msg = 'Location request timed out — try again';
|
else if (err.code === 3) msg = 'La solicitud de ubicación expiró — inténtalo de nuevo';
|
||||||
else msg = `Geolocation error (code ${err.code})`;
|
else msg = `Error de geolocalización (código ${err.code})`;
|
||||||
} else {
|
} else {
|
||||||
msg = err && err.message ? err.message : 'Could not get your location';
|
msg = err && err.message ? err.message : 'No se pudo obtener tu ubicación';
|
||||||
}
|
}
|
||||||
setLocationError(msg);
|
setLocationError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -172,22 +172,22 @@ function PublicView({
|
|||||||
setPharmacies([]);
|
setPharmacies([]);
|
||||||
setScreen('home');
|
setScreen('home');
|
||||||
}}
|
}}
|
||||||
aria-label="Back to home"
|
aria-label="Volver al inicio"
|
||||||
>
|
>
|
||||||
← Home
|
← Inicio
|
||||||
</button>
|
</button>
|
||||||
<h1>💊 FarmaFinder</h1>
|
<h1>💊 FarmaClic</h1>
|
||||||
<p>Find your medicine at nearby pharmacies</p>
|
<p>Encuentra tu medicamento en farmacias cercanas</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={setSearchQuery}
|
onChange={setSearchQuery}
|
||||||
placeholder="Search for a medicine..."
|
placeholder="Buscar un medicamento..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && <div className="loading">Searching...</div>}
|
{loading && <div className="loading">Buscando...</div>}
|
||||||
|
|
||||||
{searchQuery && !selectedMedicine && (
|
{searchQuery && !selectedMedicine && (
|
||||||
<MedicineResults
|
<MedicineResults
|
||||||
@@ -204,9 +204,9 @@ function PublicView({
|
|||||||
<div className="medicine-info">
|
<div className="medicine-info">
|
||||||
<h2>{selectedMedicine.name}</h2>
|
<h2>{selectedMedicine.name}</h2>
|
||||||
<div className="medicine-details">
|
<div className="medicine-details">
|
||||||
<span><strong>Active Ingredient:</strong> {selectedMedicine.active_ingredient}</span>
|
<span><strong>Principio Activo:</strong> {selectedMedicine.active_ingredient}</span>
|
||||||
<span><strong>Dosage:</strong> {selectedMedicine.dosage}</span>
|
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
|
||||||
<span><strong>Form:</strong> {selectedMedicine.form}</span>
|
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="back-button"
|
className="back-button"
|
||||||
@@ -215,7 +215,7 @@ function PublicView({
|
|||||||
setPharmacies([]);
|
setPharmacies([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
← Back to search
|
← Volver a búsqueda
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,15 +227,15 @@ function PublicView({
|
|||||||
disabled={locating}
|
disabled={locating}
|
||||||
>
|
>
|
||||||
{locating
|
{locating
|
||||||
? '📍 Locating…'
|
? '📍 Localizando…'
|
||||||
: sortByDistance
|
: sortByDistance
|
||||||
? '📍 Sorted by distance · Reset'
|
? '📍 Ordenado por distancia · Reset'
|
||||||
: hasSavedCoords
|
: hasSavedCoords
|
||||||
? '📍 Sort by your saved location'
|
? '📍 Ordenar por ubicación guardada'
|
||||||
: '📍 Sort by distance'}
|
: '📍 Ordenar por distancia'}
|
||||||
</button>
|
</button>
|
||||||
{sortByDistance && positionSource === 'profile' && (
|
{sortByDistance && positionSource === 'profile' && (
|
||||||
<span className="location-source">Using your saved address</span>
|
<span className="location-source">Usando tu dirección guardada</span>
|
||||||
)}
|
)}
|
||||||
{locationError && (
|
{locationError && (
|
||||||
<span className="location-error">{locationError}</span>
|
<span className="location-error">{locationError}</span>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export default defineConfig({
|
|||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'FarmaFinder',
|
name: 'FarmaClic',
|
||||||
short_name: 'FarmaFinder',
|
short_name: 'FarmaClic',
|
||||||
description: 'Find pharmacies that stock the medicine you need',
|
description: 'Encuentra medicamentos en farmacias cercanas',
|
||||||
theme_color: '#00450d',
|
theme_color: '#00450d',
|
||||||
background_color: '#f8fafb',
|
background_color: '#f8fafb',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder",
|
"name": "farma-clic",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "FarmaFinder - Full stack application",
|
"description": "FarmaClic - Full stack application",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user