From 17cd5adaef6dec622ded3b64cca10f5863c50283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 2 Jul 2026 11:20:56 +0200 Subject: [PATCH 1/3] Add design spec for admin access button in profile menu --- .../specs/2026-07-02-admin-access-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-02-admin-access-design.md diff --git a/docs/superpowers/specs/2026-07-02-admin-access-design.md b/docs/superpowers/specs/2026-07-02-admin-access-design.md new file mode 100644 index 0000000..b0d6f8a --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-admin-access-design.md @@ -0,0 +1,73 @@ +# Diseño: Acceso al Panel de Administración + +## Resumen +Añadir un botón de acceso al panel de administración en la página de perfil (pestaña "Usuario") que solo sea visible para usuarios con permisos de administrador. + +## Objetivo +Proporcionar una forma intuitiva y consistente para que los usuarios administradores accedan al panel de administración desde la interfaz principal. + +## Requisitos +1. El botón solo debe aparecer cuando el usuario tiene `is_admin: true` +2. El botón debe seguir el mismo estilo que los otros elementos del menú del perfil +3. Al hacer clic, debe navegar a la vista de administración existente +4. No debe afectar la experiencia de usuarios no administradores + +## Diseño Detallado + +### Ubicación +- **Página:** ProfileView (pestaña "Usuario" en BottomNav) +- **Sección:** Dentro de `.profile-menu`, después de "Configuración de Texto" +- **Posición:** Antes del botón "Cerrar Sesión" + +### Componente +- **Tipo:** Botón con ícono, texto y chevron (igual que otros items del menú) +- **Ícono:** Engranaje (⚙️) con fondo de color primario +- **Texto:** "Panel de Administración" +- **Estilo:** Idéntico a `.profile-menu-item` existente + +### Comportamiento +1. **Renderizado condicional:** + ```jsx + {currentUser?.is_admin && ( + + )} + ``` + +2. **Navegación:** + - Recibir función `onAdminClick` como prop + - En `App.jsx`, implementar `handleAdminClick` que cambie `screen` a `'admin'` + +### Cambios en Archivos + +#### 1. `frontend/src/views/ProfileView.jsx` +- Añadir prop `onAdminClick` +- Renderizar botón condicionalmente basado en `currentUser?.is_admin` + +#### 2. `frontend/src/App.jsx` +- Añadir función `handleAdminClick` que establezca `screen` en `'admin'` +- Pasar `onAdminClick={handleAdminClick}` a `ProfileView` + +### Estilos +No se requieren cambios en CSS - se reutilizan las clases existentes: +- `.profile-menu-item` +- `.menu-item-icon--primary` +- `.menu-item-label` +- `.menu-item-chevron` + +## Criterios de Aceptación +1. ✅ Botón visible solo para usuarios admin +2. ✅ Botón no visible para usuarios normales +3. ✅ Estilo consistente con otros elementos del menú +4. ✅ Al hacer clic, navega a la vista de administración +5. ✅ No se muestra en dispositivos móviles si el usuario no es admin + +## Impacto +- **Archivos modificados:** 2 (`ProfileView.jsx`, `App.jsx`) +- **Complejidad:** Baja +- **Riesgo:** Mínimo - solo añade un elemento condicional \ No newline at end of file -- 2.52.0 From 15c1127b34c819260d3b565b2daabd07b9a9dbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 2 Jul 2026 11:23:57 +0200 Subject: [PATCH 2/3] Add admin access button in profile menu for admin users - Add onAdminClick prop to ProfileView component - Add conditional admin button visible only for is_admin users - Add handleAdminClick function in App.jsx to navigate to admin view - Button styled consistently with existing menu items --- frontend/src/App.jsx | 5 +++++ frontend/src/views/ProfileView.jsx | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a5d80be..06a8054 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -41,6 +41,10 @@ function App() { setCurrentUser(prev => ({ ...prev, ...updated })); } + function handleAdminClick() { + setScreen('admin'); + } + const isLoggedIn = Boolean(currentUser); function handleNavChange(tab) { @@ -75,6 +79,7 @@ function App() { onProfileSaved={handleProfileSaved} onShowSaved={() => setShowSaved(true)} onLogout={handleLogout} + onAdminClick={handleAdminClick} /> ); showBack = true; diff --git a/frontend/src/views/ProfileView.jsx b/frontend/src/views/ProfileView.jsx index 99ffe89..a12e27d 100644 --- a/frontend/src/views/ProfileView.jsx +++ b/frontend/src/views/ProfileView.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import './ProfileView.css'; import { getUserPosition } from '../utils/geo'; -function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) { +function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) { const [address, setAddress] = useState(currentUser?.address || ''); const [latitude, setLatitude] = useState( currentUser?.latitude != null ? String(currentUser.latitude) : '' @@ -158,6 +158,20 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) { + + {currentUser?.is_admin && ( + + )}
-- 2.52.0 From 7606d118e483acb67df98ad5d6c546c3c3fde529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Thu, 2 Jul 2026 12:40:09 +0200 Subject: [PATCH 3/3] Design hotfixes and improvements. --- backend/server.js | 134 ++++++++++- frontend/index.html | 2 +- frontend/src/App.css | 139 +++++++++++- frontend/src/App.jsx | 45 ++-- frontend/src/components/BottomNav.css | 36 ++- .../src/components/SavedNotifications.css | 8 +- frontend/src/components/TopBar.css | 57 ----- frontend/src/components/TopBar.jsx | 28 --- frontend/src/index.css | 55 +++-- frontend/src/utils/native.js | 2 +- frontend/src/utils/notifications.js | 4 +- frontend/src/utils/notifications.test.js | 52 +++++ frontend/src/views/AlertsView.css | 41 ---- frontend/src/views/AlertsView.jsx | 209 ++++++++++++------ frontend/src/views/HomeView.css | 132 ++++++++++- frontend/src/views/ProfileView.css | 36 ++- frontend/vite.config.js | 2 +- 17 files changed, 700 insertions(+), 282 deletions(-) delete mode 100644 frontend/src/components/TopBar.css delete mode 100644 frontend/src/components/TopBar.jsx create mode 100644 frontend/src/utils/notifications.test.js diff --git a/backend/server.js b/backend/server.js index 1f52395..a321018 100644 --- a/backend/server.js +++ b/backend/server.js @@ -65,7 +65,8 @@ const sessionConfig = { resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === 'production', + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'lax', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours } @@ -225,6 +226,22 @@ async function initDatabase() { `); } + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS user_alerts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')), + medicine_nregistro TEXT, + title TEXT, + detail TEXT, + schedule TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + } + await dbRun(` CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -254,7 +271,7 @@ async function initDatabase() { `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`); - if (!pgPool) { +if (!pgPool) { // SQLite-only migrations for existing deployments try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {} @@ -262,13 +279,32 @@ async function initDatabase() { try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {} } - // Add user_id to push tables (SQLite — no cross-DB FK) +// Add user_id to push tables (SQLite — no cross-DB FK) try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {} try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {} - console.log('Database initialized successfully'); - } catch (error) { - console.error('Error initializing database:', error); + // Create alerts table for persistent Avisos data + try { + await dbRun(` + CREATE TABLE IF NOT EXISTS user_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')), + medicine_nregistro TEXT, + title TEXT, + detail TEXT, + schedule TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + } catch (e) { + if (!/table already exists/i.test(e.message)) throw e; + } + } catch (err) { + console.error('initDatabase failed:', err); + throw err; } } @@ -630,6 +666,92 @@ app.put('/api/users/me', requireAuth, async (req, res) => { } }); +// ========== USER ALERTS API ========== + +// Get all alerts for the current user +app.get('/api/alerts', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const rows = await userDbAll( + 'SELECT id, type, medicine_nregistro, title, detail, schedule, created_at, updated_at FROM user_alerts WHERE user_id = ? ORDER BY created_at DESC', + [userId] + ); + res.json(rows); + } catch (error) { + console.error('Error fetching alerts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Create a new alert +app.post('/api/alerts', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const { type, medicine_nregistro, title, detail, schedule } = req.body || {}; + + if (!type || !['reminder', 'availability', 'schedule'].includes(type)) { + return res.status(400).json({ error: 'Valid type is required (reminder, availability, schedule)' }); + } + + const result = await userDbRun( + 'INSERT INTO user_alerts (user_id, type, medicine_nregistro, title, detail, schedule) VALUES (?, ?, ?, ?, ?, ?)', + [userId, type, medicine_nregistro || null, title || null, detail || null, schedule || null] + ); + + const newAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [result.lastID]); + res.status(201).json(newAlert); + } catch (error) { + console.error('Error creating alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update an existing alert +app.put('/api/alerts/:id', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const alertId = parseInt(req.params.id); + const { medicine_nregistro, title, detail, schedule } = req.body || {}; + + const result = await userDbRun( + 'UPDATE user_alerts SET medicine_nregistro = ?, title = ?, detail = ?, schedule = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', + [medicine_nregistro || null, title || null, detail || null, schedule || null, alertId, userId] + ); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + const updatedAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [alertId]); + res.json(updatedAlert); + } catch (error) { + console.error('Error updating alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete an alert +app.delete('/api/alerts/:id', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const alertId = parseInt(req.params.id); + + const result = await userDbRun( + 'DELETE FROM user_alerts WHERE id = ? AND user_id = ?', + [alertId, userId] + ); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Alert not found' }); + } + + res.status(204).end(); + } catch (error) { + console.error('Error deleting alert:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode. app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => { const q = (req.query.q || '').trim(); diff --git a/frontend/index.html b/frontend/index.html index f3a88e2..4d57bd9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + FarmaClic diff --git a/frontend/src/App.css b/frontend/src/App.css index 58ccf16..6b03978 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -9,16 +9,141 @@ .app-content { flex: 1; + min-height: 0; overflow-y: auto; - overscroll-behavior: contain; - padding-bottom: calc(6rem + env(safe-area-inset-bottom)); + overscroll-behavior-y: contain; } -@keyframes fadeInUp { - from { opacity: 0; transform: translateY(14px); } - to { opacity: 1; transform: translateY(0); } +/* Mobile-specific styles */ +.mobile-view { + width: 100%; + padding: 0; + margin: 0; } -@keyframes spin { - to { transform: rotate(360deg); } +/* Desktop-specific styles */ +.desktop-view { + max-width: 100%; + padding: 0 var(--margin-main); +} + +/* Mobile-specific view adjustments */ +@media (max-width: 768px) { + .app-logo-wrapper { + flex-direction: column; + } + + .home-card--scan { + min-height: 4.25rem; + padding: 0.75rem 1.25rem; + } + + .home-card-icon { + width: 2.75rem; + height: 2.75rem; + } +} + +/* Tablet-specific styles */ +@media (min-width: 769px) and (max-width: 1024px) { + .home-card { + min-height: 4rem; + padding: 0.625rem 1rem; + } +} + +/* Desktop-specific styles */ +@media (min-width: 1025px) { + .app-content { + padding: 0 5rem; + } + + .home-card { + min-height: 3.5rem; + padding: 0.5rem 0.875rem; + } +} + +body { + min-height: 100vh; + font-size: calc(16px + 0.5vmin); + font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + color: var(--on-surface); + background-color: var(--surface-container-lowest); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Touch target sizing for mobile */ +@media (max-width: 768px) { + a, button { + min-height: 4rem; + padding: 1rem; + } +} + +/* Card styling improvements */ +.home-card { + width: 100%; + max-width: 28rem; + min-height: fit-content; + transition: transform 0.15s, box-shadow 0.15s; +} + +.home-card:hover { + transform: translateY(-3px); + box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1); +} + +/* Fluid typography */ +.home-desc { + font-size: clamp(1rem, 100vw, 1.5rem); + max-width: 100%; +} + +.home-card-label { + font-size: clamp(1rem, 100vw, 1.25rem); +} + +/* Global button styles using pastel variables */ +button, +.btn { + appearance: none; + background: var(--primary); + color: var(--on-primary); + border: none; + padding: 0.6rem 1rem; + border-radius: var(--radius); + box-shadow: var(--primary-shadow); + transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease; + cursor: pointer; +} + +button:hover, +.btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +button:focus, +.btn:focus { + outline: none; + box-shadow: 0 0 0 6px var(--primary-ring); +} + +/* Outline / subtle buttons */ +.btn--outline { + background: transparent; + color: var(--on-secondary); + border: 1px solid var(--secondary-container); +} + +.btn--secondary { + background: var(--secondary); + color: var(--on-secondary); +} + +.btn--ghost { + background: transparent; + color: var(--on-surface); } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 06a8054..8ab6ec8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,6 @@ import ProfileView from './views/ProfileView'; import AdminView from './views/AdminView'; import LoginModal from './components/LoginModal'; import SavedNotifications from './components/SavedNotifications'; -import TopBar from './components/TopBar'; import BottomNav from './components/BottomNav'; function App() { @@ -17,13 +16,33 @@ function App() { const [authChecked, setAuthChecked] = useState(false); const [showLogin, setShowLogin] = useState(false); const [showSaved, setShowSaved] = useState(false); + const [screenSize, setScreenSize] = useState({ + width: window.innerWidth, + height: window.innerHeight + }); + + // Device detection + const isMobile = screenSize.width <= 768; + const isTablet = screenSize.width > 768 && screenSize.width <= 1024; + const isDesktop = screenSize.width > 1024; useEffect(() => { + // Set initial screen size + const handleResize = () => { + setScreenSize({ + width: window.innerWidth, + height: window.innerHeight + }); + }; + fetch('/api/auth/check') .then(r => r.json()) .then(data => { if (data.authenticated) setCurrentUser(data.user); }) .catch(() => {}) .finally(() => setAuthChecked(true)); + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); }, []); function handleLogin(user) { @@ -63,13 +82,8 @@ function App() { } } - function handleBack() { - setScreen('home'); - } - let activeView; - let topBarTitle = 'FarmaClic'; - let showBack = false; + let deviceClass = isMobile ? 'mobile-view' : 'desktop-view'; switch (screen) { case 'profile': @@ -82,11 +96,9 @@ function App() { onAdminClick={handleAdminClick} /> ); - showBack = true; break; case 'alerts': activeView = ; - showBack = true; break; case 'search': activeView = ( @@ -95,7 +107,6 @@ function App() { onLoginRequest={() => setShowLogin(true)} /> ); - showBack = true; break; case 'scan': activeView = ( @@ -106,12 +117,9 @@ function App() { }} /> ); - showBack = false; break; case 'admin': activeView = ; - topBarTitle = 'Admin'; - showBack = true; break; default: activeView = ( @@ -122,18 +130,11 @@ function App() { onSearchClick={() => setScreen('search')} /> ); - showBack = false; break; } return ( -
- - +
{activeView}
@@ -141,7 +142,7 @@ function App() { diff --git a/frontend/src/components/BottomNav.css b/frontend/src/components/BottomNav.css index d221231..decdf5b 100644 --- a/frontend/src/components/BottomNav.css +++ b/frontend/src/components/BottomNav.css @@ -2,10 +2,6 @@ display: flex; justify-content: space-around; align-items: flex-end; - position: fixed; - left: 50%; - transform: translateX(-50%); - bottom: 0; width: 100%; max-width: 48rem; z-index: 50; @@ -14,6 +10,17 @@ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); border-radius: var(--radius-lg) var(--radius-lg) 0 0; height: 5.5rem; + flex-shrink: 0; + margin: 0 auto; +} + +/* Desktop navigation - full width */ +@media (min-width: 1025px) { + .bottom-nav { + max-width: 100%; + padding: 0.25rem 2rem calc(0.5rem + env(safe-area-inset-bottom)); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + } } .bottom-nav-item { @@ -57,17 +64,28 @@ width: 3.5rem; height: 3.5rem; border-radius: var(--radius-full); - background: var(--tertiary-container); - color: var(--on-tertiary); - box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35); + /* Stronger, brighter blue to make scan button easy to find */ + background: #2b5bb5; + color: #ffffff; + box-shadow: 0 8px 26px rgba(43, 91, 181, 0.32); margin-top: -1.5rem; - transition: transform 0.15s; + transition: transform 0.12s, box-shadow 160ms ease; } .nav-fab:active { transform: scale(0.92); } +.nav-fab:hover { + transform: translateY(-2px); + box-shadow: 0 12px 36px rgba(43, 91, 181, 0.36); +} + +.nav-fab:focus { + outline: none; + box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 26px rgba(43, 91, 181, 0.32); +} + .nav-badge { position: absolute; top: 0; @@ -116,4 +134,4 @@ .nav-elevated.active .nav-label { color: var(--tertiary); -} +} \ No newline at end of file diff --git a/frontend/src/components/SavedNotifications.css b/frontend/src/components/SavedNotifications.css index 4f99f5b..4c21c66 100644 --- a/frontend/src/components/SavedNotifications.css +++ b/frontend/src/components/SavedNotifications.css @@ -123,16 +123,16 @@ gap: 0.25rem; padding: 0.2rem 0.55rem; border-radius: 999px; - background: rgba(37, 99, 235, 0.08); - color: var(--primary, #2563eb); + background: var(--secondary-container, #dbe7ff); + color: var(--on-secondary, #06204a); font-size: 0.75rem; font-weight: 600; width: fit-content; } .saved-notifications-chip--pharmacy { - background: rgba(4, 120, 87, 0.1); - color: var(--accent, #047857); + background: var(--primary-container, #cfead0); + color: var(--on-primary, #09310a); } .saved-notifications-address { diff --git a/frontend/src/components/TopBar.css b/frontend/src/components/TopBar.css deleted file mode 100644 index ce82413..0000000 --- a/frontend/src/components/TopBar.css +++ /dev/null @@ -1,57 +0,0 @@ -.top-bar { - display: block; - position: sticky; - top: 0; - z-index: 40; - background: var(--surface); - box-shadow: 0 1px 3px rgba(0,0,0,0.06); -} - -.topbar-inner { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--touch-target-min); - padding: 0 var(--margin-main); - max-width: 48rem; - margin: 0 auto; -} - -.topbar-back { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: var(--radius-full); - border: none; - background: transparent; - color: var(--primary); - cursor: pointer; - transition: background 0.15s; -} - -.topbar-back:hover { - background: var(--surface-container-high); -} - -.topbar-title { - font-size: 1.15rem; - font-weight: 700; - color: var(--primary); - letter-spacing: -0.01em; -} - -.topbar-logo-img { - height: 2rem; - width: auto; -} - -.topbar-spacer { - width: 2.5rem; -} - -.topbar-right { - display: flex; - align-items: center; -} diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx deleted file mode 100644 index 899d83b..0000000 --- a/frontend/src/components/TopBar.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import './TopBar.css'; - -function TopBar({ title, showBack, onBack, rightAction }) { - return ( -
-
- {showBack ? ( - - ) : ( -
- )} - {showBack ? ( -

{title}

- ) : ( - FarmaClic - )} -
{rightAction ||
}
-
-
- ); -} - -export default TopBar; diff --git a/frontend/src/index.css b/frontend/src/index.css index c7f5e5b..fd2f82f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,28 +1,29 @@ :root { - --primary: #00450d; - --on-primary: #ffffff; - --primary-container: #1b5e20; - --on-primary-container: #90d689; - --secondary: #2b5bb5; - --on-secondary: #ffffff; - --secondary-container: #759efd; - --on-secondary-container: #00337c; - --tertiary: #4600ad; - --on-tertiary: #ffffff; - --tertiary-container: #6100e8; - --on-tertiary-container: #cebbff; - --surface: #f8fafb; - --on-surface: #191c1d; + /* Pastel, softer palette */ + --primary: #7fbf8f; /* soft green */ + --on-primary: #09310a; /* dark accent for contrast */ + --primary-container: #cfead0; + --on-primary-container: #0d2b12; + --secondary: #a3b8ff; /* soft blue */ + --on-secondary: #06204a; + --secondary-container: #dbe7ff; + --on-secondary-container: #0b2a66; + --tertiary: #c9b3ff; /* soft lavender */ + --on-tertiary: #241342; + --tertiary-container: #efe7ff; + --on-tertiary-container: #3b2a5a; + --surface: #fbfbfb; + --on-surface: #111417; --on-surface-variant: #41493e; --surface-container-lowest: #ffffff; --surface-container-low: #f2f4f5; --surface-container: #eceeef; --surface-container-high: #e6e8e9; --surface-container-highest: #e1e3e4; - --error: #ba1a1a; + --error: #b91c1c; --on-error: #ffffff; - --error-container: #ffdad6; - --on-error-container: #93000a; + --error-container: #feecec; + --on-error-container: #610909; --outline: #717a6d; --outline-variant: #c0c9bb; --on-background: #191c1d; @@ -64,12 +65,12 @@ --glass-border: var(--outline-variant); --glass-shadow: var(--shadow-soft); --accent: var(--primary); - --accent-warm: #b45309; - --primary-hover: #1a6b2e; - --primary-ring: rgba(0, 69, 13, 0.22); - --primary-shadow: rgba(0, 69, 13, 0.18); - --primary-light: var(--primary-fixed); - --primary-faint: rgba(0, 69, 13, 0.08); + --accent-warm: #f5a97a; + --primary-hover: #6fb47f; /* slightly darker than primary */ + --primary-ring: rgba(127, 191, 143, 0.22); + --primary-shadow: rgba(127, 191, 143, 0.12); + --primary-light: #eaf7ec; + --primary-faint: rgba(127, 191, 143, 0.06); } * { @@ -108,6 +109,14 @@ body::before { z-index: -1; } +body::after { + content: ""; + position: fixed; + inset: 0; + background-color: rgba(255, 252, 245, 0.48); + z-index: -1; +} + #root { height: 100dvh; overflow: hidden; diff --git a/frontend/src/utils/native.js b/frontend/src/utils/native.js index 5c947ef..2369c04 100644 --- a/frontend/src/utils/native.js +++ b/frontend/src/utils/native.js @@ -13,7 +13,7 @@ export async function initNativeShell() { try { const { StatusBar, Style } = await import('@capacitor/status-bar'); - await StatusBar.setBackgroundColor({ color: '#0f766e' }).catch(() => {}); + await StatusBar.setBackgroundColor({ color: '#27633a' }).catch(() => {}); await StatusBar.setStyle({ style: Style.Light }).catch(() => {}); } catch { /* plugin not present — fine on web */ diff --git a/frontend/src/utils/notifications.js b/frontend/src/utils/notifications.js index 9bfd7b7..5da2cd2 100644 --- a/frontend/src/utils/notifications.js +++ b/frontend/src/utils/notifications.js @@ -35,7 +35,7 @@ export function pushSupported() { } export async function getVapidPublicKey() { - const res = await fetch('/api/notifications/vapid-public-key'); + const res = await fetch('/api/notifications/vapid-public-key', { credentials: 'include' }); if (!res.ok) { const message = res.status === 503 ? 'Las notificaciones push no están configuradas en el servidor' @@ -88,6 +88,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI const subscription = await getOrCreateSubscription(); const res = await fetch('/api/notifications/subscribe', { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ medicine_nregistro: medicineNregistro, @@ -115,6 +116,7 @@ export async function unsubscribeFromPush(medicineNregistro, pharmacyId = null) if (endpoint) { await fetch('/api/notifications/unsubscribe', { method: 'DELETE', + credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ medicine_nregistro: medicineNregistro, diff --git a/frontend/src/utils/notifications.test.js b/frontend/src/utils/notifications.test.js new file mode 100644 index 0000000..8bb4839 --- /dev/null +++ b/frontend/src/utils/notifications.test.js @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { subscribeToPush } from './notifications.js'; + +describe('subscribeToPush', () => { + beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('sends the subscription request with credentials so the session is preserved', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ publicKey: 'test-public-key' }) }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + vi.stubGlobal('fetch', fetchMock); + + Object.defineProperty(window, 'isSecureContext', { + configurable: true, + value: true, + }); + + Object.defineProperty(window, 'PushManager', { + configurable: true, + value: class PushManager {}, + }); + + Object.defineProperty(window, 'Notification', { + configurable: true, + value: { permission: 'granted' }, + }); + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + ready: Promise.resolve({ + pushManager: { + getSubscription: vi.fn().mockResolvedValue(null), + subscribe: vi.fn().mockResolvedValue({ endpoint: 'https://example.test/push' }), + }, + }), + }, + }); + + await subscribeToPush('123456', 'Paracetamol'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + method: 'POST', + credentials: 'include', + }); + }); +}); diff --git a/frontend/src/views/AlertsView.css b/frontend/src/views/AlertsView.css index e304ffb..39a0e74 100644 --- a/frontend/src/views/AlertsView.css +++ b/frontend/src/views/AlertsView.css @@ -44,7 +44,6 @@ } .alert-card--primary { border-color: rgba(0, 69, 13, 0.1); } -.alert-card--secondary { border-color: rgba(43, 91, 181, 0.1); } .alert-card--tertiary { border-color: rgba(70, 0, 173, 0.1); } .alert-card-top { @@ -68,11 +67,6 @@ color: var(--on-primary-container); } -.alert-icon--secondary { - background: var(--secondary-container); - color: var(--on-secondary-container); -} - .alert-icon--tertiary { background: var(--tertiary-container); color: var(--on-tertiary); @@ -106,11 +100,6 @@ color: var(--on-error-container); } -.alert-badge--secondary { - background: var(--secondary-fixed); - color: var(--on-secondary-fixed-variant); -} - .alert-badge--tertiary { background: var(--tertiary-fixed); color: var(--on-tertiary-fixed-variant); @@ -122,36 +111,6 @@ align-items: center; } -.alert-btn { - flex: 1; - height: var(--touch-target-min); - border: none; - border-radius: var(--radius-md); - font-size: 1rem; - font-weight: 700; - cursor: pointer; - transition: opacity 0.15s; -} - -.alert-btn:active { - transform: scale(0.97); -} - -.alert-btn--primary { - background: var(--primary); - color: var(--on-primary); -} - -.alert-btn--secondary { - background: var(--secondary); - color: var(--on-secondary); -} - -.alert-btn--tertiary { - background: var(--tertiary-container); - color: var(--on-tertiary); -} - .alert-delete { width: var(--touch-target-min); height: var(--touch-target-min); diff --git a/frontend/src/views/AlertsView.jsx b/frontend/src/views/AlertsView.jsx index f610875..ec4ad47 100644 --- a/frontend/src/views/AlertsView.jsx +++ b/frontend/src/views/AlertsView.jsx @@ -1,36 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import './AlertsView.css'; -const alerts = [ - { - id: 1, - type: 'reminder', - title: 'Recordatorio: Comprar Paracetamol', - detail: 'Quedan 2 cajas', - color: 'primary', - icon: 'bell', - actions: [{ label: 'Ver Detalles', variant: 'secondary' }], - }, - { - id: 2, - type: 'availability', - title: 'Disponibilidad: Aspirina', - detail: 'Ya disponible en Farmacia Sol', - color: 'secondary', - icon: 'store', - actions: [{ label: 'Ir a Farmacia', variant: 'primary' }], - }, - { - id: 3, - type: 'dose', - title: 'Próxima Toma', - detail: '12:00 PM', - color: 'tertiary', - icon: 'schedule', - actions: [{ label: 'Confirmar Toma', variant: 'tertiary' }], - }, -]; - const iconMap = { bell: ( @@ -42,14 +12,84 @@ const iconMap = { ), - schedule: ( - - - - ), }; -function AlertsView({ onBack }) { +function AlertsView() { + const [availability, setAvailability] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchAvailabilityNotifications = async () => { + try { + setLoading(true); + setError(null); + + const notifsRes = await fetch('/api/notifications/mine', { credentials: 'include' }); + if (notifsRes.status === 401) { + setError('Por favor, inicia sesión para ver tus avisos.'); + setLoading(false); + return; + } + let notifsData = { global: [], pharmacy: [] }; + if (notifsRes.ok) { + notifsData = await notifsRes.json(); + } + + const mergedAvailability = [ + ...(notifsData.global || []).map(item => ({ ...item, scope: 'global' })), + ...(notifsData.pharmacy || []).map(item => ({ ...item, scope: 'pharmacy' })), + ].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); + + setAvailability(mergedAvailability); + } catch (err) { + console.error('Error fetching availability notifications:', err); + setError('No se pudieron cargar los avisos.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAvailabilityNotifications(); + }, []); + + const handleDeleteAvailability = async (item) => { + try { + const res = await fetch('/api/notifications/mine', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ scope: item.scope, id: item.id }), + }); + if (res.ok || res.status === 204) { + setAvailability(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id))); + + // Also update local storage if possible to reflect that we unsubscribed + try { + const STORAGE_KEY = 'farmaclic.subscriptions.v1'; + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const keyToSearch = item.scope === 'pharmacy' + ? `${item.medicine_nregistro}:${item.pharmacy_id}` + : item.medicine_nregistro; + const filtered = parsed.filter(k => k !== keyToSearch); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + } + } + } catch (e) { + console.warn('Could not update localStorage subscriptions:', e); + } + } else { + throw new Error('Error deleting availability alert'); + } + } catch (err) { + console.error(err); + setError('No se pudo eliminar la alerta de disponibilidad.'); + } + }; + return (
@@ -58,42 +98,71 @@ function AlertsView({ onBack }) {

Mantente al día con tus medicamentos.

-
- {alerts.map((alert) => ( -
-
-
- {iconMap[alert.icon]} -
-
-

{alert.title}

- - {alert.detail} - -
+ {loading &&

Cargando avisos...

} + {!loading && error &&

{error}

} + + {!loading && !error && ( + <> + {/* Availability Section */} +
+

Disponibilidad

+
+ {availability.length === 0 ? ( +

No tienes suscripciones de disponibilidad.

+ ) : ( + availability.map((alert) => { + const cardColor = alert.scope === 'pharmacy' ? 'primary' : 'tertiary'; + const cardIcon = alert.scope === 'pharmacy' ? 'store' : 'bell'; + const key = `${alert.scope}:${alert.id}`; + return ( +
+
+
+ {iconMap[cardIcon]} +
+
+

{alert.medicine_name || alert.medicine_nregistro}

+ {alert.scope === 'pharmacy' ? ( +
+ + 🏥 {alert.pharmacy_name || `Farmacia #${alert.pharmacy_id}`} + + {alert.pharmacy_address && ( +

+ 📍 {alert.pharmacy_address} +

+ )} +
+ ) : ( + + 🔔 Notificarme cuando esté disponible + + )} +
+
+
+ +
+
+ ); + }) + )}
-
- {alert.actions.map((action, i) => ( - - ))} - -
-
- ))} -
+ + + )}
); } -export default AlertsView; +export default AlertsView; \ No newline at end of file diff --git a/frontend/src/views/HomeView.css b/frontend/src/views/HomeView.css index 42ac374..e4b0bf6 100644 --- a/frontend/src/views/HomeView.css +++ b/frontend/src/views/HomeView.css @@ -3,13 +3,113 @@ flex-direction: column; align-items: center; justify-content: center; - height: 100%; - overflow: hidden; - padding: 1rem var(--margin-main); + min-height: 100%; + max-width: 100%; + width: 100%; + padding: 1rem; gap: 1.5rem; animation: fadeInUp 0.6s ease-out; } +/* Mobile optimizations */ +@media (max-width: 768px) { + .home-view { + padding: 0.75rem; + gap: 1rem; + } + + .home-logo-wrapper { + flex-direction: column; + align-items: center; + } + + .home-logo { + width: min(12rem, 80vw); + } + + .home-desc { + font-size: clamp(0.9rem, 5vw, 1.125rem); + max-width: 90%; + text-align: center; + } + + .home-cards { + width: 100%; + max-width: 100%; + gap: 1rem; + } + + .home-card { + min-height: 4rem; + padding: 0.75rem 1rem; + gap: 0.75rem; + flex-direction: row; + } + + .home-card-icon { + width: 2.5rem; + height: 2.5rem; + } + + .home-card-label { + font-size: 1rem; + } +} + +/* Tablet optimizations */ +@media (min-width: 769px) and (max-width: 1024px) { + .home-view { + padding: 1rem 0.5rem; + } + + .home-card { + min-height: 4.25rem; + padding: 0.75rem 1.25rem; + } + + .home-card-icon { + width: 2.75rem; + height: 2.75rem; + } +} + +/* Desktop optimizations */ +@media (min-width: 1025px) { + .home-view { + padding: 1rem var(--margin-main); + max-width: 100%; + } + + .home-hero { + text-align: left; + align-items: flex-start; + } + + .home-logo-wrapper { + flex-direction: row; + justify-content: flex-start; + } + + .home-desc { + font-size: 1.125rem; + max-width: 30rem; + text-align: left; + } + + .home-cards { + flex-direction: row; + flex-wrap: wrap; + gap: 1.5rem; + max-width: 80rem; + } + + .home-card { + min-height: 5rem; + padding: 1rem 1.5rem; + flex-direction: row; + } +} + .home-hero { display: flex; flex-direction: column; @@ -19,7 +119,7 @@ } .home-logo-wrapper { - display: flex; + display: contents; flex-direction: row; align-items: center; justify-content: center; @@ -87,15 +187,27 @@ } .home-card--search { - background: var(--primary); - color: var(--on-primary); - box-shadow: 0 4px 16px rgba(0, 69, 13, 0.3); + /* Make suggestion/search buttons softer and more harmonious */ + background: var(--primary-container); + color: var(--on-primary-container); + box-shadow: 0 6px 18px rgba(13, 43, 18, 0.06); } .home-card--scan { - background: var(--secondary); - color: var(--on-secondary); - box-shadow: 0 4px 16px rgba(43, 91, 181, 0.3); + /* Keep the scan button vivid and easy to find (intentionally prominent) */ + background: #2b5bb5; /* vivid blue to stand out */ + color: #ffffff; + box-shadow: 0 8px 28px rgba(43, 91, 181, 0.28); + transform: translateY(0); +} + +.home-card--scan:hover { + transform: translateY(-3px); +} + +.home-card--scan:focus { + outline: none; + box-shadow: 0 0 0 6px rgba(43,91,181,0.12), 0 8px 28px rgba(43,91,181,0.28); } .home-card-icon { diff --git a/frontend/src/views/ProfileView.css b/frontend/src/views/ProfileView.css index 7090118..e3f4dd0 100644 --- a/frontend/src/views/ProfileView.css +++ b/frontend/src/views/ProfileView.css @@ -300,7 +300,22 @@ background: rgba(186, 26, 26, 0.12); } -@media (max-width: 400px) { +@media (max-width: 480px) { + .profile-view { + padding: 1rem var(--margin-main) 1rem; + } + + .profile-menu-item { + height: auto; + padding: 0.875rem 1rem; + min-height: 4rem; + } + + .menu-item-icon { + width: 2.5rem; + height: 2.5rem; + } + .profile-field-row { flex-direction: column; gap: 0.85rem; @@ -310,4 +325,23 @@ width: 6rem; height: 6rem; } + + .profile-btn-secondary { + width: 100%; + justify-content: center; + } + + .profile-btn-primary { + width: 100%; + } + + .profile-actions { + width: 100%; + } + + .profile-logout-btn { + height: auto; + padding: 0.875rem 1rem; + min-height: 4rem; + } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 39f5dd9..ae68f38 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -20,7 +20,7 @@ export default defineConfig({ name: 'FarmaClic', short_name: 'FarmaClic', description: 'Encuentra medicamentos en farmacias cercanas', - theme_color: '#00450d', + theme_color: '#7fbf8f', background_color: '#f8fafb', display: 'standalone', start_url: '/', -- 2.52.0