feat: add user profile page with editable physical info and lifestyle settings
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { SignupPage } from './pages/Signup'
|
import { SignupPage } from './pages/Signup'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
|
import { ProfilePage } from './pages/Profile'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -9,6 +10,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -286,3 +286,40 @@ a:hover {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Radio groups */
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.success-message {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background-color: color-mix(in srgb, var(--indicator-normal) 10%, transparent);
|
||||||
|
color: var(--indicator-normal);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select styling */
|
||||||
|
select.input {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2371717a' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -52,7 +52,10 @@ export function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ padding: 'var(--space-lg)' }}>
|
<div style={{ padding: 'var(--space-lg)' }}>
|
||||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||||
<h1>zhealth</h1>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||||
|
<img src="/logo.svg" alt="zhealth" style={{ width: '32px', height: '32px' }} />
|
||||||
|
<h1>zhealth</h1>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||||
<button className="btn btn-secondary" onClick={toggleTheme}>
|
<button className="btn btn-secondary" onClick={toggleTheme}>
|
||||||
Theme
|
Theme
|
||||||
@@ -65,10 +68,17 @@ export function Dashboard() {
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Welcome, {user.username}</h3>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
<p className="text-secondary">
|
<div>
|
||||||
Role: <span className="indicator indicator-info">{user.role}</span>
|
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Welcome, {user.username}</h3>
|
||||||
</p>
|
<p className="text-secondary text-sm">
|
||||||
|
Role: <span className="indicator indicator-info">{user.role}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/profile" className="btn btn-secondary">
|
||||||
|
Edit Profile
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-secondary text-sm">
|
<p className="text-secondary text-sm">
|
||||||
|
|||||||
252
frontend/src/pages/Profile.tsx
Normal file
252
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface Diet {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
height_cm: number | null
|
||||||
|
blood_type: string | null
|
||||||
|
birthdate: string | null
|
||||||
|
smoking: boolean | null
|
||||||
|
alcohol: boolean | null
|
||||||
|
diet: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
const [diets, setDiets] = useState<Diet[]>([])
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [userId, setUserId] = useState<number | null>(null)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [heightCm, setHeightCm] = useState('')
|
||||||
|
const [bloodType, setBloodType] = useState('')
|
||||||
|
const [birthdate, setBirthdate] = useState('')
|
||||||
|
const [smoking, setSmoking] = useState<boolean | null>(null)
|
||||||
|
const [alcohol, setAlcohol] = useState<boolean | null>(null)
|
||||||
|
const [dietId, setDietId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch current user and diets
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
fetch('/api/diets', { credentials: 'include' }).then(r => r.json()),
|
||||||
|
])
|
||||||
|
.then(([authData, dietsData]) => {
|
||||||
|
if (!authData.user) {
|
||||||
|
navigate('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDiets(dietsData)
|
||||||
|
|
||||||
|
// Now fetch full user profile
|
||||||
|
return fetch(`/api/users/${authData.user.id}`, { credentials: 'include' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((profile: UserProfile) => {
|
||||||
|
setUserId(profile.id)
|
||||||
|
setUsername(profile.username)
|
||||||
|
setHeightCm(profile.height_cm?.toString() || '')
|
||||||
|
setBloodType(profile.blood_type || '')
|
||||||
|
setBirthdate(profile.birthdate || '')
|
||||||
|
setSmoking(profile.smoking)
|
||||||
|
setAlcohol(profile.alcohol)
|
||||||
|
// Find diet ID from name
|
||||||
|
const diet = dietsData.find((d: Diet) => d.name === profile.diet)
|
||||||
|
setDietId(diet?.id || null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => navigate('/login'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
height_cm: heightCm ? parseFloat(heightCm) : null,
|
||||||
|
blood_type: bloodType || null,
|
||||||
|
birthdate: birthdate || null,
|
||||||
|
smoking,
|
||||||
|
alcohol,
|
||||||
|
diet_id: dietId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated successfully' })
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to update profile' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Network error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 'var(--space-lg)', maxWidth: '600px', margin: '0 auto' }}>
|
||||||
|
<header style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
|
<Link to="/" className="text-secondary text-sm">← Back to Dashboard</Link>
|
||||||
|
<h1 style={{ marginTop: 'var(--space-sm)' }}>Profile</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Username (read-only) */}
|
||||||
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<h3 style={{ marginBottom: 'var(--space-md)' }}>Account</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" className="input" value={username} disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Physical Info */}
|
||||||
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<h3 style={{ marginBottom: 'var(--space-md)' }}>Physical Info</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="height">Height (cm)</label>
|
||||||
|
<input
|
||||||
|
id="height"
|
||||||
|
type="number"
|
||||||
|
className="input"
|
||||||
|
value={heightCm}
|
||||||
|
onChange={(e) => setHeightCm(e.target.value)}
|
||||||
|
placeholder="e.g. 175"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="bloodType">Blood Type</label>
|
||||||
|
<select
|
||||||
|
id="bloodType"
|
||||||
|
className="input"
|
||||||
|
value={bloodType}
|
||||||
|
onChange={(e) => setBloodType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A+">A+</option>
|
||||||
|
<option value="A-">A-</option>
|
||||||
|
<option value="B+">B+</option>
|
||||||
|
<option value="B-">B-</option>
|
||||||
|
<option value="AB+">AB+</option>
|
||||||
|
<option value="AB-">AB-</option>
|
||||||
|
<option value="O+">O+</option>
|
||||||
|
<option value="O-">O-</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="birthdate">Date of Birth</label>
|
||||||
|
<input
|
||||||
|
id="birthdate"
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={birthdate}
|
||||||
|
onChange={(e) => setBirthdate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lifestyle */}
|
||||||
|
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||||
|
<h3 style={{ marginBottom: 'var(--space-md)' }}>Lifestyle</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="diet">Diet</label>
|
||||||
|
<select
|
||||||
|
id="diet"
|
||||||
|
className="input"
|
||||||
|
value={dietId || ''}
|
||||||
|
onChange={(e) => setDietId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{diets.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Smoking</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="smoking"
|
||||||
|
checked={smoking === false}
|
||||||
|
onChange={() => setSmoking(false)}
|
||||||
|
/> No
|
||||||
|
</label>
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="smoking"
|
||||||
|
checked={smoking === true}
|
||||||
|
onChange={() => setSmoking(true)}
|
||||||
|
/> Yes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Alcohol</label>
|
||||||
|
<div className="radio-group">
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="alcohol"
|
||||||
|
checked={alcohol === false}
|
||||||
|
onChange={() => setAlcohol(false)}
|
||||||
|
/> No
|
||||||
|
</label>
|
||||||
|
<label className="radio-label">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="alcohol"
|
||||||
|
checked={alcohol === true}
|
||||||
|
onChange={() => setAlcohol(true)}
|
||||||
|
/> Yes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={message.type === 'success' ? 'success-message' : 'error-message'}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-block" disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save Profile'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user