feat: add user profile page with editable physical info and lifestyle settings

This commit is contained in:
2025-12-19 17:46:54 +05:30
parent b2ad488043
commit 0f6ef74f6c
4 changed files with 307 additions and 6 deletions

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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)' }}>
<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)' }}>
<button className="btn btn-secondary" onClick={toggleTheme}>
Theme
@@ -65,10 +68,17 @@ 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">
Role: <span className="indicator indicator-info">{user.role}</span>
</p>
<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">

View 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>
)
}