diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml index 32acfe0..832a48d 100644 --- a/.gitea/workflows/docker.yaml +++ b/.gitea/workflows/docker.yaml @@ -14,7 +14,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' - name: Install dependencies run: npm ci @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' - name: Install dependencies run: npm ci diff --git a/.gitea/workflows/test-branches.yaml b/.gitea/workflows/test-branches.yaml index 2abc517..a82d007 100644 --- a/.gitea/workflows/test-branches.yaml +++ b/.gitea/workflows/test-branches.yaml @@ -3,43 +3,43 @@ name: Run Tests on Branches on: push: branches-ignore: - - 'main' + - 'main' jobs: test-backend: name: Backend Tests runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' - - name: Install Dependencies - run: npm ci + - name: Install Dependencies + run: npm ci - - name: Run Backend Tests - run: npm test --workspace=farma-clic-backend -- --ci + - name: Run Backend Tests + run: npm test --workspace=farma-clic-backend -- --ci test-frontend: name: Frontend Tests runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' - - name: Install Dependencies - run: npm ci + - name: Install Dependencies + run: npm ci - - name: Run Frontend Tests - run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic + - name: Run Frontend Tests + run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 7778723..6b0c50e 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -1,8 +1,7 @@ -FROM node:18-slim +FROM node:24-alpine WORKDIR /app COPY apps/backend/package*.json ./ -RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* -RUN npm ci --omit=dev +RUN apk add --no-cache python3 make g++ && npm ci --omit=dev COPY apps/backend/ . COPY apps/API/ /API/ RUN mkdir -p /app/data diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index fd54a61..43a86d6 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -20,6 +20,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", @@ -3458,6 +3459,12 @@ "@types/node": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3946,6 +3953,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/barcode-detector": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz", + "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8743,6 +8759,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -9293,6 +9321,34 @@ "engines": { "node": "*" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz", + "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.7.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/apps/backend/package.json b/apps/backend/package.json index 21a99e8..924e3c8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,6 +29,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", diff --git a/apps/backend/server.js b/apps/backend/server.js index 2bf948c..52d9f52 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -290,6 +290,23 @@ if (!pgPool) { try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {} } + // Add new fields for profile redesign + if (pgPool) { + await pgPool.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS city TEXT; + `); + } else { + try { await dbRun('ALTER TABLE users ADD COLUMN first_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN last_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN email TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN city TEXT'); } catch {} + } + // Add user_id to push tables (SQLite — no cross-DB FK) try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {} try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {} @@ -313,6 +330,32 @@ if (!pgPool) { } catch (e) { if (!/table already exists/i.test(e.message)) throw e; } + + // Search history for "find pharmacies near me" + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS search_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + address TEXT NOT NULL, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + } else { + await dbRun(` + CREATE TABLE IF NOT EXISTS search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); + } } catch (err) { console.error('initDatabase failed:', err); throw err; @@ -664,6 +707,9 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => { user: { id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -710,6 +756,9 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => { user: { id: result.lastID, username: u, + first_name: null, + last_name: null, + avatar_url: null, is_admin: false, address: null, latitude: null, @@ -738,7 +787,7 @@ app.get('/api/auth/check', async (req, res) => { try { if (req.session && req.session.userId) { const user = await userDbGet( - 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', + 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) { @@ -750,6 +799,9 @@ app.get('/api/auth/check', async (req, res) => { user: { id: user.id, username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, @@ -768,13 +820,18 @@ app.get('/api/auth/check', async (req, res) => { app.get('/api/users/me', requireAuth, async (req, res) => { try { const user = await userDbGet( - 'SELECT id, username, 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) return res.status(404).json({ error: 'Not found' }); res.json({ id: user.id, username: user.username, + 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, @@ -789,7 +846,14 @@ 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 { address, latitude, longitude } = req.body || {}; + const { first_name, last_name, avatar_url, email, city, address, latitude, longitude } = req.body || {}; + + 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() === '' ? null @@ -811,17 +875,22 @@ app.put('/api/users/me', requireAuth, async (req, res) => { } await userDbRun( - 'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?', - [addr, lat, lon, req.session.userId] + '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] ); const user = await userDbGet( - 'SELECT id, username, 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] ); res.json({ id: user.id, username: user.username, + 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, @@ -919,6 +988,56 @@ app.delete('/api/alerts/:id', requireAuth, async (req, res) => { } }); +// ========== SEARCH HISTORY API ========== + +// Get search history for current user +app.get('/api/search-history', requireAuth, async (req, res) => { + try { + const rows = await userDbAll( + 'SELECT id, address, latitude, longitude, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20', + [req.session.userId] + ); + res.json(rows); + } catch (error) { + console.error('Error fetching search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add new search to history +app.post('/api/search-history', requireAuth, async (req, res) => { + try { + const { address, latitude, longitude } = req.body || {}; + if (!address) { + return res.status(400).json({ error: 'Address is required' }); + } + + const result = await userDbRun( + 'INSERT INTO search_history (user_id, address, latitude, longitude) VALUES (?, ?, ?, ?)', + [req.session.userId, address, latitude || null, longitude || null] + ); + + res.json({ id: result.lastID, address, latitude, longitude }); + } catch (error) { + console.error('Error adding search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete search history item +app.delete('/api/search-history/:id', requireAuth, async (req, res) => { + try { + await userDbRun( + 'DELETE FROM search_history WHERE id = ? AND user_id = ?', + [req.params.id, req.session.userId] + ); + res.json({ ok: true }); + } catch (error) { + console.error('Error deleting search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode. app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => { const q = (req.query.q || '').trim(); diff --git a/apps/frontend-mobile/app/(tabs)/profile.tsx b/apps/frontend-mobile/app/(tabs)/profile.tsx index f471c2e..8711d44 100644 --- a/apps/frontend-mobile/app/(tabs)/profile.tsx +++ b/apps/frontend-mobile/app/(tabs)/profile.tsx @@ -1,14 +1,145 @@ -import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal } 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'; +interface SearchHistoryItem { + id: number; + address: string; + latitude: number | null; + longitude: number | null; + created_at: string; +} + export default function ProfileScreen() { const router = useRouter(); const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); + const [firstName, setFirstName] = useState(user?.first_name || ''); + const [lastName, setLastName] = useState(user?.last_name || ''); + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); + const [searchHistory, setSearchHistory] = useState([]); + + // Config modal state + const [showConfig, setShowConfig] = useState(false); + const [configFirstName, setConfigFirstName] = useState(''); + const [configLastName, setConfigLastName] = useState(''); + const [configEmail, setConfigEmail] = useState(''); + const [configCity, setConfigCity] = useState(''); + const [configAddress, setConfigAddress] = useState(''); + const [configSaving, setConfigSaving] = useState(false); + const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + useEffect(() => { + if (isAuthenticated) { + loadSearchHistory(); + } + }, [isAuthenticated]); + + useEffect(() => { + setFirstName(user?.first_name || ''); + setLastName(user?.last_name || ''); + setAvatarUrl(user?.avatar_url || ''); + }, [user]); + + async function loadSearchHistory() { + try { + const res = await fetch('/api/search-history', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setSearchHistory(data); + } + } catch (err) { + console.error('Error loading search history:', err); + } + } + + function openConfig() { + setConfigFirstName(user?.first_name || ''); + setConfigLastName(user?.last_name || ''); + setConfigEmail(user?.email || ''); + setConfigCity(user?.city || ''); + setConfigAddress(user?.address || ''); + setConfigFeedback(null); + setShowConfig(true); + } + + async function handleConfigSave() { + setConfigSaving(true); + setConfigFeedback(null); + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + first_name: configFirstName.trim() || null, + last_name: configLastName.trim() || null, + email: configEmail.trim() || null, + city: configCity.trim() || null, + address: configAddress.trim() || null, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Error al guardar'); + } + const updated = await res.json(); + setFirstName(updated.first_name || ''); + setLastName(updated.last_name || ''); + setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' }); + setTimeout(() => setShowConfig(false), 1200); + } catch (err: any) { + setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + } finally { + setConfigSaving(false); + } + } + + async function handlePickImage() { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setAvatarUrl(result.assets[0].uri); + 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(); + // Trigger auth refresh to update user state + } + } catch (err) { + console.error('Error saving avatar:', err); + } + } + } + + async function handleDeleteSearch(id: number) { + try { + const res = await fetch(`/api/search-history/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + setSearchHistory(prev => prev.filter(item => item.id !== id)); + } + } catch (err) { + console.error('Error deleting search:', err); + } + } + const handleLogout = async () => { Alert.alert( 'Cerrar Sesión', @@ -62,16 +193,65 @@ export default function ProfileScreen() { } return ( - - - - - - {user?.username} - {isAdmin && Administrador} + + {/* Avatar Section */} + + + {avatarUrl ? ( + + ) : ( + + )} + + + + + Toca para cambiar foto + {/* Read-only Name Section */} + + + Nombre + {firstName || '—'} + + + Apellidos + {lastName || '—'} + + + + {/* Menu Section */} + + + Configuración + + + + + + Mis Direcciones + + + + {searchHistory.length > 0 && ( + + Tus búsquedas recientes: + {searchHistory.map((item) => ( + + {item.address} + handleDeleteSearch(item.id)} + style={styles.searchDelete} + > + + + + ))} + + )} + {isAdmin && ( @@ -79,31 +259,109 @@ export default function ProfileScreen() { )} - - - - Favoritos - - - - - - Notificaciones - - - - - - Ayuda - - + {/* Logout Button */} Cerrar Sesión - + + {/* Config Modal */} + + + + + Configuración + setShowConfig(false)}> + + + + + + Nombre + + + + Apellidos + + + + Correo electrónico + + + + Ciudad + + + + Dirección + + + + {configFeedback && ( + + {configFeedback.text} + + )} + + + setShowConfig(false)} + disabled={configSaving} + > + Cancelar + + + {configSaving ? ( + + ) : ( + Guardar + )} + + + + + + + ); } @@ -136,33 +394,66 @@ const styles = StyleSheet.create({ marginTop: spacing.sm, marginBottom: spacing.xl, }, - header: { + avatarSection: { alignItems: 'center', padding: spacing.xl, backgroundColor: colors.card, }, - avatar: { - width: 80, - height: 80, - borderRadius: 40, + avatarContainer: { + width: 100, + height: 100, + borderRadius: 50, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center', + overflow: 'hidden', }, - username: { - fontSize: 20, - fontWeight: 'bold', - color: colors.text, - marginTop: spacing.md, + avatarImage: { + width: 100, + height: 100, + borderRadius: 50, }, - adminBadge: { - fontSize: 12, - color: colors.primary, - backgroundColor: '#E3F2FD', - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, + avatarOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + opacity: 0.8, + }, + editAvatarText: { marginTop: spacing.sm, + color: colors.textSecondary, + fontSize: 14, + }, + infoSection: { + padding: spacing.xl, + backgroundColor: colors.card, + marginTop: spacing.md, + gap: spacing.md, + }, + infoCard: { + backgroundColor: colors.background, + padding: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + }, + infoLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.05, + marginBottom: 4, + }, + infoValue: { + fontSize: 16, + fontWeight: '500', + color: colors.text, }, menuSection: { marginTop: spacing.md, @@ -181,6 +472,33 @@ const styles = StyleSheet.create({ fontSize: 16, color: colors.text, }, + searchHistorySection: { + padding: spacing.md, + backgroundColor: colors.background, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchHistoryTitle: { + fontSize: 14, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + searchItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchAddress: { + flex: 1, + fontSize: 14, + color: colors.text, + }, + searchDelete: { + padding: spacing.xs, + }, button: { backgroundColor: colors.primary, borderRadius: borderRadius.md, @@ -216,4 +534,101 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '500', }, + // Modal styles + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: colors.card, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + maxHeight: '90%', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + color: colors.text, + }, + modalBody: { + padding: spacing.lg, + }, + modalField: { + marginBottom: spacing.md, + }, + modalLabel: { + fontSize: 13, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + modalInput: { + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + borderRadius: borderRadius.md, + padding: spacing.md, + fontSize: 16, + color: colors.text, + }, + modalFeedback: { + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.md, + }, + modalFeedbackOk: { + backgroundColor: 'rgba(0, 69, 13, 0.1)', + borderWidth: 1, + borderColor: 'rgba(0, 69, 13, 0.2)', + }, + modalFeedbackErr: { + backgroundColor: 'rgba(186, 26, 26, 0.1)', + borderWidth: 1, + borderColor: 'rgba(186, 26, 26, 0.2)', + }, + modalFeedbackText: { + fontSize: 14, + color: colors.text, + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.md, + marginTop: spacing.md, + }, + modalCancelBtn: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + }, + modalCancelText: { + fontSize: 16, + color: colors.text, + }, + modalSaveBtn: { + backgroundColor: colors.primary, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.md, + minWidth: 100, + alignItems: 'center', + }, + modalSaveBtnDisabled: { + opacity: 0.6, + }, + modalSaveText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, }); diff --git a/apps/frontend-mobile/package.json b/apps/frontend-mobile/package.json index c49d1fc..10102c9 100644 --- a/apps/frontend-mobile/package.json +++ b/apps/frontend-mobile/package.json @@ -12,6 +12,7 @@ "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.5", "expo-device": "~7.0.2", + "expo-image-picker": "~57.0.2", "expo-linking": "~57.0.1", "expo-local-authentication": "~57.0.0", "expo-notifications": "~57.0.3", diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 5cf0ec9..03dc71e 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS build +FROM node:24-alpine AS build ARG VITE_FARO_ENDPOINT ARG VITE_FARO_APP_NAME=farmaclic-frontend ARG VITE_FARO_ENV=production @@ -9,7 +9,7 @@ ENV VITE_FARO_ENV=$VITE_FARO_ENV ENV VITE_FARO_APP_VERSION=$VITE_FARO_APP_VERSION WORKDIR /app COPY package*.json ./ -RUN npm ci +RUN npm ci --legacy-peer-deps COPY . . RUN npm run build diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index ac4a2f0..b1698a6 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -18,7 +18,7 @@ "@grafana/faro-web-sdk": "^1.7.0", "@grafana/faro-web-tracing": "^1.7.0", "@zxing/browser": "^0.2.0", - "@zxing/library": "^0.22.0", + "@zxing/library": "^0.23.0", "leaflet": "^1.9.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3704,9 +3704,10 @@ } }, "node_modules/@zxing/library": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz", - "integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz", + "integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==", + "license": "Apache-2.0", "dependencies": { "ts-custom-error": "^3.3.1" }, diff --git a/apps/frontend/src/components/SearchBar.jsx b/apps/frontend/src/components/SearchBar.jsx index c0dbbf1..051e937 100644 --- a/apps/frontend/src/components/SearchBar.jsx +++ b/apps/frontend/src/components/SearchBar.jsx @@ -17,7 +17,6 @@ function SearchBar({ value, onChange, placeholder }) { onChange={(e) => onChange(e.target.value)} placeholder={placeholder} className="search-input" - autoFocus /> {value && ( + +
@@ -133,31 +198,27 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm - +
- +
+ ))} - Contactos de Emergencia - - - - - - + )} {currentUser?.is_admin && ( - - - {feedback && ( -

{feedback.text}

- )} -
- -
- - - + + {/* Config Modal */} + {showConfig && ( +
setShowConfig(false)}> +
e.stopPropagation()}> +
+

Configuración

+ +
+
+
+ + setConfigFirstName(e.target.value)} + placeholder="Tu nombre" + disabled={configSaving} + /> +
+
+ + setConfigLastName(e.target.value)} + placeholder="Tus apellidos" + disabled={configSaving} + /> +
+
+ + setConfigEmail(e.target.value)} + placeholder="tu@email.com" + disabled={configSaving} + /> +
+
+ + setConfigCity(e.target.value)} + placeholder="Tu ciudad" + disabled={configSaving} + /> +
+
+ + setConfigAddress(e.target.value)} + placeholder="Calle Mayor 1, Madrid" + disabled={configSaving} + /> +
+ + {configFeedback && ( +

{configFeedback.text}

+ )} + +
+ + +
+
+
+
+ )} ); } diff --git a/apps/frontend/src/views/PublicView.jsx b/apps/frontend/src/views/PublicView.jsx index 1124e52..aa383c9 100644 --- a/apps/frontend/src/views/PublicView.jsx +++ b/apps/frontend/src/views/PublicView.jsx @@ -92,6 +92,21 @@ function PublicView({ setUserPosition({ lat: savedLat, lon: savedLon }); setPositionSource('profile'); setSortByDistance(true); + // Save to search history + try { + fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: currentUser?.address, + latitude: savedLat, + longitude: savedLon, + }), + }); + } catch (err) { + console.error('Error saving search history:', err); + } return; } if (userPosition) { @@ -104,6 +119,21 @@ function PublicView({ setUserPosition(pos); setPositionSource('browser'); setSortByDistance(true); + // Save to search history + try { + fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: 'Ubicación actual', + latitude: pos.lat, + longitude: pos.lon, + }), + }); + } catch (err) { + console.error('Error saving search history:', err); + } } catch (err) { console.error('[location] error', err); let msg; diff --git a/apps/frontend/src/views/SearchView.jsx b/apps/frontend/src/views/SearchView.jsx index e559fc2..6cd007e 100644 --- a/apps/frontend/src/views/SearchView.jsx +++ b/apps/frontend/src/views/SearchView.jsx @@ -120,6 +120,21 @@ function SearchView({ currentUser, onLoginRequest }) { setUserPosition({ lat: savedLat, lon: savedLon }); setPositionSource('profile'); setSortByDistance(true); + // Save to search history + try { + fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: currentUser?.address, + latitude: savedLat, + longitude: savedLon, + }), + }); + } catch (err) { + console.error('Error saving search history:', err); + } return; } if (userPosition) { @@ -132,6 +147,21 @@ function SearchView({ currentUser, onLoginRequest }) { setUserPosition(pos); setPositionSource('browser'); setSortByDistance(true); + // Save to search history + try { + fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: 'Ubicación actual', + latitude: pos.lat, + longitude: pos.lon, + }), + }); + } catch (err) { + console.error('Error saving search history:', err); + } } catch (err) { let msg = 'No se pudo obtener tu ubicación'; if (err && typeof err.code === 'number') { diff --git a/docs/superpowers/plans/2026-07-06-profile-redesign.md b/docs/superpowers/plans/2026-07-06-profile-redesign.md new file mode 100644 index 0000000..064bba8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-profile-redesign.md @@ -0,0 +1,1135 @@ +# Profile Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the Profile section to be more user-friendly: editable name/last name, avatar upload, search history as addresses, and remove irrelevant sections. + +**Architecture:** Add new fields to users table (first_name, last_name, avatar_url), create search_history table for location searches, update frontend/mobile profile views to be editable and modern. + +**Tech Stack:** React (frontend), React Native/Expo (mobile), SQLite/PostgreSQL (backend), Node.js/Express + +--- + +## File Structure + +### Backend +- Modify: `apps/backend/server.js` - Add new fields, search history API +- Create: `apps/backend/migrations/` - Database migration scripts + +### Frontend (Web) +- Modify: `apps/frontend/src/views/ProfileView.jsx` - Redesign profile view +- Modify: `apps/frontend/src/views/ProfileView.css` - Update styles + +### Mobile +- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx` - Redesign mobile profile + +--- + +## Task 1: Backend - Add new user fields and search history table + +**Files:** +- Modify: `apps/backend/server.js` + +- [ ] **Step 1: Add migration for new user fields** + +Add to `initDatabase()` function, after existing user table creation: + +```javascript +// Add new fields for profile redesign +if (pgPool) { + await pgPool.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; + `); +} else { + try { await dbRun('ALTER TABLE users ADD COLUMN first_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN last_name TEXT'); } catch {} + try { await dbRun('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch {} +} +``` + +- [ ] **Step 2: Create search_history table** + +Add after user alerts table creation: + +```javascript +// Search history for "find pharmacies near me" +if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS search_history ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + address TEXT NOT NULL, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); +} else { + await dbRun(` + CREATE TABLE IF NOT EXISTS search_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + address TEXT NOT NULL, + latitude REAL, + longitude REAL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `); +} +``` + +- [ ] **Step 3: Update GET /api/users/me to include new fields** + +Change the SELECT query in `app.get('/api/users/me', ...)`: + +```javascript +const user = await userDbGet( + 'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?', + [req.session.userId] +); +``` + +And update the response: + +```javascript +res.json({ + id: user.id, + username: user.username, + first_name: user.first_name || null, + last_name: user.last_name || null, + avatar_url: user.avatar_url || null, + is_admin: Boolean(user.is_admin), + address: user.address || null, + latitude: user.latitude, + longitude: user.longitude, +}); +``` + +- [ ] **Step 4: Update PUT /api/users/me to handle new fields** + +Change the PUT handler to accept and save new fields: + +```javascript +app.put('/api/users/me', requireAuth, async (req, res) => { + try { + const { first_name, last_name, avatar_url, address, latitude, longitude } = req.body || {}; + + 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); + + // ... existing address/latitude/longitude handling ... + + await userDbRun( + 'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?', + [fName, lName, avatar, addr, lat, lon, req.session.userId] + ); + + // ... rest of response ... + } +}); +``` + +- [ ] **Step 5: Add search history API endpoints** + +Add after user alerts API: + +```javascript +// Get search history for current user +app.get('/api/search-history', requireAuth, async (req, res) => { + try { + const rows = await userDbAll( + 'SELECT id, address, latitude, longitude, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20', + [req.session.userId] + ); + res.json(rows); + } catch (error) { + console.error('Error fetching search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Add new search to history +app.post('/api/search-history', requireAuth, async (req, res) => { + try { + const { address, latitude, longitude } = req.body || {}; + if (!address) { + return res.status(400).json({ error: 'Address is required' }); + } + + const result = await userDbRun( + 'INSERT INTO search_history (user_id, address, latitude, longitude) VALUES (?, ?, ?, ?)', + [req.session.userId, address, latitude || null, longitude || null] + ); + + res.json({ id: result.lastID, address, latitude, longitude }); + } catch (error) { + console.error('Error adding search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete search history item +app.delete('/api/search-history/:id', requireAuth, async (req, res) => { + try { + await userDbRun( + 'DELETE FROM search_history WHERE id = ? AND user_id = ?', + [req.params.id, req.session.userId] + ); + res.json({ ok: true }); + } catch (error) { + console.error('Error deleting search history:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); +``` + +- [ ] **Step 6: Commit backend changes** + +```bash +git add apps/backend/server.js +git commit -m "feat: add profile fields and search history API" +``` + +--- + +## Task 2: Frontend - Redesign ProfileView + +**Files:** +- Modify: `apps/frontend/src/views/ProfileView.jsx` +- Modify: `apps/frontend/src/views/ProfileView.css` + +- [ ] **Step 1: Update ProfileView.jsx with editable fields** + +Replace the entire ProfileView.jsx with: + +```jsx +import React, { useEffect, useState, useRef } from 'react'; +import './ProfileView.css'; + +function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) { + const [firstName, setFirstName] = useState(currentUser?.first_name || ''); + const [lastName, setLastName] = useState(currentUser?.last_name || ''); + const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || ''); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + const [searchHistory, setSearchHistory] = useState([]); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + setFirstName(currentUser?.first_name || ''); + setLastName(currentUser?.last_name || ''); + setAvatarUrl(currentUser?.avatar_url || ''); + setFeedback(null); + loadSearchHistory(); + }, [currentUser?.id]); + + async function loadSearchHistory() { + try { + const res = await fetch('/api/search-history', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setSearchHistory(data); + } + } catch (err) { + console.error('Error loading search history:', err); + } + } + + async function handleSave(e) { + e?.preventDefault(); + setSaving(true); + setFeedback(null); + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + avatar_url: avatarUrl || null, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Error al guardar (HTTP ${res.status})`); + } + const updated = await res.json(); + onProfileSaved?.(updated); + setFeedback({ type: 'ok', text: 'Perfil guardado.' }); + } catch (err) { + setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + } finally { + setSaving(false); + } + } + + async function handleAvatarUpload(e) { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + setFeedback(null); + try { + // Convert to base64 for simplicity (in production, use a file upload service) + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result; + setAvatarUrl(base64); + // Auto-save after upload + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ avatar_url: base64 }), + }); + if (res.ok) { + const updated = await res.json(); + onProfileSaved?.(updated); + setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); + } + } catch (err) { + setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + } + setUploading(false); + }; + reader.readAsDataURL(file); + } catch (err) { + setFeedback({ type: 'err', text: 'Error al procesar la imagen' }); + setUploading(false); + } + } + + async function handleDeleteSearch(id) { + try { + const res = await fetch(`/api/search-history/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + setSearchHistory(prev => prev.filter(item => item.id !== id)); + } + } catch (err) { + console.error('Error deleting search:', err); + } + } + + const displayName = [firstName, lastName].filter(Boolean).join(' ') || currentUser?.username || 'Usuario'; + + return ( +
+ {/* Avatar Section */} +
+
fileInputRef.current?.click()} + > + {avatarUrl ? ( + Avatar + ) : ( + + + + )} +
+ + + +
+
+ + {uploading &&

Subiendo foto...

} +
+ + {/* Editable Name Section */} +
+
+
+ + setFirstName(e.target.value)} + placeholder="Tu nombre" + disabled={saving} + /> +
+
+ + setLastName(e.target.value)} + placeholder="Tus apellidos" + disabled={saving} + /> +
+
+ + {feedback && ( +

{feedback.text}

+ )} + +
+ +
+
+ + {/* Search History Section */} +
+ + + {searchHistory.length > 0 && ( +
+

Tus búsquedas recientes:

+ {searchHistory.map((item) => ( +
+ {item.address} + +
+ ))} +
+ )} + + {currentUser?.is_admin && ( + + )} +
+ + +
+ ); +} + +export default ProfileView; +``` + +- [ ] **Step 2: Update ProfileView.css with new styles** + +Add these styles to the end of the CSS file: + +```css +/* Profile Avatar Editable */ +.profile-avatar-editable { + cursor: pointer; + position: relative; +} + +.profile-avatar-editable:hover .profile-avatar-overlay { + opacity: 1; +} + +.profile-avatar-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s; + color: white; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + border-radius: var(--radius-full); + object-fit: cover; +} + +/* Editable Info Cards */ +.info-card-input { + width: 100%; + padding: 0.75rem; + border: 2px solid var(--outline-variant); + border-radius: var(--radius); + background: var(--surface); + color: var(--on-surface); + font-size: 1rem; + font-family: inherit; + margin-top: 0.5rem; +} + +.info-card-input:focus { + outline: none; + border-color: var(--primary); +} + +.info-card-input:disabled { + opacity: 0.6; +} + +/* Search History */ +.profile-search-history { + background: var(--surface-container-lowest); + border: 1px solid var(--outline-variant); + border-radius: var(--radius-md); + padding: 1rem; + margin-top: 0.5rem; +} + +.profile-search-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--outline-variant); +} + +.profile-search-item:last-child { + border-bottom: none; +} + +.profile-search-address { + flex: 1; + font-size: 0.9rem; + color: var(--on-surface); +} + +.profile-search-delete { + background: none; + border: none; + cursor: pointer; + color: var(--on-surface-variant); + padding: 0.25rem; + border-radius: var(--radius); + transition: background 0.15s; +} + +.profile-search-delete:hover { + background: rgba(186, 26, 26, 0.1); + color: var(--error); +} +``` + +- [ ] **Step 3: Commit frontend changes** + +```bash +git add apps/frontend/src/views/ProfileView.jsx apps/frontend/src/views/ProfileView.css +git commit -m "feat: redesign profile view with editable fields and search history" +``` + +--- + +## Task 3: Mobile - Redesign ProfileScreen + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/profile.tsx` + +- [ ] **Step 1: Update ProfileScreen.tsx with new functionality** + +Replace the entire profile.tsx with: + +```tsx +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } 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'; + +interface SearchHistoryItem { + id: number; + address: string; + latitude: number | null; + longitude: number | null; + created_at: string; +} + +export default function ProfileScreen() { + const router = useRouter(); + const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); + + const [firstName, setFirstName] = useState(user?.first_name || ''); + const [lastName, setLastName] = useState(user?.last_name || ''); + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || ''); + const [searchHistory, setSearchHistory] = useState([]); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + useEffect(() => { + if (isAuthenticated) { + loadSearchHistory(); + } + }, [isAuthenticated]); + + useEffect(() => { + setFirstName(user?.first_name || ''); + setLastName(user?.last_name || ''); + setAvatarUrl(user?.avatar_url || ''); + }, [user]); + + async function loadSearchHistory() { + try { + const res = await fetch('/api/search-history', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + setSearchHistory(data); + } + } catch (err) { + console.error('Error loading search history:', err); + } + } + + async function handleSave() { + setSaving(true); + setFeedback(null); + try { + const res = await fetch('/api/users/me', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + first_name: firstName.trim() || null, + last_name: lastName.trim() || null, + avatar_url: avatarUrl || null, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'Error al guardar'); + } + setFeedback({ type: 'ok', text: 'Perfil guardado.' }); + } catch (err: any) { + setFeedback({ type: 'err', text: err.message || 'Error al guardar' }); + } finally { + setSaving(false); + } + } + + async function handlePickImage() { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + setAvatarUrl(result.assets[0].uri); + // Auto-save + 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) { + setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' }); + } + } catch (err) { + setFeedback({ type: 'err', text: 'Error al guardar la foto' }); + } + } + } + + async function handleDeleteSearch(id: number) { + try { + const res = await fetch(`/api/search-history/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (res.ok) { + setSearchHistory(prev => prev.filter(item => item.id !== id)); + } + } catch (err) { + console.error('Error deleting search:', err); + } + } + + const handleLogout = async () => { + Alert.alert( + 'Cerrar Sesión', + '¿Estás seguro que deseas cerrar sesión?', + [ + { text: 'Cancelar', style: 'cancel' }, + { + text: 'Cerrar Sesión', + style: 'destructive', + onPress: async () => { + await logout(); + router.replace('/auth/login'); + } + }, + ] + ); + }; + + if (isLoading) { + return ( + + Cargando... + + ); + } + + if (!isAuthenticated) { + return ( + + + + Inicia Sesión + + Inicia sesión para acceder a todas las funcionalidades + + router.push('/auth/login')} + > + Iniciar Sesión + + router.push('/auth/register')} + > + Crear cuenta nueva + + + + ); + } + + return ( + + {/* Avatar Section */} + + + {avatarUrl ? ( + + ) : ( + + )} + + + + + Toca para cambiar foto + + + {/* Editable Fields */} + + + Nombre + + + + Apellidos + + + + {feedback && ( + + {feedback.text} + + )} + + + {saving ? ( + + ) : ( + Guardar Cambios + )} + + + + {/* Menu Section */} + + router.push('/saved')}> + + Mis Direcciones + + + + {searchHistory.length > 0 && ( + + Tus búsquedas recientes: + {searchHistory.map((item) => ( + + {item.address} + handleDeleteSearch(item.id)} + style={styles.searchDelete} + > + + + + ))} + + )} + + {isAdmin && ( + + + Panel Admin + + + )} + + + {/* Logout Button */} + + + Cerrar Sesión + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + loadingText: { + textAlign: 'center', + marginTop: spacing.xxl, + color: colors.textSecondary, + }, + authPrompt: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + authTitle: { + fontSize: 24, + fontWeight: 'bold', + color: colors.text, + marginTop: spacing.lg, + }, + authSubtitle: { + fontSize: 16, + color: colors.textSecondary, + textAlign: 'center', + marginTop: spacing.sm, + marginBottom: spacing.xl, + }, + avatarSection: { + alignItems: 'center', + padding: spacing.xl, + backgroundColor: colors.card, + }, + avatarContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + avatarImage: { + width: 100, + height: 100, + borderRadius: 50, + }, + avatarOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + opacity: 0.8, + }, + editAvatarText: { + marginTop: spacing.sm, + color: colors.textSecondary, + fontSize: 14, + }, + formSection: { + padding: spacing.xl, + backgroundColor: colors.card, + marginTop: spacing.md, + }, + inputGroup: { + marginBottom: spacing.md, + }, + label: { + fontSize: 14, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + input: { + borderWidth: 1, + borderColor: colors.border || '#E0E0E0', + borderRadius: borderRadius.md, + padding: spacing.md, + fontSize: 16, + color: colors.text, + }, + feedback: { + padding: spacing.md, + borderRadius: borderRadius.md, + marginBottom: spacing.md, + }, + feedbackOk: { + backgroundColor: 'rgba(0, 69, 13, 0.1)', + borderWidth: 1, + borderColor: 'rgba(0, 69, 13, 0.2)', + }, + feedbackErr: { + backgroundColor: 'rgba(186, 26, 26, 0.1)', + borderWidth: 1, + borderColor: 'rgba(186, 26, 26, 0.2)', + }, + feedbackText: { + fontSize: 14, + }, + saveButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + }, + saveButtonDisabled: { + opacity: 0.6, + }, + saveButtonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + menuSection: { + marginTop: spacing.md, + backgroundColor: colors.card, + }, + menuItem: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + menuText: { + flex: 1, + marginLeft: spacing.md, + fontSize: 16, + color: colors.text, + }, + searchHistorySection: { + padding: spacing.md, + backgroundColor: colors.background, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchHistoryTitle: { + fontSize: 14, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + searchItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.separator, + }, + searchAddress: { + flex: 1, + fontSize: 14, + color: colors.text, + }, + searchDelete: { + padding: spacing.xs, + }, + button: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + }, + buttonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: '600', + }, + linkButton: { + marginTop: spacing.md, + }, + linkText: { + color: colors.primary, + fontSize: 14, + }, + logoutButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + margin: spacing.xl, + padding: spacing.md, + backgroundColor: colors.card, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.danger, + }, + logoutText: { + marginLeft: spacing.sm, + color: colors.danger, + fontSize: 16, + fontWeight: '500', + }, +}); +``` + +- [ ] **Step 2: Add expo-image-picker dependency** + +```bash +cd apps/frontend-mobile && npx expo install expo-image-picker +``` + +- [ ] **Step 3: Commit mobile changes** + +```bash +git add apps/frontend-mobile/app/\(tabs\)/profile.tsx +git commit -m "feat: redesign mobile profile with editable fields and avatar upload" +``` + +--- + +## Task 4: Integration - Save search history when user searches nearby + +**Files:** +- Modify: `apps/frontend-mobile/app/(tabs)/index.tsx` or wherever the "find pharmacies near me" search happens + +- [ ] **Step 1: Find the nearby search function** + +Search for the component that handles "buscar farmacias cerca de mí" or similar nearby search. + +- [ ] **Step 2: Add search history saving** + +After a successful nearby search, add: + +```javascript +// Save to search history +try { + await fetch('/api/search-history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + address: userAddress, // The address used in the search + latitude: userLatitude, + longitude: userLongitude, + }), + }); +} catch (err) { + console.error('Error saving search history:', err); +} +``` + +- [ ] **Step 3: Commit integration changes** + +```bash +git add +git commit -m "feat: save search history when searching nearby pharmacies" +``` + +--- + +## Task 5: Final verification and cleanup + +- [ ] **Step 1: Run tests** + +```bash +npm test +``` + +- [ ] **Step 2: Build and verify** + +```bash +npm run build +``` + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "chore: complete profile redesign implementation" +``` + +--- + +## Summary of Changes + +1. **Backend**: Added `first_name`, `last_name`, `avatar_url` fields to users table; created `search_history` table; added API endpoints for search history CRUD +2. **Frontend (Web)**: Redesigned ProfileView with editable name fields, clickable avatar for photo upload, search history display, removed emergency contacts and text configuration sections +3. **Mobile**: Redesigned ProfileScreen with same features using React Native components and expo-image-picker +4. **Integration**: Added search history saving when user performs nearby pharmacy searches diff --git a/package-lock.json b/package-lock.json index 6bc3d29..c8e181c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "turbo": "^2.5.0" } }, - "apps/API": {}, + "apps/API": { + "name": "farma-clic-api-sources" + }, "apps/backend": { "name": "farma-clic-backend", "version": "1.0.0", @@ -40,6 +42,7 @@ "@opentelemetry/sdk-trace-base": "^1.28.0", "@opentelemetry/semantic-conventions": "^1.28.0", "axios": "^1.6.0", + "barcode-detector": "^3.2.0", "bcrypt": "^5.1.1", "connect-pg-simple": "^10.0.0", "connect-sqlite3": "^0.9.16", @@ -103,6 +106,7 @@ "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.5", "expo-device": "~7.0.2", + "expo-image-picker": "~57.0.2", "expo-linking": "~57.0.1", "expo-local-authentication": "~57.0.0", "expo-notifications": "~57.0.3", @@ -724,6 +728,25 @@ "react-native": "*" } }, + "apps/frontend-mobile/node_modules/expo-image-loader": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.0.tgz", + "integrity": "sha512-EhwnoPC4T/EMdB7Nsg7qITzhO/qB0hUnmVghmtAKQwwDVaLWWYCGE4NLkes3zk9Ub451SmS/swgPp4PCPzOlnw==", + "peerDependencies": { + "expo": "*" + } + }, + "apps/frontend-mobile/node_modules/expo-image-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.2.tgz", + "integrity": "sha512-XW+C5weIthkOLCJZzExeRJf9pcWfaXNHSDm76gmZVhDUVIHdi9IS3eihAIF0WA0fBo9ogIfQs/iep/c6CzIMKg==", + "dependencies": { + "expo-image-loader": "~57.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "apps/frontend-mobile/node_modules/expo-keep-awake": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz",