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

This commit is contained in:
Ichitux
2026-05-20 22:28:17 +02:00
parent 7b288d33cb
commit 2eff93e66a
118 changed files with 13780 additions and 209 deletions
+260
View File
@@ -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;
}
}
+222
View File
@@ -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;
+98 -8
View File
@@ -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;