deckhearth/pages/profile.js
varutasu ca302a89c1
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)

The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.

Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).

Architect to address:
  - Q1: logged-out rendering branch design (navbar, mobile-nav,
        auth-only items treatment)
  - Q2: page audit triage into always-auth / public-or-auth /
        anonymous-allowed buckets
  - Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
  - Q4: whether to add vitest coverage for the logged-out branch
        (recommend yes — small surface, high regression protection)

Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).

depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
            drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness

Co-authored-by: Cursor <cursoragent@cursor.com>

* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)

2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).

Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
  UserProfileDropdown when logged out. Adds first jsdom test in the repo
  at test/components/Layout.test.js (Decision D2) with 5 regression-lock
  assertions. devDeps: jsdom@^29, @testing-library/react@^16.

Brief 2: page audit sweep — 7 pages need code changes:
  - Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
    deck/[id].js (×3), decks.js (×3)
  - Replace page-level useState({email: 'me@...'}) → useState(null) +
    null-guards: profile.js, settings.js
  - Replace hardcoded const user = {email: 'me@...'} with useAuth():
    card/[id].js

Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.

Decisions:
  A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
       hides auth-only dropdown (Profile/Settings/Logout/Admin);
       keeps public + community nav visible
  B  — Per-page bucket assignment (10 already correct, 7 need fix);
       full per-page table with justification in convoy file
  C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
       sweep depends on Brief 1). C1 buries the conceptual change under
       mechanical edits; C3 is over-orchestrated for this scope
  D2 — vitest lock-in; first jsdom test in repo; same negative-regression
       style as test/lib/permission-middleware.test.js (synthetic-admin
       shape). devDeps jsdom + @testing-library/react

Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).

MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.

Flagged-but-deferred:
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - Layout headers still render "Deck Hearth" / "DH" branding
    → pick-a-name (queued P1 #12)
  - MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
    or fold into god-component-split

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)

Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.

components/Layout.js:
  - Default user prop is now null (was hardcoded to
    { email: 'me@randallstillwell.com', role: 'user' })
  - UserProfileDropdown renders a "Sign in" link to /login when
    user === null instead of the maintainer's email + auth-only menu
    items (Decision A1)
  - All user.* accesses guarded with optional chaining or null checks
  - useState hook stays above the new null-user early return to satisfy
    rules-of-hooks (boot-the-brief caught this on the first try;
    see AGENTS.md Gotcha #11.5)

test/components/Layout.test.js (new):
  - First jsdom test in the repo (Decision D2)
  - 5 regression-lock assertions: no maintainer email ever rendered
    (prop omitted, prop=null), Sign-in link exists with href=/login,
    supplied email renders when prop is set, no "Guest" placeholder
    (locks A1 copy choice)
  - Mocks next/link, next/router (prefetch, replace, events, query),
    and theme-context.useTheme for jsdom safety under Next 16

package.json + package-lock.json:
  - Add jsdom@^29 and @testing-library/react@^16 to devDependencies
  - @testing-library/dom@^10 added explicitly (peer auto-install
    skipped it under npm 11; brief anticipated this fallback)

vitest.config.js (deviation from brief — see PR description):
  - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
    parse JSX in .js files. Required to import any React component
    written in the repo's Next.js pages-router .js convention
    (AGENTS.md Gotcha #9). The brief said "no change" to this file,
    but JSX-in-.js parsing is a hard prerequisite for the new test
    to import components/Layout.js — the alternatives (rename test
    to .test.jsx; rewrite test in React.createElement) either break
    the test glob or still hit the same Layout.js parse failure.
    Other tests are unaffected (they import non-JSX modules).

Smoke output: see PR description.

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)

Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.

Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.

Pass user={user} to Layout (4 pages, 11 call sites):
  - pages/scanner.js (1 call)
  - pages/decks.js (3 calls)
  - pages/deck-builder.js (4 calls)
  - pages/deck/[id].js (3 calls)
  (All four still import useAuth from lib/auth-context.js — that's
   intentional and stays as-is until the single-auth-provider convoy
   collapses the three parallel auth surfaces.)

Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
  - pages/profile.js: useState({email: 'me@...', role: 'user', ...})
                     → useState(null) + ?. on every sync user.* read
                     + early-return guards in getDisplayName/getInitials
                     + conditional render around the "Member since" block
                       so formatDate(undefined) never runs
  - pages/settings.js: same pattern (single user.email reader guarded)

Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
  - pages/card/[id].js: const user = {email: 'me@...'}
                       → const { user } = useAuth() (called unconditionally
                       at the top of the component; rules-of-hooks safe)

Verification:
  - grep 'me@randallstillwell.com' pages/ → 0 hits
  - 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
  - npm run lint matches baseline (128 problems pre, 128 post; verified
    via git stash before/after)
  - Manual static read-through of every diff; ReadLints clean on the 7
    files
  - Dev-server smoke: /cards anonymous returned HTTP 200 with 0
    'me@randallstillwell' matches before the user's shared dev server
    became unresponsive mid-session (same dev-server-shared-by-user
    constraint flagged in Brief 1); interactive logged-in smoke is
    parent/operator gated

Flagged-but-deferred (untouched per scope):
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - components/MobileNavigation.js still receives dead user prop
    → cleanup-mobile-nav-dead-props (or fold into god-component-split)

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:31:37 -05:00

619 lines
No EOL
23 KiB
JavaScript

import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
export default function Profile() {
const router = useRouter();
const fileInputRef = useRef(null);
// User state
const [user, setUser] = useState(null);
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [stats, setStats] = useState({
totalCards: 0,
totalCollections: 0,
totalDecks: 0,
totalValue: 0
});
// Form state
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
username: '',
bio: '',
favorite_games: []
});
useEffect(() => {
loadUserProfile();
loadUserStats();
}, []);
const loadUserProfile = async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) {
router.push('/login');
return;
}
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
setFormData({
first_name: userData.first_name || '',
last_name: userData.last_name || '',
username: userData.username || '',
bio: userData.bio || '',
favorite_games: userData.favorite_games || []
});
} else if (response.status === 401) {
router.push('/login');
}
} catch (error) {
console.error('Error loading profile:', error);
setMessage({ type: 'error', text: 'Failed to load profile' });
} finally {
setLoading(false);
}
};
const loadUserStats = async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) return;
const response = await fetch('/api/user/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const statsData = await response.json();
setStats(statsData);
}
} catch (error) {
console.error('Error loading stats:', error);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleGameToggle = (game) => {
setFormData(prev => ({
...prev,
favorite_games: prev.favorite_games.includes(game)
? prev.favorite_games.filter(g => g !== game)
: [...prev.favorite_games, game]
}));
};
const handleSave = async () => {
setSaving(true);
setMessage({ type: '', text: '' });
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
});
if (response.ok) {
const updatedUser = await response.json();
setUser(updatedUser);
setEditMode(false);
setMessage({ type: 'success', text: 'Profile updated successfully!' });
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to update profile' });
}
} catch (error) {
console.error('Error updating profile:', error);
setMessage({ type: 'error', text: 'Failed to update profile' });
} finally {
setSaving(false);
}
};
const handleAvatarUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
// Validate file type and size
if (!file.type.startsWith('image/')) {
setMessage({ type: 'error', text: 'Please select an image file' });
return;
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
setMessage({ type: 'error', text: 'Image must be less than 5MB' });
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
setSaving(true);
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/avatar', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (response.ok) {
const result = await response.json();
setUser(prev => ({ ...prev, avatar_url: result.avatar_url }));
setMessage({ type: 'success', text: 'Avatar updated successfully!' });
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to upload avatar' });
}
} catch (error) {
console.error('Error uploading avatar:', error);
setMessage({ type: 'error', text: 'Failed to upload avatar' });
} finally {
setSaving(false);
}
};
const generateAvatar = async () => {
try {
setSaving(true);
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/user/avatar/generate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const result = await response.json();
setUser(prev => ({ ...prev, avatar_url: result.avatar_url }));
setMessage({ type: 'success', text: 'Avatar generated successfully!' });
} else {
const error = await response.json();
setMessage({ type: 'error', text: error.message || 'Failed to generate avatar' });
}
} catch (error) {
console.error('Error generating avatar:', error);
setMessage({ type: 'error', text: 'Failed to generate avatar' });
} finally {
setSaving(false);
}
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const getDisplayName = () => {
if (!user) return '';
if (user.first_name || user.last_name) {
return `${user.first_name} ${user.last_name}`.trim();
}
return user.username || user.email;
};
const getInitials = () => {
if (!user) return '';
if (user.first_name || user.last_name) {
return `${user.first_name?.charAt(0) || ''}${user.last_name?.charAt(0) || ''}`.toUpperCase();
}
return user.email?.charAt(0).toUpperCase() || 'U';
};
const gameOptions = [
{ value: 'MTG', label: 'Magic: The Gathering', color: 'purple', icon: '🔮' },
{ value: 'Pokemon', label: 'Pokemon', color: 'blue', icon: '⚡' },
{ value: 'Lorcana', label: 'Disney Lorcana', color: 'pink', icon: '✨' }
];
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
Profile
</h1>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
Manage your account information and preferences
</p>
</div>
{/* Message */}
{message.text && (
<div className={`mb-6 p-4 rounded-xl ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
}`}>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Profile Card */}
<div className="lg:col-span-1">
<div className="card p-6 text-center">
{/* Avatar */}
<div className="mb-6">
<div className="relative inline-block">
{user?.avatar_url ? (
<img
src={user.avatar_url}
alt="Profile"
className="w-32 h-32 rounded-full object-cover mx-auto border-4"
style={{ borderColor: 'var(--accent-ember)' }}
/>
) : (
<div
className="w-32 h-32 rounded-full flex items-center justify-center mx-auto text-4xl font-bold text-white border-4"
style={{
backgroundColor: 'var(--accent-ember)',
borderColor: 'var(--accent-flame)'
}}
>
{getInitials()}
</div>
)}
{/* Avatar Upload Button */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={saving}
className="absolute bottom-0 right-0 p-2 rounded-full shadow-lg transition-all duration-200 hover:scale-110"
style={{ backgroundColor: 'var(--accent-flame)', color: 'white' }}
title="Upload new avatar"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
</div>
{/* Generate Avatar Button */}
<button
onClick={generateAvatar}
disabled={saving}
className="mt-4 px-4 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--border)'
}}
>
Generate Avatar
</button>
</div>
{/* Basic Info */}
<div className="mb-6">
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{getDisplayName()}
</h2>
{user?.username && (
<p className="text-lg mb-2" style={{ color: 'var(--text-secondary)' }}>
@{user.username}
</p>
)}
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.email}
</p>
<div className="flex items-center justify-center mt-2">
<span
className="px-3 py-1 text-xs rounded-full font-medium"
style={{
backgroundColor: user?.role === 'admin' ? 'var(--accent-ember)' : 'var(--accent-gold)',
color: 'white'
}}
>
{user?.role?.toUpperCase()}
</span>
</div>
</div>
{/* Bio */}
{user?.bio && (
<div className="mb-6">
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{user.bio}
</p>
</div>
)}
{/* Member Since */}
{user?.created_at && (
<div className="text-center">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Member since {formatDate(user.created_at)}
</p>
</div>
)}
</div>
{/* Stats Card */}
<div className="card p-6 mt-6">
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
Collection Stats
</h3>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span style={{ color: 'var(--text-secondary)' }}>Total Cards</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{stats.totalCards.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span style={{ color: 'var(--text-secondary)' }}>Collections</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{stats.totalCollections}
</span>
</div>
<div className="flex justify-between items-center">
<span style={{ color: 'var(--text-secondary)' }}>Decks</span>
<span className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{stats.totalDecks}
</span>
</div>
<div className="flex justify-between items-center pt-2 border-t" style={{ borderColor: 'var(--border)' }}>
<span style={{ color: 'var(--text-secondary)' }}>Total Value</span>
<span className="font-bold text-lg" style={{ color: 'var(--accent-ember)' }}>
{formatCurrency(stats.totalValue)}
</span>
</div>
</div>
</div>
</div>
{/* Profile Form */}
<div className="lg:col-span-2">
<div className="card p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
Profile Information
</h3>
<button
onClick={() => editMode ? handleSave() : setEditMode(true)}
disabled={saving}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: editMode ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: editMode ? 'white' : 'var(--text-primary)',
border: `1px solid ${editMode ? 'var(--accent-ember)' : 'var(--border)'}`
}}
>
{saving ? 'Saving...' : editMode ? 'Save Changes' : 'Edit Profile'}
</button>
</div>
<div className="space-y-6">
{/* Name Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
First Name
</label>
{editMode ? (
<input
type="text"
value={formData.first_name}
onChange={(e) => handleInputChange('first_name', e.target.value)}
className="input-field w-full"
placeholder="Enter your first name"
/>
) : (
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.first_name || 'Not set'}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Last Name
</label>
{editMode ? (
<input
type="text"
value={formData.last_name}
onChange={(e) => handleInputChange('last_name', e.target.value)}
className="input-field w-full"
placeholder="Enter your last name"
/>
) : (
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.last_name || 'Not set'}
</p>
)}
</div>
</div>
{/* Username */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Username
</label>
{editMode ? (
<input
type="text"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
className="input-field w-full"
placeholder="Choose a unique username"
/>
) : (
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.username || 'Not set'}
</p>
)}
</div>
{/* Bio */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Bio
</label>
{editMode ? (
<textarea
value={formData.bio}
onChange={(e) => handleInputChange('bio', e.target.value)}
className="input-field w-full h-24 resize-none"
placeholder="Tell us about yourself..."
maxLength={500}
/>
) : (
<p className="py-2 text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
{user?.bio || 'No bio set'}
</p>
)}
</div>
{/* Favorite Games */}
<div>
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
Favorite Games
</label>
<div className="flex flex-wrap gap-3">
{gameOptions.map(game => (
<button
key={game.value}
onClick={() => editMode && handleGameToggle(game.value)}
disabled={!editMode}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-200 flex items-center gap-2 ${
editMode ? 'cursor-pointer hover:shadow-md' : 'cursor-default'
} ${
(editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: (editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
? 'var(--accent-ember)'
: 'var(--bg-tertiary)',
color: (editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
? 'white'
: 'var(--text-primary)',
border: `1px solid ${
(editMode ? formData.favorite_games : user?.favorite_games)?.includes(game.value)
? 'var(--accent-ember)'
: 'var(--border)'
}`
}}
>
<span>{game.icon}</span>
{game.label}
</button>
))}
</div>
</div>
{/* Email (Read-only) */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Email Address
</label>
<div className="flex items-center gap-3">
<p className="py-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
{user?.email}
</p>
<span className="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
Verified
</span>
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
To change your email, please contact support
</p>
</div>
{/* Cancel Button (only in edit mode) */}
{editMode && (
<div className="flex justify-end pt-4">
<button
onClick={() => {
setEditMode(false);
setFormData({
first_name: user?.first_name || '',
last_name: user?.last_name || '',
username: user?.username || '',
bio: user?.bio || '',
favorite_games: user?.favorite_games || []
});
setMessage({ type: '', text: '' });
}}
className="px-4 py-2 rounded-xl font-medium transition-all duration-200 hover:shadow-md"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border)'
}}
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}