More changes than expected. Mobile version improvements & PWA
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 52s
Build & Push Docker Images / build-backend (push) Failing after 34s
Build & Push Docker Images / build-frontend (push) Failing after 30s
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 52s
Build & Push Docker Images / build-backend (push) Failing after 34s
Build & Push Docker Images / build-frontend (push) Failing after 30s
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
.profile-view {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.5rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-faint);
|
||||
color: var(--primary);
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.profile-pill {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.4rem 1.5rem;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.profile-section h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.profile-section-sub {
|
||||
margin: 0 0 1.1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.profile-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-field label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.profile-field input {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-field input:focus {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile-field input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.profile-field-row {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.profile-helper-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-btn-secondary {
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.profile-btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.profile-feedback {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.profile-feedback--ok {
|
||||
background: var(--primary-faint);
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary-ring);
|
||||
}
|
||||
|
||||
.profile-feedback--err {
|
||||
background: rgba(185, 28, 28, 0.08);
|
||||
color: #b91c1c;
|
||||
border: 1px solid rgba(185, 28, 28, 0.2);
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.profile-btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.65rem 1.6rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.profile-btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.profile-btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.profile-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
padding: 0.8rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-main);
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.profile-link-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile-link-row:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.profile-link-row-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-link-row-label {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-link-row-chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-link-row--danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.profile-link-row--danger:hover {
|
||||
background: rgba(185, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.profile-field-row {
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 1.1rem 1.1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import './ProfileView.css';
|
||||
import { getUserPosition } from '../utils/geo';
|
||||
|
||||
function ProfileView({ currentUser, onProfileSaved, onShowSaved, onLogout }) {
|
||||
const [address, setAddress] = useState(currentUser?.address || '');
|
||||
const [latitude, setLatitude] = useState(
|
||||
currentUser?.latitude != null ? String(currentUser.latitude) : ''
|
||||
);
|
||||
const [longitude, setLongitude] = useState(
|
||||
currentUser?.longitude != null ? String(currentUser.longitude) : ''
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAddress(currentUser?.address || '');
|
||||
setLatitude(currentUser?.latitude != null ? String(currentUser.latitude) : '');
|
||||
setLongitude(currentUser?.longitude != null ? String(currentUser.longitude) : '');
|
||||
setFeedback(null);
|
||||
}, [currentUser?.id]);
|
||||
|
||||
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({
|
||||
address: address.trim() || null,
|
||||
latitude: latitude === '' ? null : latitude,
|
||||
longitude: longitude === '' ? null : longitude,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Save failed (HTTP ${res.status})`);
|
||||
}
|
||||
const updated = await res.json();
|
||||
onProfileSaved?.(updated);
|
||||
setFeedback({ type: 'ok', text: 'Profile saved.' });
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Save failed' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUseLocation() {
|
||||
setLocating(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const pos = await getUserPosition();
|
||||
setLatitude(String(pos.lat));
|
||||
setLongitude(String(pos.lon));
|
||||
setFeedback({ type: 'ok', text: 'Got your current location — remember to Save.' });
|
||||
} catch (err) {
|
||||
let msg = err && err.message ? err.message : 'Could not get location';
|
||||
if (err && typeof err.code === 'number') {
|
||||
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings.';
|
||||
else if (err.code === 2) msg = 'Location unavailable — check OS location services.';
|
||||
else if (err.code === 3) msg = 'Location request timed out — try again.';
|
||||
}
|
||||
setFeedback({ type: 'err', text: msg });
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGeocode() {
|
||||
const q = address.trim();
|
||||
if (!q) {
|
||||
setFeedback({ type: 'err', text: 'Enter an address first.' });
|
||||
return;
|
||||
}
|
||||
setGeocoding(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const res = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Lookup failed (HTTP ${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setLatitude(String(data.lat));
|
||||
setLongitude(String(data.lon));
|
||||
setFeedback({
|
||||
type: 'ok',
|
||||
text: `Located: ${data.displayName} — remember to Save.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Lookup failed' });
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasSavedCoords =
|
||||
currentUser?.latitude != null && currentUser?.longitude != null;
|
||||
|
||||
return (
|
||||
<div className="profile-view">
|
||||
<header className="profile-header">
|
||||
<div className="profile-avatar" aria-hidden="true">👤</div>
|
||||
<div className="profile-info">
|
||||
<h2>{currentUser?.username}</h2>
|
||||
<p className="profile-role">
|
||||
{currentUser?.is_admin ? 'Administrator' : 'Member'}
|
||||
{hasSavedCoords && <span className="profile-pill"> · Location saved</span>}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="profile-section">
|
||||
<h3>Your location</h3>
|
||||
<p className="profile-section-sub">
|
||||
Save your address so "Sort by distance" uses it without asking the browser every time.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSave} className="profile-form">
|
||||
<div className="profile-field">
|
||||
<label htmlFor="profile-address">Address</label>
|
||||
<input
|
||||
id="profile-address"
|
||||
type="text"
|
||||
placeholder="123 Main St, Madrid, Spain"
|
||||
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">Latitude</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">Longitude</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 ? 'Finding…' : '📍 Find from address'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-btn-secondary"
|
||||
onClick={handleUseLocation}
|
||||
disabled={locating || saving}
|
||||
>
|
||||
{locating ? 'Locating…' : '🛰️ Use my current location'}
|
||||
</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 ? 'Saving…' : 'Save profile'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="profile-section">
|
||||
<h3>Account</h3>
|
||||
<button type="button" className="profile-link-row" onClick={onShowSaved}>
|
||||
<span className="profile-link-row-icon">🔔</span>
|
||||
<span className="profile-link-row-label">Saved notifications</span>
|
||||
<span className="profile-link-row-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-link-row profile-link-row--danger"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<span className="profile-link-row-icon">↪</span>
|
||||
<span className="profile-link-row-label">Logout</span>
|
||||
<span className="profile-link-row-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileView;
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import '../App.css';
|
||||
import SearchBar from '../components/SearchBar';
|
||||
import MedicineResults from '../components/MedicineResults';
|
||||
import PharmacyList from '../components/PharmacyList';
|
||||
import PharmacyMap from '../components/PharmacyMap';
|
||||
import { haversineKm, getUserPosition } from '../utils/geo';
|
||||
|
||||
function PublicView() {
|
||||
function PublicView({ currentUser, onLoginRequest }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [medicines, setMedicines] = useState([]);
|
||||
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
||||
const [pharmacies, setPharmacies] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userPosition, setUserPosition] = useState(null);
|
||||
const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser'
|
||||
const [sortByDistance, setSortByDistance] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationError, setLocationError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const searchMedicines = async () => {
|
||||
@@ -59,6 +65,61 @@ function PublicView() {
|
||||
fetchPharmacies();
|
||||
}, [selectedMedicine]);
|
||||
|
||||
const savedLat = currentUser?.latitude;
|
||||
const savedLon = currentUser?.longitude;
|
||||
const hasSavedCoords = savedLat != null && savedLon != null;
|
||||
|
||||
const handleSortByDistance = async () => {
|
||||
if (sortByDistance) {
|
||||
setSortByDistance(false);
|
||||
return;
|
||||
}
|
||||
setLocationError('');
|
||||
if (hasSavedCoords) {
|
||||
setUserPosition({ lat: savedLat, lon: savedLon });
|
||||
setPositionSource('profile');
|
||||
setSortByDistance(true);
|
||||
return;
|
||||
}
|
||||
if (userPosition) {
|
||||
setSortByDistance(true);
|
||||
return;
|
||||
}
|
||||
setLocating(true);
|
||||
try {
|
||||
const pos = await getUserPosition();
|
||||
setUserPosition(pos);
|
||||
setPositionSource('browser');
|
||||
setSortByDistance(true);
|
||||
} catch (err) {
|
||||
console.error('[location] error', err);
|
||||
let msg;
|
||||
if (err && typeof err.code === 'number') {
|
||||
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings';
|
||||
else if (err.code === 2) msg = 'Location unavailable — check OS location services / WiFi';
|
||||
else if (err.code === 3) msg = 'Location request timed out — try again';
|
||||
else msg = `Geolocation error (code ${err.code})`;
|
||||
} else {
|
||||
msg = err && err.message ? err.message : 'Could not get your location';
|
||||
}
|
||||
setLocationError(msg);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayedPharmacies = useMemo(() => {
|
||||
if (!sortByDistance || !userPosition) return pharmacies;
|
||||
return [...pharmacies].sort((a, b) => {
|
||||
if (a.latitude == null || a.longitude == null) return 1;
|
||||
if (b.latitude == null || b.longitude == null) return -1;
|
||||
return (
|
||||
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
|
||||
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
|
||||
);
|
||||
});
|
||||
}, [pharmacies, sortByDistance, userPosition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
@@ -67,7 +128,7 @@ function PublicView() {
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<SearchBar
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search for a medicine..."
|
||||
@@ -76,10 +137,12 @@ function PublicView() {
|
||||
{loading && <div className="loading">Searching...</div>}
|
||||
|
||||
{searchQuery && !selectedMedicine && (
|
||||
<MedicineResults
|
||||
<MedicineResults
|
||||
medicines={medicines}
|
||||
onSelect={setSelectedMedicine}
|
||||
query={searchQuery}
|
||||
currentUser={currentUser}
|
||||
onLoginRequest={onLoginRequest}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -92,7 +155,7 @@ function PublicView() {
|
||||
<span><strong>Dosage:</strong> {selectedMedicine.dosage}</span>
|
||||
<span><strong>Form:</strong> {selectedMedicine.form}</span>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
className="back-button"
|
||||
onClick={() => {
|
||||
setSelectedMedicine(null);
|
||||
@@ -103,10 +166,38 @@ function PublicView() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PharmacyMap pharmacies={pharmacies} />
|
||||
{pharmacies.length > 0 && (
|
||||
<div className="pharmacy-controls">
|
||||
<button
|
||||
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
||||
onClick={handleSortByDistance}
|
||||
disabled={locating}
|
||||
>
|
||||
{locating
|
||||
? '📍 Locating…'
|
||||
: sortByDistance
|
||||
? '📍 Sorted by distance · Reset'
|
||||
: hasSavedCoords
|
||||
? '📍 Sort by your saved location'
|
||||
: '📍 Sort by distance'}
|
||||
</button>
|
||||
{sortByDistance && positionSource === 'profile' && (
|
||||
<span className="location-source">Using your saved address</span>
|
||||
)}
|
||||
{locationError && (
|
||||
<span className="location-error">{locationError}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PharmacyMap pharmacies={displayedPharmacies} />
|
||||
<PharmacyList
|
||||
pharmacies={pharmacies}
|
||||
pharmacies={displayedPharmacies}
|
||||
loading={loading}
|
||||
userPosition={sortByDistance ? userPosition : null}
|
||||
medicine={selectedMedicine}
|
||||
currentUser={currentUser}
|
||||
onLoginRequest={onLoginRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -116,4 +207,3 @@ function PublicView() {
|
||||
}
|
||||
|
||||
export default PublicView;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user