Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/profile-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ The profile route keeps its static shell server-rendered and limits client JavaS
- `src/app/profile/components/ProfileTabs.tsx` owns the small client-side tab state.
- The default profile panel renders first, while settings and achievements are split into lazy-loaded tab panels.
- Shared profile, preference, and achievement data lives in `src/app/profile/profile-data.ts` to avoid recreating arrays during render.
- Tab buttons, settings switches, and achievement cards are memoized so tab and switch updates touch fewer child components.
- Profile edit callbacks are stable, which keeps memoized children from rerendering only because handler identities changed.
- Avatar previews use temporary object URLs instead of base64 data URLs and revoke them when replaced or unmounted.
- Tabs and switches use semantic roles and accessible names so the optimization does not trade away usability.

## Validation

Run the focused regression suite with:

```bash
pnpm vitest run src/app/profile/__tests__/ProfileTabs.test.tsx
pnpm vitest run src/app/profile/__tests__/ProfileTabs.test.tsx src/components/shared/ImageUploader.test.tsx
```
167 changes: 103 additions & 64 deletions src/app/components/profile/PreferencesSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { memo, useCallback, useState } from 'react';

interface PreferenceOption {
id: string;
Expand Down Expand Up @@ -44,84 +44,123 @@ const privacyPreferences: PreferenceOption[] = [
},
];

export default function PreferencesSection() {
const [notificationSettings, setNotificationSettings] = useState<Record<string, boolean>>({
email_notifications: true,
marketing_emails: false,
course_updates: true,
});
const defaultNotificationSettings = {
email_notifications: true,
marketing_emails: false,
course_updates: true,
};

const [privacySettings, setPrivacySettings] = useState<Record<string, boolean>>({
profile_visibility: true,
show_progress: true,
show_achievements: true,
});
const defaultPrivacySettings = {
profile_visibility: true,
show_progress: true,
show_achievements: true,
};

const handleNotificationChange = (id: string) => {
interface PreferenceCheckboxProps {
preference: PreferenceOption;
checked: boolean;
onToggle: (id: string) => void;
}

const PreferenceCheckbox = memo(function PreferenceCheckbox({
preference,
checked,
onToggle,
}: PreferenceCheckboxProps) {
const handleChange = useCallback(() => {
onToggle(preference.id);
}, [onToggle, preference.id]);

return (
<div className="flex items-start">
<div className="flex h-5 items-center">
<input
id={preference.id}
type="checkbox"
checked={checked}
onChange={handleChange}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</div>
<div className="ml-3">
<label htmlFor={preference.id} className="text-sm font-medium text-gray-700">
{preference.label}
</label>
<p className="text-sm text-gray-500">{preference.description}</p>
</div>
</div>
);
});

interface PreferenceGroupProps {
title: string;
preferences: PreferenceOption[];
settings: Record<string, boolean>;
onToggle: (id: string) => void;
}

const PreferenceGroup = memo(function PreferenceGroup({
title,
preferences,
settings,
onToggle,
}: PreferenceGroupProps) {
return (
<div className="rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-6 text-xl font-semibold">{title}</h2>
<div className="space-y-4">
{preferences.map((preference) => (
<PreferenceCheckbox
key={preference.id}
preference={preference}
checked={settings[preference.id]}
onToggle={onToggle}
/>
))}
</div>
</div>
);
});

function PreferencesSection() {
const [notificationSettings, setNotificationSettings] = useState<Record<string, boolean>>(
defaultNotificationSettings,
);

const [privacySettings, setPrivacySettings] =
useState<Record<string, boolean>>(defaultPrivacySettings);

const handleNotificationChange = useCallback((id: string) => {
setNotificationSettings((prev) => ({
...prev,
[id]: !prev[id],
}));
};
}, []);

const handlePrivacyChange = (id: string) => {
const handlePrivacyChange = useCallback((id: string) => {
setPrivacySettings((prev) => ({
...prev,
[id]: !prev[id],
}));
};
}, []);

return (
<div className="space-y-8">
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-6">Notification Preferences</h2>
<div className="space-y-4">
{notificationPreferences.map((preference) => (
<div key={preference.id} className="flex items-start">
<div className="flex items-center h-5">
<input
id={preference.id}
type="checkbox"
checked={notificationSettings[preference.id]}
onChange={() => handleNotificationChange(preference.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div className="ml-3">
<label htmlFor={preference.id} className="text-sm font-medium text-gray-700">
{preference.label}
</label>
<p className="text-sm text-gray-500">{preference.description}</p>
</div>
</div>
))}
</div>
</div>
<PreferenceGroup
title="Notification Preferences"
preferences={notificationPreferences}
settings={notificationSettings}
onToggle={handleNotificationChange}
/>

<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-6">Privacy Settings</h2>
<div className="space-y-4">
{privacyPreferences.map((preference) => (
<div key={preference.id} className="flex items-start">
<div className="flex items-center h-5">
<input
id={preference.id}
type="checkbox"
checked={privacySettings[preference.id]}
onChange={() => handlePrivacyChange(preference.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
<div className="ml-3">
<label htmlFor={preference.id} className="text-sm font-medium text-gray-700">
{preference.label}
</label>
<p className="text-sm text-gray-500">{preference.description}</p>
</div>
</div>
))}
</div>
</div>
<PreferenceGroup
title="Privacy Settings"
preferences={privacyPreferences}
settings={privacySettings}
onToggle={handlePrivacyChange}
/>
</div>
);
}

export default memo(PreferencesSection);
33 changes: 19 additions & 14 deletions src/app/components/profile/ProfileEditForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
Expand All @@ -23,6 +24,18 @@ const profileSchema = z.object({

type ProfileFormData = z.infer<typeof profileSchema>;

const profileFormDefaults: ProfileFormData = {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
bio: 'Software developer and tech enthusiast',
location: 'New York, USA',
website: 'https://johndoe.com',
twitter: '@johndoe',
github: 'johndoe',
linkedin: 'johndoe',
};

export default function ProfileEditForm() {
const { updateProfile, isLoading } = useProfileUpdate();

Expand All @@ -32,35 +45,27 @@ export default function ProfileEditForm() {
formState: { errors },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
bio: 'Software developer and tech enthusiast',
location: 'New York, USA',
website: 'https://johndoe.com',
twitter: '@johndoe',
github: 'johndoe',
linkedin: 'johndoe',
},
defaultValues: profileFormDefaults,
});

const onSubmit = async (data: ProfileFormData) => {
const onSubmit = useCallback(async (data: ProfileFormData) => {
try {
await updateProfile(data);
toast.success('Profile updated successfully!');
} catch {
toast.error('Failed to update profile. Please try again.');
}
};
}, [updateProfile]);

const handleImageSelect = useCallback(() => {}, []);

return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="mb-6 text-xl font-semibold">Personal Information</h2>

<div className="mb-6">
<ImageUploader onImageSelect={(file) => {}} />
<ImageUploader onImageSelect={handleImageSelect} />
</div>

<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
Expand Down
11 changes: 4 additions & 7 deletions src/app/hooks/useProfileUpdate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';

interface ProfileData {
firstName: string;
Expand All @@ -15,7 +15,7 @@ interface ProfileData {
export function useProfileUpdate() {
const [isLoading, setIsLoading] = useState(false);

const updateProfile = async (data: ProfileData) => {
const updateProfile = useCallback(async (data: ProfileData) => {
setIsLoading(true);
try {
// TODO: Replace with actual API call
Expand All @@ -41,10 +41,7 @@ export function useProfileUpdate() {
} finally {
setIsLoading(false);
}
};
}, []);

return {
updateProfile,
isLoading,
};
return useMemo(() => ({ updateProfile, isLoading }), [updateProfile, isLoading]);
}
37 changes: 25 additions & 12 deletions src/app/profile/components/AchievementsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
'use client';

import { memo } from 'react';
import type { Achievement } from '../profile-data';
import { achievements } from '../profile-data';

export default function AchievementsPanel() {
interface AchievementCardProps {
achievement: Achievement;
}

const AchievementCard = memo(function AchievementCard({ achievement }: AchievementCardProps) {
return (
<article className="rounded-lg border border-gray-200 p-4 text-center">
<div className="mb-2 text-4xl" aria-hidden="true">
{achievement.icon}
</div>
<h3 className="font-semibold text-gray-900">{achievement.title}</h3>
<p className="text-sm text-gray-500">{achievement.description}</p>
<p className="mt-1 text-xs text-gray-400">{achievement.earnedAt}</p>
</article>
);
});

function AchievementsPanel() {
return (
<section
id="achievements-panel"
Expand All @@ -12,19 +33,11 @@ export default function AchievementsPanel() {

<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{achievements.map((achievement) => (
<article
key={achievement.id}
className="rounded-lg border border-gray-200 p-4 text-center"
>
<div className="mb-2 text-4xl" aria-hidden="true">
{achievement.icon}
</div>
<h3 className="font-semibold text-gray-900">{achievement.title}</h3>
<p className="text-sm text-gray-500">{achievement.description}</p>
<p className="mt-1 text-xs text-gray-400">{achievement.earnedAt}</p>
</article>
<AchievementCard key={achievement.id} achievement={achievement} />
))}
</div>
</section>
);
}

export default memo(AchievementsPanel);
Loading
Loading