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 { SignupPage } from './pages/Signup'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { ProfilePage } from './pages/Profile'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -9,6 +10,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -286,3 +286,40 @@ a:hover {
|
||||
opacity: 0.6;
|
||||
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 { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
@@ -52,7 +52,10 @@ export function Dashboard() {
|
||||
return (
|
||||
<div style={{ padding: 'var(--space-lg)' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||||
<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)' }}>
|
||||
<button className="btn btn-secondary" onClick={toggleTheme}>
|
||||
Theme
|
||||
@@ -65,11 +68,18 @@ export function Dashboard() {
|
||||
|
||||
<main>
|
||||
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
<h3 style={{ marginBottom: 'var(--space-md)' }}>Welcome, {user.username}</h3>
|
||||
<p className="text-secondary">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Welcome, {user.username}</h3>
|
||||
<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>
|
||||
|
||||
<p className="text-secondary text-sm">
|
||||
Dashboard coming soon...
|
||||
|
||||
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