Rebranding app and naming #1

Merged
Ichitux merged 1 commits from Rebranding into main 2026-07-01 13:57:19 +00:00
38 changed files with 272 additions and 261 deletions
Showing only changes of commit 1c31362e0b - Show all commits
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{ {
"name": "farma-finder-api-sources", "name": "farma-clic-api-sources",
"private": true, "private": true,
"type": "module" "type": "module"
} }
+1 -1
View File
@@ -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 -1
View File
@@ -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.
*/ */
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+5 -5
View File
@@ -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({
+2 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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) {
+15 -15
View File
@@ -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">332 characters: letters, digits, or underscore.</p> <p className="modal-hint">332 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>
+12 -12
View File
@@ -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>
); );
+12 -12
View File
@@ -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>
+10 -10
View File
@@ -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>
); );
+1 -1
View File
@@ -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>
+11 -11
View File
@@ -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 'Sesn 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 &amp; 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
View File
@@ -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',
+2 -2
View File
@@ -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';
+2 -2
View File
@@ -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
View File
@@ -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() {
+8 -8
View File
@@ -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)]);
} }
+9 -9
View File
@@ -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>
+11 -1
View File
@@ -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);
+2 -1
View File
@@ -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>
+20 -20
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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",