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.
|
||||
*/
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
|
||||
const res = await fetch(u, {
|
||||
headers: {
|
||||
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();
|
||||
|
||||
@@ -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 }.
|
||||
*
|
||||
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ async function fetchOverpass(endpoint, body) {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'FarmaFinder/1.0 (OSM pharmacy import; local admin)',
|
||||
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "farma-finder-api-sources",
|
||||
"name": "farma-clic-api-sources",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "farma-finder-backend",
|
||||
"name": "farma-clic-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for FarmaFinder",
|
||||
"description": "Backend API for FarmaClic",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔄 FarmaFinder - Quick Database Reset"
|
||||
echo "🔄 FarmaClic - Quick Database Reset"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
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.
|
||||
const logger = pino({
|
||||
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
|
||||
@@ -61,7 +61,7 @@ if (PG_URL) {
|
||||
}
|
||||
|
||||
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,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
@@ -703,7 +703,7 @@ async function nominatimSearchFirstHit(originalQuery) {
|
||||
Accept: 'application/json',
|
||||
'Accept-Language': 'es,en',
|
||||
'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();
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
// 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)
|
||||
//
|
||||
// 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 { 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 resource = typeof resources.resourceFromAttributes === 'function'
|
||||
? resources.resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
||||
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||
})
|
||||
: new resources.Resource({
|
||||
[ATTR_SERVICE_NAME]: serviceName,
|
||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
||||
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "net.hacecalor.farmafinder",
|
||||
"appName": "FarmaFinder",
|
||||
"appId": "net.hacecalor.farmaclic",
|
||||
"appName": "FarmaClic",
|
||||
"webDir": "frontend/dist",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
|
||||
+10
-10
@@ -7,14 +7,14 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: farmafinder
|
||||
POSTGRES_USER: farmafinder
|
||||
POSTGRES_DB: farmaclic
|
||||
POSTGRES_USER: farmaclic
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
backend:
|
||||
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
||||
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
@@ -25,19 +25,19 @@ services:
|
||||
PORT: "3001"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
CORS_ORIGIN: http://localhost:4000
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
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
|
||||
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic
|
||||
# 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_TRACES_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
|
||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
depends_on:
|
||||
@@ -45,17 +45,17 @@ services:
|
||||
- postgres
|
||||
|
||||
frontend:
|
||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
||||
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
|
||||
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_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-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"
|
||||
- "4000:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
VITE_FARO_ENDPOINT=http://localhost:4318
|
||||
|
||||
# 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).
|
||||
VITE_FARO_ENV=production
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
FROM node:20-alpine AS build
|
||||
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_APP_VERSION=1.0.0
|
||||
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<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.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "farma-finder-frontend",
|
||||
"name": "farma-clic-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -64,7 +64,7 @@ function App() {
|
||||
}
|
||||
|
||||
let activeView;
|
||||
let topBarTitle = 'FarmaFinder';
|
||||
let topBarTitle = 'FarmaClic';
|
||||
let showBack = false;
|
||||
|
||||
switch (screen) {
|
||||
|
||||
@@ -25,7 +25,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
const u = username.trim();
|
||||
if (!u || !password) return;
|
||||
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;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -40,12 +40,12 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
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 {
|
||||
onLogin(data.user);
|
||||
}
|
||||
} catch {
|
||||
setError('Network error — try again');
|
||||
setError('Error de red — inténtalo de nuevo');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
onClick={() => setMode('login')}
|
||||
disabled={loading}
|
||||
>
|
||||
Sign in
|
||||
Iniciar sesión
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -81,20 +81,20 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
onClick={() => setMode('register')}
|
||||
disabled={loading}
|
||||
>
|
||||
Create account
|
||||
Crear cuenta
|
||||
</button>
|
||||
</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">
|
||||
{isRegister
|
||||
? 'Save your address and get notified when medicines arrive.'
|
||||
: 'Sign in to manage your profile and notifications.'}
|
||||
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
|
||||
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="modal-field">
|
||||
<label htmlFor="modal-username">Username</label>
|
||||
<label htmlFor="modal-username">Usuario</label>
|
||||
<input
|
||||
id="modal-username"
|
||||
type="text"
|
||||
@@ -107,11 +107,11 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
maxLength={isRegister ? 32 : undefined}
|
||||
/>
|
||||
{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 className="modal-field">
|
||||
<label htmlFor="modal-password">Password</label>
|
||||
<label htmlFor="modal-password">Contraseña</label>
|
||||
<input
|
||||
id="modal-password"
|
||||
type="password"
|
||||
@@ -122,7 +122,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
minLength={isRegister ? 8 : undefined}
|
||||
/>
|
||||
{isRegister && (
|
||||
<p className="modal-hint">At least 8 characters.</p>
|
||||
<p className="modal-hint">Al menos 8 caracteres.</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
@@ -133,7 +133,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -141,8 +141,8 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
||||
disabled={loading || !username.trim() || !password}
|
||||
>
|
||||
{loading
|
||||
? (isRegister ? 'Creating…' : 'Signing in…')
|
||||
: (isRegister ? 'Create account' : 'Sign in')}
|
||||
? (isRegister ? 'Creando…' : 'Iniciando sesión…')
|
||||
: (isRegister ? 'Crear cuenta' : 'Iniciar sesión')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,7 +11,7 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque
|
||||
if (medicines.length === 0 && query.length >= 2) {
|
||||
return (
|
||||
<div className="no-results">
|
||||
<p>No medicines found matching "{query}"</p>
|
||||
<p>No se encontraron medicamentos para "{query}"</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (busy) return;
|
||||
@@ -65,7 +65,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
||||
}
|
||||
} catch (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 {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -83,29 +83,29 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
||||
aria-pressed={subscribed && !!currentUser}
|
||||
aria-label={
|
||||
!currentUser
|
||||
? 'Login to enable notifications'
|
||||
? 'Inicia sesión para activar notificaciones'
|
||||
: subscribed
|
||||
? 'Stop notifications for this medicine'
|
||||
: 'Notify me when this medicine becomes available'
|
||||
? 'Desactivar notificaciones para este medicamento'
|
||||
: 'Notificarme cuando esté disponible'
|
||||
}
|
||||
title={
|
||||
!currentUser
|
||||
? 'Login to enable notifications'
|
||||
? 'Inicia sesión para activar notificaciones'
|
||||
: subscribed
|
||||
? 'Notifications on — click to turn off'
|
||||
: 'Notify me when this medicine is added to a pharmacy'
|
||||
? 'Notificaciones activadas — clic para desactivar'
|
||||
: 'Notificarme cuando este medicamento esté en una farmacia'
|
||||
}
|
||||
>
|
||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="medicine-card-body">
|
||||
<p><strong>Active Ingredient:</strong> {medicine.active_ingredient}</p>
|
||||
<p><strong>Dosage:</strong> {medicine.dosage} • <strong>Form:</strong> {medicine.form}</p>
|
||||
<p><strong>Principio Activo:</strong> {medicine.active_ingredient}</p>
|
||||
<p><strong>Dosis:</strong> {medicine.dosage} • <strong>Forma:</strong> {medicine.form}</p>
|
||||
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
|
||||
</div>
|
||||
<div className="medicine-card-footer">
|
||||
<span className="view-pharmacies">View pharmacies →</span>
|
||||
<span className="view-pharmacies">Ver farmacias →</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-pharmacies">
|
||||
<p>Loading pharmacies...</p>
|
||||
<p>Cargando farmacias...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
||||
if (pharmacies.length === 0) {
|
||||
return (
|
||||
<div className="no-pharmacies">
|
||||
<p>No pharmacies found selling this medicine</p>
|
||||
<p>No se encontraron farmacias con este medicamento</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
||||
return (
|
||||
<div className="pharmacy-list">
|
||||
<h3 className="pharmacy-list-title">
|
||||
Available at {pharmacies.length} {pharmacies.length === 1 ? 'pharmacy' : 'pharmacies'}
|
||||
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
|
||||
</h3>
|
||||
<div className="pharmacy-grid">
|
||||
{pharmacies.map((pharmacy) => {
|
||||
@@ -76,7 +76,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (busy) return;
|
||||
@@ -92,7 +92,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
}
|
||||
} catch (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 {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -116,18 +116,18 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
disabled={busy}
|
||||
aria-pressed={subscribed && !!currentUser}
|
||||
aria-label={
|
||||
!currentUser
|
||||
? 'Login to enable notifications'
|
||||
: subscribed
|
||||
? 'Stop notifications for this pharmacy'
|
||||
: 'Notify me when this medicine arrives at this pharmacy'
|
||||
}
|
||||
title={
|
||||
!currentUser
|
||||
? 'Login to enable notifications'
|
||||
: subscribed
|
||||
? 'Notifications on for this pharmacy — click to turn off'
|
||||
: 'Notify me when this medicine arrives at this pharmacy'
|
||||
!currentUser
|
||||
? 'Inicia sesión para activar notificaciones'
|
||||
: subscribed
|
||||
? 'Desactivar notificaciones para esta farmacia'
|
||||
: 'Notificarme cuando llegue a esta farmacia'
|
||||
}
|
||||
title={
|
||||
!currentUser
|
||||
? 'Inicia sesión para activar notificaciones'
|
||||
: subscribed
|
||||
? 'Notificaciones activadas para esta farmacia — clic para desactivar'
|
||||
: 'Notificarme cuando llegue a esta farmacia'
|
||||
}
|
||||
>
|
||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||
@@ -152,7 +152,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
)}
|
||||
{pharmacy.stock !== undefined && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ function SavedNotifications({ onClose }) {
|
||||
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||
setItems(merged);
|
||||
} 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 {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function SavedNotifications({ onClose }) {
|
||||
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)));
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove notification');
|
||||
setError(err.message || 'No se pudo eliminar la notificación');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -52,15 +52,15 @@ function SavedNotifications({ onClose }) {
|
||||
<div className="saved-notifications-backdrop" onClick={onClose}>
|
||||
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="saved-notifications-header">
|
||||
<h2>🔔 Saved Notifications</h2>
|
||||
<button className="saved-notifications-close" onClick={onClose} aria-label="Close">×</button>
|
||||
<h2>🔔 Notificaciones Guardadas</h2>
|
||||
<button className="saved-notifications-close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
<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 && items.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{!loading && !error && items.length > 0 && (
|
||||
@@ -77,7 +77,7 @@ function SavedNotifications({ onClose }) {
|
||||
{item.scope === '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>
|
||||
{item.pharmacy_address && (
|
||||
<span className="saved-notifications-address">{item.pharmacy_address}</span>
|
||||
@@ -85,7 +85,7 @@ function SavedNotifications({ onClose }) {
|
||||
</>
|
||||
) : (
|
||||
<span className="saved-notifications-chip">
|
||||
Any pharmacy
|
||||
Cualquier farmacia
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,9 +95,9 @@ function SavedNotifications({ onClose }) {
|
||||
className="saved-notifications-remove"
|
||||
onClick={() => handleDelete(item)}
|
||||
disabled={busyId === key}
|
||||
aria-label="Remove notification"
|
||||
aria-label="Eliminar notificación"
|
||||
>
|
||||
{busyId === key ? '…' : 'Remove'}
|
||||
{busyId === key ? '…' : 'Eliminar'}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ function TopBar({ title, showBack, onBack, rightAction }) {
|
||||
{showBack ? (
|
||||
<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>
|
||||
|
||||
@@ -25,14 +25,14 @@ function LoginForm({ onLogin }) {
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login failed');
|
||||
throw new Error(data.error || 'Inicio de sesión fallido');
|
||||
}
|
||||
|
||||
// Success - notify parent component
|
||||
onLogin(data.user);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
setError(error.message || 'Invalid username or password');
|
||||
setError(error.message || 'Usuario o contraseña inválidos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -42,8 +42,8 @@ function LoginForm({ onLogin }) {
|
||||
<div className="login-container">
|
||||
<div className="login-box">
|
||||
<div className="login-header">
|
||||
<h2>🔐 Admin Login</h2>
|
||||
<p>Please enter your credentials to access the admin panel</p>
|
||||
<h2>🔐 Acceso Administración</h2>
|
||||
<p>Introduce tus credenciales para acceder al panel de administración</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
@@ -54,13 +54,13 @@ function LoginForm({ onLogin }) {
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<label htmlFor="username">Usuario</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
placeholder="Introduce usuario"
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
@@ -68,13 +68,13 @@ function LoginForm({ onLogin }) {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<label htmlFor="password">Contraseña</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
placeholder="Introduce contraseña"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -85,16 +85,16 @@ function LoginForm({ onLogin }) {
|
||||
className="login-button"
|
||||
disabled={loading || !username || !password}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p className="help-text">
|
||||
Default credentials: <code>admin</code> / <code>admin123</code>
|
||||
Credenciales por defecto: <code>admin</code> / <code>admin123</code>
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ function MedicineManagement() {
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
console.error('Error searching medicines:', error);
|
||||
alert('Error searching medicines from CIMA API');
|
||||
alert('Error al buscar medicamentos en la API CIMA');
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ function MedicineManagement() {
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<div className="section-header">
|
||||
<h2>Search Medicines (CIMA API)</h2>
|
||||
<h2>Buscar Medicamentos (API CIMA)</h2>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
@@ -54,7 +54,7 @@ function MedicineManagement() {
|
||||
|
||||
<div className="admin-form">
|
||||
<div className="form-group">
|
||||
<label>Search for medicines</label>
|
||||
<label>Buscar medicamentos</label>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
@@ -64,11 +64,11 @@ function MedicineManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="loading">Searching CIMA API...</div>}
|
||||
{loading && <div className="loading">Buscando en API CIMA...</div>}
|
||||
|
||||
{!loading && medicines.length > 0 && (
|
||||
<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) => (
|
||||
<div key={medicine.nregistro} className="admin-item">
|
||||
<div className="item-content">
|
||||
|
||||
@@ -56,10 +56,10 @@ function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
}
|
||||
|
||||
const REGION_PRESETS = [
|
||||
{ id: 'custom', label: 'Custom coordinates', lat: '', lon: '', radio: '' },
|
||||
{ id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' },
|
||||
{
|
||||
id: 'rubi',
|
||||
label: 'Example: Rubí area (1.5 km)',
|
||||
label: 'Ejemplo: Área de Rubí (1.5 km)',
|
||||
lat: '41.5631',
|
||||
lon: '2.0038',
|
||||
radio: '1500',
|
||||
@@ -76,16 +76,16 @@ async function geocodeErrorMessage(response) {
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
||||
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) {
|
||||
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
|
||||
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() {
|
||||
@@ -132,7 +132,7 @@ function PharmacyManagement() {
|
||||
setPharmacies(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pharmacies:', error);
|
||||
alert('Error loading pharmacies');
|
||||
alert('Error al cargar farmacias');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ function PharmacyManagement() {
|
||||
e?.preventDefault();
|
||||
const q = cityQuery.trim();
|
||||
if (!q) {
|
||||
setCityLookupMessage({ type: 'err', text: 'Enter a city or place name.' });
|
||||
setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' });
|
||||
return;
|
||||
}
|
||||
setCityLookupLoading(true);
|
||||
@@ -253,7 +253,7 @@ function PharmacyManagement() {
|
||||
const lon = parseFloat(regionLon);
|
||||
const radio = parseFloat(regionRadio);
|
||||
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', {
|
||||
method: 'POST',
|
||||
@@ -307,7 +307,7 @@ function PharmacyManagement() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update pharmacy');
|
||||
throw new Error(error.error || 'Error al actualizar farmacia');
|
||||
}
|
||||
} else {
|
||||
const response = await fetch('/api/admin/pharmacies', {
|
||||
@@ -319,16 +319,16 @@ function PharmacyManagement() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create pharmacy');
|
||||
throw new Error(error.error || 'Error al crear farmacia');
|
||||
}
|
||||
}
|
||||
|
||||
resetForm();
|
||||
fetchPharmacies();
|
||||
alert(editingPharmacy ? 'Pharmacy updated successfully!' : 'Pharmacy added successfully!');
|
||||
alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!');
|
||||
} catch (error) {
|
||||
console.error('Error saving pharmacy:', error);
|
||||
alert(`Error saving pharmacy: ${error.message}`);
|
||||
alert(`Error al guardar farmacia: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -348,7 +348,7 @@ function PharmacyManagement() {
|
||||
};
|
||||
|
||||
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 {
|
||||
const response = await fetch(`/api/admin/pharmacies/${id}`, {
|
||||
@@ -356,13 +356,13 @@ function PharmacyManagement() {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete pharmacy');
|
||||
if (!response.ok) throw new Error('Error al eliminar farmacia');
|
||||
|
||||
fetchPharmacies();
|
||||
alert('Pharmacy deleted successfully!');
|
||||
alert('¡Farmacia eliminada!');
|
||||
} catch (error) {
|
||||
console.error('Error deleting pharmacy:', error);
|
||||
alert('Error deleting pharmacy');
|
||||
alert('Error al eliminar farmacia');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,7 +391,7 @@ function PharmacyManagement() {
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<div className="section-header">
|
||||
<h2>Manage Pharmacies</h2>
|
||||
<h2>Gestionar Farmacias</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
@@ -400,16 +400,16 @@ function PharmacyManagement() {
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
+ Add New Pharmacy
|
||||
+ Añadir Nueva Farmacia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pharmacy-tools-card">
|
||||
<h3>City, region & import</h3>
|
||||
<h3>Ciudad, región e importación</h3>
|
||||
<p className="pharmacy-tools-hint">
|
||||
<strong>Find city</strong> sets latitude, longitude and radius for the map filter and for imports.
|
||||
Choose a <strong>data source</strong> below: <strong>OpenStreetMap</strong> is free (no key);{' '}
|
||||
<strong>Open data URL</strong> loads JSON you host (array or GeoJSON). Geocoding uses{' '}
|
||||
<strong>Buscar ciudad</strong> establece latitud, longitud y radio para el filtro de mapa y las importaciones.
|
||||
Elige una <strong>fuente de datos</strong>: <strong>OpenStreetMap</strong> es gratuita (sin clave);{' '}
|
||||
<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">
|
||||
OpenStreetMap
|
||||
</a>{' '}
|
||||
@@ -418,11 +418,11 @@ function PharmacyManagement() {
|
||||
|
||||
<form className="city-lookup-form" onSubmit={handleCityLookup}>
|
||||
<div className="form-group city-lookup-input-wrap">
|
||||
<label htmlFor="city-finder">Find city</label>
|
||||
<label htmlFor="city-finder">Buscar ciudad</label>
|
||||
<input
|
||||
id="city-finder"
|
||||
type="search"
|
||||
placeholder="e.g. Rubí, Spain — or Madrid, Valencia…"
|
||||
placeholder="Ej: Rubí, Madrid, Valencia…"
|
||||
value={cityQuery}
|
||||
onChange={(e) => {
|
||||
setCityQuery(e.target.value);
|
||||
@@ -436,7 +436,7 @@ function PharmacyManagement() {
|
||||
className="btn-secondary city-lookup-submit"
|
||||
disabled={cityLookupLoading}
|
||||
>
|
||||
{cityLookupLoading ? 'Looking up…' : 'Look up city'}
|
||||
{cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'}
|
||||
</button>
|
||||
</form>
|
||||
{cityLookupMessage && (
|
||||
@@ -449,7 +449,7 @@ function PharmacyManagement() {
|
||||
)}
|
||||
|
||||
<div className="region-presets">
|
||||
<label htmlFor="region-preset">Area preset</label>
|
||||
<label htmlFor="region-preset">Preset de área</label>
|
||||
<select
|
||||
id="region-preset"
|
||||
value={regionPreset}
|
||||
@@ -465,7 +465,7 @@ function PharmacyManagement() {
|
||||
|
||||
<div className="region-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="region-lat">Latitude</label>
|
||||
<label htmlFor="region-lat">Latitud</label>
|
||||
<input
|
||||
id="region-lat"
|
||||
type="text"
|
||||
@@ -476,7 +476,7 @@ function PharmacyManagement() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="region-lon">Longitude</label>
|
||||
<label htmlFor="region-lon">Longitud</label>
|
||||
<input
|
||||
id="region-lon"
|
||||
type="text"
|
||||
@@ -487,7 +487,7 @@ function PharmacyManagement() {
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="region-radio">Radius (m)</label>
|
||||
<label htmlFor="region-radio">Radio (m)</label>
|
||||
<input
|
||||
id="region-radio"
|
||||
type="text"
|
||||
@@ -501,7 +501,7 @@ function PharmacyManagement() {
|
||||
|
||||
<div className="import-mode-row">
|
||||
<div className="form-group import-mode-select-wrap">
|
||||
<label htmlFor="import-mode">Data source</label>
|
||||
<label htmlFor="import-mode">Fuente de datos</label>
|
||||
<select
|
||||
id="import-mode"
|
||||
value={importMode}
|
||||
@@ -510,16 +510,16 @@ function PharmacyManagement() {
|
||||
setImportFeedback(null);
|
||||
}}
|
||||
>
|
||||
<option value="osm">OpenStreetMap (Overpass, free)</option>
|
||||
<option value="webhook">n8n webhook (legacy)</option>
|
||||
<option value="openData">Open data JSON URL</option>
|
||||
<option value="osm">OpenStreetMap (Overpass, gratuito)</option>
|
||||
<option value="webhook">n8n webhook (heredado)</option>
|
||||
<option value="openData">URL de datos abiertos JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importMode === 'openData' && (
|
||||
<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
|
||||
id="open-data-url"
|
||||
type="url"
|
||||
@@ -539,12 +539,12 @@ function PharmacyManagement() {
|
||||
disabled={importing}
|
||||
>
|
||||
{importing
|
||||
? 'Importing…'
|
||||
? 'Importando…'
|
||||
: importMode === 'webhook'
|
||||
? 'Import from webhook'
|
||||
? 'Importar desde webhook'
|
||||
: importMode === 'openData'
|
||||
? 'Import from URL'
|
||||
: `Import from ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
||||
? 'Importar desde URL'
|
||||
: `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
||||
</button>
|
||||
<label className="filter-region-toggle">
|
||||
<input
|
||||
@@ -552,7 +552,7 @@ function PharmacyManagement() {
|
||||
checked={filterByRegion}
|
||||
onChange={(e) => setFilterByRegion(e.target.checked)}
|
||||
/>
|
||||
Show only pharmacies inside radius
|
||||
Mostrar solo farmacias dentro del radio
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -568,10 +568,10 @@ function PharmacyManagement() {
|
||||
|
||||
{showForm && (
|
||||
<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">
|
||||
<label>Name *</label>
|
||||
<label>Nombre *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
@@ -581,7 +581,7 @@ function PharmacyManagement() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Address *</label>
|
||||
<label>Dirección *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
@@ -591,7 +591,7 @@ function PharmacyManagement() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<label>Teléfono</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
@@ -601,7 +601,7 @@ function PharmacyManagement() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Latitude</label>
|
||||
<label>Latitud</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -611,7 +611,7 @@ function PharmacyManagement() {
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Longitude</label>
|
||||
<label>Longitud</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -622,8 +622,8 @@ function PharmacyManagement() {
|
||||
</div>
|
||||
|
||||
<fieldset className="hours-editor">
|
||||
<legend>Opening hours</legend>
|
||||
<p className="hours-editor-hint">Leave a day checked as <em>Closed</em> if the pharmacy doesn't open that day.</p>
|
||||
<legend>Horario de apertura</legend>
|
||||
<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) => {
|
||||
const d = hoursDraft[day];
|
||||
return (
|
||||
@@ -635,14 +635,14 @@ function PharmacyManagement() {
|
||||
checked={d.closed}
|
||||
onChange={(e) => updateDay(day, { closed: e.target.checked })}
|
||||
/>
|
||||
Closed
|
||||
Cerrado
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={d.open}
|
||||
disabled={d.closed}
|
||||
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>
|
||||
<input
|
||||
@@ -650,7 +650,7 @@ function PharmacyManagement() {
|
||||
value={d.close}
|
||||
disabled={d.closed}
|
||||
onChange={(e) => updateDay(day, { close: e.target.value })}
|
||||
aria-label={`${DAY_LABEL[day]} closes at`}
|
||||
aria-label={`${DAY_LABEL[day]} cierra a las`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -659,28 +659,28 @@ function PharmacyManagement() {
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : editingPharmacy ? 'Update' : 'Add'} Pharmacy
|
||||
{saving ? 'Guardando...' : editingPharmacy ? 'Actualizar' : 'Añadir'} Farmacia
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
|
||||
Cancel
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading pharmacies...</div>
|
||||
<div className="loading">Cargando farmacias...</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
<p className="list-meta">
|
||||
Showing {displayedPharmacies.length} of {pharmacies.length} pharmacies
|
||||
{filterByRegion && ' (inside radius)'}
|
||||
Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias
|
||||
{filterByRegion && ' (dentro del radio)'}
|
||||
</p>
|
||||
{displayedPharmacies.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
{pharmacies.length === 0
|
||||
? 'No pharmacies yet. Import from webhook or add one manually.'
|
||||
: 'No pharmacies in this radius with coordinates. Widen the radius, look up a different city, or turn off the region filter.'}
|
||||
? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.'
|
||||
: 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'}
|
||||
</p>
|
||||
) : (
|
||||
displayedPharmacies.map((pharmacy) => (
|
||||
@@ -697,10 +697,10 @@ function PharmacyManagement() {
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
|
||||
Edit
|
||||
Editar
|
||||
</button>
|
||||
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
|
||||
Delete
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,7 @@ function PharmacyMedicineLink() {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedMedicine) {
|
||||
alert('Please select a medicine first');
|
||||
alert('Por favor, selecciona un medicamento primero');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,16 +131,16 @@ function PharmacyMedicineLink() {
|
||||
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();
|
||||
if (selectedPharmacy) {
|
||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||
}
|
||||
alert('Medicine linked to pharmacy successfully!');
|
||||
alert('¡Medicamento vinculado a la farmacia correctamente!');
|
||||
} catch (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 })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
if (!response.ok) throw new Error('Error al actualizar');
|
||||
|
||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||
alert('Updated successfully!');
|
||||
alert('¡Actualizado correctamente!');
|
||||
} catch (error) {
|
||||
console.error('Error updating:', error);
|
||||
alert('Error updating');
|
||||
alert('Error al actualizar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Remove this medicine from the pharmacy?')) return;
|
||||
if (!confirm('¿Eliminar este medicamento de la farmacia?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
||||
@@ -172,13 +172,13 @@ function PharmacyMedicineLink() {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
if (!response.ok) throw new Error('Error al eliminar');
|
||||
|
||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||
alert('Medicine removed from pharmacy!');
|
||||
alert('¡Medicamento eliminado de la farmacia!');
|
||||
} catch (error) {
|
||||
console.error('Error deleting:', error);
|
||||
alert('Error removing medicine');
|
||||
alert('Error al eliminar medicamento');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,11 +216,11 @@ function PharmacyMedicineLink() {
|
||||
|
||||
return (
|
||||
<div className="admin-section">
|
||||
<h2>Link Medicine to Pharmacy</h2>
|
||||
<h2>Vincular Medicamento a Farmacia</h2>
|
||||
|
||||
<form className="admin-form" onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Pharmacy *</label>
|
||||
<label>Farmacia *</label>
|
||||
<input
|
||||
ref={pharmacyInputRef}
|
||||
type="text"
|
||||
@@ -235,7 +235,7 @@ function PharmacyMedicineLink() {
|
||||
}}
|
||||
onFocus={() => setPharmacyDropdownOpen(true)}
|
||||
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"
|
||||
required={!selectedPharmacy}
|
||||
/>
|
||||
@@ -243,7 +243,7 @@ function PharmacyMedicineLink() {
|
||||
<div className="medicine-search-results">
|
||||
{filteredPharmacies.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
filteredPharmacies.map((pharmacy) => (
|
||||
@@ -264,14 +264,14 @@ function PharmacyMedicineLink() {
|
||||
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
|
||||
<p className="medicine-details">{selectedPharmacy.address}</p>
|
||||
<button type="button" className="btn-small" onClick={clearPharmacy}>
|
||||
Change pharmacy
|
||||
Cambiar farmacia
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Search Medicine (CIMA API) *</label>
|
||||
<label>Buscar Medicamento (API CIMA) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={medicineSearch}
|
||||
@@ -279,10 +279,10 @@ function PharmacyMedicineLink() {
|
||||
setMedicineSearch(e.target.value);
|
||||
setSelectedMedicine(null);
|
||||
}}
|
||||
placeholder="Type to search medicines from CIMA..."
|
||||
placeholder="Escribe para buscar medicamentos en CIMA..."
|
||||
required
|
||||
/>
|
||||
{searching && <p className="loading-text">Searching...</p>}
|
||||
{searching && <p className="loading-text">Buscando...</p>}
|
||||
|
||||
{medicineResults.length > 0 && !selectedMedicine && (
|
||||
<div className="medicine-search-results">
|
||||
@@ -316,7 +316,7 @@ function PharmacyMedicineLink() {
|
||||
setMedicineSearch('');
|
||||
}}
|
||||
>
|
||||
Change medicine
|
||||
Cambiar medicamento
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +324,7 @@ function PharmacyMedicineLink() {
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Price (€)</label>
|
||||
<label>Precio (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -347,21 +347,21 @@ function PharmacyMedicineLink() {
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary">
|
||||
Link Medicine
|
||||
Vincular Medicamento
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={resetForm}>
|
||||
Reset
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{selectedPharmacy && (
|
||||
<div className="pharmacy-medicines-section">
|
||||
<h3>Medicines at {selectedPharmacy.name}</h3>
|
||||
<h3>Medicamentos en {selectedPharmacy.name}</h3>
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
<div className="loading">Cargando...</div>
|
||||
) : 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">
|
||||
{pharmacyMedicines.map((pm) => (
|
||||
@@ -369,7 +369,7 @@ function PharmacyMedicineLink() {
|
||||
<div className="item-content">
|
||||
<h4>{pm.medicine_name}</h4>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -377,17 +377,17 @@ function PharmacyMedicineLink() {
|
||||
<button
|
||||
className="btn-edit"
|
||||
onClick={() => {
|
||||
const newPrice = prompt('Enter new price:', pm.price || '');
|
||||
const newStock = prompt('Enter new stock:', pm.stock || '0');
|
||||
const newPrice = prompt('Introduce nuevo precio:', pm.price || '');
|
||||
const newStock = prompt('Introduce nuevo stock:', pm.stock || '0');
|
||||
if (newPrice !== null && newStock !== null) {
|
||||
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update
|
||||
Actualizar
|
||||
</button>
|
||||
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
||||
Remove
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -14,10 +14,10 @@ self.addEventListener('push', (event) => {
|
||||
try {
|
||||
data = event.data.json();
|
||||
} 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 = {
|
||||
body: data.body || '',
|
||||
icon: '/icon.svg',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// 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_APP_NAME — e.g. farmaclic-frontend
|
||||
// VITE_FARO_ENV — e.g. production | staging | development
|
||||
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
|
||||
|
||||
@@ -29,7 +29,7 @@ export function initFaro() {
|
||||
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 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() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation not supported by this browser'));
|
||||
reject(new Error('Geolocalización no compatible con este navegador'));
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
|
||||
+13
-13
@@ -1,12 +1,12 @@
|
||||
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
const DAY_LABELS = {
|
||||
sun: 'Sunday',
|
||||
mon: 'Monday',
|
||||
tue: 'Tuesday',
|
||||
wed: 'Wednesday',
|
||||
thu: 'Thursday',
|
||||
fri: 'Friday',
|
||||
sat: 'Saturday',
|
||||
sun: 'Domingo',
|
||||
mon: 'Lunes',
|
||||
tue: 'Martes',
|
||||
wed: 'Miércoles',
|
||||
thu: 'Jueves',
|
||||
fri: 'Viernes',
|
||||
sat: 'Sábado',
|
||||
};
|
||||
|
||||
export const DAY_KEYS = DAYS;
|
||||
@@ -33,8 +33,8 @@ function findNextOpen(hours, now) {
|
||||
const range = hours[day];
|
||||
if (Array.isArray(range) && range.length === 2) {
|
||||
const openStr = range[0];
|
||||
if (offset === 1) return `tomorrow at ${openStr}`;
|
||||
return `${DAY_LABELS[day]} at ${openStr}`;
|
||||
if (offset === 1) return `mañana a las ${openStr}`;
|
||||
return `${DAY_LABELS[day]} a las ${openStr}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -49,7 +49,7 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
||||
|
||||
if (!Array.isArray(range) || range.length !== 2) {
|
||||
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]);
|
||||
@@ -59,13 +59,13 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
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) {
|
||||
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() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const STORAGE_KEY = 'farmafinder.subscriptions.v1';
|
||||
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
|
||||
|
||||
export function getStoredSubscriptions() {
|
||||
try {
|
||||
@@ -38,12 +38,12 @@ export async function getVapidPublicKey() {
|
||||
const res = await fetch('/api/notifications/vapid-public-key');
|
||||
if (!res.ok) {
|
||||
const message = res.status === 503
|
||||
? 'Push notifications are not configured on the server yet'
|
||||
: `Could not fetch VAPID key (HTTP ${res.status})`;
|
||||
? 'Las notificaciones push no están configuradas en el servidor'
|
||||
: `No se pudo obtener la clave VAPID (HTTP ${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -58,17 +58,17 @@ export function urlBase64ToUint8Array(base64String) {
|
||||
|
||||
async function getOrCreateSubscription() {
|
||||
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) {
|
||||
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;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (sub) return sub;
|
||||
|
||||
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') {
|
||||
const result = await Notification.requestPermission();
|
||||
@@ -98,7 +98,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
|
||||
});
|
||||
if (!res.ok) {
|
||||
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)]);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function AdminView() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-main">
|
||||
<div className="loading">Checking authentication...</div>
|
||||
<div className="loading">Comprobando autenticación...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,8 +68,8 @@ function AdminView() {
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<h1>⚙️ Admin Panel</h1>
|
||||
<p>Authentication required</p>
|
||||
<h1>⚙️ Panel de Administración</h1>
|
||||
<p>Autenticación requerida</p>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<LoginForm onLogin={handleLogin} />
|
||||
@@ -83,13 +83,13 @@ function AdminView() {
|
||||
<header className="app-header">
|
||||
<div className="admin-header-content">
|
||||
<div>
|
||||
<h1>⚙️ Admin Panel</h1>
|
||||
<p>Manage pharmacies and medicines</p>
|
||||
<h1>⚙️ Panel de Administración</h1>
|
||||
<p>Gestiona farmacias y medicamentos</p>
|
||||
</div>
|
||||
<div className="admin-user-info">
|
||||
<span>👤 {user?.username}</span>
|
||||
<button className="logout-button" onClick={handleLogout}>
|
||||
Logout
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,19 +101,19 @@ function AdminView() {
|
||||
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('pharmacies')}
|
||||
>
|
||||
🏥 Pharmacies
|
||||
🏥 Farmacias
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('medicines')}
|
||||
>
|
||||
💊 Medicines
|
||||
💊 Medicamentos
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('link')}
|
||||
>
|
||||
🔗 Link Medicine to Pharmacy
|
||||
🔗 Vincular Medicamento a Farmacia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,16 +20,26 @@
|
||||
|
||||
.home-logo-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
width: min(16rem, 70vw);
|
||||
width: min(10rem, 50vw);
|
||||
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) {
|
||||
.home-logo {
|
||||
width: min(12rem, 60vw);
|
||||
|
||||
@@ -6,7 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
|
||||
<div className="home-view">
|
||||
<div className="home-hero">
|
||||
<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>
|
||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||
</div>
|
||||
|
||||
@@ -108,12 +108,12 @@ function PublicView({
|
||||
console.error('[location] error', err);
|
||||
let msg;
|
||||
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 / WiFi';
|
||||
else if (err.code === 3) msg = 'Location request timed out — try again';
|
||||
else msg = `Geolocation error (code ${err.code})`;
|
||||
if (err.code === 1) msg = 'Permiso de ubicación denegado — actívalo en la configuración del navegador';
|
||||
else if (err.code === 2) msg = 'Ubicación no disponible — comprueba los servicios de ubicación/WiFi';
|
||||
else if (err.code === 3) msg = 'La solicitud de ubicación expiró — inténtalo de nuevo';
|
||||
else msg = `Error de geolocalización (código ${err.code})`;
|
||||
} 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);
|
||||
} finally {
|
||||
@@ -172,22 +172,22 @@ function PublicView({
|
||||
setPharmacies([]);
|
||||
setScreen('home');
|
||||
}}
|
||||
aria-label="Back to home"
|
||||
aria-label="Volver al inicio"
|
||||
>
|
||||
← Home
|
||||
← Inicio
|
||||
</button>
|
||||
<h1>💊 FarmaFinder</h1>
|
||||
<p>Find your medicine at nearby pharmacies</p>
|
||||
<h1>💊 FarmaClic</h1>
|
||||
<p>Encuentra tu medicamento en farmacias cercanas</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
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 && (
|
||||
<MedicineResults
|
||||
@@ -204,9 +204,9 @@ function PublicView({
|
||||
<div className="medicine-info">
|
||||
<h2>{selectedMedicine.name}</h2>
|
||||
<div className="medicine-details">
|
||||
<span><strong>Active Ingredient:</strong> {selectedMedicine.active_ingredient}</span>
|
||||
<span><strong>Dosage:</strong> {selectedMedicine.dosage}</span>
|
||||
<span><strong>Form:</strong> {selectedMedicine.form}</span>
|
||||
<span><strong>Principio 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"
|
||||
@@ -215,7 +215,7 @@ function PublicView({
|
||||
setPharmacies([]);
|
||||
}}
|
||||
>
|
||||
← Back to search
|
||||
← Volver a búsqueda
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -227,15 +227,15 @@ function PublicView({
|
||||
disabled={locating}
|
||||
>
|
||||
{locating
|
||||
? '📍 Locating…'
|
||||
? '📍 Localizando…'
|
||||
: sortByDistance
|
||||
? '📍 Sorted by distance · Reset'
|
||||
? '📍 Ordenado por distancia · Reset'
|
||||
: hasSavedCoords
|
||||
? '📍 Sort by your saved location'
|
||||
: '📍 Sort by distance'}
|
||||
? '📍 Ordenar por ubicación guardada'
|
||||
: '📍 Ordenar por distancia'}
|
||||
</button>
|
||||
{sortByDistance && positionSource === 'profile' && (
|
||||
<span className="location-source">Using your saved address</span>
|
||||
<span className="location-source">Usando tu dirección guardada</span>
|
||||
)}
|
||||
{locationError && (
|
||||
<span className="location-error">{locationError}</span>
|
||||
|
||||
@@ -17,9 +17,9 @@ export default defineConfig({
|
||||
navigateFallback: 'index.html',
|
||||
},
|
||||
manifest: {
|
||||
name: 'FarmaFinder',
|
||||
short_name: 'FarmaFinder',
|
||||
description: 'Find pharmacies that stock the medicine you need',
|
||||
name: 'FarmaClic',
|
||||
short_name: 'FarmaClic',
|
||||
description: 'Encuentra medicamentos en farmacias cercanas',
|
||||
theme_color: '#00450d',
|
||||
background_color: '#f8fafb',
|
||||
display: 'standalone',
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "farma-finder",
|
||||
"name": "farma-clic",
|
||||
"version": "1.0.0",
|
||||
"description": "FarmaFinder - Full stack application",
|
||||
"description": "FarmaClic - Full stack application",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||
|
||||
Reference in New Issue
Block a user