Merge pull request 'Profile_Fixes' (#4) from Profile_Fixes into main
Build & Push Docker Images / test-backend (push) Successful in 36s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Build & Push Docker Images / test-backend (push) Successful in 36s
Build & Push Docker Images / deploy (push) Successful in 7s
Build & Push Docker Images / build-backend (push) Successful in 22s
Build & Push Docker Images / build-frontend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 31s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+128
-6
@@ -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();
|
||||
|
||||
@@ -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 && (
|
||||
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||
<div className="menu-item-icon menu-item-icon--primary">
|
||||
<svg>...</svg>
|
||||
</div>
|
||||
<span className="menu-item-label">Panel de Administración</span>
|
||||
<svg className="menu-item-chevron">...</svg>
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
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
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#00450d" />
|
||||
<meta name="theme-color" content="#7fbf8f" />
|
||||
<title>FarmaClic</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
|
||||
+132
-7
@@ -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);
|
||||
}
|
||||
|
||||
+28
-22
@@ -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) {
|
||||
@@ -41,6 +60,10 @@ function App() {
|
||||
setCurrentUser(prev => ({ ...prev, ...updated }));
|
||||
}
|
||||
|
||||
function handleAdminClick() {
|
||||
setScreen('admin');
|
||||
}
|
||||
|
||||
const isLoggedIn = Boolean(currentUser);
|
||||
|
||||
function handleNavChange(tab) {
|
||||
@@ -59,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':
|
||||
@@ -75,13 +93,12 @@ function App() {
|
||||
onProfileSaved={handleProfileSaved}
|
||||
onShowSaved={() => setShowSaved(true)}
|
||||
onLogout={handleLogout}
|
||||
onAdminClick={handleAdminClick}
|
||||
/>
|
||||
);
|
||||
showBack = true;
|
||||
break;
|
||||
case 'alerts':
|
||||
activeView = <AlertsView />;
|
||||
showBack = true;
|
||||
break;
|
||||
case 'search':
|
||||
activeView = (
|
||||
@@ -90,7 +107,6 @@ function App() {
|
||||
onLoginRequest={() => setShowLogin(true)}
|
||||
/>
|
||||
);
|
||||
showBack = true;
|
||||
break;
|
||||
case 'scan':
|
||||
activeView = (
|
||||
@@ -101,12 +117,9 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
showBack = false;
|
||||
break;
|
||||
case 'admin':
|
||||
activeView = <AdminView />;
|
||||
topBarTitle = 'Admin';
|
||||
showBack = true;
|
||||
break;
|
||||
default:
|
||||
activeView = (
|
||||
@@ -117,18 +130,11 @@ function App() {
|
||||
onSearchClick={() => setScreen('search')}
|
||||
/>
|
||||
);
|
||||
showBack = false;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar
|
||||
title={topBarTitle}
|
||||
showBack={showBack}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<div className={`app ${deviceClass}`}>
|
||||
<div className="app-content">
|
||||
{activeView}
|
||||
</div>
|
||||
@@ -136,7 +142,7 @@ function App() {
|
||||
<BottomNav
|
||||
activeTab={screen}
|
||||
onChange={handleNavChange}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isLoggedIn={Boolean(currentUser)}
|
||||
badgeCount={currentUser ? 2 : 0}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import './TopBar.css';
|
||||
|
||||
function TopBar({ title, showBack, onBack, rightAction }) {
|
||||
return (
|
||||
<header className="top-bar">
|
||||
<div className="topbar-inner">
|
||||
{showBack ? (
|
||||
<button className="topbar-back" onClick={onBack} aria-label="Volver">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 12H5" />
|
||||
<polyline points="12 19 5 12 12 5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div className="topbar-spacer" />
|
||||
)}
|
||||
{showBack ? (
|
||||
<h1 className="topbar-title">{title}</h1>
|
||||
) : (
|
||||
<img src="/logo.png" alt="FarmaClic" className="topbar-logo-img" />
|
||||
)}
|
||||
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopBar;
|
||||
+32
-23
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -42,14 +12,84 @@ const iconMap = {
|
||||
<path d="M18.36 9l.6 3H5.04l.6-3h12.72M20 4H4v2h16V4zm0 3H4l-1 5v2h1v6h10v-6h4v6h2v-6h1v-2l-1-5zM6 18v-4h6v4H6z" />
|
||||
</svg>
|
||||
),
|
||||
schedule: (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="alerts-view">
|
||||
<main className="alerts-main">
|
||||
@@ -58,39 +98,68 @@ function AlertsView({ onBack }) {
|
||||
<p className="alerts-subtitle">Mantente al día con tus medicamentos.</p>
|
||||
</div>
|
||||
|
||||
<div className="alerts-list">
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className={`alert-card alert-card--${alert.color}`}>
|
||||
<div className="alert-card-top">
|
||||
<div className={`alert-icon alert-icon--${alert.color}`}>
|
||||
{iconMap[alert.icon]}
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<h3 className="alert-title">{alert.title}</h3>
|
||||
<span className={`alert-badge alert-badge--${alert.color}`}>
|
||||
{alert.detail}
|
||||
</span>
|
||||
</div>
|
||||
{loading && <p className="alerts-status" style={{ textAlign: 'center', padding: '2rem' }}>Cargando avisos...</p>}
|
||||
{!loading && error && <p className="alerts-error" style={{ color: 'var(--error)', textAlign: 'center', padding: '2rem' }}>{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Availability Section */}
|
||||
<section className="alerts-section">
|
||||
<h3 className="alerts-section-title" style={{ marginBottom: '1rem' }}>Disponibilidad</h3>
|
||||
<div className="alerts-list">
|
||||
{availability.length === 0 ? (
|
||||
<p className="alerts-empty" style={{ color: 'var(--on-surface-variant)', fontStyle: 'italic' }}>No tienes suscripciones de disponibilidad.</p>
|
||||
) : (
|
||||
availability.map((alert) => {
|
||||
const cardColor = alert.scope === 'pharmacy' ? 'primary' : 'tertiary';
|
||||
const cardIcon = alert.scope === 'pharmacy' ? 'store' : 'bell';
|
||||
const key = `${alert.scope}:${alert.id}`;
|
||||
return (
|
||||
<div key={key} className={`alert-card alert-card--${cardColor}`}>
|
||||
<div className="alert-card-top">
|
||||
<div className={`alert-icon alert-icon--${cardColor}`}>
|
||||
{iconMap[cardIcon]}
|
||||
</div>
|
||||
<div className="alert-info">
|
||||
<h3 className="alert-title">{alert.medicine_name || alert.medicine_nregistro}</h3>
|
||||
{alert.scope === 'pharmacy' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', marginTop: '0.25rem' }}>
|
||||
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content' }}>
|
||||
🏥 {alert.pharmacy_name || `Farmacia #${alert.pharmacy_id}`}
|
||||
</span>
|
||||
{alert.pharmacy_address && (
|
||||
<p className="alert-address-text" style={{ fontSize: '0.9rem', color: 'var(--on-surface-variant)', margin: 0 }}>
|
||||
📍 {alert.pharmacy_address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={`alert-badge alert-badge--${cardColor}`} style={{ width: 'fit-content', marginTop: '0.25rem' }}>
|
||||
🔔 Notificarme cuando esté disponible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="alert-actions" style={{ justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="alert-delete"
|
||||
aria-label="Eliminar"
|
||||
onClick={() => handleDeleteAvailability(alert)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="alert-actions">
|
||||
{alert.actions.map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`alert-btn alert-btn--${action.variant}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<button className="alert-delete" aria-label="Eliminar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+122
-10
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{currentUser?.is_admin && (
|
||||
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||
<div className="menu-item-icon menu-item-icon--primary">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="menu-item-label">Panel de Administración</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-location-section">
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
Reference in New Issue
Block a user