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';
|
import redisClient from './redis-client.js';
|
||||||
|
|
||||||
const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest';
|
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
|
* Separa los términos de dosis (ej: "1g", "1000 mg") del nombre del
|
||||||
* search term in the commercial name or active ingredient (full-word style).
|
* 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) {
|
function filterMedicinesByFullQuery(medicines, searchTerm) {
|
||||||
const terms = searchTerm
|
const parsed = parseSearchQuery(searchTerm);
|
||||||
.trim()
|
const nameTerms = parsed.nameQuery.split(/\s+/).filter(Boolean);
|
||||||
.toLowerCase()
|
const dosageTerms = parsed.dosageTerms;
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean);
|
if (nameTerms.length === 0 && dosageTerms.length === 0) return medicines;
|
||||||
if (terms.length === 0) return medicines;
|
|
||||||
return medicines.filter((m) => {
|
return medicines.filter((m) => {
|
||||||
|
if (nameTerms.length > 0) {
|
||||||
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
|
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
|
||||||
return terms.every((term) => hay.includes(term));
|
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 searchTerm = query.trim().toLowerCase();
|
||||||
const cacheKey = `medicines:search:v2:${searchTerm}`;
|
const cacheKey = `medicines:search:v3:${searchTerm}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Intentar obtener del caché
|
// Intentar obtener del caché
|
||||||
@@ -43,11 +77,13 @@ export async function searchMedicines(query) {
|
|||||||
return JSON.parse(cachedData);
|
return JSON.parse(cachedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no está en caché, consultar la API de CIMA
|
const parsed = parseSearchQuery(searchTerm);
|
||||||
console.log(`🌐 Fetching from CIMA API: ${searchTerm}`);
|
const apiSearchTerm = parsed.nameQuery || searchTerm;
|
||||||
|
|
||||||
|
console.log(`🌐 Fetching from CIMA API: ${apiSearchTerm}`);
|
||||||
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
|
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
|
||||||
params: {
|
params: {
|
||||||
nombre: searchTerm
|
nombre: apiSearchTerm
|
||||||
},
|
},
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ app.use(cors({
|
|||||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(pinoHttp({ logger }));
|
app.use(pinoHttp({ logger }));
|
||||||
|
|
||||||
const PG_URL = process.env.PG_URL;
|
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) {
|
} catch (err) {
|
||||||
console.error('initDatabase failed:', err);
|
console.error('initDatabase failed:', err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -787,7 +817,7 @@ app.get('/api/auth/check', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (req.session && req.session.userId) {
|
if (req.session && req.session.userId) {
|
||||||
const user = await userDbGet(
|
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]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -802,6 +832,8 @@ app.get('/api/auth/check', async (req, res) => {
|
|||||||
first_name: user.first_name || null,
|
first_name: user.first_name || null,
|
||||||
last_name: user.last_name || null,
|
last_name: user.last_name || null,
|
||||||
avatar_url: user.avatar_url || null,
|
avatar_url: user.avatar_url || null,
|
||||||
|
email: user.email || null,
|
||||||
|
city: user.city || null,
|
||||||
is_admin: Boolean(user.is_admin),
|
is_admin: Boolean(user.is_admin),
|
||||||
address: user.address || null,
|
address: user.address || null,
|
||||||
latitude: user.latitude,
|
latitude: user.latitude,
|
||||||
@@ -846,38 +878,76 @@ app.get('/api/users/me', requireAuth, async (req, res) => {
|
|||||||
// Update the current user's address + coordinates
|
// Update the current user's address + coordinates
|
||||||
app.put('/api/users/me', requireAuth, async (req, res) => {
|
app.put('/api/users/me', requireAuth, async (req, res) => {
|
||||||
try {
|
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);
|
if ('first_name' in body) {
|
||||||
const lName = last_name == null ? null : String(last_name).trim().slice(0, 100);
|
const v = body.first_name == null ? null : String(body.first_name).trim().slice(0, 100);
|
||||||
const avatar = avatar_url == null ? null : String(avatar_url).slice(0, 500);
|
sets.push('first_name = ?');
|
||||||
const userEmail = email == null ? null : String(email).trim().slice(0, 200);
|
vals.push(v);
|
||||||
const userCity = city == null ? null : String(city).trim().slice(0, 200);
|
}
|
||||||
|
if ('last_name' in body) {
|
||||||
const addr =
|
const v = body.last_name == null ? null : String(body.last_name).trim().slice(0, 100);
|
||||||
address == null || String(address).trim() === ''
|
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
|
? null
|
||||||
: String(address).trim().slice(0, 500);
|
: String(body.address).trim().slice(0, 500);
|
||||||
|
sets.push('address = ?');
|
||||||
let lat = null;
|
vals.push(v);
|
||||||
let lon = null;
|
}
|
||||||
if (latitude !== '' && latitude != null) {
|
if ('latitude' in body) {
|
||||||
lat = typeof latitude === 'number' ? latitude : parseFloat(latitude);
|
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) {
|
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
|
||||||
return res.status(400).json({ error: 'Invalid latitude' });
|
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 ('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) {
|
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
|
||||||
return res.status(400).json({ error: 'Invalid longitude' });
|
return res.status(400).json({ error: 'Invalid longitude' });
|
||||||
}
|
}
|
||||||
|
sets.push('longitude = ?');
|
||||||
|
vals.push(lon);
|
||||||
|
} else {
|
||||||
|
sets.push('longitude = ?');
|
||||||
|
vals.push(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sets.length > 0) {
|
||||||
|
vals.push(req.session.userId);
|
||||||
await userDbRun(
|
await userDbRun(
|
||||||
'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, email = ?, city = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
'UPDATE users SET ' + sets.join(', ') + ' WHERE id = ?',
|
||||||
[fName, lName, avatar, userEmail, userCity, addr, lat, lon, req.session.userId]
|
vals
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = await userDbGet(
|
const user = await userDbGet(
|
||||||
'SELECT id, username, first_name, last_name, avatar_url, email, city, 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 = ?',
|
||||||
@@ -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.
|
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
|
||||||
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
|
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
|
||||||
const q = (req.query.q || '').trim();
|
const q = (req.query.q || '').trim();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
"scheme": "farmafinder",
|
"scheme": "farmafinder",
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": ""
|
"projectId": "9a424f20-1073-4477-93e1-6b302a1ae389"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { colors, spacing, borderRadius } from '../../constants/theme';
|
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 {
|
interface SearchHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -14,6 +32,16 @@ interface SearchHistoryItem {
|
|||||||
created_at: string;
|
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() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
||||||
@@ -21,8 +49,13 @@ export default function ProfileScreen() {
|
|||||||
const [firstName, setFirstName] = useState(user?.first_name || '');
|
const [firstName, setFirstName] = useState(user?.first_name || '');
|
||||||
const [lastName, setLastName] = useState(user?.last_name || '');
|
const [lastName, setLastName] = useState(user?.last_name || '');
|
||||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
|
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
|
||||||
|
const [avatarLocalSource, setAvatarLocalSource] = useState<number | null>(null);
|
||||||
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// Avatar modal state
|
||||||
|
const [showAvatarModal, setShowAvatarModal] = useState(false);
|
||||||
|
const [avatarTab, setAvatarTab] = useState<'presets' | 'colors' | 'upload'>('presets');
|
||||||
|
|
||||||
// Config modal state
|
// Config modal state
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [configFirstName, setConfigFirstName] = useState('');
|
const [configFirstName, setConfigFirstName] = useState('');
|
||||||
@@ -33,6 +66,18 @@ export default function ProfileScreen() {
|
|||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
loadSearchHistory();
|
loadSearchHistory();
|
||||||
@@ -109,6 +154,7 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
if (!result.canceled && result.assets[0]) {
|
if (!result.canceled && result.assets[0]) {
|
||||||
setAvatarUrl(result.assets[0].uri);
|
setAvatarUrl(result.assets[0].uri);
|
||||||
|
setShowAvatarModal(false);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/me', {
|
const res = await fetch('/api/users/me', {
|
||||||
method: 'PUT',
|
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) {
|
async function handleDeleteSearch(id: number) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search-history/${id}`, {
|
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 () => {
|
const handleLogout = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Cerrar Sesión',
|
'Cerrar Sesión',
|
||||||
@@ -196,7 +414,7 @@ export default function ProfileScreen() {
|
|||||||
<ScrollView style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
{/* Avatar Section */}
|
{/* Avatar Section */}
|
||||||
<View style={styles.avatarSection}>
|
<View style={styles.avatarSection}>
|
||||||
<TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
|
<TouchableOpacity style={styles.avatarContainer} onPress={() => setShowAvatarModal(true)}>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
|
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
|
||||||
) : (
|
) : (
|
||||||
@@ -229,11 +447,11 @@ export default function ProfileScreen() {
|
|||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.menuItem}>
|
<TouchableOpacity style={styles.menuItem} onPress={openAddresses}>
|
||||||
<Ionicons name="location" size={24} color={colors.primary} />
|
<Ionicons name="location" size={24} color={colors.primary} />
|
||||||
<Text style={styles.menuText}>Mis Direcciones</Text>
|
<Text style={styles.menuText}>Mis Direcciones</Text>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{searchHistory.length > 0 && (
|
{searchHistory.length > 0 && (
|
||||||
<View style={styles.searchHistorySection}>
|
<View style={styles.searchHistorySection}>
|
||||||
@@ -267,6 +485,95 @@ export default function ProfileScreen() {
|
|||||||
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
||||||
</TouchableOpacity>
|
</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 */}
|
{/* Config Modal */}
|
||||||
<Modal visible={showConfig} animationType="slide" transparent>
|
<Modal visible={showConfig} animationType="slide" transparent>
|
||||||
<View style={styles.modalBackdrop}>
|
<View style={styles.modalBackdrop}>
|
||||||
@@ -361,6 +668,126 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -631,4 +1058,184 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
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 [showLogin, setShowLogin] = useState(false);
|
||||||
const [showSaved, setShowSaved] = useState(false);
|
const [showSaved, setShowSaved] = useState(false);
|
||||||
const [badgeCount, setBadgeCount] = useState(0);
|
const [badgeCount, setBadgeCount] = useState(0);
|
||||||
|
const [prescriptionSearch, setPrescriptionSearch] = useState('');
|
||||||
const [screenSize, setScreenSize] = useState({
|
const [screenSize, setScreenSize] = useState({
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight
|
height: window.innerHeight
|
||||||
@@ -131,6 +132,7 @@ function App() {
|
|||||||
<SearchView
|
<SearchView
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onLoginRequest={() => setShowLogin(true)}
|
onLoginRequest={() => setShowLogin(true)}
|
||||||
|
initialQuery={prescriptionSearch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -139,7 +141,8 @@ function App() {
|
|||||||
<ScannerView
|
<ScannerView
|
||||||
onClose={() => setScreen('home')}
|
onClose={() => setScreen('home')}
|
||||||
onSelectMedicine={(name) => {
|
onSelectMedicine={(name) => {
|
||||||
setScreen('home');
|
setPrescriptionSearch(name);
|
||||||
|
setScreen('search');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
max-height: 60vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
animation: fadeInUp 0.5s ease-out;
|
animation: fadeInUp 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.medicine-results {
|
||||||
|
max-height: 76vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.medicine-card {
|
.medicine-card {
|
||||||
background: var(--surface-container-lowest);
|
background: var(--surface-container-lowest);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -560,3 +560,288 @@
|
|||||||
.profile-btn-cancel:disabled {
|
.profile-btn-cancel:disabled {
|
||||||
opacity: 0.6;
|
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 React, { useEffect, useState, useRef } from 'react';
|
||||||
import './ProfileView.css';
|
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 }) {
|
function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
||||||
const [firstName, setFirstName] = useState(currentUser?.first_name || '');
|
const [firstName, setFirstName] = useState(currentUser?.first_name || '');
|
||||||
const [lastName, setLastName] = useState(currentUser?.last_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 [searchHistory, setSearchHistory] = useState([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState('');
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
// Avatar modal state
|
||||||
|
const [showAvatarModal, setShowAvatarModal] = useState(false);
|
||||||
|
const [avatarTab, setAvatarTab] = useState('presets');
|
||||||
|
|
||||||
// Config modal state
|
// Config modal state
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [configFirstName, setConfigFirstName] = useState('');
|
const [configFirstName, setConfigFirstName] = useState('');
|
||||||
@@ -19,10 +57,22 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
const [configSaving, setConfigSaving] = useState(false);
|
const [configSaving, setConfigSaving] = useState(false);
|
||||||
const [configFeedback, setConfigFeedback] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setFirstName(currentUser?.first_name || '');
|
setFirstName(currentUser?.first_name || '');
|
||||||
setLastName(currentUser?.last_name || '');
|
setLastName(currentUser?.last_name || '');
|
||||||
setAvatarUrl(currentUser?.avatar_url || '');
|
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
|
||||||
loadSearchHistory();
|
loadSearchHistory();
|
||||||
}, [currentUser?.id]);
|
}, [currentUser?.id]);
|
||||||
|
|
||||||
@@ -48,6 +98,108 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
setShowConfig(true);
|
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) {
|
async function handleConfigSave(e) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
setConfigSaving(true);
|
setConfigSaving(true);
|
||||||
@@ -86,7 +238,16 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploadError('');
|
||||||
|
setShowAvatarModal(false);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
setUploadError('La imagen no puede superar los 5 MB');
|
||||||
|
setUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async () => {
|
reader.onload = async () => {
|
||||||
@@ -102,19 +263,67 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
onProfileSaved?.(updated);
|
onProfileSaved?.(updated);
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
|
||||||
|
setUploadError('Error al guardar la imagen. Intenta con otra foto.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving avatar:', err);
|
setAvatarUrl(resolveAvatarUrl(currentUser?.avatar_url));
|
||||||
|
setUploadError('Error de conexión al guardar la imagen.');
|
||||||
}
|
}
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error processing image:', err);
|
setUploadError('Error al procesar la imagen.');
|
||||||
setUploading(false);
|
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) {
|
async function handleDeleteSearch(id) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search-history/${id}`, {
|
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-section">
|
||||||
<div
|
<div
|
||||||
className="profile-avatar-circle profile-avatar-editable"
|
className="profile-avatar-circle profile-avatar-editable"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => setShowAvatarModal(true)}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={avatarUrl} alt="Avatar" className="profile-avatar-image" />
|
<img src={avatarUrl} alt="Avatar" className="profile-avatar-image" />
|
||||||
@@ -160,6 +369,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
{uploading && <p className="profile-section-sub">Subiendo foto...</p>}
|
{uploading && <p className="profile-section-sub">Subiendo foto...</p>}
|
||||||
|
{uploadError && <p className="profile-feedback profile-feedback--err">{uploadError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read-only Name Section */}
|
{/* Read-only Name Section */}
|
||||||
@@ -188,7 +398,7 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="profile-menu-item">
|
<button className="profile-menu-item" onClick={openAddresses}>
|
||||||
<div className="menu-item-icon menu-item-icon--primary">
|
<div className="menu-item-icon menu-item-icon--primary">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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">
|
<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" />
|
<polyline points="9 18 15 12 9 6" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{searchHistory.length > 0 && (
|
{searchHistory.length > 0 && (
|
||||||
<div className="profile-search-history">
|
<div className="profile-search-history">
|
||||||
@@ -244,6 +454,90 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
<span>Cerrar Sesión</span>
|
<span>Cerrar Sesión</span>
|
||||||
</button>
|
</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 */}
|
{/* Config Modal */}
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="profile-modal-backdrop" onClick={() => setShowConfig(false)}>
|
<div className="profile-modal-backdrop" onClick={() => setShowConfig(false)}>
|
||||||
@@ -320,6 +614,110 @@ function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.search-view {
|
||||||
|
max-width: 70rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search-content {
|
.search-content {
|
||||||
padding: 1rem var(--margin-main) 2rem;
|
padding: 1rem var(--margin-main) 2rem;
|
||||||
animation: fadeInUp 0.5s ease-out;
|
animation: fadeInUp 0.5s ease-out;
|
||||||
@@ -195,11 +201,18 @@
|
|||||||
|
|
||||||
.selected-medicine-section {
|
.selected-medicine-section {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
max-height: 60vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.selected-medicine-section {
|
||||||
|
max-height: 76vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.medicine-info {
|
.medicine-info {
|
||||||
background: var(--surface-container-lowest);
|
background: var(--surface-container-lowest);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const suggestions = [
|
|||||||
{ name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
|
{ name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function SearchView({ currentUser, onLoginRequest }) {
|
function SearchView({ currentUser, onLoginRequest, initialQuery = '' }) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||||
const [medicines, setMedicines] = useState([]);
|
const [medicines, setMedicines] = useState([]);
|
||||||
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
||||||
const [pharmacies, setPharmacies] = useState([]);
|
const [pharmacies, setPharmacies] = useState([]);
|
||||||
|
|||||||