diff --git a/apps/backend/cima-service.js b/apps/backend/cima-service.js index 8c323d6..be824ee 100644 --- a/apps/backend/cima-service.js +++ b/apps/backend/cima-service.js @@ -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 }); diff --git a/apps/backend/server.js b/apps/backend/server.js index 52d9f52..a461e0c 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -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(); diff --git a/apps/frontend-mobile/app.json b/apps/frontend-mobile/app.json index b1f6c5b..c0a3b5b 100644 --- a/apps/frontend-mobile/app.json +++ b/apps/frontend-mobile/app.json @@ -35,7 +35,7 @@ "scheme": "farmafinder", "extra": { "eas": { - "projectId": "" + "projectId": "9a424f20-1073-4477-93e1-6b302a1ae389" } } } diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index 8711d44..afb33ae 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -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(null); const [searchHistory, setSearchHistory] = useState([]); + // 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([]); + 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(() => { 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() { {/* Avatar Section */} - + setShowAvatarModal(true)}> {avatarUrl ? ( ) : ( @@ -229,11 +447,11 @@ export default function ProfileScreen() { - + Mis Direcciones - + {searchHistory.length > 0 && ( @@ -267,6 +485,95 @@ export default function ProfileScreen() { Cerrar Sesión + {/* Avatar Selection Modal */} + + + + + Cambiar Avatar + setShowAvatarModal(false)}> + + + + + {/* Tab Buttons */} + + setAvatarTab('presets')} + > + + Prediseñado + + setAvatarTab('colors')} + > + + Colores + + setAvatarTab('upload')} + > + + Subir + + + + {/* Tab Content */} + + {avatarTab === 'presets' && ( + + {AVATARS.map((avatar, index) => ( + handleSelectPresetAvatar(index)} + > + + + ))} + + )} + + {avatarTab === 'colors' && ( + + {COLOR_CIRCLES.map((color, index) => ( + handleSelectColor(index)} + > + + + ))} + + )} + + {avatarTab === 'upload' && ( + + + + + + Tomar foto + Usa la cámara de tu dispositivo + + + + + + Elegir de galería + Selecciona una imagen existente + + + )} + + + + + {/* Config Modal */} @@ -361,6 +668,126 @@ export default function ProfileScreen() { + + {/* Addresses Modal */} + + + + + Mis Direcciones + { setShowAddresses(false); setShowAddressForm(false); }}> + + + + + {showAddressForm ? ( + + + Dirección + + + + Etiqueta (opcional) + + + setFormDefault(!formDefault)} + disabled={formSaving} + > + + Dirección predeterminada + + {formError ? ( + + {formError} + + ) : null} + + { setShowAddressForm(false); setFormError(''); }} + disabled={formSaving} + > + Cancelar + + + {formSaving ? ( + + ) : ( + {editingAddressId ? 'Actualizar' : 'Añadir'} + )} + + + + ) : ( + + {addressesLoading ? ( + Cargando direcciones... + ) : ( + + {user?.address ? ( + + + Dirección principal + {user.address} + + Predeterminada + + + + ) : null} + {addresses.map((addr) => ( + + + {addr.label ? {addr.label} : null} + {addr.address} + handleSetDefault(addr.id)}> + Establecer como predeterminada + + + + openEditAddressForm(addr)}> + + + handleDeleteAddress(addr.id)}> + + + + + ))} + + )} + + + Añadir más + + + )} + + + + ); } @@ -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, + }, }); diff --git a/apps/frontend-mobile/assets/avatars/avatar1.png b/apps/frontend-mobile/assets/avatars/avatar1.png new file mode 100644 index 0000000..c4affa3 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar1.png differ diff --git a/apps/frontend-mobile/assets/avatars/avatar2.png b/apps/frontend-mobile/assets/avatars/avatar2.png new file mode 100644 index 0000000..5a7c9b8 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar2.png differ diff --git a/apps/frontend-mobile/assets/avatars/avatar3.png b/apps/frontend-mobile/assets/avatars/avatar3.png new file mode 100644 index 0000000..1461a55 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar3.png differ diff --git a/apps/frontend-mobile/assets/avatars/avatar4.png b/apps/frontend-mobile/assets/avatars/avatar4.png new file mode 100644 index 0000000..1822276 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar4.png differ diff --git a/apps/frontend-mobile/assets/avatars/avatar5.png b/apps/frontend-mobile/assets/avatars/avatar5.png new file mode 100644 index 0000000..b6f0ca8 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar5.png differ diff --git a/apps/frontend-mobile/assets/avatars/avatar6.png b/apps/frontend-mobile/assets/avatars/avatar6.png new file mode 100644 index 0000000..1012fea Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/avatar6.png differ diff --git a/apps/frontend-mobile/assets/avatars/color1.png b/apps/frontend-mobile/assets/avatars/color1.png new file mode 100644 index 0000000..24bcd1e Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color1.png differ diff --git a/apps/frontend-mobile/assets/avatars/color2.png b/apps/frontend-mobile/assets/avatars/color2.png new file mode 100644 index 0000000..2413022 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color2.png differ diff --git a/apps/frontend-mobile/assets/avatars/color3.png b/apps/frontend-mobile/assets/avatars/color3.png new file mode 100644 index 0000000..4a354b9 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color3.png differ diff --git a/apps/frontend-mobile/assets/avatars/color4.png b/apps/frontend-mobile/assets/avatars/color4.png new file mode 100644 index 0000000..ab3a471 Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color4.png differ diff --git a/apps/frontend-mobile/assets/avatars/color5.png b/apps/frontend-mobile/assets/avatars/color5.png new file mode 100644 index 0000000..72a9a8d Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color5.png differ diff --git a/apps/frontend-mobile/assets/avatars/color6.png b/apps/frontend-mobile/assets/avatars/color6.png new file mode 100644 index 0000000..e30098a Binary files /dev/null and b/apps/frontend-mobile/assets/avatars/color6.png differ diff --git a/apps/frontend/public/avatars/avatar1.png b/apps/frontend/public/avatars/avatar1.png new file mode 100644 index 0000000..c4affa3 Binary files /dev/null and b/apps/frontend/public/avatars/avatar1.png differ diff --git a/apps/frontend/public/avatars/avatar2.png b/apps/frontend/public/avatars/avatar2.png new file mode 100644 index 0000000..5a7c9b8 Binary files /dev/null and b/apps/frontend/public/avatars/avatar2.png differ diff --git a/apps/frontend/public/avatars/avatar3.png b/apps/frontend/public/avatars/avatar3.png new file mode 100644 index 0000000..1461a55 Binary files /dev/null and b/apps/frontend/public/avatars/avatar3.png differ diff --git a/apps/frontend/public/avatars/avatar4.png b/apps/frontend/public/avatars/avatar4.png new file mode 100644 index 0000000..1822276 Binary files /dev/null and b/apps/frontend/public/avatars/avatar4.png differ diff --git a/apps/frontend/public/avatars/avatar5.png b/apps/frontend/public/avatars/avatar5.png new file mode 100644 index 0000000..b6f0ca8 Binary files /dev/null and b/apps/frontend/public/avatars/avatar5.png differ diff --git a/apps/frontend/public/avatars/avatar6.png b/apps/frontend/public/avatars/avatar6.png new file mode 100644 index 0000000..1012fea Binary files /dev/null and b/apps/frontend/public/avatars/avatar6.png differ diff --git a/apps/frontend/public/avatars/color1.png b/apps/frontend/public/avatars/color1.png new file mode 100644 index 0000000..24bcd1e Binary files /dev/null and b/apps/frontend/public/avatars/color1.png differ diff --git a/apps/frontend/public/avatars/color2.png b/apps/frontend/public/avatars/color2.png new file mode 100644 index 0000000..2413022 Binary files /dev/null and b/apps/frontend/public/avatars/color2.png differ diff --git a/apps/frontend/public/avatars/color3.png b/apps/frontend/public/avatars/color3.png new file mode 100644 index 0000000..4a354b9 Binary files /dev/null and b/apps/frontend/public/avatars/color3.png differ diff --git a/apps/frontend/public/avatars/color4.png b/apps/frontend/public/avatars/color4.png new file mode 100644 index 0000000..ab3a471 Binary files /dev/null and b/apps/frontend/public/avatars/color4.png differ diff --git a/apps/frontend/public/avatars/color5.png b/apps/frontend/public/avatars/color5.png new file mode 100644 index 0000000..72a9a8d Binary files /dev/null and b/apps/frontend/public/avatars/color5.png differ diff --git a/apps/frontend/public/avatars/color6.png b/apps/frontend/public/avatars/color6.png new file mode 100644 index 0000000..e30098a Binary files /dev/null and b/apps/frontend/public/avatars/color6.png differ diff --git a/apps/frontend/src/App.jsx b/apps/frontend/src/App.jsx index 712f227..a21df80 100644 --- a/apps/frontend/src/App.jsx +++ b/apps/frontend/src/App.jsx @@ -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() { setShowLogin(true)} + initialQuery={prescriptionSearch} /> ); break; @@ -139,7 +141,8 @@ function App() { setScreen('home')} onSelectMedicine={(name) => { - setScreen('home'); + setPrescriptionSearch(name); + setScreen('search'); }} /> ); diff --git a/apps/frontend/src/components/MedicineResults.css b/apps/frontend/src/components/MedicineResults.css index 2344d2b..83a4b3e 100644 --- a/apps/frontend/src/components/MedicineResults.css +++ b/apps/frontend/src/components/MedicineResults.css @@ -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); diff --git a/apps/frontend/src/views/ProfileView.css b/apps/frontend/src/views/ProfileView.css index 364e2ce..70c53b1 100644 --- a/apps/frontend/src/views/ProfileView.css +++ b/apps/frontend/src/views/ProfileView.css @@ -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; + } +} diff --git a/apps/frontend/src/views/ProfileView.jsx b/apps/frontend/src/views/ProfileView.jsx index e3e9f72..12f1151 100644 --- a/apps/frontend/src/views/ProfileView.jsx +++ b/apps/frontend/src/views/ProfileView.jsx @@ -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); @@ -85,8 +237,17 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { async function handleAvatarUpload(e) { 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 }) {
fileInputRef.current?.click()} + onClick={() => setShowAvatarModal(true)} > {avatarUrl ? ( Avatar @@ -160,6 +369,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { style={{ display: 'none' }} /> {uploading &&

Subiendo foto...

} + {uploadError &&

{uploadError}

}
{/* Read-only Name Section */} @@ -188,7 +398,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { -
+ {searchHistory.length > 0 && (
@@ -244,6 +454,90 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) { Cerrar Sesión + {/* Avatar Modal */} + {showAvatarModal && ( +
setShowAvatarModal(false)}> +
e.stopPropagation()}> +
+

Cambiar Avatar

+ +
+
+ + + +
+
+ {avatarTab === 'presets' && ( +
+ {AVATARS.map((src, index) => ( + + ))} +
+ )} + {avatarTab === 'colors' && ( +
+ {COLOR_CIRCLES.map((src, index) => ( + + ))} +
+ )} + {avatarTab === 'upload' && ( +
+ +
+ )} +
+
+
+ )} + {/* Config Modal */} {showConfig && (
setShowConfig(false)}> @@ -320,6 +614,110 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
)} + + {/* Addresses Modal */} + {showAddresses && ( +
{ setShowAddresses(false); setShowAddressForm(false); }}> +
e.stopPropagation()}> +
+

Mis Direcciones

+ +
+
+ {showAddressForm && ( +
+
+ + setFormAddress(e.target.value)} + placeholder="Calle Mayor 1, Madrid" + disabled={formSaving} + /> +
+
+ + setFormLabel(e.target.value)} + placeholder="Ej: Casa, Trabajo, Segunda residencia" + disabled={formSaving} + /> +
+ + {formError &&

{formError}

} +
+ + +
+
+ )} + {!showAddressForm && ( + <> + {addressesLoading ? ( +

Cargando direcciones...

+ ) : ( +
+ {currentUser?.address && ( +
+
+ Dirección principal + {currentUser.address} + Predeterminada +
+
+ )} + {addresses.map((addr) => ( +
+
+ {addr.label && {addr.label}} + {addr.address} + +
+
+ + +
+
+ ))} +
+ )} + + + )} +
+
+
+ )}
); } diff --git a/apps/frontend/src/views/SearchView.css b/apps/frontend/src/views/SearchView.css index 25d21dd..dbea7f3 100644 --- a/apps/frontend/src/views/SearchView.css +++ b/apps/frontend/src/views/SearchView.css @@ -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); diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index 6cd007e..e86159b 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -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([]);