Components: - DashboardShell — main layout combining sidebar + topbar + content area - Sidebar — collapsible nav with sections, badges, active state, auto-settings link - TopBar — user avatar/menu, notifications bell, sign out, custom actions - ProfilePage — avatar, name/email form, loading/error/success states - BillingPage — current plan card, status badge, trial info, plan comparison grid - SettingsPage — section-based layout with empty state Features: - NavItem[] or NavSection[] for flat or grouped navigation - ShellFeatures toggle: profile, billing, settings, notifications, themeToggle - ShellUser with avatar, role, initials fallback - onNavigate callback for SPA routers (Next.js, etc.) - Collapsible sidebar with toggle button - All styled via --bl-shell-* CSS custom properties with fallbacks - 41 tests covering all components
181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
|
import type { ProfilePageProps } from './types.js';
|
|
|
|
export function ProfilePage({
|
|
user,
|
|
onUpdateProfile,
|
|
isLoading,
|
|
error,
|
|
success,
|
|
}: ProfilePageProps): ReactNode {
|
|
const [name, setName] = useState(user.name);
|
|
const [email, setEmail] = useState(user.email);
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (onUpdateProfile) onUpdateProfile({ name, email });
|
|
};
|
|
|
|
return (
|
|
<div data-testid="bl-shell-profile-page" style={{ maxWidth: 600 }}>
|
|
<h1
|
|
style={{
|
|
fontSize: 24,
|
|
fontWeight: 700,
|
|
marginBottom: 24,
|
|
color: 'var(--color-foreground, #111827)',
|
|
}}
|
|
>
|
|
Profile
|
|
</h1>
|
|
|
|
{error && (
|
|
<div data-testid="bl-profile-error" style={alertStyle('var(--color-destructive, #dc2626)')}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div data-testid="bl-profile-success" style={alertStyle('var(--color-success, #16a34a)')}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{/* Avatar */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 32 }}>
|
|
<div
|
|
data-testid="bl-profile-avatar"
|
|
style={{
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: '50%',
|
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
|
color: '#fff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 24,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{user.avatarUrl ? (
|
|
<img
|
|
src={user.avatarUrl}
|
|
alt={user.name}
|
|
style={{ width: 64, height: 64, borderRadius: '50%' }}
|
|
/>
|
|
) : (
|
|
user.name
|
|
.split(' ')
|
|
.map(w => w[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 18, fontWeight: 600 }}>{user.name}</div>
|
|
<div style={{ fontSize: 14, color: 'var(--color-muted-foreground, #6b7280)' }}>
|
|
{user.email}
|
|
</div>
|
|
{user.role && (
|
|
<div
|
|
style={{
|
|
fontSize: 12,
|
|
color: 'var(--color-muted-foreground, #6b7280)',
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
Role: {user.role}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<div>
|
|
<label style={labelStyle} htmlFor="bl-profile-name">
|
|
Name
|
|
</label>
|
|
<input
|
|
id="bl-profile-name"
|
|
data-testid="bl-profile-name"
|
|
type="text"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
required
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle} htmlFor="bl-profile-email">
|
|
Email
|
|
</label>
|
|
<input
|
|
id="bl-profile-email"
|
|
data-testid="bl-profile-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
required
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
|
|
{onUpdateProfile && (
|
|
<button
|
|
data-testid="bl-profile-submit"
|
|
type="submit"
|
|
disabled={isLoading}
|
|
style={{
|
|
padding: '10px 20px',
|
|
borderRadius: 8,
|
|
border: 'none',
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
|
background: 'var(--bl-shell-accent, var(--color-primary, #2563eb))',
|
|
color: '#fff',
|
|
opacity: isLoading ? 0.6 : 1,
|
|
alignSelf: 'flex-start',
|
|
}}
|
|
>
|
|
{isLoading ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
)}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: 'block',
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
marginBottom: 6,
|
|
color: 'var(--color-foreground, #111827)',
|
|
};
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
padding: '10px 12px',
|
|
borderRadius: 8,
|
|
border: '1px solid var(--bl-shell-border, var(--color-border, #e5e7eb))',
|
|
fontSize: 14,
|
|
background: 'var(--color-surface, #fff)',
|
|
color: 'var(--color-foreground, #111827)',
|
|
boxSizing: 'border-box',
|
|
};
|
|
|
|
function alertStyle(color: string): React.CSSProperties {
|
|
return {
|
|
padding: '10px 14px',
|
|
borderRadius: 8,
|
|
marginBottom: 16,
|
|
fontSize: 14,
|
|
color,
|
|
background: `color-mix(in srgb, ${color} 10%, transparent)`,
|
|
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
|
|
};
|
|
}
|