feat: avatar popup options, profile redesign, and search dosage parsing #13
@@ -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
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"scheme": "farmafinder",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": ""
|
||||
"projectId": "9a424f20-1073-4477-93e1-6b302a1ae389"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||