Rebranding app and naming

This commit is contained in:
Antoni Nuñez Romeu
2026-07-01 15:54:10 +02:00
parent 2e9d6a94dd
commit 1c31362e0b
38 changed files with 272 additions and 261 deletions
+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.
*/
+1 -1
View File
@@ -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 -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 }.
*
* Supports the patterns commonly seen on amenity=pharmacy nodes:
+1 -1
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"name": "farma-finder-api-sources",
"name": "farma-clic-api-sources",
"private": true,
"type": "module"
}
+1 -1
View File
@@ -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 -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.
*/
+2 -2
View File
@@ -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
View File
@@ -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
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.
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();
+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).
//
// 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({
+2 -2
View File
@@ -1,6 +1,6 @@
{
"appId": "net.hacecalor.farmafinder",
"appName": "FarmaFinder",
"appId": "net.hacecalor.farmaclic",
"appName": "FarmaClic",
"webDir": "frontend/dist",
"server": {
"androidScheme": "https"
+10 -10
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "farma-finder-frontend",
"name": "farma-clic-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -64,7 +64,7 @@ function App() {
}
let activeView;
let topBarTitle = 'FarmaFinder';
let topBarTitle = 'FarmaClic';
let showBack = false;
switch (screen) {
+15 -15
View File
@@ -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">332 characters: letters, digits, or underscore.</p>
<p className="modal-hint">332 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>
+12 -12
View File
@@ -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>
);
+18 -18
View File
@@ -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>
+10 -10
View File
@@ -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>
);
+1 -1
View File
@@ -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>
+11 -11
View File
@@ -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 'Sesn 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 &amp; 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
View File
@@ -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',
+2 -2
View File
@@ -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';
+2 -2
View File
@@ -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
View File
@@ -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() {
+8 -8
View File
@@ -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)]);
}
+9 -9
View File
@@ -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>
+11 -1
View File
@@ -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);
+2 -1
View File
@@ -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>
+20 -20
View File
@@ -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>
+3 -3
View File
@@ -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
View File
@@ -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",