feat: mobile profile with read-only fields and Configuración modal

This commit is contained in:
Antoni Nuñez Romeu
2026-07-07 01:38:38 +02:00
parent b899d9a297
commit c5417ff787
+262 -97
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -22,8 +22,16 @@ export default function ProfileScreen() {
const [lastName, setLastName] = useState(user?.last_name || ''); const [lastName, setLastName] = useState(user?.last_name || '');
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]); const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [saving, setSaving] = useState(false);
const [feedback, setFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); // Config modal state
const [showConfig, setShowConfig] = useState(false);
const [configFirstName, setConfigFirstName] = useState('');
const [configLastName, setConfigLastName] = useState('');
const [configEmail, setConfigEmail] = useState('');
const [configCity, setConfigCity] = useState('');
const [configAddress, setConfigAddress] = useState('');
const [configSaving, setConfigSaving] = useState(false);
const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
@@ -49,29 +57,45 @@ export default function ProfileScreen() {
} }
} }
async function handleSave() { function openConfig() {
setSaving(true); setConfigFirstName(user?.first_name || '');
setFeedback(null); setConfigLastName(user?.last_name || '');
setConfigEmail(user?.email || '');
setConfigCity(user?.city || '');
setConfigAddress(user?.address || '');
setConfigFeedback(null);
setShowConfig(true);
}
async function handleConfigSave() {
setConfigSaving(true);
setConfigFeedback(null);
try { try {
const res = await fetch('/api/users/me', { const res = await fetch('/api/users/me', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
first_name: firstName.trim() || null, first_name: configFirstName.trim() || null,
last_name: lastName.trim() || null, last_name: configLastName.trim() || null,
avatar_url: avatarUrl || null, email: configEmail.trim() || null,
city: configCity.trim() || null,
address: configAddress.trim() || null,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Error al guardar'); throw new Error(err.error || 'Error al guardar');
} }
setFeedback({ type: 'ok', text: 'Perfil guardado.' }); const updated = await res.json();
setFirstName(updated.first_name || '');
setLastName(updated.last_name || '');
setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' });
setTimeout(() => setShowConfig(false), 1200);
} catch (err: any) { } catch (err: any) {
setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' });
} finally { } finally {
setSaving(false); setConfigSaving(false);
} }
} }
@@ -93,10 +117,11 @@ export default function ProfileScreen() {
body: JSON.stringify({ avatar_url: result.assets[0].uri }), body: JSON.stringify({ avatar_url: result.assets[0].uri }),
}); });
if (res.ok) { if (res.ok) {
setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); const updated = await res.json();
// Trigger auth refresh to update user state
} }
} catch (err) { } catch (err) {
setFeedback({ type: 'err', text: 'Error al guardar la foto' }); console.error('Error saving avatar:', err);
} }
} }
} }
@@ -184,50 +209,26 @@ export default function ProfileScreen() {
<Text style={styles.editAvatarText}>Toca para cambiar foto</Text> <Text style={styles.editAvatarText}>Toca para cambiar foto</Text>
</View> </View>
{/* Editable Fields */} {/* Read-only Name Section */}
<View style={styles.formSection}> <View style={styles.infoSection}>
<View style={styles.inputGroup}> <View style={styles.infoCard}>
<Text style={styles.label}>Nombre</Text> <Text style={styles.infoLabel}>Nombre</Text>
<TextInput <Text style={styles.infoValue}>{firstName || '—'}</Text>
style={styles.input}
value={firstName}
onChangeText={setFirstName}
placeholder="Tu nombre"
editable={!saving}
/>
</View> </View>
<View style={styles.inputGroup}> <View style={styles.infoCard}>
<Text style={styles.label}>Apellidos</Text> <Text style={styles.infoLabel}>Apellidos</Text>
<TextInput <Text style={styles.infoValue}>{lastName || '—'}</Text>
style={styles.input}
value={lastName}
onChangeText={setLastName}
placeholder="Tus apellidos"
editable={!saving}
/>
</View> </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> </View>
{/* Menu Section */} {/* Menu Section */}
<View style={styles.menuSection}> <View style={styles.menuSection}>
<TouchableOpacity style={styles.menuItem} onPress={openConfig}>
<Ionicons name="settings" size={24} color={colors.textSecondary} />
<Text style={styles.menuText}>Configuración</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.menuItem}> <View style={styles.menuItem}>
<Ionicons name="location" size={24} color={colors.primary} /> <Ionicons name="location" size={24} color={colors.primary} />
<Text style={styles.menuText}>Mis Direcciones</Text> <Text style={styles.menuText}>Mis Direcciones</Text>
@@ -265,6 +266,101 @@ export default function ProfileScreen() {
<Ionicons name="log-out" size={20} color={colors.danger} /> <Ionicons name="log-out" size={20} color={colors.danger} />
<Text style={styles.logoutText}>Cerrar Sesión</Text> <Text style={styles.logoutText}>Cerrar Sesión</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Config Modal */}
<Modal visible={showConfig} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Configuración</Text>
<TouchableOpacity onPress={() => setShowConfig(false)}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody}>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Nombre</Text>
<TextInput
style={styles.modalInput}
value={configFirstName}
onChangeText={setConfigFirstName}
placeholder="Tu nombre"
editable={!configSaving}
/>
</View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Apellidos</Text>
<TextInput
style={styles.modalInput}
value={configLastName}
onChangeText={setConfigLastName}
placeholder="Tus apellidos"
editable={!configSaving}
/>
</View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Correo electrónico</Text>
<TextInput
style={styles.modalInput}
value={configEmail}
onChangeText={setConfigEmail}
placeholder="tu@email.com"
keyboardType="email-address"
autoCapitalize="none"
editable={!configSaving}
/>
</View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Ciudad</Text>
<TextInput
style={styles.modalInput}
value={configCity}
onChangeText={setConfigCity}
placeholder="Tu ciudad"
editable={!configSaving}
/>
</View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Dirección</Text>
<TextInput
style={styles.modalInput}
value={configAddress}
onChangeText={setConfigAddress}
placeholder="Calle Mayor 1, Madrid"
editable={!configSaving}
/>
</View>
{configFeedback && (
<View style={[styles.modalFeedback, configFeedback.type === 'ok' ? styles.modalFeedbackOk : styles.modalFeedbackErr]}>
<Text style={styles.modalFeedbackText}>{configFeedback.text}</Text>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.modalCancelBtn}
onPress={() => setShowConfig(false)}
disabled={configSaving}
>
<Text style={styles.modalCancelText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalSaveBtn, configSaving && styles.modalSaveBtnDisabled]}
onPress={handleConfigSave}
disabled={configSaving}
>
{configSaving ? (
<ActivityIndicator color={colors.textInverse} />
) : (
<Text style={styles.modalSaveText}>Guardar</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</Modal>
</ScrollView> </ScrollView>
); );
} }
@@ -333,59 +429,31 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
fontSize: 14, fontSize: 14,
}, },
formSection: { infoSection: {
padding: spacing.xl, padding: spacing.xl,
backgroundColor: colors.card, backgroundColor: colors.card,
marginTop: spacing.md, marginTop: spacing.md,
gap: spacing.md,
}, },
inputGroup: { infoCard: {
marginBottom: spacing.md, backgroundColor: colors.background,
}, padding: spacing.md,
label: { borderRadius: borderRadius.md,
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.xs,
},
input: {
borderWidth: 1, borderWidth: 1,
borderColor: colors.border || '#E0E0E0', borderColor: colors.border || '#E0E0E0',
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
}, },
feedback: { infoLabel: {
padding: spacing.md, fontSize: 12,
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', fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.05,
marginBottom: 4,
},
infoValue: {
fontSize: 16,
fontWeight: '500',
color: colors.text,
}, },
menuSection: { menuSection: {
marginTop: spacing.md, marginTop: spacing.md,
@@ -466,4 +534,101 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '500', fontWeight: '500',
}, },
// Modal styles
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.card,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
color: colors.text,
},
modalBody: {
padding: spacing.lg,
},
modalField: {
marginBottom: spacing.md,
},
modalLabel: {
fontSize: 13,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.xs,
},
modalInput: {
borderWidth: 1,
borderColor: colors.border || '#E0E0E0',
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: 16,
color: colors.text,
},
modalFeedback: {
padding: spacing.md,
borderRadius: borderRadius.md,
marginBottom: spacing.md,
},
modalFeedbackOk: {
backgroundColor: 'rgba(0, 69, 13, 0.1)',
borderWidth: 1,
borderColor: 'rgba(0, 69, 13, 0.2)',
},
modalFeedbackErr: {
backgroundColor: 'rgba(186, 26, 26, 0.1)',
borderWidth: 1,
borderColor: 'rgba(186, 26, 26, 0.2)',
},
modalFeedbackText: {
fontSize: 14,
color: colors.text,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.md,
marginTop: spacing.md,
},
modalCancelBtn: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border || '#E0E0E0',
},
modalCancelText: {
fontSize: 16,
color: colors.text,
},
modalSaveBtn: {
backgroundColor: colors.primary,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
borderRadius: borderRadius.md,
minWidth: 100,
alignItems: 'center',
},
modalSaveBtnDisabled: {
opacity: 0.6,
},
modalSaveText: {
color: colors.textInverse,
fontSize: 16,
fontWeight: '600',
},
}); });