Files
FarmaFinder/docs/superpowers/plans/2026-07-06-profile-redesign.md
T
Antoni Nuñez Romeu b5e5edda30
Run Tests on Branches / Backend Tests (push) Successful in 2m22s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
ci/cd fixes
2026-07-07 01:49:55 +02:00

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