feat: avatar popup options, profile redesign, and search dosage parsing #13

Merged
Ichitux merged 1 commits from feature/avatar-popup-options into main 2026-07-07 10:14:02 +00:00
34 changed files with 1625 additions and 60 deletions
Showing only changes of commit 4404db62ee - Show all commits
+51 -15
View File
@@ -2,22 +2,56 @@ import axios from 'axios';
import redisClient from './redis-client.js';
const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest';
const CACHE_TTL = 3600; // 1 hora en segundos
const CACHE_TTL = 3600;
/**
* CIMA's nombre filter is prefix-oriented; narrow to rows that contain every
* search term in the commercial name or active ingredient (full-word style).
* Separa los términos de dosis (ej: "1g", "1000 mg") del nombre del
* medicamento, para poder buscar en CIMA solo con el nombre y luego
* filtrar por dosis con mayor precisión.
*/
function parseSearchQuery(query) {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const dosagePattern = /^\d+(g|mg|mcg|µg|ml|ui|%)$/i;
const dosageTerms = [];
const nameTerms = [];
for (const term of terms) {
const clean = term.replace(/\s+/g, '');
if (dosagePattern.test(clean)) {
dosageTerms.push(clean);
} else {
nameTerms.push(term);
}
}
return { nameQuery: nameTerms.join(' '), dosageTerms };
}
/**
* Filtra los resultados de CIMA comprobando que todos los términos del
* nombre aparezcan en el nombre comercial o principio activo, y que los
* términos de dosis coincidan con el campo dosage normalizado.
*/
function filterMedicinesByFullQuery(medicines, searchTerm) {
const terms = searchTerm
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (terms.length === 0) return medicines;
const parsed = parseSearchQuery(searchTerm);
const nameTerms = parsed.nameQuery.split(/\s+/).filter(Boolean);
const dosageTerms = parsed.dosageTerms;
if (nameTerms.length === 0 && dosageTerms.length === 0) return medicines;
return medicines.filter((m) => {
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
return terms.every((term) => hay.includes(term));
if (nameTerms.length > 0) {
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
if (!nameTerms.every((term) => hay.includes(term))) return false;
}
if (dosageTerms.length > 0) {
const dosageHay = (m.dosage || '').toLowerCase().replace(/\s+/g, '');
if (!dosageTerms.every((term) => dosageHay.includes(term))) return false;
}
return true;
});
}
@@ -32,7 +66,7 @@ export async function searchMedicines(query) {
}
const searchTerm = query.trim().toLowerCase();
const cacheKey = `medicines:search:v2:${searchTerm}`;
const cacheKey = `medicines:search:v3:${searchTerm}`;
try {
// Intentar obtener del caché
@@ -43,11 +77,13 @@ export async function searchMedicines(query) {
return JSON.parse(cachedData);
}
// Si no está en caché, consultar la API de CIMA
console.log(`🌐 Fetching from CIMA API: ${searchTerm}`);
const parsed = parseSearchQuery(searchTerm);
const apiSearchTerm = parsed.nameQuery || searchTerm;
console.log(`🌐 Fetching from CIMA API: ${apiSearchTerm}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
params: {
nombre: searchTerm
nombre: apiSearchTerm
},
timeout: 5000
});
+244 -27
View File
@@ -53,7 +53,7 @@ app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
app.use(express.json({ limit: '10mb' }));
app.use(pinoHttp({ logger }));
const PG_URL = process.env.PG_URL;
@@ -356,6 +356,36 @@ if (!pgPool) {
)
`);
}
// User addresses table
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS user_addresses (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
address TEXT NOT NULL,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS user_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
} catch (err) {
console.error('initDatabase failed:', err);
throw err;
@@ -787,7 +817,7 @@ app.get('/api/auth/check', async (req, res) => {
try {
if (req.session && req.session.userId) {
const user = await userDbGet(
'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?',
'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);
if (!user) {
@@ -802,6 +832,8 @@ app.get('/api/auth/check', async (req, res) => {
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
email: user.email || null,
city: user.city || null,
is_admin: Boolean(user.is_admin),
address: user.address || null,
latitude: user.latitude,
@@ -846,38 +878,76 @@ app.get('/api/users/me', requireAuth, async (req, res) => {
// Update the current user's address + coordinates
app.put('/api/users/me', requireAuth, async (req, res) => {
try {
const { first_name, last_name, avatar_url, email, city, address, latitude, longitude } = req.body || {};
const body = req.body || {};
const sets = [];
const vals = [];
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);
const userEmail = email == null ? null : String(email).trim().slice(0, 200);
const userCity = city == null ? null : String(city).trim().slice(0, 200);
const addr =
address == null || String(address).trim() === ''
if ('first_name' in body) {
const v = body.first_name == null ? null : String(body.first_name).trim().slice(0, 100);
sets.push('first_name = ?');
vals.push(v);
}
if ('last_name' in body) {
const v = body.last_name == null ? null : String(body.last_name).trim().slice(0, 100);
sets.push('last_name = ?');
vals.push(v);
}
if ('avatar_url' in body) {
const v = body.avatar_url == null ? null : String(body.avatar_url);
sets.push('avatar_url = ?');
vals.push(v);
}
if ('email' in body) {
const v = body.email == null ? null : String(body.email).trim().slice(0, 200);
sets.push('email = ?');
vals.push(v);
}
if ('city' in body) {
const v = body.city == null ? null : String(body.city).trim().slice(0, 200);
sets.push('city = ?');
vals.push(v);
}
if ('address' in body) {
const v = body.address == null || String(body.address).trim() === ''
? null
: String(address).trim().slice(0, 500);
let lat = null;
let lon = null;
if (latitude !== '' && latitude != null) {
lat = typeof latitude === 'number' ? latitude : parseFloat(latitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
: String(body.address).trim().slice(0, 500);
sets.push('address = ?');
vals.push(v);
}
if ('latitude' in body) {
if (body.latitude !== '' && body.latitude != null) {
const lat = typeof body.latitude === 'number' ? body.latitude : parseFloat(body.latitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
}
sets.push('latitude = ?');
vals.push(lat);
} else {
sets.push('latitude = ?');
vals.push(null);
}
}
if (longitude !== '' && longitude != null) {
lon = typeof longitude === 'number' ? longitude : parseFloat(longitude);
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
if ('longitude' in body) {
if (body.longitude !== '' && body.longitude != null) {
const lon = typeof body.longitude === 'number' ? body.longitude : parseFloat(body.longitude);
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
}
sets.push('longitude = ?');
vals.push(lon);
} else {
sets.push('longitude = ?');
vals.push(null);
}
}
await userDbRun(
'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, email = ?, city = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?',
[fName, lName, avatar, userEmail, userCity, addr, lat, lon, req.session.userId]
);
if (sets.length > 0) {
vals.push(req.session.userId);
await userDbRun(
'UPDATE users SET ' + sets.join(', ') + ' WHERE id = ?',
vals
);
}
const user = await userDbGet(
'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?',
@@ -1038,6 +1108,153 @@ app.delete('/api/search-history/:id', requireAuth, async (req, res) => {
}
});
// ========== USER ADDRESSES API ==========
// List all addresses for the current user
app.get('/api/addresses', requireAuth, async (req, res) => {
try {
const rows = await userDbAll(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE user_id = ? ORDER BY is_default DESC, created_at ASC',
[req.session.userId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching addresses:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a new address
app.post('/api/addresses', requireAuth, async (req, res) => {
try {
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
const result = await userDbRun(
'INSERT INTO user_addresses (user_id, address, latitude, longitude, label, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[req.session.userId, address, latitude || null, longitude || null, label || '', is_default ? 1 : 0]
);
const newAddress = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[result.lastID]
);
res.status(201).json(newAddress);
} catch (error) {
console.error('Error adding address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update an address
app.put('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
await userDbRun(
'UPDATE user_addresses SET address = ?, latitude = ?, longitude = ?, label = ?, is_default = ? WHERE id = ? AND user_id = ?',
[address, latitude || null, longitude || null, label || '', is_default ? 1 : 0, addressId, req.session.userId]
);
const updated = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[addressId]
);
if (!updated) {
return res.status(404).json({ error: 'Address not found' });
}
res.json(updated);
} catch (error) {
console.error('Error updating address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Set address as default
app.put('/api/addresses/:id/default', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const existing = await userDbGet(
'SELECT id FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
if (!existing) {
return res.status(404).json({ error: 'Address not found' });
}
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
await userDbRun(
'UPDATE user_addresses SET is_default = 1 WHERE id = ?',
[addressId]
);
res.json({ ok: true });
} catch (error) {
console.error('Error setting default address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete an address
app.delete('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const deleted = await userDbGet(
'SELECT is_default FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
await userDbRun(
'DELETE FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
// If the deleted address was default, set the oldest remaining as default
if (deleted?.is_default) {
const oldest = await userDbGet(
'SELECT id FROM user_addresses WHERE user_id = ? ORDER BY created_at ASC LIMIT 1',
[req.session.userId]
);
if (oldest) {
await userDbRun('UPDATE user_addresses SET is_default = 1 WHERE id = ?', [oldest.id]);
}
}
res.json({ ok: true });
} catch (error) {
console.error('Error deleting address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
const q = (req.query.q || '').trim();
+1 -1
View File
@@ -35,7 +35,7 @@
"scheme": "farmafinder",
"extra": {
"eas": {
"projectId": ""
"projectId": "9a424f20-1073-4477-93e1-6b302a1ae389"
}
}
}
+611 -4
View File
@@ -1,11 +1,29 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal, Pressable } 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';
const AVATARS = [
require('../../assets/avatars/avatar1.png'),
require('../../assets/avatars/avatar2.png'),
require('../../assets/avatars/avatar3.png'),
require('../../assets/avatars/avatar4.png'),
require('../../assets/avatars/avatar5.png'),
require('../../assets/avatars/avatar6.png'),
];
const COLOR_CIRCLES = [
require('../../assets/avatars/color1.png'),
require('../../assets/avatars/color2.png'),
require('../../assets/avatars/color3.png'),
require('../../assets/avatars/color4.png'),
require('../../assets/avatars/color5.png'),
require('../../assets/avatars/color6.png'),
];
interface SearchHistoryItem {
id: number;
address: string;
@@ -14,6 +32,16 @@ interface SearchHistoryItem {
created_at: string;
}
interface Address {
id: number;
address: string;
latitude: number | null;
longitude: number | null;
label: string;
is_default: number;
created_at: string;
}
export default function ProfileScreen() {
const router = useRouter();
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
@@ -21,8 +49,13 @@ export default function ProfileScreen() {
const [firstName, setFirstName] = useState(user?.first_name || '');
const [lastName, setLastName] = useState(user?.last_name || '');
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
const [avatarLocalSource, setAvatarLocalSource] = useState<number | null>(null);
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
// Avatar modal state
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [avatarTab, setAvatarTab] = useState<'presets' | 'colors' | 'upload'>('presets');
// Config modal state
const [showConfig, setShowConfig] = useState(false);
const [configFirstName, setConfigFirstName] = useState('');
@@ -33,6 +66,18 @@ export default function ProfileScreen() {
const [configSaving, setConfigSaving] = useState(false);
const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
// Addresses modal state
const [showAddresses, setShowAddresses] = useState(false);
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressesLoading, setAddressesLoading] = useState(false);
const [showAddressForm, setShowAddressForm] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(null);
const [formAddress, setFormAddress] = useState('');
const [formLabel, setFormLabel] = useState('');
const [formDefault, setFormDefault] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [formError, setFormError] = useState('');
useEffect(() => {
if (isAuthenticated) {
loadSearchHistory();
@@ -109,6 +154,7 @@ export default function ProfileScreen() {
if (!result.canceled && result.assets[0]) {
setAvatarUrl(result.assets[0].uri);
setShowAvatarModal(false);
try {
const res = await fetch('/api/users/me', {
method: 'PUT',
@@ -126,6 +172,76 @@ export default function ProfileScreen() {
}
}
async function handleTakePhoto() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permiso requerido', 'Necesitamos permiso para acceder a la cámara.');
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setAvatarUrl(result.assets[0].uri);
setShowAvatarModal(false);
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) {
const updated = await res.json();
}
} catch (err) {
console.error('Error saving avatar:', err);
}
}
}
async function handleSelectPresetAvatar(index: number) {
const avatarSource = AVATARS[index];
setAvatarUrl(avatarSource);
setShowAvatarModal(false);
try {
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ avatar_url: `preset_avatar_${index + 1}` }),
});
if (res.ok) {
const updated = await res.json();
}
} catch (err) {
console.error('Error saving avatar:', err);
}
}
async function handleSelectColor(index: number) {
const colorSource = COLOR_CIRCLES[index];
setAvatarUrl(colorSource);
setShowAvatarModal(false);
try {
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ avatar_url: `color_circle_${index + 1}` }),
});
if (res.ok) {
const updated = await res.json();
}
} catch (err) {
console.error('Error saving avatar:', err);
}
}
async function handleDeleteSearch(id: number) {
try {
const res = await fetch(`/api/search-history/${id}`, {
@@ -140,6 +256,108 @@ export default function ProfileScreen() {
}
}
// Addresses
async function loadAddresses() {
setAddressesLoading(true);
try {
const res = await fetch('/api/addresses', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAddresses(data);
}
} catch (err) {
console.error('Error loading addresses:', err);
} finally {
setAddressesLoading(false);
}
}
function openAddresses() {
setShowAddresses(true);
loadAddresses();
}
function openAddAddressForm() {
setEditingAddressId(null);
setFormAddress('');
setFormLabel('');
setFormDefault(addresses.length === 0);
setFormError('');
setShowAddressForm(true);
}
function openEditAddressForm(addr: Address) {
setEditingAddressId(addr.id);
setFormAddress(addr.address);
setFormLabel(addr.label || '');
setFormDefault(Boolean(addr.is_default));
setFormError('');
setShowAddressForm(true);
}
async function handleAddressSave() {
const addr = formAddress.trim();
if (!addr) {
setFormError('La dirección es obligatoria');
return;
}
setFormSaving(true);
setFormError('');
try {
const url = editingAddressId ? `/api/addresses/${editingAddressId}` : '/api/addresses';
const method = editingAddressId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
address: addr,
label: formLabel.trim(),
is_default: formDefault,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Error al guardar la dirección');
}
setShowAddressForm(false);
setEditingAddressId(null);
loadAddresses();
} catch (err: any) {
setFormError(err.message);
} finally {
setFormSaving(false);
}
}
async function handleDeleteAddress(id: number) {
try {
const res = await fetch(`/api/addresses/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) {
setAddresses(prev => prev.filter(a => a.id !== id));
}
} catch (err) {
console.error('Error deleting address:', err);
}
}
async function handleSetDefault(id: number) {
try {
const res = await fetch(`/api/addresses/${id}/default`, {
method: 'PUT',
credentials: 'include',
});
if (res.ok) {
loadAddresses();
}
} catch (err) {
console.error('Error setting default address:', err);
}
}
const handleLogout = async () => {
Alert.alert(
'Cerrar Sesión',
@@ -196,7 +414,7 @@ export default function ProfileScreen() {
<ScrollView style={styles.container}>
{/* Avatar Section */}
<View style={styles.avatarSection}>
<TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
<TouchableOpacity style={styles.avatarContainer} onPress={() => setShowAvatarModal(true)}>
{avatarUrl ? (
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
) : (
@@ -229,11 +447,11 @@ export default function ProfileScreen() {
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.menuItem}>
<TouchableOpacity style={styles.menuItem} onPress={openAddresses}>
<Ionicons name="location" size={24} color={colors.primary} />
<Text style={styles.menuText}>Mis Direcciones</Text>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
{searchHistory.length > 0 && (
<View style={styles.searchHistorySection}>
@@ -267,6 +485,95 @@ export default function ProfileScreen() {
<Text style={styles.logoutText}>Cerrar Sesión</Text>
</TouchableOpacity>
{/* Avatar Selection Modal */}
<Modal visible={showAvatarModal} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={styles.avatarModalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Cambiar Avatar</Text>
<TouchableOpacity onPress={() => setShowAvatarModal(false)}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Tab Buttons */}
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tabButton, avatarTab === 'presets' && styles.tabButtonActive]}
onPress={() => setAvatarTab('presets')}
>
<Ionicons name="person" size={20} color={avatarTab === 'presets' ? colors.primary : colors.textSecondary} />
<Text style={[styles.tabText, avatarTab === 'presets' && styles.tabTextActive]}>Prediseñado</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tabButton, avatarTab === 'colors' && styles.tabButtonActive]}
onPress={() => setAvatarTab('colors')}
>
<Ionicons name="color-palette" size={20} color={avatarTab === 'colors' ? colors.primary : colors.textSecondary} />
<Text style={[styles.tabText, avatarTab === 'colors' && styles.tabTextActive]}>Colores</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tabButton, avatarTab === 'upload' && styles.tabButtonActive]}
onPress={() => setAvatarTab('upload')}
>
<Ionicons name="cloud-upload" size={20} color={avatarTab === 'upload' ? colors.primary : colors.textSecondary} />
<Text style={[styles.tabText, avatarTab === 'upload' && styles.tabTextActive]}>Subir</Text>
</TouchableOpacity>
</View>
{/* Tab Content */}
<ScrollView style={styles.tabContent}>
{avatarTab === 'presets' && (
<View style={styles.avatarGrid}>
{AVATARS.map((avatar, index) => (
<TouchableOpacity
key={index}
style={styles.avatarOption}
onPress={() => handleSelectPresetAvatar(index)}
>
<Image source={avatar} style={styles.avatarOptionImage} />
</TouchableOpacity>
))}
</View>
)}
{avatarTab === 'colors' && (
<View style={styles.avatarGrid}>
{COLOR_CIRCLES.map((color, index) => (
<TouchableOpacity
key={index}
style={styles.avatarOption}
onPress={() => handleSelectColor(index)}
>
<Image source={color} style={styles.avatarOptionImage} />
</TouchableOpacity>
))}
</View>
)}
{avatarTab === 'upload' && (
<View style={styles.uploadSection}>
<TouchableOpacity style={styles.uploadOption} onPress={handleTakePhoto}>
<View style={styles.uploadIconContainer}>
<Ionicons name="camera" size={32} color={colors.primary} />
</View>
<Text style={styles.uploadOptionTitle}>Tomar foto</Text>
<Text style={styles.uploadOptionSubtitle}>Usa la cámara de tu dispositivo</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.uploadOption} onPress={handlePickImage}>
<View style={styles.uploadIconContainer}>
<Ionicons name="images" size={32} color={colors.primary} />
</View>
<Text style={styles.uploadOptionTitle}>Elegir de galería</Text>
<Text style={styles.uploadOptionSubtitle}>Selecciona una imagen existente</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
{/* Config Modal */}
<Modal visible={showConfig} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
@@ -361,6 +668,126 @@ export default function ProfileScreen() {
</View>
</View>
</Modal>
{/* Addresses Modal */}
<Modal visible={showAddresses} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Mis Direcciones</Text>
<TouchableOpacity onPress={() => { setShowAddresses(false); setShowAddressForm(false); }}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody}>
{showAddressForm ? (
<View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Dirección</Text>
<TextInput
style={styles.modalInput}
value={formAddress}
onChangeText={setFormAddress}
placeholder="Calle Mayor 1, Madrid"
editable={!formSaving}
/>
</View>
<View style={styles.modalField}>
<Text style={styles.modalLabel}>Etiqueta (opcional)</Text>
<TextInput
style={styles.modalInput}
value={formLabel}
onChangeText={setFormLabel}
placeholder="Ej: Casa, Trabajo, Segunda residencia"
editable={!formSaving}
/>
</View>
<TouchableOpacity
style={styles.addressDefaultCheckbox}
onPress={() => setFormDefault(!formDefault)}
disabled={formSaving}
>
<Ionicons
name={formDefault ? 'checkbox' : 'square-outline'}
size={22}
color={formDefault ? colors.primary : colors.textSecondary}
/>
<Text style={styles.addressDefaultLabel}>Dirección predeterminada</Text>
</TouchableOpacity>
{formError ? (
<View style={[styles.modalFeedback, styles.modalFeedbackErr]}>
<Text style={styles.modalFeedbackText}>{formError}</Text>
</View>
) : null}
<View style={styles.modalActions}>
<TouchableOpacity
style={styles.modalCancelBtn}
onPress={() => { setShowAddressForm(false); setFormError(''); }}
disabled={formSaving}
>
<Text style={styles.modalCancelText}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalSaveBtn, formSaving && styles.modalSaveBtnDisabled]}
onPress={handleAddressSave}
disabled={formSaving}
>
{formSaving ? (
<ActivityIndicator color={colors.textInverse} />
) : (
<Text style={styles.modalSaveText}>{editingAddressId ? 'Actualizar' : 'Añadir'}</Text>
)}
</TouchableOpacity>
</View>
</View>
) : (
<View>
{addressesLoading ? (
<Text style={styles.addressLoadingText}>Cargando direcciones...</Text>
) : (
<View style={styles.addressList}>
{user?.address ? (
<View style={[styles.addressItem, styles.addressItemDefault]}>
<View style={styles.addressItemInfo}>
<Text style={styles.addressLabel}>Dirección principal</Text>
<Text style={styles.addressText}>{user.address}</Text>
<View style={styles.addressBadge}>
<Text style={styles.addressBadgeText}>Predeterminada</Text>
</View>
</View>
</View>
) : null}
{addresses.map((addr) => (
<View key={addr.id} style={styles.addressItem}>
<View style={styles.addressItemInfo}>
{addr.label ? <Text style={styles.addressLabel}>{addr.label}</Text> : null}
<Text style={styles.addressText}>{addr.address}</Text>
<TouchableOpacity onPress={() => handleSetDefault(addr.id)}>
<Text style={styles.addressSetDefault}>Establecer como predeterminada</Text>
</TouchableOpacity>
</View>
<View style={styles.addressItemActions}>
<TouchableOpacity style={styles.addressActionBtn} onPress={() => openEditAddressForm(addr)}>
<Ionicons name="pencil" size={18} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.addressActionBtn} onPress={() => handleDeleteAddress(addr.id)}>
<Ionicons name="close" size={18} color={colors.danger} />
</TouchableOpacity>
</View>
</View>
))}
</View>
)}
<TouchableOpacity style={styles.addressAddBtn} onPress={openAddAddressForm}>
<Ionicons name="add" size={22} color={colors.primary} />
<Text style={styles.addressAddBtnText}>Añadir más</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
</ScrollView>
);
}
@@ -631,4 +1058,184 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
// Avatar Modal styles
avatarModalContent: {
backgroundColor: colors.card,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '80%',
},
tabContainer: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: colors.separator,
},
tabButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
gap: spacing.xs,
},
tabButtonActive: {
borderBottomWidth: 2,
borderBottomColor: colors.primary,
},
tabText: {
fontSize: 14,
color: colors.textSecondary,
},
tabTextActive: {
color: colors.primary,
fontWeight: '600',
},
tabContent: {
padding: spacing.lg,
},
avatarGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: spacing.md,
},
avatarOption: {
width: '30%',
aspectRatio: 1,
},
avatarOptionImage: {
width: '100%',
height: '100%',
borderRadius: borderRadius.lg,
},
uploadSection: {
gap: spacing.md,
},
uploadOption: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
backgroundColor: colors.background,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border || '#E5E5EA',
},
uploadIconContainer: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.card,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
uploadOptionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
flex: 1,
},
uploadOptionSubtitle: {
fontSize: 13,
color: colors.textSecondary,
flex: 1,
},
// Addresses
addressLoadingText: {
textAlign: 'center',
color: colors.textSecondary,
fontSize: 14,
paddingVertical: spacing.lg,
},
addressList: {
gap: spacing.sm,
},
addressItem: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
padding: spacing.md,
backgroundColor: colors.background,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border || '#E0E0E0',
},
addressItemDefault: {
borderColor: colors.primary,
backgroundColor: 'rgba(0, 69, 13, 0.03)',
},
addressItemInfo: {
flex: 1,
gap: 2,
},
addressLabel: {
fontSize: 13,
fontWeight: '700',
color: colors.primary,
textTransform: 'uppercase',
},
addressText: {
fontSize: 14,
color: colors.text,
},
addressBadge: {
alignSelf: 'flex-start',
backgroundColor: colors.primary,
borderRadius: 12,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
marginTop: 4,
},
addressBadgeText: {
color: colors.textInverse,
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
},
addressSetDefault: {
fontSize: 12,
fontWeight: '600',
color: colors.primary,
marginTop: 4,
textDecorationLine: 'underline',
},
addressItemActions: {
flexDirection: 'row',
gap: spacing.xs,
marginLeft: spacing.sm,
},
addressActionBtn: {
width: 32,
height: 32,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
addressAddBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
padding: spacing.md,
marginTop: spacing.md,
borderWidth: 2,
borderColor: colors.border || '#E0E0E0',
borderStyle: 'dashed',
borderRadius: borderRadius.md,
},
addressAddBtnText: {
fontSize: 15,
fontWeight: '600',
color: colors.primary,
},
addressDefaultCheckbox: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
addressDefaultLabel: {
fontSize: 14,
color: colors.text,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+4 -1
View File
@@ -17,6 +17,7 @@ function App() {
const [showLogin, setShowLogin] = useState(false);
const [showSaved, setShowSaved] = useState(false);
const [badgeCount, setBadgeCount] = useState(0);
const [prescriptionSearch, setPrescriptionSearch] = useState('');
const [screenSize, setScreenSize] = useState({
width: window.innerWidth,
height: window.innerHeight
@@ -131,6 +132,7 @@ function App() {
<SearchView
currentUser={currentUser}
onLoginRequest={() => setShowLogin(true)}
initialQuery={prescriptionSearch}
/>
);
break;
@@ -139,7 +141,8 @@ function App() {
<ScannerView
onClose={() => setScreen('home')}
onSelectMedicine={(name) => {
setScreen('home');
setPrescriptionSearch(name);
setScreen('search');
}}
/>
);
@@ -3,12 +3,18 @@
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
margin-top: 0.5rem;
max-height: 60vh;
max-height: 70vh;
overflow-y: auto;
overscroll-behavior: contain;
animation: fadeInUp 0.5s ease-out;
}
@media (min-width: 1024px) {
.medicine-results {
max-height: 76vh;
}
}
.medicine-card {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
+285
View File
@@ -560,3 +560,288 @@
.profile-btn-cancel:disabled {
opacity: 0.6;
}
/* Avatar Modal */
.profile-modal-avatar {
max-width: 32rem;
}
.profile-avatar-tabs {
display: flex;
border-bottom: 1px solid var(--outline-variant);
}
.profile-avatar-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.85rem 0.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
color: var(--on-surface-variant);
font-family: inherit;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.profile-avatar-tab:hover {
color: var(--on-surface);
}
.profile-avatar-tab--active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.profile-avatar-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.profile-avatar-option {
width: 100%;
aspect-ratio: 1;
border: 2px solid var(--outline-variant);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
padding: 0;
background: none;
transition: border-color 0.15s, transform 0.15s;
}
.profile-avatar-option:hover {
border-color: var(--primary);
transform: scale(1.03);
}
.profile-avatar-option-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.profile-avatar-upload {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.profile-avatar-upload-option {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 1rem;
background: var(--surface);
border: 2px solid var(--outline-variant);
border-radius: var(--radius);
cursor: pointer;
font-family: inherit;
text-align: left;
transition: border-color 0.15s;
}
.profile-avatar-upload-option:hover {
border-color: var(--primary);
}
.profile-avatar-upload-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--surface-container-low);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
flex-shrink: 0;
}
.profile-avatar-upload-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--on-surface);
}
.profile-avatar-upload-sub {
margin: 0.2rem 0 0;
font-size: 0.85rem;
color: var(--on-surface-variant);
}
/* Addresses Modal */
.profile-modal-addresses {
max-width: 32rem;
}
.profile-address-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-address-default-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--on-surface);
cursor: pointer;
}
.profile-address-default-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--primary);
cursor: pointer;
}
.profile-address-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-address-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
background: var(--surface);
border: 2px solid var(--outline-variant);
border-radius: var(--radius);
transition: border-color 0.15s;
}
.profile-address-item--default {
border-color: var(--primary);
background: rgba(0, 69, 13, 0.03);
}
.profile-address-item-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.profile-address-label {
font-size: 0.85rem;
font-weight: 700;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.profile-address-text {
font-size: 0.95rem;
color: var(--on-surface);
word-break: break-word;
}
.profile-address-badge {
align-self: flex-start;
font-size: 0.7rem;
font-weight: 700;
color: var(--on-primary);
background: var(--primary);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full);
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.profile-address-set-default {
align-self: flex-start;
background: none;
border: none;
font-size: 0.75rem;
font-weight: 600;
color: var(--secondary);
cursor: pointer;
padding: 0;
font-family: inherit;
margin-top: 0.25rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.profile-address-set-default:hover {
color: var(--on-secondary-container);
}
.profile-address-item-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.profile-address-btn-icon {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius);
cursor: pointer;
color: var(--on-surface-variant);
transition: background 0.15s, color 0.15s;
}
.profile-address-btn-icon:hover {
background: var(--surface-container);
color: var(--on-surface);
}
.profile-address-btn-icon--delete:hover {
background: rgba(186, 26, 26, 0.1);
color: var(--error);
}
.profile-address-add-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.85rem;
margin-top: 0.5rem;
background: var(--surface);
border: 2px dashed var(--outline-variant);
border-radius: var(--radius);
cursor: pointer;
color: var(--primary);
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
}
.profile-address-add-btn:hover {
background: rgba(0, 69, 13, 0.04);
border-color: var(--primary);
}
@media (max-width: 480px) {
.profile-address-item {
flex-direction: column;
}
.profile-address-item-actions {
align-self: flex-end;
}
}
+405 -7
View File
@@ -1,14 +1,52 @@
import React, { useEffect, useState, useRef } from 'react';
import './ProfileView.css';
const AVATARS = [
'/avatars/avatar1.png',
'/avatars/avatar2.png',
'/avatars/avatar3.png',
'/avatars/avatar4.png',
'/avatars/avatar5.png',
'/avatars/avatar6.png',
];
const COLOR_CIRCLES = [
'/avatars/color1.png',
'/avatars/color2.png',
'/avatars/color3.png',
'/avatars/color4.png',
'/avatars/color5.png',
'/avatars/color6.png',
];
function resolveAvatarUrl(url) {
if (!url) return '';
const matchPreset = url.match(/^preset_avatar_(\d+)$/);
if (matchPreset) {
const idx = parseInt(matchPreset[1], 10);
if (idx >= 1 && idx <= 6) return AVATARS[idx - 1];
}
const matchColor = url.match(/^color_circle_(\d+)$/);
if (matchColor) {
const idx = parseInt(matchColor[1], 10);
if (idx >= 1 && idx <= 6) return COLOR_CIRCLES[idx - 1];
}
return url;
}
function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
const [firstName, setFirstName] = useState(currentUser?.first_name || '');
const [lastName, setLastName] = useState(currentUser?.last_name || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || '');
const [avatarUrl, setAvatarUrl] = useState(resolveAvatarUrl(currentUser?.avatar_url));
const [searchHistory, setSearchHistory] = useState([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const fileInputRef = useRef(null);
// Avatar modal state
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [avatarTab, setAvatarTab] = useState('presets');
// Config modal state
const [showConfig, setShowConfig] = useState(false);
const [configFirstName, setConfigFirstName] = useState('');
@@ -19,10 +57,22 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
const [configSaving, setConfigSaving] = useState(false);
const [configFeedback, setConfigFeedback] = useState(null);
// Addresses modal state
const [showAddresses, setShowAddresses] = useState(false);
const [addresses, setAddresses] = useState([]);
const [addressesLoading, setAddressesLoading] = useState(false);
const [showAddressForm, setShowAddressForm] = useState(false);
const [editingAddressId, setEditingAddressId] = useState(null);
const [formAddress, setFormAddress] = useState('');
const [formLabel, setFormLabel] = useState('');
const [formDefault, setFormDefault] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [formError, setFormError] = useState('');
useEffect(() => {
setFirstName(currentUser?.first_name || '');
setLastName(currentUser?.last_name || '');
setAvatarUrl(currentUser?.avatar_url || '');
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
loadSearchHistory();
}, [currentUser?.id]);
@@ -48,6 +98,108 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
setShowConfig(true);
}
async function loadAddresses() {
setAddressesLoading(true);
try {
const res = await fetch('/api/addresses', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setAddresses(data);
}
} catch (err) {
console.error('Error loading addresses:', err);
} finally {
setAddressesLoading(false);
}
}
function openAddresses() {
setShowAddresses(true);
loadAddresses();
}
function openAddAddressForm() {
setEditingAddressId(null);
setFormAddress('');
setFormLabel('');
setFormDefault(addresses.length === 0);
setFormError('');
setShowAddressForm(true);
}
function openEditAddressForm(addr) {
setEditingAddressId(addr.id);
setFormAddress(addr.address);
setFormLabel(addr.label || '');
setFormDefault(Boolean(addr.is_default));
setFormError('');
setShowAddressForm(true);
}
async function handleAddressSave(e) {
e?.preventDefault();
const addr = formAddress.trim();
if (!addr) {
setFormError('La dirección es obligatoria');
return;
}
setFormSaving(true);
setFormError('');
try {
const url = editingAddressId ? `/api/addresses/${editingAddressId}` : '/api/addresses';
const method = editingAddressId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
address: addr,
label: formLabel.trim(),
is_default: formDefault,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || 'Error al guardar la dirección');
}
setShowAddressForm(false);
setEditingAddressId(null);
loadAddresses();
} catch (err) {
setFormError(err.message);
} finally {
setFormSaving(false);
}
}
async function handleDeleteAddress(id) {
try {
const res = await fetch(`/api/addresses/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (res.ok) {
setAddresses(prev => prev.filter(a => a.id !== id));
}
} catch (err) {
console.error('Error deleting address:', err);
}
}
async function handleSetDefault(id) {
try {
const res = await fetch(`/api/addresses/${id}/default`, {
method: 'PUT',
credentials: 'include',
});
if (res.ok) {
loadAddresses();
}
} catch (err) {
console.error('Error setting default address:', err);
}
}
async function handleConfigSave(e) {
e?.preventDefault();
setConfigSaving(true);
@@ -86,7 +238,16 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
const file = e.target.files?.[0];
if (!file) return;
setUploadError('');
setShowAvatarModal(false);
setUploading(true);
if (file.size > 5 * 1024 * 1024) {
setUploadError('La imagen no puede superar los 5 MB');
setUploading(false);
return;
}
try {
const reader = new FileReader();
reader.onload = async () => {
@@ -102,19 +263,67 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
if (res.ok) {
const updated = await res.json();
onProfileSaved?.(updated);
} else {
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
setUploadError('Error al guardar la imagen. Intenta con otra foto.');
}
} catch (err) {
console.error('Error saving avatar:', err);
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
setUploadError('Error de conexión al guardar la imagen.');
}
setUploading(false);
};
reader.readAsDataURL(file);
} catch (err) {
console.error('Error processing image:', err);
setUploadError('Error al procesar la imagen.');
setUploading(false);
}
}
async function handleSelectPresetAvatar(index) {
const url = AVATARS[index];
setAvatarUrl(url);
setShowAvatarModal(false);
try {
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ avatar_url: `preset_avatar_${index + 1}` }),
});
if (res.ok) {
const updated = await res.json();
onProfileSaved?.(updated);
}
} catch (err) {
console.error('Error saving avatar:', err);
}
}
async function handleSelectColor(index) {
const url = COLOR_CIRCLES[index];
setAvatarUrl(url);
setShowAvatarModal(false);
try {
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ avatar_url: `color_circle_${index + 1}` }),
});
if (res.ok) {
const updated = await res.json();
onProfileSaved?.(updated);
}
} catch (err) {
console.error('Error saving avatar:', err);
}
}
function handleGalleryUpload() {
fileInputRef.current?.click();
}
async function handleDeleteSearch(id) {
try {
const res = await fetch(`/api/search-history/${id}`, {
@@ -137,7 +346,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
<div className="profile-avatar-section">
<div
className="profile-avatar-circle profile-avatar-editable"
onClick={() => fileInputRef.current?.click()}
onClick={() => setShowAvatarModal(true)}
>
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="profile-avatar-image" />
@@ -160,6 +369,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
style={{ display: 'none' }}
/>
{uploading && <p className="profile-section-sub">Subiendo foto...</p>}
{uploadError && <p className="profile-feedback profile-feedback--err">{uploadError}</p>}
</div>
{/* Read-only Name Section */}
@@ -188,7 +398,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
</svg>
</button>
<div className="profile-menu-item">
<button className="profile-menu-item" onClick={openAddresses}>
<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" />
@@ -198,7 +408,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
<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>
</div>
</button>
{searchHistory.length > 0 && (
<div className="profile-search-history">
@@ -244,6 +454,90 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
<span>Cerrar Sesión</span>
</button>
{/* Avatar Modal */}
{showAvatarModal && (
<div className="profile-modal-backdrop" onClick={() => setShowAvatarModal(false)}>
<div className="profile-modal profile-modal-avatar" onClick={(e) => e.stopPropagation()}>
<div className="profile-modal-header">
<h3>Cambiar Avatar</h3>
<button className="profile-modal-close" onClick={() => setShowAvatarModal(false)}>×</button>
</div>
<div className="profile-avatar-tabs">
<button
className={`profile-avatar-tab ${avatarTab === 'presets' ? 'profile-avatar-tab--active' : ''}`}
onClick={() => setAvatarTab('presets')}
>
<svg width="18" height="18" 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>
<span>Prediseñado</span>
</button>
<button
className={`profile-avatar-tab ${avatarTab === 'colors' ? 'profile-avatar-tab--active' : ''}`}
onClick={() => setAvatarTab('colors')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.38-.61-.38-1.01 0-.83.67-1.5 1.5-1.5H16c2.76 0 5-2.24 5-5 0-4.42-4.03-8-9-8zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 9 6.5 9 8 9.67 8 10.5 7.33 12 6.5 12zm3-4C8.67 8 8 7.33 8 6.5S8.67 5 9.5 5s1.5.67 1.5 1.5S10.33 8 9.5 8zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 5 14.5 5s1.5.67 1.5 1.5S15.33 8 14.5 8zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 9 17.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z" />
</svg>
<span>Colores</span>
</button>
<button
className={`profile-avatar-tab ${avatarTab === 'upload' ? 'profile-avatar-tab--active' : ''}`}
onClick={() => setAvatarTab('upload')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z" />
</svg>
<span>Subir</span>
</button>
</div>
<div className="profile-modal-body">
{avatarTab === 'presets' && (
<div className="profile-avatar-grid">
{AVATARS.map((src, index) => (
<button
key={index}
className="profile-avatar-option"
onClick={() => handleSelectPresetAvatar(index)}
>
<img src={src} alt={`Avatar ${index + 1}`} className="profile-avatar-option-img" />
</button>
))}
</div>
)}
{avatarTab === 'colors' && (
<div className="profile-avatar-grid">
{COLOR_CIRCLES.map((src, index) => (
<button
key={index}
className="profile-avatar-option"
onClick={() => handleSelectColor(index)}
>
<img src={src} alt={`Color ${index + 1}`} className="profile-avatar-option-img" />
</button>
))}
</div>
)}
{avatarTab === 'upload' && (
<div className="profile-avatar-upload">
<button className="profile-avatar-upload-option" onClick={handleGalleryUpload}>
<div className="profile-avatar-upload-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
</svg>
</div>
<div>
<p className="profile-avatar-upload-title">Elegir de galería</p>
<p className="profile-avatar-upload-sub">Selecciona una imagen existente</p>
</div>
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Config Modal */}
{showConfig && (
<div className="profile-modal-backdrop" onClick={() => setShowConfig(false)}>
@@ -320,6 +614,110 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
</div>
</div>
)}
{/* Addresses Modal */}
{showAddresses && (
<div className="profile-modal-backdrop" onClick={() => { setShowAddresses(false); setShowAddressForm(false); }}>
<div className="profile-modal profile-modal-addresses" onClick={(e) => e.stopPropagation()}>
<div className="profile-modal-header">
<h3>Mis Direcciones</h3>
<button className="profile-modal-close" onClick={() => { setShowAddresses(false); setShowAddressForm(false); }}>×</button>
</div>
<div className="profile-modal-body">
{showAddressForm && (
<form onSubmit={handleAddressSave} className="profile-address-form">
<div className="profile-modal-field">
<label>Dirección</label>
<input
type="text"
value={formAddress}
onChange={(e) => setFormAddress(e.target.value)}
placeholder="Calle Mayor 1, Madrid"
disabled={formSaving}
/>
</div>
<div className="profile-modal-field">
<label>Etiqueta (opcional)</label>
<input
type="text"
value={formLabel}
onChange={(e) => setFormLabel(e.target.value)}
placeholder="Ej: Casa, Trabajo, Segunda residencia"
disabled={formSaving}
/>
</div>
<label className="profile-address-default-label">
<input
type="checkbox"
checked={formDefault}
onChange={(e) => setFormDefault(e.target.checked)}
disabled={formSaving}
/>
<span>Dirección predeterminada</span>
</label>
{formError && <p className="profile-feedback profile-feedback--err">{formError}</p>}
<div className="profile-modal-actions">
<button type="button" className="profile-btn-cancel" onClick={() => { setShowAddressForm(false); setFormError(''); }} disabled={formSaving}>
Cancelar
</button>
<button type="submit" className="profile-btn-primary" disabled={formSaving}>
{formSaving ? 'Guardando...' : editingAddressId ? 'Actualizar' : 'Añadir'}
</button>
</div>
</form>
)}
{!showAddressForm && (
<>
{addressesLoading ? (
<p className="profile-section-sub">Cargando direcciones...</p>
) : (
<div className="profile-address-list">
{currentUser?.address && (
<div className="profile-address-item profile-address-item--default">
<div className="profile-address-item-info">
<span className="profile-address-label">Dirección principal</span>
<span className="profile-address-text">{currentUser.address}</span>
<span className="profile-address-badge">Predeterminada</span>
</div>
</div>
)}
{addresses.map((addr) => (
<div key={addr.id} className="profile-address-item">
<div className="profile-address-item-info">
{addr.label && <span className="profile-address-label">{addr.label}</span>}
<span className="profile-address-text">{addr.address}</span>
<button className="profile-address-set-default" onClick={() => handleSetDefault(addr.id)}>
Establecer como predeterminada
</button>
</div>
<div className="profile-address-item-actions">
<button className="profile-address-btn-icon" onClick={() => openEditAddressForm(addr)} title="Editar">
<svg width="18" height="18" 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>
</button>
<button className="profile-address-btn-icon profile-address-btn-icon--delete" onClick={() => handleDeleteAddress(addr.id)} title="Eliminar">
<svg width="18" height="18" 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>
))}
</div>
)}
<button className="profile-address-add-btn" onClick={openAddAddressForm}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
<span>Añadir más</span>
</button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
+14 -1
View File
@@ -4,6 +4,12 @@
margin: 0 auto;
}
@media (min-width: 1024px) {
.search-view {
max-width: 70rem;
}
}
.search-content {
padding: 1rem var(--margin-main) 2rem;
animation: fadeInUp 0.5s ease-out;
@@ -195,11 +201,18 @@
.selected-medicine-section {
margin-top: 1.5rem;
max-height: 60vh;
max-height: 70vh;
overflow-y: auto;
overscroll-behavior: contain;
}
@media (min-width: 1024px) {
.selected-medicine-section {
max-height: 76vh;
}
}
.medicine-info {
background: var(--surface-container-lowest);
border-radius: var(--radius-md);
+2 -2
View File
@@ -13,8 +13,8 @@ const suggestions = [
{ name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
];
function SearchView({ currentUser, onLoginRequest }) {
const [searchQuery, setSearchQuery] = useState('');
function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
const [searchQuery, setSearchQuery] = useState(initialQuery);
const [medicines, setMedicines] = useState([]);
const [selectedMedicine, setSelectedMedicine] = useState(null);
const [pharmacies, setPharmacies] = useState([]);