From 86a4e119b0395c980bee11bdf29cac50424f2191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 6 Jul 2026 18:01:18 +0200 Subject: [PATCH 1/9] feat: add profile fields and search history API --- apps/backend/server.js | 121 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 6 deletions(-) diff --git a/apps/backend/server.js b/apps/backend/server.js index 2bf948c..d6d10c0 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -290,6 +290,19 @@ if (!pgPool) { try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {} } + // Add new fields for profile redesign + if (pgPool) { + await pgPool.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; + `); + } else { + try { await dbRun('ALTER TABLE users ADD COLUMN first_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN last_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch {} + } + // 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 {} @@ -313,6 +326,32 @@ if (!pgPool) { } catch (e) { if (!/table already exists/i.test(e.message)) throw e; } + + // Search history for "find pharmacies near me" + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS search_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + address TEXT NOT NULL, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + } else { + await dbRun(` + CREATE TABLE IF NOT EXISTS search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + } } catch (err) { console.error('initDatabase failed:', err); throw err; @@ -664,6 +703,9 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => { user: { id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -710,6 +752,9 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => { user: { id: result.lastID, username: u, + first_name: null, + last_name: null, + avatar_url: null, is_admin: false, address: null, latitude: null, @@ -738,7 +783,7 @@ app.get('/api/auth/check', async (req, res) => { try { if (req.session && req.session.userId) { const user = await userDbGet( - 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) { @@ -750,6 +795,9 @@ app.get('/api/auth/check', async (req, res) => { user: { id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -768,13 +816,16 @@ app.get('/api/auth/check', async (req, res) => { app.get('/api/users/me', requireAuth, async (req, res) => { try { const user = await userDbGet( - 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) return res.status(404).json({ error: 'Not found' }); res.json({ id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -789,7 +840,12 @@ app.get('/api/users/me', requireAuth, async (req, res) => { // Update the current user's address + coordinates app.put('/api/users/me', requireAuth, async (req, res) => { try { - const { address, latitude, longitude } = req.body || {}; + const { first_name, last_name, avatar_url, address, latitude, longitude } = req.body || {}; + + const fName = first_name == null ? null : String(first_name).trim().slice(0, 100); + const lName = last_name == null ? null : String(last_name).trim().slice(0, 100); + const avatar = avatar_url == null ? null : String(avatar_url).slice(0, 500); + const addr = address == null || String(address).trim() === '' ? null @@ -811,17 +867,20 @@ app.put('/api/users/me', requireAuth, async (req, res) => { } await userDbRun( - 'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?', - [addr, lat, lon, req.session.userId] + 'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?', + [fName, lName, avatar, addr, lat, lon, req.session.userId] ); const user = await userDbGet( - 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); res.json({ id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -919,6 +978,56 @@ app.delete('/api/alerts/:id', requireAuth, async (req, res) => { } }); +// ========== SEARCH HISTORY API ========== + +// Get search history for current user +app.get('/api/search-history', requireAuth, async (req, res) => { + try { + const rows = await userDbAll( + 'SELECT id, address, latitude, longitude, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20', + [req.session.userId] + ); + res.json(rows); + } catch (error) { + console.error('Error fetching search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add new search to history +app.post('/api/search-history', requireAuth, async (req, res) => { + try { + const { address, latitude, longitude } = req.body || {}; + if (!address) { + return res.status(400).json({ error: 'Address is required' }); + } + + const result = await userDbRun( + 'INSERT INTO search_history (user_id, address, latitude, longitude) VALUES (?, ?, ?, ?)', + [req.session.userId, address, latitude || null, longitude || null] + ); + + res.json({ id: result.lastID, address, latitude, longitude }); + } catch (error) { + console.error('Error adding search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete search history item +app.delete('/api/search-history/:id', requireAuth, async (req, res) => { + try { + await userDbRun( + 'DELETE FROM search_history WHERE id = ? AND user_id = ?', + [req.params.id, req.session.userId] + ); + res.json({ ok: true }); + } catch (error) { + console.error('Error deleting search history:', 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(); From bc8b9ecd315ed4e9d3e0d7bbc78b560034cb619e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 6 Jul 2026 18:04:37 +0200 Subject: [PATCH 2/9] feat: redesign profile view with editable fields and search history --- apps/frontend/src/views/ProfileView.css | 97 ++++++++ apps/frontend/src/views/ProfileView.jsx | 292 ++++++++++++------------ 2 files changed, 240 insertions(+), 149 deletions(-) diff --git a/apps/frontend/src/views/ProfileView.css b/apps/frontend/src/views/ProfileView.css index e3f4dd0..b0e698a 100644 --- a/apps/frontend/src/views/ProfileView.css +++ b/apps/frontend/src/views/ProfileView.css @@ -345,3 +345,100 @@ min-height: 4rem; } } + +/* Profile Avatar Editable */ +.profile-avatar-editable { + cursor: pointer; + position: relative; +} + +.profile-avatar-editable:hover .profile-avatar-overlay { + opacity: 1; +} + +.profile-avatar-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s; + color: white; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + border-radius: var(--radius-full); + object-fit: cover; +} + +/* Editable Info Cards */ +.info-card-input { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--outline-variant); + border-radius: var(--radius); + background: var(--surface); + color: var(--on-surface); + font-size: 1rem; + font-family: inherit; + margin-top: 0.5rem; +} + +.info-card-input:focus { + outline: none; + border-color: var(--primary); +} + +.info-card-input:disabled { + opacity: 0.6; +} + +/* Search History */ +.profile-search-history { + background: var(--surface-container-lowest); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 1rem; + margin-top: 0.5rem; +} + +.profile-search-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--outline-variant); +} + +.profile-search-item:last-child { + border-bottom: none; +} + +.profile-search-address { + flex: 1; + font-size: 0.9rem; + color: var(--on-surface); +} + +.profile-search-delete { + background: none; + border: none; + cursor: pointer; + color: var(--on-surface-variant); + padding: 0.25rem; + border-radius: var(--radius); + transition: background 0.15s; +} + +.profile-search-delete:hover { + background: rgba(186, 26, 26, 0.1); + color: var(--error); +} diff --git a/apps/frontend/src/views/ProfileView.jsx b/apps/frontend/src/views/ProfileView.jsx index a12e27d..21d4332 100644 --- a/apps/frontend/src/views/ProfileView.jsx +++ b/apps/frontend/src/views/ProfileView.jsx @@ -1,27 +1,36 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import './ProfileView.css'; -import { getUserPosition } from '../utils/geo'; function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) { - const [address, setAddress] = useState(currentUser?.address || ''); - const [latitude, setLatitude] = useState( - currentUser?.latitude != null ? String(currentUser.latitude) : '' - ); - const [longitude, setLongitude] = useState( - currentUser?.longitude != null ? String(currentUser.longitude) : '' - ); + const [firstName, setFirstName] = useState(currentUser?.first_name || ''); + const [lastName, setLastName] = useState(currentUser?.last_name || ''); + const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || ''); const [saving, setSaving] = useState(false); - const [geocoding, setGeocoding] = useState(false); - const [locating, setLocating] = useState(false); const [feedback, setFeedback] = useState(null); + const [searchHistory, setSearchHistory] = useState([]); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); useEffect(() => { - setAddress(currentUser?.address || ''); - setLatitude(currentUser?.latitude != null ? String(currentUser.latitude) : ''); - setLongitude(currentUser?.longitude != null ? String(currentUser.longitude) : ''); + setFirstName(currentUser?.first_name || ''); + setLastName(currentUser?.last_name || ''); + setAvatarUrl(currentUser?.avatar_url || ''); setFeedback(null); + loadSearchHistory(); }, [currentUser?.id]); + async function loadSearchHistory() { + try { + const res = await fetch('/api/search-history', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setSearchHistory(data); + } + } catch (err) { + console.error('Error loading search history:', err); + } + } + async function handleSave(e) { e?.preventDefault(); setSaving(true); @@ -32,9 +41,9 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - address: address.trim() || null, - latitude: latitude === '' ? null : latitude, - longitude: longitude === '' ? null : longitude, + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + avatar_url: avatarUrl || null, }), }); if (!res.ok) { @@ -51,77 +60,127 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm } } - async function handleUseLocation() { - setLocating(true); + async function handleAvatarUpload(e) { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); setFeedback(null); try { - const pos = await getUserPosition(); - setLatitude(String(pos.lat)); - setLongitude(String(pos.lon)); - setFeedback({ type: 'ok', text: 'Ubicación obtenida — recuerda Guardar.' }); + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result; + setAvatarUrl(base64); + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ avatar_url: base64 }), + }); + if (res.ok) { + const updated = await res.json(); + onProfileSaved?.(updated); + setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); + } + } catch (err) { + setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + } + setUploading(false); + }; + reader.readAsDataURL(file); } catch (err) { - let msg = 'No se pudo obtener la ubicación'; - if (err && typeof err.code === 'number') { - if (err.code === 1) msg = 'Permiso de ubicación denegado'; - else if (err.code === 2) msg = 'Ubicación no disponible'; - else if (err.code === 3) msg = 'La solicitud expiró'; - } - setFeedback({ type: 'err', text: msg }); - } finally { - setLocating(false); + setFeedback({ type: 'err', text: 'Error al procesar la imagen' }); + setUploading(false); } } - async function handleGeocode() { - const q = address.trim(); - if (!q) { - setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' }); - return; - } - setGeocoding(true); - setFeedback(null); + async function handleDeleteSearch(id) { try { - const res = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`, { + const res = await fetch(`/api/search-history/${id}`, { + method: 'DELETE', credentials: 'include', }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `Error en la búsqueda (HTTP ${res.status})`); + if (res.ok) { + setSearchHistory(prev => prev.filter(item => item.id !== id)); } - const data = await res.json(); - setLatitude(String(data.lat)); - setLongitude(String(data.lon)); - setFeedback({ - type: 'ok', - text: `Ubicado: ${data.displayName} — recuerda Guardar.`, - }); } catch (err) { - setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' }); - } finally { - setGeocoding(false); + console.error('Error deleting search:', err); } } - const username = currentUser?.username || 'Usuario'; + const displayName = [firstName, lastName].filter(Boolean).join(' ') || currentUser?.username || 'Usuario'; return (
+ {/* Avatar Section */}
-
- - - +
fileInputRef.current?.click()} + > + {avatarUrl ? ( + Avatar + ) : ( + + + + )} +
+ + + +
-

Mi Perfil

+ + {uploading &&

Subiendo foto...

}
-
-
-

Nombre

-

{username}

+ {/* Editable Name Section */} +
+
+
+ + setFirstName(e.target.value)} + placeholder="Tu nombre" + disabled={saving} + /> +
+
+ + setLastName(e.target.value)} + placeholder="Tus apellidos" + disabled={saving} + /> +
-
+ {feedback && ( +

{feedback.text}

+ )} + +
+ +
+ + + {/* Search History Section */}
- +
+ ))}
- Contactos de Emergencia - - - - - - + )} {currentUser?.is_admin && (
-
-

Tu Ubicación

-

Guarda tu dirección para ordenar por distancia sin pedir permiso cada vez.

-
-
- - setAddress(e.target.value)} - autoComplete="street-address" - disabled={saving} - /> -
-
-
- - setLatitude(e.target.value)} - disabled={saving} - /> -
-
- - setLongitude(e.target.value)} - disabled={saving} - /> -
-
-
- - -
- {feedback && ( -

{feedback.text}

- )} -
- -
-
-
- +
{searchHistory.length > 0 && (
From b899d9a29700bc753de40fa0a6d89326d19a4fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 7 Jul 2026 01:37:57 +0200 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20read-only=20name=20fields=20with=20?= =?UTF-8?q?Configuraci=C3=B3n=20modal=20for=20profile=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/server.js | 20 ++- apps/frontend/src/views/ProfileView.css | 118 +++++++++++++++ apps/frontend/src/views/ProfileView.jsx | 189 +++++++++++++++++------- 3 files changed, 269 insertions(+), 58 deletions(-) diff --git a/apps/backend/server.js b/apps/backend/server.js index d6d10c0..52d9f52 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -296,11 +296,15 @@ if (!pgPool) { ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS city TEXT; `); } else { try { await dbRun('ALTER TABLE users ADD COLUMN first_name TEXT'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN last_name TEXT'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN email TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN city TEXT'); } catch {} } // Add user_id to push tables (SQLite — no cross-DB FK) @@ -816,7 +820,7 @@ app.get('/api/auth/check', async (req, res) => { app.get('/api/users/me', requireAuth, async (req, res) => { try { const user = await userDbGet( - 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) return res.status(404).json({ error: 'Not found' }); @@ -826,6 +830,8 @@ app.get('/api/users/me', requireAuth, async (req, res) => { first_name: user.first_name || null, last_name: user.last_name || null, avatar_url: user.avatar_url || null, + email: user.email || null, + city: user.city || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -840,11 +846,13 @@ app.get('/api/users/me', requireAuth, async (req, res) => { // Update the current user's address + coordinates app.put('/api/users/me', requireAuth, async (req, res) => { try { - const { first_name, last_name, avatar_url, address, latitude, longitude } = req.body || {}; + const { first_name, last_name, avatar_url, email, city, address, latitude, longitude } = req.body || {}; const fName = first_name == null ? null : String(first_name).trim().slice(0, 100); const lName = last_name == null ? null : String(last_name).trim().slice(0, 100); const avatar = avatar_url == null ? null : String(avatar_url).slice(0, 500); + const userEmail = email == null ? null : String(email).trim().slice(0, 200); + const userCity = city == null ? null : String(city).trim().slice(0, 200); const addr = address == null || String(address).trim() === '' @@ -867,12 +875,12 @@ app.put('/api/users/me', requireAuth, async (req, res) => { } await userDbRun( - 'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?', - [fName, lName, avatar, addr, lat, lon, req.session.userId] + 'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, email = ?, city = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?', + [fName, lName, avatar, userEmail, userCity, addr, lat, lon, req.session.userId] ); const user = await userDbGet( - 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); res.json({ @@ -881,6 +889,8 @@ app.put('/api/users/me', requireAuth, async (req, res) => { first_name: user.first_name || null, last_name: user.last_name || null, avatar_url: user.avatar_url || null, + email: user.email || null, + city: user.city || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, diff --git a/apps/frontend/src/views/ProfileView.css b/apps/frontend/src/views/ProfileView.css index b0e698a..364e2ce 100644 --- a/apps/frontend/src/views/ProfileView.css +++ b/apps/frontend/src/views/ProfileView.css @@ -442,3 +442,121 @@ background: rgba(186, 26, 26, 0.1); color: var(--error); } + +/* Config Modal */ +.profile-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.profile-modal { + background: var(--surface-container-lowest); + border-radius: var(--radius-md); + width: 100%; + max-width: 28rem; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.profile-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--outline-variant); +} + +.profile-modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--on-surface); +} + +.profile-modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--on-surface-variant); + padding: 0.25rem; + line-height: 1; +} + +.profile-modal-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.profile-modal-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.profile-modal-field label { + font-size: 0.8rem; + font-weight: 600; + color: var(--on-surface-variant); + letter-spacing: 0.03em; +} + +.profile-modal-field input { + padding: 0.7rem 0.85rem; + border: 2px solid var(--outline-variant); + border-radius: var(--radius); + background: var(--surface); + color: var(--on-surface); + font-size: 0.95rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +.profile-modal-field input:focus { + border-color: var(--primary); +} + +.profile-modal-field input:disabled { + opacity: 0.6; +} + +.profile-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.profile-btn-cancel { + background: var(--surface-container-low); + border: 1px solid var(--outline-variant); + color: var(--on-surface); + padding: 0.65rem 1.25rem; + border-radius: var(--radius-full); + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + font-family: inherit; + transition: background 0.15s; +} + +.profile-btn-cancel:hover:not(:disabled) { + background: var(--surface-container); +} + +.profile-btn-cancel:disabled { + opacity: 0.6; +} diff --git a/apps/frontend/src/views/ProfileView.jsx b/apps/frontend/src/views/ProfileView.jsx index af0fdb7..e3e9f72 100644 --- a/apps/frontend/src/views/ProfileView.jsx +++ b/apps/frontend/src/views/ProfileView.jsx @@ -5,17 +5,24 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { const [firstName, setFirstName] = useState(currentUser?.first_name || ''); const [lastName, setLastName] = useState(currentUser?.last_name || ''); const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || ''); - const [saving, setSaving] = useState(false); - const [feedback, setFeedback] = useState(null); const [searchHistory, setSearchHistory] = useState([]); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); + // Config modal state + const [showConfig, setShowConfig] = useState(false); + const [configFirstName, setConfigFirstName] = useState(''); + const [configLastName, setConfigLastName] = useState(''); + const [configEmail, setConfigEmail] = useState(''); + const [configCity, setConfigCity] = useState(''); + const [configAddress, setConfigAddress] = useState(''); + const [configSaving, setConfigSaving] = useState(false); + const [configFeedback, setConfigFeedback] = useState(null); + useEffect(() => { setFirstName(currentUser?.first_name || ''); setLastName(currentUser?.last_name || ''); setAvatarUrl(currentUser?.avatar_url || ''); - setFeedback(null); loadSearchHistory(); }, [currentUser?.id]); @@ -31,19 +38,31 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { } } - async function handleSave(e) { + function openConfig() { + setConfigFirstName(currentUser?.first_name || ''); + setConfigLastName(currentUser?.last_name || ''); + setConfigEmail(currentUser?.email || ''); + setConfigCity(currentUser?.city || ''); + setConfigAddress(currentUser?.address || ''); + setConfigFeedback(null); + setShowConfig(true); + } + + async function handleConfigSave(e) { e?.preventDefault(); - setSaving(true); - setFeedback(null); + setConfigSaving(true); + setConfigFeedback(null); try { const res = await fetch('/api/users/me', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - first_name: firstName.trim() || null, - last_name: lastName.trim() || null, - avatar_url: avatarUrl || null, + first_name: configFirstName.trim() || null, + last_name: configLastName.trim() || null, + email: configEmail.trim() || null, + city: configCity.trim() || null, + address: configAddress.trim() || null, }), }); if (!res.ok) { @@ -52,11 +71,14 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { } const updated = await res.json(); onProfileSaved?.(updated); - setFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setFirstName(updated.first_name || ''); + setLastName(updated.last_name || ''); + setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setTimeout(() => setShowConfig(false), 1200); } catch (err) { - setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' }); } finally { - setSaving(false); + setConfigSaving(false); } } @@ -65,7 +87,6 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { if (!file) return; setUploading(true); - setFeedback(null); try { const reader = new FileReader(); reader.onload = async () => { @@ -81,16 +102,15 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { if (res.ok) { const updated = await res.json(); onProfileSaved?.(updated); - setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); } } catch (err) { - setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + console.error('Error saving avatar:', err); } setUploading(false); }; reader.readAsDataURL(file); } catch (err) { - setFeedback({ type: 'err', text: 'Error al procesar la imagen' }); + console.error('Error processing image:', err); setUploading(false); } } @@ -142,46 +162,32 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { {uploading &&

Subiendo foto...

}
- {/* Editable Name Section */} -
-
-
- - setFirstName(e.target.value)} - placeholder="Tu nombre" - disabled={saving} - /> -
-
- - setLastName(e.target.value)} - placeholder="Tus apellidos" - disabled={saving} - /> -
+ {/* Read-only Name Section */} +
+
+

Nombre

+

{firstName || '—'}

- - {feedback && ( -

{feedback.text}

- )} - -
- +
+

Apellidos

+

{lastName || '—'}

- +
- {/* Search History Section */} + {/* Menu Section */}
+ +
@@ -237,8 +243,85 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
Cerrar Sesión + + {/* Config Modal */} + {showConfig && ( +
setShowConfig(false)}> +
e.stopPropagation()}> +
+

Configuración

+ +
+
+
+ + setConfigFirstName(e.target.value)} + placeholder="Tu nombre" + disabled={configSaving} + /> +
+
+ + setConfigLastName(e.target.value)} + placeholder="Tus apellidos" + disabled={configSaving} + /> +
+
+ + setConfigEmail(e.target.value)} + placeholder="tu@email.com" + disabled={configSaving} + /> +
+
+ + setConfigCity(e.target.value)} + placeholder="Tu ciudad" + disabled={configSaving} + /> +
+
+ + setConfigAddress(e.target.value)} + placeholder="Calle Mayor 1, Madrid" + disabled={configSaving} + /> +
+ + {configFeedback && ( +

{configFeedback.text}

+ )} + +
+ + +
+
+
+
+ )}
); } -export default ProfileView; \ No newline at end of file +export default ProfileView; From c5417ff78703195ee2d6c4b65547747ba50354d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 7 Jul 2026 01:38:38 +0200 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20mobile=20profile=20with=20read-only?= =?UTF-8?q?=20fields=20and=20Configuraci=C3=B3n=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend-mobile/app/(tabs)/profile.tsx | 365 ++++++++++++++------ 1 file changed, 265 insertions(+), 100 deletions(-) diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index 03e5f20..8711d44 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal } from 'react-native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; @@ -22,8 +22,16 @@ export default function ProfileScreen() { const [lastName, setLastName] = useState(user?.last_name || ''); const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); const [searchHistory, setSearchHistory] = useState([]); - const [saving, setSaving] = useState(false); - const [feedback, setFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + // Config modal state + const [showConfig, setShowConfig] = useState(false); + const [configFirstName, setConfigFirstName] = useState(''); + const [configLastName, setConfigLastName] = useState(''); + const [configEmail, setConfigEmail] = useState(''); + const [configCity, setConfigCity] = useState(''); + const [configAddress, setConfigAddress] = useState(''); + const [configSaving, setConfigSaving] = useState(false); + const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); useEffect(() => { if (isAuthenticated) { @@ -49,29 +57,45 @@ export default function ProfileScreen() { } } - async function handleSave() { - setSaving(true); - setFeedback(null); + function openConfig() { + setConfigFirstName(user?.first_name || ''); + setConfigLastName(user?.last_name || ''); + setConfigEmail(user?.email || ''); + setConfigCity(user?.city || ''); + setConfigAddress(user?.address || ''); + setConfigFeedback(null); + setShowConfig(true); + } + + async function handleConfigSave() { + setConfigSaving(true); + setConfigFeedback(null); try { const res = await fetch('/api/users/me', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - first_name: firstName.trim() || null, - last_name: lastName.trim() || null, - avatar_url: avatarUrl || null, + first_name: configFirstName.trim() || null, + last_name: configLastName.trim() || null, + email: configEmail.trim() || null, + city: configCity.trim() || null, + address: configAddress.trim() || null, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Error al guardar'); } - setFeedback({ type: 'ok', text: 'Perfil guardado.' }); + const updated = await res.json(); + setFirstName(updated.first_name || ''); + setLastName(updated.last_name || ''); + setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setTimeout(() => setShowConfig(false), 1200); } catch (err: any) { - setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' }); } finally { - setSaving(false); + setConfigSaving(false); } } @@ -93,10 +117,11 @@ export default function ProfileScreen() { body: JSON.stringify({ avatar_url: result.assets[0].uri }), }); if (res.ok) { - setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); + const updated = await res.json(); + // Trigger auth refresh to update user state } } catch (err) { - setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + console.error('Error saving avatar:', err); } } } @@ -121,8 +146,8 @@ export default function ProfileScreen() { '¿Estás seguro que deseas cerrar sesión?', [ { text: 'Cancelar', style: 'cancel' }, - { - text: 'Cerrar Sesión', + { + text: 'Cerrar Sesión', style: 'destructive', onPress: async () => { await logout(); @@ -184,50 +209,26 @@ export default function ProfileScreen() { Toca para cambiar foto - {/* Editable Fields */} - - - Nombre - + {/* Read-only Name Section */} + + + Nombre + {firstName || '—'} - - Apellidos - + + Apellidos + {lastName || '—'} - - {feedback && ( - - {feedback.text} - - )} - - - {saving ? ( - - ) : ( - Guardar Cambios - )} - {/* Menu Section */} + + + Configuración + + + Mis Direcciones @@ -240,7 +241,7 @@ export default function ProfileScreen() { {searchHistory.map((item) => ( {item.address} - handleDeleteSearch(item.id)} style={styles.searchDelete} > @@ -265,6 +266,101 @@ export default function ProfileScreen() { Cerrar Sesión + + {/* Config Modal */} + + + + + Configuración + setShowConfig(false)}> + + + + + + Nombre + + + + Apellidos + + + + Correo electrónico + + + + Ciudad + + + + Dirección + + + + {configFeedback && ( + + {configFeedback.text} + + )} + + + setShowConfig(false)} + disabled={configSaving} + > + Cancelar + + + {configSaving ? ( + + ) : ( + Guardar + )} + + + + + + ); } @@ -333,59 +429,31 @@ const styles = StyleSheet.create({ color: colors.textSecondary, fontSize: 14, }, - formSection: { + infoSection: { padding: spacing.xl, backgroundColor: colors.card, marginTop: spacing.md, + gap: spacing.md, }, - inputGroup: { - marginBottom: spacing.md, - }, - label: { - fontSize: 14, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.xs, - }, - input: { + infoCard: { + backgroundColor: colors.background, + padding: spacing.md, + borderRadius: borderRadius.md, borderWidth: 1, borderColor: colors.border || '#E0E0E0', - borderRadius: borderRadius.md, - padding: spacing.md, - fontSize: 16, - color: colors.text, }, - feedback: { - padding: spacing.md, - borderRadius: borderRadius.md, - marginBottom: spacing.md, - }, - feedbackOk: { - backgroundColor: 'rgba(0, 69, 13, 0.1)', - borderWidth: 1, - borderColor: 'rgba(0, 69, 13, 0.2)', - }, - feedbackErr: { - backgroundColor: 'rgba(186, 26, 26, 0.1)', - borderWidth: 1, - borderColor: 'rgba(186, 26, 26, 0.2)', - }, - feedbackText: { - fontSize: 14, - }, - saveButton: { - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - saveButtonDisabled: { - opacity: 0.6, - }, - saveButtonText: { - color: colors.textInverse, - fontSize: 16, + infoLabel: { + fontSize: 12, fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.05, + marginBottom: 4, + }, + infoValue: { + fontSize: 16, + fontWeight: '500', + color: colors.text, }, menuSection: { marginTop: spacing.md, @@ -466,4 +534,101 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '500', }, + // Modal styles + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: colors.card, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: '90%', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + color: colors.text, + }, + modalBody: { + padding: spacing.lg, + }, + modalField: { + marginBottom: spacing.md, + }, + modalLabel: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + modalInput: { + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + borderRadius: borderRadius.md, + padding: spacing.md, + fontSize: 16, + color: colors.text, + }, + modalFeedback: { + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.md, + }, + modalFeedbackOk: { + backgroundColor: 'rgba(0, 69, 13, 0.1)', + borderWidth: 1, + borderColor: 'rgba(0, 69, 13, 0.2)', + }, + modalFeedbackErr: { + backgroundColor: 'rgba(186, 26, 26, 0.1)', + borderWidth: 1, + borderColor: 'rgba(186, 26, 26, 0.2)', + }, + modalFeedbackText: { + fontSize: 14, + color: colors.text, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.md, + marginTop: spacing.md, + }, + modalCancelBtn: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + }, + modalCancelText: { + fontSize: 16, + color: colors.text, + }, + modalSaveBtn: { + backgroundColor: colors.primary, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.md, + minWidth: 100, + alignItems: 'center', + }, + modalSaveBtnDisabled: { + opacity: 0.6, + }, + modalSaveText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, }); From fe22e6ee9bc151c2ca64e8a387e94b2279511e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 7 Jul 2026 01:45:09 +0200 Subject: [PATCH 8/9] Profile and design hotfixes --- apps/backend/Dockerfile | 5 +- apps/backend/package-lock.json | 56 ++++++++++++++++++++++ apps/backend/package.json | 1 + apps/frontend/Dockerfile | 4 +- apps/frontend/package-lock.json | 9 ++-- apps/frontend/src/components/SearchBar.jsx | 1 - 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 7778723..6b0c50e 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,8 +1,7 @@ -FROM node:18-slim +FROM node:24-alpine WORKDIR /app COPY apps/backend/package*.json ./ -RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* -RUN npm ci --omit=dev +RUN apk add --no-cache python3 make g++ && npm ci --omit=dev COPY apps/backend/ . COPY apps/API/ /API/ RUN mkdir -p /app/data diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index fd54a61..43a86d6 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -20,6 +20,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", @@ -3458,6 +3459,12 @@ "@types/node": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3946,6 +3953,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/barcode-detector": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz", + "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8743,6 +8759,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -9293,6 +9321,34 @@ "engines": { "node": "*" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz", + "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.7.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/apps/backend/package.json b/apps/backend/package.json index 21a99e8..924e3c8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,6 +29,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 5cf0ec9..03dc71e 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS build +FROM node:24-alpine AS build ARG VITE_FARO_ENDPOINT ARG VITE_FARO_APP_NAME=farmaclic-frontend ARG VITE_FARO_ENV=production @@ -9,7 +9,7 @@ ENV VITE_FARO_ENV=$VITE_FARO_ENV ENV VITE_FARO_APP_VERSION=$VITE_FARO_APP_VERSION WORKDIR /app COPY package*.json ./ -RUN npm ci +RUN npm ci --legacy-peer-deps COPY . . RUN npm run build diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index ac4a2f0..b1698a6 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -18,7 +18,7 @@ "@grafana/faro-web-sdk": "^1.7.0", "@grafana/faro-web-tracing": "^1.7.0", "@zxing/browser": "^0.2.0", - "@zxing/library": "^0.22.0", + "@zxing/library": "^0.23.0", "leaflet": "^1.9.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3704,9 +3704,10 @@ } }, "node_modules/@zxing/library": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz", - "integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz", + "integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==", + "license": "Apache-2.0", "dependencies": { "ts-custom-error": "^3.3.1" }, diff --git a/apps/frontend/src/components/SearchBar.jsx b/apps/frontend/src/components/SearchBar.jsx index c0dbbf1..051e937 100644 --- a/apps/frontend/src/components/SearchBar.jsx +++ b/apps/frontend/src/components/SearchBar.jsx @@ -17,7 +17,6 @@ function SearchBar({ value, onChange, placeholder }) { onChange={(e) => onChange(e.target.value)} placeholder={placeholder} className="search-input" - autoFocus /> {value && ( +
+ + + {/* Search History Section */} +
+ + + {searchHistory.length > 0 && ( +
+

Tus búsquedas recientes:

+ {searchHistory.map((item) => ( +
+ {item.address} + +
+ ))} +
+ )} + + {currentUser?.is_admin && ( + + )} +
+ + +
+ ); +} + +export default ProfileView; +``` + +- [ ] **Step 2: Update ProfileView.css with new styles** + +Add these styles to the end of the CSS file: + +```css +/* Profile Avatar Editable */ +.profile-avatar-editable { + cursor: pointer; + position: relative; +} + +.profile-avatar-editable:hover .profile-avatar-overlay { + opacity: 1; +} + +.profile-avatar-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s; + color: white; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + border-radius: var(--radius-full); + object-fit: cover; +} + +/* Editable Info Cards */ +.info-card-input { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--outline-variant); + border-radius: var(--radius); + background: var(--surface); + color: var(--on-surface); + font-size: 1rem; + font-family: inherit; + margin-top: 0.5rem; +} + +.info-card-input:focus { + outline: none; + border-color: var(--primary); +} + +.info-card-input:disabled { + opacity: 0.6; +} + +/* Search History */ +.profile-search-history { + background: var(--surface-container-lowest); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 1rem; + margin-top: 0.5rem; +} + +.profile-search-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--outline-variant); +} + +.profile-search-item:last-child { + border-bottom: none; +} + +.profile-search-address { + flex: 1; + font-size: 0.9rem; + color: var(--on-surface); +} + +.profile-search-delete { + background: none; + border: none; + cursor: pointer; + color: var(--on-surface-variant); + padding: 0.25rem; + border-radius: var(--radius); + transition: background 0.15s; +} + +.profile-search-delete:hover { + background: rgba(186, 26, 26, 0.1); + color: var(--error); +} +``` + +- [ ] **Step 3: Commit frontend changes** + +```bash +git add apps/frontend/src/views/ProfileView.jsx apps/frontend/src/views/ProfileView.css +git commit -m "feat: redesign profile view with editable fields and search history" +``` + +--- + +## Task 3: Mobile - Redesign ProfileScreen + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx` + +- [ ] **Step 1: Update ProfileScreen.tsx with new functionality** + +Replace the entire profile.tsx with: + +```tsx +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; +import { useAuth } from '../../hooks/useAuth'; +import { colors, spacing, borderRadius } from '../../constants/theme'; + +interface SearchHistoryItem { + id: number; + address: string; + latitude: number | null; + longitude: number | null; + created_at: string; +} + +export default function ProfileScreen() { + const router = useRouter(); + const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); + + const [firstName, setFirstName] = useState(user?.first_name || ''); + const [lastName, setLastName] = useState(user?.last_name || ''); + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); + const [searchHistory, setSearchHistory] = useState([]); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + useEffect(() => { + if (isAuthenticated) { + loadSearchHistory(); + } + }, [isAuthenticated]); + + useEffect(() => { + setFirstName(user?.first_name || ''); + setLastName(user?.last_name || ''); + setAvatarUrl(user?.avatar_url || ''); + }, [user]); + + async function loadSearchHistory() { + try { + const res = await fetch('/api/search-history', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setSearchHistory(data); + } + } catch (err) { + console.error('Error loading search history:', err); + } + } + + async function handleSave() { + setSaving(true); + setFeedback(null); + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + avatar_url: avatarUrl || null, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Error al guardar'); + } + setFeedback({ type: 'ok', text: 'Perfil guardado.' }); + } catch (err: any) { + setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + } finally { + setSaving(false); + } + } + + async function handlePickImage() { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setAvatarUrl(result.assets[0].uri); + // Auto-save + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ avatar_url: result.assets[0].uri }), + }); + if (res.ok) { + setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); + } + } catch (err) { + setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + } + } + } + + async function handleDeleteSearch(id: number) { + try { + const res = await fetch(`/api/search-history/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + setSearchHistory(prev => prev.filter(item => item.id !== id)); + } + } catch (err) { + console.error('Error deleting search:', err); + } + } + + const handleLogout = async () => { + Alert.alert( + 'Cerrar Sesión', + '¿Estás seguro que deseas cerrar sesión?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Cerrar Sesión', + style: 'destructive', + onPress: async () => { + await logout(); + router.replace('/auth/login'); + } + }, + ] + ); + }; + + if (isLoading) { + return ( + + Cargando... + + ); + } + + if (!isAuthenticated) { + return ( + + + + Inicia Sesión + + Inicia sesión para acceder a todas las funcionalidades + + router.push('/auth/login')} + > + Iniciar Sesión + + router.push('/auth/register')} + > + Crear cuenta nueva + + + + ); + } + + return ( + + {/* Avatar Section */} + + + {avatarUrl ? ( + + ) : ( + + )} + + + + + Toca para cambiar foto + + + {/* Editable Fields */} + + + Nombre + + + + Apellidos + + + + {feedback && ( + + {feedback.text} + + )} + + + {saving ? ( + + ) : ( + Guardar Cambios + )} + + + + {/* Menu Section */} + + router.push('/saved')}> + + Mis Direcciones + + + + {searchHistory.length > 0 && ( + + Tus búsquedas recientes: + {searchHistory.map((item) => ( + + {item.address} + handleDeleteSearch(item.id)} + style={styles.searchDelete} + > + + + + ))} + + )} + + {isAdmin && ( + + + Panel Admin + + + )} + + + {/* Logout Button */} + + + Cerrar Sesión + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + loadingText: { + textAlign: 'center', + marginTop: spacing.xxl, + color: colors.textSecondary, + }, + authPrompt: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + authTitle: { + fontSize: 24, + fontWeight: 'bold', + color: colors.text, + marginTop: spacing.lg, + }, + authSubtitle: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.sm, + marginBottom: spacing.xl, + }, + avatarSection: { + alignItems: 'center', + padding: spacing.xl, + backgroundColor: colors.card, + }, + avatarContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + avatarImage: { + width: 100, + height: 100, + borderRadius: 50, + }, + avatarOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + opacity: 0.8, + }, + editAvatarText: { + marginTop: spacing.sm, + color: colors.textSecondary, + fontSize: 14, + }, + formSection: { + padding: spacing.xl, + backgroundColor: colors.card, + marginTop: spacing.md, + }, + inputGroup: { + marginBottom: spacing.md, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + input: { + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + borderRadius: borderRadius.md, + padding: spacing.md, + fontSize: 16, + color: colors.text, + }, + feedback: { + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.md, + }, + feedbackOk: { + backgroundColor: 'rgba(0, 69, 13, 0.1)', + borderWidth: 1, + borderColor: 'rgba(0, 69, 13, 0.2)', + }, + feedbackErr: { + backgroundColor: 'rgba(186, 26, 26, 0.1)', + borderWidth: 1, + borderColor: 'rgba(186, 26, 26, 0.2)', + }, + feedbackText: { + fontSize: 14, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + }, + saveButtonDisabled: { + opacity: 0.6, + }, + saveButtonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + menuSection: { + marginTop: spacing.md, + backgroundColor: colors.card, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + menuText: { + flex: 1, + marginLeft: spacing.md, + fontSize: 16, + color: colors.text, + }, + searchHistorySection: { + padding: spacing.md, + backgroundColor: colors.background, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchHistoryTitle: { + fontSize: 14, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + searchItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchAddress: { + flex: 1, + fontSize: 14, + color: colors.text, + }, + searchDelete: { + padding: spacing.xs, + }, + button: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + }, + buttonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + marginTop: spacing.md, + }, + linkText: { + color: colors.primary, + fontSize: 14, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + margin: spacing.xl, + padding: spacing.md, + backgroundColor: colors.card, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.danger, + }, + logoutText: { + marginLeft: spacing.sm, + color: colors.danger, + fontSize: 16, + fontWeight: '500', + }, +}); +``` + +- [ ] **Step 2: Add expo-image-picker dependency** + +```bash +cd apps/frontend-mobile && npx expo install expo-image-picker +``` + +- [ ] **Step 3: Commit mobile changes** + +```bash +git add apps/frontend-mobile/app/\(tabs\)/profile.tsx +git commit -m "feat: redesign mobile profile with editable fields and avatar upload" +``` + +--- + +## Task 4: Integration - Save search history when user searches nearby + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/index.tsx` or wherever the "find pharmacies near me" search happens + +- [ ] **Step 1: Find the nearby search function** + +Search for the component that handles "buscar farmacias cerca de mí" or similar nearby search. + +- [ ] **Step 2: Add search history saving** + +After a successful nearby search, add: + +```javascript +// Save to search history +try { + await fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: userAddress, // The address used in the search + latitude: userLatitude, + longitude: userLongitude, + }), + }); +} catch (err) { + console.error('Error saving search history:', err); +} +``` + +- [ ] **Step 3: Commit integration changes** + +```bash +git add +git commit -m "feat: save search history when searching nearby pharmacies" +``` + +--- + +## Task 5: Final verification and cleanup + +- [ ] **Step 1: Run tests** + +```bash +npm test +``` + +- [ ] **Step 2: Build and verify** + +```bash +npm run build +``` + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "chore: complete profile redesign implementation" +``` + +--- + +## Summary of Changes + +1. **Backend**: Added `first_name`, `last_name`, `avatar_url` fields to users table; created `search_history` table; added API endpoints for search history CRUD +2. **Frontend (Web)**: Redesigned ProfileView with editable name fields, clickable avatar for photo upload, search history display, removed emergency contacts and text configuration sections +3. **Mobile**: Redesigned ProfileScreen with same features using React Native components and expo-image-picker +4. **Integration**: Added search history saving when user performs nearby pharmacy searches diff --git a/package-lock.json b/package-lock.json index 6bc3d29..c8e181c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "turbo": "^2.5.0" } }, - "apps/API": {}, + "apps/API": { + "name": "farma-clic-api-sources" + }, "apps/backend": { "name": "farma-clic-backend", "version": "1.0.0", @@ -40,6 +42,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", @@ -103,6 +106,7 @@ "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.5", "expo-device": "~7.0.2", + "expo-image-picker": "~57.0.2", "expo-linking": "~57.0.1", "expo-local-authentication": "~57.0.0", "expo-notifications": "~57.0.3", @@ -724,6 +728,25 @@ "react-native": "*" } }, + "apps/frontend-mobile/node_modules/expo-image-loader": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz", + "integrity": "sha512-EhwnoPC4T/EMdB7Nsg7qITzhO/qB0hUnmVghmtAKQwwDVaLWWYCGE4NLkes3zk9Ub451SmS/swgPp4PCPzOlnw==", + "peerDependencies": { + "expo": "*" + } + }, + "apps/frontend-mobile/node_modules/expo-image-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.2.tgz", + "integrity": "sha512-XW+C5weIthkOLCJZzExeRJf9pcWfaXNHSDm76gmZVhDUVIHdi9IS3eihAIF0WA0fBo9ogIfQs/iep/c6CzIMKg==", + "dependencies": { + "expo-image-loader": "~57.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "apps/frontend-mobile/node_modules/expo-keep-awake": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz",