Merge pull request 'feature/profile-redesign' (#12) from feature/profile-redesign into main
Build & Push Docker Images / test-backend (push) Successful in 2m1s
Build & Push Docker Images / test-frontend (push) Successful in 1m52s
Build & Push Docker Images / build-backend (push) Successful in 2m54s
Build & Push Docker Images / build-frontend (push) Successful in 1m13s
Build & Push Docker Images / deploy (push) Successful in 46s
Build & Push Docker Images / test-backend (push) Successful in 2m1s
Build & Push Docker Images / test-frontend (push) Successful in 1m52s
Build & Push Docker Images / build-backend (push) Successful in 2m54s
Build & Push Docker Images / build-frontend (push) Successful in 1m13s
Build & Push Docker Images / deploy (push) Successful in 46s
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '24'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '24'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -3,43 +3,43 @@ name: Run Tests on Branches
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-backend:
|
test-backend:
|
||||||
name: Backend Tests
|
name: Backend Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '24'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run Backend Tests
|
- name: Run Backend Tests
|
||||||
run: npm test --workspace=farma-clic-backend -- --ci
|
run: npm test --workspace=farma-clic-backend -- --ci
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
name: Frontend Tests
|
name: Frontend Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '24'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run Frontend Tests
|
- name: Run Frontend Tests
|
||||||
run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic
|
run: npm test --workspace=farma-clic-frontend -- --run --reporter=basic
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
FROM node:18-slim
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY apps/backend/package*.json ./
|
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 apk add --no-cache python3 make g++ && npm ci --omit=dev
|
||||||
RUN npm ci --omit=dev
|
|
||||||
COPY apps/backend/ .
|
COPY apps/backend/ .
|
||||||
COPY apps/API/ /API/
|
COPY apps/API/ /API/
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
Generated
+56
@@ -20,6 +20,7 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"barcode-detector": "^3.2.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
@@ -3458,6 +3459,12 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/graceful-fs": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||||
@@ -3946,6 +3953,15 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -8743,6 +8759,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tar": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
@@ -9293,6 +9321,34 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"barcode-detector": "^3.2.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
|
|||||||
+125
-6
@@ -290,6 +290,23 @@ if (!pgPool) {
|
|||||||
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
|
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)
|
// 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 ADD COLUMN user_id INTEGER'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy 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) {
|
} catch (e) {
|
||||||
if (!/table already exists/i.test(e.message)) throw 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) {
|
} catch (err) {
|
||||||
console.error('initDatabase failed:', err);
|
console.error('initDatabase failed:', err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -664,6 +707,9 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
|||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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),
|
is_admin: Boolean(user.is_admin),
|
||||||
address: user.address || null,
|
address: user.address || null,
|
||||||
latitude: user.latitude,
|
latitude: user.latitude,
|
||||||
@@ -710,6 +756,9 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => {
|
|||||||
user: {
|
user: {
|
||||||
id: result.lastID,
|
id: result.lastID,
|
||||||
username: u,
|
username: u,
|
||||||
|
first_name: null,
|
||||||
|
last_name: null,
|
||||||
|
avatar_url: null,
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
address: null,
|
address: null,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
@@ -738,7 +787,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, 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]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -750,6 +799,9 @@ app.get('/api/auth/check', async (req, res) => {
|
|||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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),
|
is_admin: Boolean(user.is_admin),
|
||||||
address: user.address || null,
|
address: user.address || null,
|
||||||
latitude: user.latitude,
|
latitude: user.latitude,
|
||||||
@@ -768,13 +820,18 @@ app.get('/api/auth/check', async (req, res) => {
|
|||||||
app.get('/api/users/me', requireAuth, async (req, res) => {
|
app.get('/api/users/me', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await userDbGet(
|
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]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
if (!user) return res.status(404).json({ error: 'Not found' });
|
if (!user) return res.status(404).json({ error: 'Not found' });
|
||||||
res.json({
|
res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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),
|
is_admin: Boolean(user.is_admin),
|
||||||
address: user.address || null,
|
address: user.address || null,
|
||||||
latitude: user.latitude,
|
latitude: user.latitude,
|
||||||
@@ -789,7 +846,14 @@ 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 { 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 =
|
const addr =
|
||||||
address == null || String(address).trim() === ''
|
address == null || String(address).trim() === ''
|
||||||
? null
|
? null
|
||||||
@@ -811,17 +875,22 @@ app.put('/api/users/me', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await userDbRun(
|
await userDbRun(
|
||||||
'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, email = ?, city = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
||||||
[addr, lat, lon, req.session.userId]
|
[fName, lName, avatar, userEmail, userCity, addr, lat, lon, req.session.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = await userDbGet(
|
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]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
res.json({
|
res.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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),
|
is_admin: Boolean(user.is_admin),
|
||||||
address: user.address || null,
|
address: user.address || null,
|
||||||
latitude: user.latitude,
|
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.
|
// 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();
|
||||||
|
|||||||
@@ -1,14 +1,145 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal } 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 { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { colors, spacing, borderRadius } from '../../constants/theme';
|
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() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
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<SearchHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// 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 () => {
|
const handleLogout = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Cerrar Sesión',
|
'Cerrar Sesión',
|
||||||
@@ -62,16 +193,65 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<ScrollView style={styles.container}>
|
||||||
<View style={styles.header}>
|
{/* Avatar Section */}
|
||||||
<View style={styles.avatar}>
|
<View style={styles.avatarSection}>
|
||||||
<Ionicons name="person" size={40} color={colors.primary} />
|
<TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
|
||||||
</View>
|
{avatarUrl ? (
|
||||||
<Text style={styles.username}>{user?.username}</Text>
|
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
|
||||||
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
|
) : (
|
||||||
|
<Ionicons name="person" size={40} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
<View style={styles.avatarOverlay}>
|
||||||
|
<Ionicons name="camera" size={24} color="white" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.editAvatarText}>Toca para cambiar foto</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Read-only Name Section */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<Text style={styles.infoLabel}>Nombre</Text>
|
||||||
|
<Text style={styles.infoValue}>{firstName || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<Text style={styles.infoLabel}>Apellidos</Text>
|
||||||
|
<Text style={styles.infoValue}>{lastName || '—'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Menu Section */}
|
||||||
<View style={styles.menuSection}>
|
<View style={styles.menuSection}>
|
||||||
|
<TouchableOpacity style={styles.menuItem} onPress={openConfig}>
|
||||||
|
<Ionicons name="settings" size={24} color={colors.textSecondary} />
|
||||||
|
<Text style={styles.menuText}>Configuración</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.menuItem}>
|
||||||
|
<Ionicons name="location" size={24} color={colors.primary} />
|
||||||
|
<Text style={styles.menuText}>Mis Direcciones</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{searchHistory.length > 0 && (
|
||||||
|
<View style={styles.searchHistorySection}>
|
||||||
|
<Text style={styles.searchHistoryTitle}>Tus búsquedas recientes:</Text>
|
||||||
|
{searchHistory.map((item) => (
|
||||||
|
<View key={item.id} style={styles.searchItem}>
|
||||||
|
<Text style={styles.searchAddress}>{item.address}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleDeleteSearch(item.id)}
|
||||||
|
style={styles.searchDelete}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<TouchableOpacity style={styles.menuItem}>
|
<TouchableOpacity style={styles.menuItem}>
|
||||||
<Ionicons name="settings" size={24} color={colors.text} />
|
<Ionicons name="settings" size={24} color={colors.text} />
|
||||||
@@ -79,31 +259,109 @@ export default function ProfileScreen() {
|
|||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TouchableOpacity style={styles.menuItem}>
|
|
||||||
<Ionicons name="heart" size={24} color={colors.text} />
|
|
||||||
<Text style={styles.menuText}>Favoritos</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.menuItem}>
|
|
||||||
<Ionicons name="notifications" size={24} color={colors.text} />
|
|
||||||
<Text style={styles.menuText}>Notificaciones</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.menuItem}>
|
|
||||||
<Ionicons name="help-circle" size={24} color={colors.text} />
|
|
||||||
<Text style={styles.menuText}>Ayuda</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
<Ionicons name="log-out" size={20} color={colors.danger} />
|
<Ionicons name="log-out" size={20} color={colors.danger} />
|
||||||
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
{/* Config Modal */}
|
||||||
|
<Modal visible={showConfig} animationType="slide" transparent>
|
||||||
|
<View style={styles.modalBackdrop}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Configuración</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowConfig(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={styles.modalBody}>
|
||||||
|
<View style={styles.modalField}>
|
||||||
|
<Text style={styles.modalLabel}>Nombre</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={configFirstName}
|
||||||
|
onChangeText={setConfigFirstName}
|
||||||
|
placeholder="Tu nombre"
|
||||||
|
editable={!configSaving}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalField}>
|
||||||
|
<Text style={styles.modalLabel}>Apellidos</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={configLastName}
|
||||||
|
onChangeText={setConfigLastName}
|
||||||
|
placeholder="Tus apellidos"
|
||||||
|
editable={!configSaving}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalField}>
|
||||||
|
<Text style={styles.modalLabel}>Correo electrónico</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={configEmail}
|
||||||
|
onChangeText={setConfigEmail}
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
editable={!configSaving}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalField}>
|
||||||
|
<Text style={styles.modalLabel}>Ciudad</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={configCity}
|
||||||
|
onChangeText={setConfigCity}
|
||||||
|
placeholder="Tu ciudad"
|
||||||
|
editable={!configSaving}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.modalField}>
|
||||||
|
<Text style={styles.modalLabel}>Dirección</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={configAddress}
|
||||||
|
onChangeText={setConfigAddress}
|
||||||
|
placeholder="Calle Mayor 1, Madrid"
|
||||||
|
editable={!configSaving}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{configFeedback && (
|
||||||
|
<View style={[styles.modalFeedback, configFeedback.type === 'ok' ? styles.modalFeedbackOk : styles.modalFeedbackErr]}>
|
||||||
|
<Text style={styles.modalFeedbackText}>{configFeedback.text}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.modalCancelBtn}
|
||||||
|
onPress={() => setShowConfig(false)}
|
||||||
|
disabled={configSaving}
|
||||||
|
>
|
||||||
|
<Text style={styles.modalCancelText}>Cancelar</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modalSaveBtn, configSaving && styles.modalSaveBtnDisabled]}
|
||||||
|
onPress={handleConfigSave}
|
||||||
|
disabled={configSaving}
|
||||||
|
>
|
||||||
|
{configSaving ? (
|
||||||
|
<ActivityIndicator color={colors.textInverse} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.modalSaveText}>Guardar</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,33 +394,66 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
marginBottom: spacing.xl,
|
marginBottom: spacing.xl,
|
||||||
},
|
},
|
||||||
header: {
|
avatarSection: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: spacing.xl,
|
padding: spacing.xl,
|
||||||
backgroundColor: colors.card,
|
backgroundColor: colors.card,
|
||||||
},
|
},
|
||||||
avatar: {
|
avatarContainer: {
|
||||||
width: 80,
|
width: 100,
|
||||||
height: 80,
|
height: 100,
|
||||||
borderRadius: 40,
|
borderRadius: 50,
|
||||||
backgroundColor: colors.background,
|
backgroundColor: colors.background,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
username: {
|
avatarImage: {
|
||||||
fontSize: 20,
|
width: 100,
|
||||||
fontWeight: 'bold',
|
height: 100,
|
||||||
color: colors.text,
|
borderRadius: 50,
|
||||||
marginTop: spacing.md,
|
|
||||||
},
|
},
|
||||||
adminBadge: {
|
avatarOverlay: {
|
||||||
fontSize: 12,
|
position: 'absolute',
|
||||||
color: colors.primary,
|
top: 0,
|
||||||
backgroundColor: '#E3F2FD',
|
left: 0,
|
||||||
paddingHorizontal: spacing.sm,
|
right: 0,
|
||||||
paddingVertical: spacing.xs,
|
bottom: 0,
|
||||||
borderRadius: borderRadius.sm,
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
editAvatarText: {
|
||||||
marginTop: spacing.sm,
|
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: {
|
menuSection: {
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
@@ -181,6 +472,33 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: colors.text,
|
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: {
|
button: {
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
@@ -216,4 +534,101 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"expo-constants": "~57.0.3",
|
"expo-constants": "~57.0.3",
|
||||||
"expo-dev-client": "~57.0.5",
|
"expo-dev-client": "~57.0.5",
|
||||||
"expo-device": "~7.0.2",
|
"expo-device": "~7.0.2",
|
||||||
|
"expo-image-picker": "~57.0.2",
|
||||||
"expo-linking": "~57.0.1",
|
"expo-linking": "~57.0.1",
|
||||||
"expo-local-authentication": "~57.0.0",
|
"expo-local-authentication": "~57.0.0",
|
||||||
"expo-notifications": "~57.0.3",
|
"expo-notifications": "~57.0.3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:24-alpine AS build
|
||||||
ARG VITE_FARO_ENDPOINT
|
ARG VITE_FARO_ENDPOINT
|
||||||
ARG VITE_FARO_APP_NAME=farmaclic-frontend
|
ARG VITE_FARO_APP_NAME=farmaclic-frontend
|
||||||
ARG VITE_FARO_ENV=production
|
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
|
ENV VITE_FARO_APP_VERSION=$VITE_FARO_APP_VERSION
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-4
@@ -18,7 +18,7 @@
|
|||||||
"@grafana/faro-web-sdk": "^1.7.0",
|
"@grafana/faro-web-sdk": "^1.7.0",
|
||||||
"@grafana/faro-web-tracing": "^1.7.0",
|
"@grafana/faro-web-tracing": "^1.7.0",
|
||||||
"@zxing/browser": "^0.2.0",
|
"@zxing/browser": "^0.2.0",
|
||||||
"@zxing/library": "^0.22.0",
|
"@zxing/library": "^0.23.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -3704,9 +3704,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@zxing/library": {
|
"node_modules/@zxing/library": {
|
||||||
"version": "0.22.0",
|
"version": "0.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
|
||||||
"integrity": "sha512-BmInervZV7NwaZWX1LW64sZ4Lh4wxXYFZwGmj98ArPOkRXCtO9b8Gog0Xyh82dsYYGOeRxX+aAhLSq+hQ2XLZQ==",
|
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ts-custom-error": "^3.3.1"
|
"ts-custom-error": "^3.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function SearchBar({ value, onChange, placeholder }) {
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="search-input"
|
className="search-input"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -345,3 +345,218 @@
|
|||||||
min-height: 4rem;
|
min-height: 4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config Modal */
|
||||||
|
.profile-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal {
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-field input {
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border: 2px solid var(--outline-variant);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--on-surface);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-field input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-field input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn-cancel {
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
color: var(--on-surface);
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-btn-cancel:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,68 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import './ProfileView.css';
|
import './ProfileView.css';
|
||||||
import { getUserPosition } from '../utils/geo';
|
|
||||||
|
|
||||||
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdminClick }) {
|
function ProfileView({ currentUser, onProfileSaved, onLogout, onAdminClick }) {
|
||||||
const [address, setAddress] = useState(currentUser?.address || '');
|
const [firstName, setFirstName] = useState(currentUser?.first_name || '');
|
||||||
const [latitude, setLatitude] = useState(
|
const [lastName, setLastName] = useState(currentUser?.last_name || '');
|
||||||
currentUser?.latitude != null ? String(currentUser.latitude) : ''
|
const [avatarUrl, setAvatarUrl] = useState(currentUser?.avatar_url || '');
|
||||||
);
|
const [searchHistory, setSearchHistory] = useState([]);
|
||||||
const [longitude, setLongitude] = useState(
|
const [uploading, setUploading] = useState(false);
|
||||||
currentUser?.longitude != null ? String(currentUser.longitude) : ''
|
const fileInputRef = useRef(null);
|
||||||
);
|
|
||||||
const [saving, setSaving] = useState(false);
|
// Config modal state
|
||||||
const [geocoding, setGeocoding] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [locating, setLocating] = useState(false);
|
const [configFirstName, setConfigFirstName] = useState('');
|
||||||
const [feedback, setFeedback] = useState(null);
|
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(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAddress(currentUser?.address || '');
|
setFirstName(currentUser?.first_name || '');
|
||||||
setLatitude(currentUser?.latitude != null ? String(currentUser.latitude) : '');
|
setLastName(currentUser?.last_name || '');
|
||||||
setLongitude(currentUser?.longitude != null ? String(currentUser.longitude) : '');
|
setAvatarUrl(currentUser?.avatar_url || '');
|
||||||
setFeedback(null);
|
loadSearchHistory();
|
||||||
}, [currentUser?.id]);
|
}, [currentUser?.id]);
|
||||||
|
|
||||||
async function handleSave(e) {
|
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(currentUser?.first_name || '');
|
||||||
|
setConfigLastName(currentUser?.last_name || '');
|
||||||
|
setConfigEmail(currentUser?.email || '');
|
||||||
|
setConfigCity(currentUser?.city || '');
|
||||||
|
setConfigAddress(currentUser?.address || '');
|
||||||
|
setConfigFeedback(null);
|
||||||
|
setShowConfig(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfigSave(e) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
setSaving(true);
|
setConfigSaving(true);
|
||||||
setFeedback(null);
|
setConfigFeedback(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/me', {
|
const res = await fetch('/api/users/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
address: address.trim() || null,
|
first_name: configFirstName.trim() || null,
|
||||||
latitude: latitude === '' ? null : latitude,
|
last_name: configLastName.trim() || null,
|
||||||
longitude: longitude === '' ? null : longitude,
|
email: configEmail.trim() || null,
|
||||||
|
city: configCity.trim() || null,
|
||||||
|
address: configAddress.trim() || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -43,87 +71,124 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
|||||||
}
|
}
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
onProfileSaved?.(updated);
|
onProfileSaved?.(updated);
|
||||||
setFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
setFirstName(updated.first_name || '');
|
||||||
|
setLastName(updated.last_name || '');
|
||||||
|
setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
||||||
|
setTimeout(() => setShowConfig(false), 1200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setConfigSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUseLocation() {
|
async function handleAvatarUpload(e) {
|
||||||
setLocating(true);
|
const file = e.target.files?.[0];
|
||||||
setFeedback(null);
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const pos = await getUserPosition();
|
const reader = new FileReader();
|
||||||
setLatitude(String(pos.lat));
|
reader.onload = async () => {
|
||||||
setLongitude(String(pos.lon));
|
const base64 = reader.result;
|
||||||
setFeedback({ type: 'ok', text: 'Ubicación obtenida — recuerda Guardar.' });
|
setAvatarUrl(base64);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving avatar:', err);
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let msg = 'No se pudo obtener la ubicación';
|
console.error('Error processing image:', err);
|
||||||
if (err && typeof err.code === 'number') {
|
setUploading(false);
|
||||||
if (err.code === 1) msg = 'Permiso de ubicación denegado';
|
|
||||||
else if (err.code === 2) msg = 'Ubicación no disponible';
|
|
||||||
else if (err.code === 3) msg = 'La solicitud expiró';
|
|
||||||
}
|
|
||||||
setFeedback({ type: 'err', text: msg });
|
|
||||||
} finally {
|
|
||||||
setLocating(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGeocode() {
|
async function handleDeleteSearch(id) {
|
||||||
const q = address.trim();
|
|
||||||
if (!q) {
|
|
||||||
setFeedback({ type: 'err', text: 'Ingresa una dirección primero.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGeocoding(true);
|
|
||||||
setFeedback(null);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`, {
|
const res = await fetch(`/api/search-history/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
setSearchHistory(prev => prev.filter(item => item.id !== id));
|
||||||
throw new Error(err.error || `Error en la búsqueda (HTTP ${res.status})`);
|
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
|
||||||
setLatitude(String(data.lat));
|
|
||||||
setLongitude(String(data.lon));
|
|
||||||
setFeedback({
|
|
||||||
type: 'ok',
|
|
||||||
text: `Ubicado: ${data.displayName} — recuerda Guardar.`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFeedback({ type: 'err', text: err.message || 'Error en la búsqueda' });
|
console.error('Error deleting search:', err);
|
||||||
} finally {
|
|
||||||
setGeocoding(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = currentUser?.username || 'Usuario';
|
const displayName = [firstName, lastName].filter(Boolean).join(' ') || currentUser?.username || 'Usuario';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="profile-view">
|
<div className="profile-view">
|
||||||
|
{/* Avatar Section */}
|
||||||
<div className="profile-avatar-section">
|
<div className="profile-avatar-section">
|
||||||
<div className="profile-avatar-circle">
|
<div
|
||||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor">
|
className="profile-avatar-circle profile-avatar-editable"
|
||||||
<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" />
|
onClick={() => fileInputRef.current?.click()}
|
||||||
</svg>
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="Avatar" className="profile-avatar-image" />
|
||||||
|
) : (
|
||||||
|
<svg width="80" height="80" 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>
|
||||||
|
)}
|
||||||
|
<div className="profile-avatar-overlay">
|
||||||
|
<svg width="24" height="24" 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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="profile-name">Mi Perfil</h2>
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
{uploading && <p className="profile-section-sub">Subiendo foto...</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Read-only Name Section */}
|
||||||
<div className="profile-info-cards">
|
<div className="profile-info-cards">
|
||||||
<div className="profile-info-card">
|
<div className="profile-info-card">
|
||||||
<p className="info-card-label">Nombre</p>
|
<p className="info-card-label">Nombre</p>
|
||||||
<p className="info-card-value">{username}</p>
|
<p className="info-card-value">{firstName || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="profile-info-card">
|
||||||
|
<p className="info-card-label">Apellidos</p>
|
||||||
|
<p className="info-card-value">{lastName || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Section */}
|
||||||
<div className="profile-menu">
|
<div className="profile-menu">
|
||||||
<button className="profile-menu-item" onClick={onShowSaved}>
|
<button className="profile-menu-item" onClick={openConfig}>
|
||||||
|
<div className="menu-item-icon menu-item-icon--secondary">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="menu-item-label">Configuración</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="profile-menu-item">
|
||||||
<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" />
|
||||||
@@ -133,31 +198,27 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
|||||||
<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>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button className="profile-menu-item">
|
{searchHistory.length > 0 && (
|
||||||
<div className="menu-item-icon menu-item-icon--error">
|
<div className="profile-search-history">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<p className="profile-section-sub">Tus búsquedas recientes:</p>
|
||||||
<path d="M22 3H2C.9 3 0 3.9 0 5v14c0 1.1.9 2 2 2h20c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm6 12H2v-1c0-2 4-3.1 6-3.1s6 1.1 6 3.1v1zm8-5h-5V9h5v4z" />
|
{searchHistory.map((item) => (
|
||||||
</svg>
|
<div key={item.id} className="profile-search-item">
|
||||||
|
<span className="profile-search-address">{item.address}</span>
|
||||||
|
<button
|
||||||
|
className="profile-search-delete"
|
||||||
|
onClick={() => handleDeleteSearch(item.id)}
|
||||||
|
title="Eliminar"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" 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>
|
||||||
<span className="menu-item-label">Contactos de Emergencia</span>
|
)}
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="profile-menu-item">
|
|
||||||
<div className="menu-item-icon menu-item-icon--secondary">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-7 2h2.5v12H13V6zm-2 12H8.5V6H11v12zM4 6h2.5v12H4V6zm16 12h-2.5V6H20v12z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="menu-item-label">Configuración de Texto</span>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="menu-item-chevron">
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{currentUser?.is_admin && (
|
{currentUser?.is_admin && (
|
||||||
<button className="profile-menu-item" onClick={onAdminClick}>
|
<button className="profile-menu-item" onClick={onAdminClick}>
|
||||||
@@ -174,67 +235,6 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profile-location-section">
|
|
||||||
<h3>Tu Ubicación</h3>
|
|
||||||
<p className="profile-section-sub">Guarda tu dirección para ordenar por distancia sin pedir permiso cada vez.</p>
|
|
||||||
<form onSubmit={handleSave} className="profile-form">
|
|
||||||
<div className="profile-field">
|
|
||||||
<label htmlFor="profile-address">Dirección</label>
|
|
||||||
<input
|
|
||||||
id="profile-address"
|
|
||||||
type="text"
|
|
||||||
placeholder="Calle Mayor 1, Madrid"
|
|
||||||
value={address}
|
|
||||||
onChange={(e) => setAddress(e.target.value)}
|
|
||||||
autoComplete="street-address"
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="profile-field-row">
|
|
||||||
<div className="profile-field">
|
|
||||||
<label htmlFor="profile-lat">Latitud</label>
|
|
||||||
<input
|
|
||||||
id="profile-lat"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="40.4168"
|
|
||||||
value={latitude}
|
|
||||||
onChange={(e) => setLatitude(e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="profile-field">
|
|
||||||
<label htmlFor="profile-lon">Longitud</label>
|
|
||||||
<input
|
|
||||||
id="profile-lon"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="-3.7038"
|
|
||||||
value={longitude}
|
|
||||||
onChange={(e) => setLongitude(e.target.value)}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="profile-helper-actions">
|
|
||||||
<button type="button" className="profile-btn-secondary" onClick={handleGeocode} disabled={geocoding || saving}>
|
|
||||||
{geocoding ? 'Buscando…' : '📍 Buscar desde dirección'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="profile-btn-secondary" onClick={handleUseLocation} disabled={locating || saving}>
|
|
||||||
{locating ? 'Localizando…' : '🛰️ Usar mi ubicación'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{feedback && (
|
|
||||||
<p className={`profile-feedback profile-feedback--${feedback.type}`}>{feedback.text}</p>
|
|
||||||
)}
|
|
||||||
<div className="profile-actions">
|
|
||||||
<button type="submit" className="profile-btn-primary" disabled={saving}>
|
|
||||||
{saving ? 'Guardando…' : 'Guardar'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="profile-logout-btn" onClick={onLogout}>
|
<button className="profile-logout-btn" onClick={onLogout}>
|
||||||
<div className="menu-item-icon menu-item-icon--error-outline">
|
<div className="menu-item-icon menu-item-icon--error-outline">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -243,6 +243,83 @@ function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout, onAdm
|
|||||||
</div>
|
</div>
|
||||||
<span>Cerrar Sesión</span>
|
<span>Cerrar Sesión</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Config Modal */}
|
||||||
|
{showConfig && (
|
||||||
|
<div className="profile-modal-backdrop" onClick={() => setShowConfig(false)}>
|
||||||
|
<div className="profile-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="profile-modal-header">
|
||||||
|
<h3>Configuración</h3>
|
||||||
|
<button className="profile-modal-close" onClick={() => setShowConfig(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleConfigSave} className="profile-modal-body">
|
||||||
|
<div className="profile-modal-field">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configFirstName}
|
||||||
|
onChange={(e) => setConfigFirstName(e.target.value)}
|
||||||
|
placeholder="Tu nombre"
|
||||||
|
disabled={configSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="profile-modal-field">
|
||||||
|
<label>Apellidos</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configLastName}
|
||||||
|
onChange={(e) => setConfigLastName(e.target.value)}
|
||||||
|
placeholder="Tus apellidos"
|
||||||
|
disabled={configSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="profile-modal-field">
|
||||||
|
<label>Correo electrónico</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={configEmail}
|
||||||
|
onChange={(e) => setConfigEmail(e.target.value)}
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
disabled={configSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="profile-modal-field">
|
||||||
|
<label>Ciudad</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configCity}
|
||||||
|
onChange={(e) => setConfigCity(e.target.value)}
|
||||||
|
placeholder="Tu ciudad"
|
||||||
|
disabled={configSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="profile-modal-field">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configAddress}
|
||||||
|
onChange={(e) => setConfigAddress(e.target.value)}
|
||||||
|
placeholder="Calle Mayor 1, Madrid"
|
||||||
|
disabled={configSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configFeedback && (
|
||||||
|
<p className={`profile-feedback profile-feedback--${configFeedback.type}`}>{configFeedback.text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profile-modal-actions">
|
||||||
|
<button type="button" className="profile-btn-cancel" onClick={() => setShowConfig(false)} disabled={configSaving}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="profile-btn-primary" disabled={configSaving}>
|
||||||
|
{configSaving ? 'Guardando...' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ function PublicView({
|
|||||||
setUserPosition({ lat: savedLat, lon: savedLon });
|
setUserPosition({ lat: savedLat, lon: savedLon });
|
||||||
setPositionSource('profile');
|
setPositionSource('profile');
|
||||||
setSortByDistance(true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (userPosition) {
|
if (userPosition) {
|
||||||
@@ -104,6 +119,21 @@ function PublicView({
|
|||||||
setUserPosition(pos);
|
setUserPosition(pos);
|
||||||
setPositionSource('browser');
|
setPositionSource('browser');
|
||||||
setSortByDistance(true);
|
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) {
|
} catch (err) {
|
||||||
console.error('[location] error', err);
|
console.error('[location] error', err);
|
||||||
let msg;
|
let msg;
|
||||||
|
|||||||
@@ -120,6 +120,21 @@ function SearchView({ currentUser, onLoginRequest }) {
|
|||||||
setUserPosition({ lat: savedLat, lon: savedLon });
|
setUserPosition({ lat: savedLat, lon: savedLon });
|
||||||
setPositionSource('profile');
|
setPositionSource('profile');
|
||||||
setSortByDistance(true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (userPosition) {
|
if (userPosition) {
|
||||||
@@ -132,6 +147,21 @@ function SearchView({ currentUser, onLoginRequest }) {
|
|||||||
setUserPosition(pos);
|
setUserPosition(pos);
|
||||||
setPositionSource('browser');
|
setPositionSource('browser');
|
||||||
setSortByDistance(true);
|
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) {
|
} catch (err) {
|
||||||
let msg = 'No se pudo obtener tu ubicación';
|
let msg = 'No se pudo obtener tu ubicación';
|
||||||
if (err && typeof err.code === 'number') {
|
if (err && typeof err.code === 'number') {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Generated
+24
-1
@@ -23,7 +23,9 @@
|
|||||||
"turbo": "^2.5.0"
|
"turbo": "^2.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/API": {},
|
"apps/API": {
|
||||||
|
"name": "farma-clic-api-sources"
|
||||||
|
},
|
||||||
"apps/backend": {
|
"apps/backend": {
|
||||||
"name": "farma-clic-backend",
|
"name": "farma-clic-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
"@opentelemetry/sdk-trace-base": "^1.28.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.28.0",
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"barcode-detector": "^3.2.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
@@ -103,6 +106,7 @@
|
|||||||
"expo-constants": "~57.0.3",
|
"expo-constants": "~57.0.3",
|
||||||
"expo-dev-client": "~57.0.5",
|
"expo-dev-client": "~57.0.5",
|
||||||
"expo-device": "~7.0.2",
|
"expo-device": "~7.0.2",
|
||||||
|
"expo-image-picker": "~57.0.2",
|
||||||
"expo-linking": "~57.0.1",
|
"expo-linking": "~57.0.1",
|
||||||
"expo-local-authentication": "~57.0.0",
|
"expo-local-authentication": "~57.0.0",
|
||||||
"expo-notifications": "~57.0.3",
|
"expo-notifications": "~57.0.3",
|
||||||
@@ -724,6 +728,25 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"apps/frontend-mobile/node_modules/expo-keep-awake": {
|
||||||
"version": "57.0.0",
|
"version": "57.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.0.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user