1136 lines
33 KiB
Markdown
1136 lines
33 KiB
Markdown
# Profile Redesign Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Redesign the Profile section to be more user-friendly: editable name/last name, avatar upload, search history as addresses, and remove irrelevant sections.
|
|
|
|
**Architecture:** Add new fields to users table (first_name, last_name, avatar_url), create search_history table for location searches, update frontend/mobile profile views to be editable and modern.
|
|
|
|
**Tech Stack:** React (frontend), React Native/Expo (mobile), SQLite/PostgreSQL (backend), Node.js/Express
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Backend
|
|
- Modify: `apps/backend/server.js` - Add new fields, search history API
|
|
- Create: `apps/backend/migrations/` - Database migration scripts
|
|
|
|
### Frontend (Web)
|
|
- Modify: `apps/frontend/src/views/ProfileView.jsx` - Redesign profile view
|
|
- Modify: `apps/frontend/src/views/ProfileView.css` - Update styles
|
|
|
|
### Mobile
|
|
- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx` - Redesign mobile profile
|
|
|
|
---
|
|
|
|
## Task 1: Backend - Add new user fields and search history table
|
|
|
|
**Files:**
|
|
- Modify: `apps/backend/server.js`
|
|
|
|
- [ ] **Step 1: Add migration for new user fields**
|
|
|
|
Add to `initDatabase()` function, after existing user table creation:
|
|
|
|
```javascript
|
|
// 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 {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create search_history table**
|
|
|
|
Add after user alerts table creation:
|
|
|
|
```javascript
|
|
// 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)
|
|
)
|
|
`);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update GET /api/users/me to include new fields**
|
|
|
|
Change the SELECT query in `app.get('/api/users/me', ...)`:
|
|
|
|
```javascript
|
|
const user = await userDbGet(
|
|
'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
|
[req.session.userId]
|
|
);
|
|
```
|
|
|
|
And update the response:
|
|
|
|
```javascript
|
|
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,
|
|
longitude: user.longitude,
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Update PUT /api/users/me to handle new fields**
|
|
|
|
Change the PUT handler to accept and save new fields:
|
|
|
|
```javascript
|
|
app.put('/api/users/me', requireAuth, async (req, res) => {
|
|
try {
|
|
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);
|
|
|
|
// ... existing address/latitude/longitude handling ...
|
|
|
|
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]
|
|
);
|
|
|
|
// ... rest of response ...
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Add search history API endpoints**
|
|
|
|
Add after user alerts API:
|
|
|
|
```javascript
|
|
// 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' });
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Commit backend changes**
|
|
|
|
```bash
|
|
git add apps/backend/server.js
|
|
git commit -m "feat: add profile fields and search history API"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Frontend - Redesign ProfileView
|
|
|
|
**Files:**
|
|
- Modify: `apps/frontend/src/views/ProfileView.jsx`
|
|
- Modify: `apps/frontend/src/views/ProfileView.css`
|
|
|
|
- [ ] **Step 1: Update ProfileView.jsx with editable fields**
|
|
|
|
Replace the entire ProfileView.jsx with:
|
|
|
|
```jsx
|
|
import React, { useEffect, useState, useRef } from 'react';
|
|
import './ProfileView.css';
|
|
|
|
function ProfileView({ currentUser, onProfileSaved, onShowSaved, 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);
|
|
|
|
useEffect(() => {
|
|
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);
|
|
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 (HTTP ${res.status})`);
|
|
}
|
|
const updated = await res.json();
|
|
onProfileSaved?.(updated);
|
|
setFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
|
} catch (err) {
|
|
setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleAvatarUpload(e) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploading(true);
|
|
setFeedback(null);
|
|
try {
|
|
// Convert to base64 for simplicity (in production, use a file upload service)
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
const base64 = reader.result;
|
|
setAvatarUrl(base64);
|
|
// Auto-save after upload
|
|
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) {
|
|
setFeedback({ type: 'err', text: 'Error al procesar la imagen' });
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteSearch(id) {
|
|
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 displayName = [firstName, lastName].filter(Boolean).join(' ') || currentUser?.username || 'Usuario';
|
|
|
|
return (
|
|
<div className="profile-view">
|
|
{/* Avatar Section */}
|
|
<div className="profile-avatar-section">
|
|
<div
|
|
className="profile-avatar-circle profile-avatar-editable"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
{avatarUrl ? (
|
|
<img src={avatarUrl} alt="Avatar" className="profile-avatar-image" />
|
|
) : (
|
|
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
|
</svg>
|
|
)}
|
|
<div className="profile-avatar-overlay">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleAvatarUpload}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
{uploading && <p className="profile-section-sub">Subiendo foto...</p>}
|
|
</div>
|
|
|
|
{/* Editable Name Section */}
|
|
<form onSubmit={handleSave} className="profile-form">
|
|
<div className="profile-info-cards">
|
|
<div className="profile-info-card">
|
|
<label className="info-card-label">Nombre</label>
|
|
<input
|
|
type="text"
|
|
className="info-card-input"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
placeholder="Tu nombre"
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
<div className="profile-info-card">
|
|
<label className="info-card-label">Apellidos</label>
|
|
<input
|
|
type="text"
|
|
className="info-card-input"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
placeholder="Tus apellidos"
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{feedback && (
|
|
<p className={`profile-feedback profile-feedback--${feedback.type}`}>{feedback.text}</p>
|
|
)}
|
|
|
|
<div className="profile-actions">
|
|
<button type="submit" className="profile-btn-primary" disabled={saving}>
|
|
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Search History Section */}
|
|
<div className="profile-menu">
|
|
<button className="profile-menu-item" onClick={onShowSaved}>
|
|
<div className="menu-item-icon menu-item-icon--primary">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" />
|
|
</svg>
|
|
</div>
|
|
<span className="menu-item-label">Mis Direcciones</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>
|
|
|
|
{searchHistory.length > 0 && (
|
|
<div className="profile-search-history">
|
|
<p className="profile-section-sub">Tus búsquedas recientes:</p>
|
|
{searchHistory.map((item) => (
|
|
<div key={item.id} className="profile-search-item">
|
|
<span className="profile-search-address">{item.address}</span>
|
|
<button
|
|
className="profile-search-delete"
|
|
onClick={() => handleDeleteSearch(item.id)}
|
|
title="Eliminar"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
|
|
<button className="profile-logout-btn" onClick={onLogout}>
|
|
<div className="menu-item-icon menu-item-icon--error-outline">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z" />
|
|
</svg>
|
|
</div>
|
|
<span>Cerrar Sesión</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<SearchHistoryItem[]>([]);
|
|
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 (
|
|
<View style={styles.container}>
|
|
<Text style={styles.loadingText}>Cargando...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.authPrompt}>
|
|
<Ionicons name="person-outline" size={64} color={colors.textSecondary} />
|
|
<Text style={styles.authTitle}>Inicia Sesión</Text>
|
|
<Text style={styles.authSubtitle}>
|
|
Inicia sesión para acceder a todas las funcionalidades
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={() => router.push('/auth/login')}
|
|
>
|
|
<Text style={styles.buttonText}>Iniciar Sesión</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={styles.linkButton}
|
|
onPress={() => router.push('/auth/register')}
|
|
>
|
|
<Text style={styles.linkText}>Crear cuenta nueva</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollView style={styles.container}>
|
|
{/* Avatar Section */}
|
|
<View style={styles.avatarSection}>
|
|
<TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
|
|
{avatarUrl ? (
|
|
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
|
|
) : (
|
|
<Ionicons name="person" size={40} color={colors.primary} />
|
|
)}
|
|
<View style={styles.avatarOverlay}>
|
|
<Ionicons name="camera" size={24} color="white" />
|
|
</View>
|
|
</TouchableOpacity>
|
|
<Text style={styles.editAvatarText}>Toca para cambiar foto</Text>
|
|
</View>
|
|
|
|
{/* Editable Fields */}
|
|
<View style={styles.formSection}>
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>Nombre</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={firstName}
|
|
onChangeText={setFirstName}
|
|
placeholder="Tu nombre"
|
|
editable={!saving}
|
|
/>
|
|
</View>
|
|
<View style={styles.inputGroup}>
|
|
<Text style={styles.label}>Apellidos</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={lastName}
|
|
onChangeText={setLastName}
|
|
placeholder="Tus apellidos"
|
|
editable={!saving}
|
|
/>
|
|
</View>
|
|
|
|
{feedback && (
|
|
<View style={[styles.feedback, feedback.type === 'ok' ? styles.feedbackOk : styles.feedbackErr]}>
|
|
<Text style={styles.feedbackText}>{feedback.text}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
|
onPress={handleSave}
|
|
disabled={saving}
|
|
>
|
|
{saving ? (
|
|
<ActivityIndicator color={colors.textInverse} />
|
|
) : (
|
|
<Text style={styles.saveButtonText}>Guardar Cambios</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Menu Section */}
|
|
<View style={styles.menuSection}>
|
|
<TouchableOpacity style={styles.menuItem} onPress={() => router.push('/saved')}>
|
|
<Ionicons name="location" size={24} color={colors.primary} />
|
|
<Text style={styles.menuText}>Mis Direcciones</Text>
|
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
|
</TouchableOpacity>
|
|
|
|
{searchHistory.length > 0 && (
|
|
<View style={styles.searchHistorySection}>
|
|
<Text style={styles.searchHistoryTitle}>Tus búsquedas recientes:</Text>
|
|
{searchHistory.map((item) => (
|
|
<View key={item.id} style={styles.searchItem}>
|
|
<Text style={styles.searchAddress}>{item.address}</Text>
|
|
<TouchableOpacity
|
|
onPress={() => handleDeleteSearch(item.id)}
|
|
style={styles.searchDelete}
|
|
>
|
|
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<TouchableOpacity style={styles.menuItem}>
|
|
<Ionicons name="settings" size={24} color={colors.text} />
|
|
<Text style={styles.menuText}>Panel Admin</Text>
|
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Logout Button */}
|
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
|
<Ionicons name="log-out" size={20} color={colors.danger} />
|
|
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
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 <modified-file>
|
|
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
|