Files
membership-fe/src/pages/Profile.js
Koncept Kit 8c0d9a2a18 - Profile Picture\
Donation Tracking\
Validation Rejection\
Subscription Data Export\
Admin Dashboard Logo\
Admin Navbar Reorganization
2025-12-18 17:04:50 +07:00

712 lines
29 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Textarea } from '../components/ui/textarea';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog';
const Profile = () => {
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [profileData, setProfileData] = useState(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [profilePhotoUrl, setProfilePhotoUrl] = useState(null);
const [previewImage, setPreviewImage] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const fileInputRef = useRef(null);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes
const [formData, setFormData] = useState({
// Personal Information
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
// Partner Information
partner_first_name: '',
partner_last_name: '',
partner_is_member: false,
partner_plan_to_become_member: false,
// Newsletter Preferences
newsletter_publish_name: false,
newsletter_publish_photo: false,
newsletter_publish_birthday: false,
newsletter_publish_none: false,
// Volunteer Interests (array)
volunteer_interests: [],
// Member Directory Settings
show_in_directory: false,
directory_email: '',
directory_bio: '',
directory_address: '',
directory_phone: '',
directory_dob: '',
directory_partner_name: ''
});
useEffect(() => {
fetchConfig();
fetchProfile();
}, []);
const fetchConfig = async () => {
try {
const response = await api.get('/config');
setMaxFileSizeMB(response.data.max_file_size_mb);
setMaxFileSizeBytes(response.data.max_file_size_bytes);
} catch (error) {
console.error('Failed to fetch config, using defaults:', error);
// Keep default values if fetch fails
}
};
const fetchProfile = async () => {
try {
const response = await api.get('/users/profile');
setProfileData(response.data);
setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url);
setFormData({
// Personal Information
first_name: response.data.first_name || '',
last_name: response.data.last_name || '',
phone: response.data.phone || '',
address: response.data.address || '',
city: response.data.city || '',
state: response.data.state || '',
zipcode: response.data.zipcode || '',
// Partner Information
partner_first_name: response.data.partner_first_name || '',
partner_last_name: response.data.partner_last_name || '',
partner_is_member: response.data.partner_is_member || false,
partner_plan_to_become_member: response.data.partner_plan_to_become_member || false,
// Newsletter Preferences
newsletter_publish_name: response.data.newsletter_publish_name || false,
newsletter_publish_photo: response.data.newsletter_publish_photo || false,
newsletter_publish_birthday: response.data.newsletter_publish_birthday || false,
newsletter_publish_none: response.data.newsletter_publish_none || false,
// Volunteer Interests
volunteer_interests: response.data.volunteer_interests || [],
// Member Directory Settings
show_in_directory: response.data.show_in_directory || false,
directory_email: response.data.directory_email || '',
directory_bio: response.data.directory_bio || '',
directory_address: response.data.directory_address || '',
directory_phone: response.data.directory_phone || '',
directory_dob: response.data.directory_dob || '',
directory_partner_name: response.data.directory_partner_name || ''
});
} catch (error) {
toast.error('Failed to load profile');
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleCheckboxChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({ ...prev, [name]: checked }));
};
const handleVolunteerToggle = (interest) => {
setFormData(prev => ({
...prev,
volunteer_interests: prev.volunteer_interests.includes(interest)
? prev.volunteer_interests.filter(i => i !== interest)
: [...prev.volunteer_interests, interest]
}));
};
// Volunteer interest options
const volunteerOptions = [
'Event Planning',
'Social Media',
'Newsletter',
'Fundraising',
'Community Outreach',
'Graphic Design',
'Photography',
'Writing/Editing',
'Tech Support',
'Hospitality'
];
const handlePhotoUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
// Validate file size
if (file.size > maxFileSizeBytes) {
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
return;
}
setUploadingPhoto(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/members/profile/upload-photo', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url);
toast.success('Profile photo updated successfully!');
} catch (error) {
toast.error('Failed to upload photo');
} finally {
setUploadingPhoto(false);
}
};
const handlePhotoDelete = async () => {
if (!profilePhotoUrl) return;
setUploadingPhoto(true);
try {
await api.delete('/members/profile/delete-photo');
setProfilePhotoUrl(null);
setPreviewImage(null);
toast.success('Profile photo deleted successfully!');
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setUploadingPhoto(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
await api.put('/users/profile', formData);
toast.success('Profile updated successfully!');
fetchProfile();
} catch (error) {
toast.error('Failed to update profile');
} finally {
setLoading(false);
}
};
if (!profileData) {
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
My Profile
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your personal information below.
</p>
</div>
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
{/* Read-only Information */}
<div className="mb-8 pb-8 border-b border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<User className="h-6 w-6 text-[#664fa3]" />
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(profileData.date_of_birth).toLocaleDateString()}
</p>
</div>
</div>
<div className="mt-6">
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
variant="outline"
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-6 py-3"
>
<Lock className="h-4 w-4 mr-2" />
Change Password
</Button>
</div>
</div>
{/* Profile Photo Section */}
<div className="pb-8 mb-8 border-b border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Camera className="h-6 w-6 text-[#664fa3]" />
Profile Photo
</h2>
<div className="flex flex-col md:flex-row items-center gap-6">
<Avatar className="h-32 w-32 border-4 border-[#ddd8eb]">
<AvatarImage src={previewImage} alt="Profile" />
<AvatarFallback className="bg-[#f1eef9] text-[#664fa3] text-3xl">
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-3">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handlePhotoUpload}
className="hidden"
/>
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto}
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-6 py-3"
>
<Upload className="h-4 w-4 mr-2" />
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
</Button>
{profilePhotoUrl && (
<Button
type="button"
onClick={handlePhotoDelete}
disabled={uploadingPhoto}
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-6 py-3"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Photo
</Button>
)}
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload a profile photo (Max {maxFileSizeMB}MB)
</p>
</div>
</div>
</div>
{/* Editable Form */}
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form">
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Personal Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="first-name-input"
/>
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="last-name-input"
/>
</div>
</div>
<div>
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
name="phone"
type="tel"
value={formData.phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="phone-input"
/>
</div>
<div>
<Label htmlFor="address">Address</Label>
<Input
id="address"
name="address"
value={formData.address}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="address-input"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
<div>
<Label htmlFor="city">City</Label>
<Input
id="city"
name="city"
value={formData.city}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="city-input"
/>
</div>
<div>
<Label htmlFor="state">State</Label>
<Input
id="state"
name="state"
value={formData.state}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="state-input"
/>
</div>
<div>
<Label htmlFor="zipcode">Zipcode</Label>
<Input
id="zipcode"
name="zipcode"
value={formData.zipcode}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
data-testid="zipcode-input"
/>
</div>
</div>
{/* Section 2: Partner Information */}
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-6 w-6 text-[#ff9e77]" />
Partner Information
</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<Label htmlFor="partner_first_name">Partner First Name</Label>
<Input
id="partner_first_name"
name="partner_first_name"
value={formData.partner_first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional"
/>
</div>
<div>
<Label htmlFor="partner_last_name">Partner Last Name</Label>
<Input
id="partner_last_name"
name="partner_last_name"
value={formData.partner_last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_is_member"
name="partner_is_member"
checked={formData.partner_is_member}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="partner_is_member" className="cursor-pointer text-[#422268]">
My partner is a current member
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="partner_plan_to_become_member"
name="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="partner_plan_to_become_member" className="cursor-pointer text-[#422268]">
My partner plans to become a member
</Label>
</div>
</div>
</div>
</div>
{/* Section 3: Newsletter Preferences */}
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6 text-[#81B29A]" />
Newsletter Preferences
</h2>
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose what information you'd like published in our member newsletter.
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_name"
name="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="newsletter_publish_name" className="cursor-pointer text-[#422268]">
Publish my name
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_photo"
name="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="newsletter_publish_photo" className="cursor-pointer text-[#422268]">
Publish my photo
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_birthday"
name="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="newsletter_publish_birthday" className="cursor-pointer text-[#422268]">
Publish my birthday
</Label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="newsletter_publish_none"
name="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="newsletter_publish_none" className="cursor-pointer text-[#422268]">
Do not publish any information
</Label>
</div>
</div>
</div>
{/* Section 4: Volunteer Interests */}
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-6 w-6 text-[#664fa3]" />
Volunteer Interests
</h2>
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Select areas where you'd like to volunteer and help our community.
</p>
<div className="grid md:grid-cols-2 gap-3">
{volunteerOptions.map(option => (
<div key={option} className="flex items-center gap-3">
<input
type="checkbox"
id={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
checked={formData.volunteer_interests.includes(option)}
onChange={() => handleVolunteerToggle(option)}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label
htmlFor={`volunteer_${option.replace(/\s+/g, '_').toLowerCase()}`}
className="cursor-pointer text-[#422268]"
>
{option}
</Label>
</div>
))}
</div>
</div>
{/* Section 5: Member Directory Settings */}
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-6 w-6 text-[#ff9e77]" />
Member Directory Settings
</h2>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory.
</p>
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 bg-[#f9f5ff] rounded-lg">
<input
type="checkbox"
id="show_in_directory"
name="show_in_directory"
checked={formData.show_in_directory}
onChange={handleCheckboxChange}
className="w-5 h-5 text-[#664fa3] border-2 border-[#ddd8eb] rounded focus:ring-[#664fa3]"
/>
<Label htmlFor="show_in_directory" className="cursor-pointer text-[#422268] font-medium">
Include me in the member directory
</Label>
</div>
{formData.show_in_directory && (
<div className="space-y-6 pl-4 border-l-4 border-[#DDD8EB]">
<div>
<Label htmlFor="directory_email">Directory Email</Label>
<Input
id="directory_email"
name="directory_email"
type="email"
value={formData.directory_email}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional - email to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_bio">Bio</Label>
<Textarea
id="directory_bio"
name="directory_bio"
value={formData.directory_bio}
onChange={handleInputChange}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] min-h-[100px]"
placeholder="Tell other members about yourself..."
/>
</div>
<div>
<Label htmlFor="directory_address">Address</Label>
<Input
id="directory_address"
name="directory_address"
value={formData.directory_address}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional - address to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_phone">Phone</Label>
<Input
id="directory_phone"
name="directory_phone"
type="tel"
value={formData.directory_phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional - phone to show in directory"
/>
</div>
<div>
<Label htmlFor="directory_dob">Date of Birth</Label>
<Input
id="directory_dob"
name="directory_dob"
type="date"
value={formData.directory_dob}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
<div>
<Label htmlFor="directory_partner_name">Partner Name</Label>
<Input
id="directory_partner_name"
name="directory_partner_name"
value={formData.directory_partner_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Optional - partner name to show in directory"
/>
</div>
</div>
)}
</div>
</div>
<div className="pt-8 mt-8 border-t border-[#ddd8eb]">
<Button
type="submit"
disabled={loading}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50"
data-testid="save-profile-button"
>
<Save className="h-5 w-5 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Card>
<ChangePasswordDialog
open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen}
/>
</div>
<MemberFooter />
</div>
);
};
export default Profile;