Files
FarmaFinder/docs/superpowers/plans/2026-07-06-profile-redesign.md
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

33 KiB

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:

// 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:

// 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', ...):

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:

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:

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:

// 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
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:

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:

/* 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
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:

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
cd apps/frontend-mobile && npx expo install expo-image-picker
  • Step 3: Commit mobile changes
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:

// 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
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
npm test
  • Step 2: Build and verify
npm run build
  • Step 3: Final commit
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