feat: redesign profile view with editable fields and search history
This commit is contained in:
@@ -345,3 +345,100 @@
|
||||
min-height: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Profile Avatar Editable */
|
||||
.profile-avatar-editable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-avatar-editable:hover .profile-avatar-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile-avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Editable Info Cards */
|
||||
.info-card-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--outline-variant);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--on-surface);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.info-card-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.info-card-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Search History */
|
||||
.profile-search-history {
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--outline-variant);
|
||||
}
|
||||
|
||||
.profile-search-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile-search-address {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.profile-search-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--on-surface-variant);
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.profile-search-delete:hover {
|
||||
background: rgba(186, 26, 26, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import './ProfileView.css';
|
||||
import { getUserPosition } from '../utils/geo';
|
||||
|
||||
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) {
|
||||
const [address, setAddress] = useState(currentUser?.address || '');
|
||||
const [latitude, setLatitude] = useState(
|
||||
currentUser?.latitude != null ? String(currentUser.latitude) : ''
|
||||
);
|
||||
const [longitude, setLongitude] = useState(
|
||||
currentUser?.longitude != null ? String(currentUser.longitude) : ''
|
||||
);
|
||||
const [firstName, setFirstName] = useState(currentUser?.first_name || '');
|
||||
const [lastName, setLastName] = useState(currentUser?.last_name || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [searchHistory, setSearchHistory] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAddress(currentUser?.address || '');
|
||||
setLatitude(currentUser?.latitude != null ? String(currentUser.latitude) : '');
|
||||
setLongitude(currentUser?.longitude != null ? String(currentUser.longitude) : '');
|
||||
setFirstName(currentUser?.first_name || '');
|
||||
setLastName(currentUser?.last_name || '');
|
||||
setAvatarUrl(currentUser?.avatar_url || '');
|
||||
setFeedback(null);
|
||||
loadSearchHistory();
|
||||
}, [currentUser?.id]);
|
||||
|
||||
async function loadSearchHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/search-history', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchHistory(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading search history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e?.preventDefault();
|
||||
setSaving(true);
|
||||
@@ -32,9 +41,9 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
address: address.trim() || null,
|
||||
latitude: latitude === '' ? null : latitude,
|
||||
longitude: longitude === '' ? null : longitude,
|
||||
first_name: firstName.trim() || null,
|
||||
last_name: lastName.trim() || null,
|
||||
avatar_url: avatarUrl || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -51,77 +60,127 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUseLocation() {
|
||||
setLocating(true);
|
||||
async function handleAvatarUpload(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const pos = await getUserPosition();
|
||||
setLatitude(String(pos.lat));
|
||||
setLongitude(String(pos.lon));
|
||||
setFeedback({ type: 'ok', text: 'Ubicación obtenida — recuerda Guardar.' });
|
||||
} catch (err) {
|
||||
let msg = 'No se pudo obtener la ubicación';
|
||||
if (err && typeof err.code === 'number') {
|
||||
if (err.code === 1) msg = 'Permiso de ubicación denegado';
|
||||
else if (err.code === 2) msg = 'Ubicación no disponible';
|
||||
else if (err.code === 3) msg = 'La solicitud expiró';
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result;
|
||||
setAvatarUrl(base64);
|
||||
try {
|
||||
const res = await fetch('/api/users/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ avatar_url: base64 }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
onProfileSaved?.(updated);
|
||||
setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' });
|
||||
}
|
||||
setFeedback({ type: 'err', text: msg });
|
||||
} finally {
|
||||
setLocating(false);
|
||||
} 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 handleGeocode() {
|
||||
const q = address.trim();
|
||||
if (!q) {
|
||||
setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' });
|
||||
return;
|
||||
}
|
||||
setGeocoding(true);
|
||||
setFeedback(null);
|
||||
async function handleDeleteSearch(id) {
|
||||
try {
|
||||
const res = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`, {
|
||||
const res = await fetch(`/api/search-history/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Error en la búsqueda (HTTP ${res.status})`);
|
||||
if (res.ok) {
|
||||
setSearchHistory(prev => prev.filter(item => item.id !== id));
|
||||
}
|
||||
const data = await res.json();
|
||||
setLatitude(String(data.lat));
|
||||
setLongitude(String(data.lon));
|
||||
setFeedback({
|
||||
type: 'ok',
|
||||
text: `Ubicado: ${data.displayName} — recuerda Guardar.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' });
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
console.error('Error deleting search:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const username = currentUser?.username || 'Usuario';
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(' ') || currentUser?.username || 'Usuario';
|
||||
|
||||
return (
|
||||
<div className="profile-view">
|
||||
{/* Avatar Section */}
|
||||
<div className="profile-avatar-section">
|
||||
<div className="profile-avatar-circle">
|
||||
<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>
|
||||
<h2 className="profile-name">Mi Perfil</h2>
|
||||
</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">
|
||||
<p className="info-card-label">Nombre</p>
|
||||
<p className="info-card-value">{username}</p>
|
||||
<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">
|
||||
@@ -135,29 +194,25 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="profile-menu-item">
|
||||
<div className="menu-item-icon menu-item-icon--error">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 3H2C.9 3 0 3.9 0 5v14c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm6 12H2v-1c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1zm8-5h-5V9h5v4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="menu-item-label">Contactos de Emergencia</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" />
|
||||
{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>
|
||||
|
||||
<button className="profile-menu-item">
|
||||
<div className="menu-item-icon menu-item-icon--secondary">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 2h2.5v12H13V6zm-2 12H8.5V6H11v12zM4 6h2.5v12H4V6zm16 12h-2.5V6H20v12z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="menu-item-label">Configuración de Texto</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>
|
||||
)}
|
||||
|
||||
{currentUser?.is_admin && (
|
||||
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||
@@ -174,67 +229,6 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-location-section">
|
||||
<h3>Tu Ubicación</h3>
|
||||
<p className="profile-section-sub">Guarda tu dirección para ordenar por distancia sin pedir permiso cada vez.</p>
|
||||
<form onSubmit={handleSave} className="profile-form">
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-address">Dirección</label>
|
||||
<input
|
||||
id="profile-address"
|
||||
type="text"
|
||||
placeholder="Calle Mayor 1, Madrid"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
autoComplete="street-address"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-field-row">
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-lat">Latitud</label>
|
||||
<input
|
||||
id="profile-lat"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="40.4168"
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-lon">Longitud</label>
|
||||
<input
|
||||
id="profile-lon"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="-3.7038"
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-helper-actions">
|
||||
<button type="button" className="profile-btn-secondary" onClick={handleGeocode} disabled={geocoding || saving}>
|
||||
{geocoding ? 'Buscando…' : '📍 Buscar desde dirección'}
|
||||
</button>
|
||||
<button type="button" className="profile-btn-secondary" onClick={handleUseLocation} disabled={locating || saving}>
|
||||
{locating ? 'Localizando…' : '🛰️ Usar mi ubicación'}
|
||||
</button>
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user