2 Commits

Author SHA1 Message Date
kayela
7eef62560e feat: staff can edit registration responses 2026-01-29 21:49:25 -06:00
kayela
f70a133e18 feat: tabs layout for edit profile 2026-01-29 20:49:13 -06:00
2 changed files with 941 additions and 478 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
@@ -9,12 +9,77 @@ import {
} from './ui/dialog';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Checkbox } from './ui/checkbox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
import StatusBadge from './StatusBadge';
import api from '../utils/api';
import { toast } from 'sonner';
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
if (!user) return null;
const [formData, setFormData] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const autoSaveTimeoutRef = useRef(null);
const pendingSaveRef = useRef(false);
const leadSourceOptions = [
'Current member',
'Friend',
'OutSmart Magazine',
'Search engine (Google etc.)',
"I've known about LOAF for a long time",
'Other'
];
const volunteerOptions = [
'Welcoming new members at events',
'Sending out birthday cards',
'Care Team Calls',
'Sharing ideas for events',
'Researching grants',
'Applying for grants',
'Assisting with TeatherLOAFers',
'Assisting with ActiveLOAFers',
'Assisting with weekday Lunch Bunch',
'Uploading Photos to the Website',
'Assisting with eNewsletter',
'Other administrative task'
];
useEffect(() => {
if (!open || !user) return;
const nextFormData = {
lead_sources: Array.isArray(user.lead_sources) ? user.lead_sources : [],
partner_first_name: user.partner_first_name || '',
partner_last_name: user.partner_last_name || '',
partner_is_member: Boolean(user.partner_is_member),
partner_plan_to_become_member: Boolean(user.partner_plan_to_become_member),
newsletter_publish_name: Boolean(user.newsletter_publish_name),
newsletter_publish_photo: Boolean(user.newsletter_publish_photo),
newsletter_publish_birthday: Boolean(user.newsletter_publish_birthday),
newsletter_publish_none: Boolean(user.newsletter_publish_none),
referred_by_member_name: user.referred_by_member_name || '',
volunteer_interests: Array.isArray(user.volunteer_interests) ? user.volunteer_interests : [],
scholarship_requested: Boolean(user.scholarship_requested),
scholarship_reason: user.scholarship_reason || ''
};
setFormData(nextFormData);
setHasUnsavedChanges(false);
}, [open, user]);
useEffect(() => {
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, []);
const formatDate = (dateString) => {
if (!dateString) return '—';
return new Date(dateString).toLocaleDateString('en-US', {
@@ -44,6 +109,91 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
return phone;
};
const saveProfile = async (showToast = true) => {
if (!formData) return;
if (isSaving) {
pendingSaveRef.current = true;
return;
}
setIsSaving(true);
try {
await api.put('/users/profile', {
lead_sources: formData.lead_sources,
partner_first_name: formData.partner_first_name,
partner_last_name: formData.partner_last_name,
partner_is_member: formData.partner_is_member,
partner_plan_to_become_member: formData.partner_plan_to_become_member,
newsletter_publish_name: formData.newsletter_publish_name,
newsletter_publish_photo: formData.newsletter_publish_photo,
newsletter_publish_birthday: formData.newsletter_publish_birthday,
newsletter_publish_none: formData.newsletter_publish_none,
referred_by_member_name: formData.referred_by_member_name,
volunteer_interests: formData.volunteer_interests,
scholarship_requested: formData.scholarship_requested,
scholarship_reason: formData.scholarship_reason
});
setHasUnsavedChanges(false);
if (showToast) {
toast.success('Registration details saved');
}
} catch (error) {
if (showToast) {
toast.error(error.response?.data?.detail || 'Failed to save registration details');
}
} finally {
setIsSaving(false);
if (pendingSaveRef.current) {
pendingSaveRef.current = false;
saveProfile(showToast);
}
}
};
const scheduleAutoSave = () => {
setHasUnsavedChanges(true);
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
saveProfile(false);
}, 800);
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => {
const next = { ...prev, [name]: value };
return next;
});
scheduleAutoSave();
};
const handleCheckboxChange = (name, checked) => {
setFormData(prev => ({ ...prev, [name]: checked }));
scheduleAutoSave();
};
const handleLeadSourceChange = (source) => {
setFormData(prev => {
const sources = prev.lead_sources.includes(source)
? prev.lead_sources.filter((item) => item !== source)
: [...prev.lead_sources, source];
return { ...prev, lead_sources: sources };
});
scheduleAutoSave();
};
const handleVolunteerChange = (option) => {
setFormData(prev => {
const interests = prev.volunteer_interests.includes(option)
? prev.volunteer_interests.filter((item) => item !== option)
: [...prev.volunteer_interests, option];
return { ...prev, volunteer_interests: interests };
});
scheduleAutoSave();
};
const InfoRow = ({ icon: Icon, label, value }) => (
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
@@ -109,10 +259,214 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
Registration Details
</h3>
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
<InfoRow icon={UserCheck} label="Referred By" value={user.referred_by_member_name} />
<InfoRow icon={UserCheck} label="Referred By" value={formData?.referred_by_member_name} />
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
</Card>
{formData && (
<>
{/* How Did You Hear About Us */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
How Did You Hear About Us? *
</h3>
<div className="space-y-3">
{leadSourceOptions.map((source) => (
<div key={source} className="flex items-center space-x-2">
<Checkbox
id={`lead_${source}`}
checked={formData.lead_sources.includes(source)}
onCheckedChange={() => handleLeadSourceChange(source)}
/>
<Label htmlFor={`lead_${source}`} className="text-base cursor-pointer">
{source}
</Label>
</div>
))}
</div>
</Card>
{/* Partner Information */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Partner Information (Optional)
</h3>
<div className="grid md:grid-cols-2 gap-4 mb-4">
<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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="partner_is_member"
checked={formData.partner_is_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_is_member', checked)}
/>
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
Is your partner already a member?
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="partner_plan_to_become_member"
checked={formData.partner_plan_to_become_member}
onCheckedChange={(checked) => handleCheckboxChange('partner_plan_to_become_member', checked)}
/>
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
Does your partner plan to become a member?
</Label>
</div>
</div>
</Card>
{/* Newsletter Preferences */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Newsletter Publication Preferences *
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Please check what information may be published in LOAF Newsletter
</p>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_name"
checked={formData.newsletter_publish_name}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_name', checked)}
/>
<Label htmlFor="newsletter_publish_name" className="text-base cursor-pointer">
Name
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_photo"
checked={formData.newsletter_publish_photo}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_photo', checked)}
/>
<Label htmlFor="newsletter_publish_photo" className="text-base cursor-pointer">
Photo (added later in profile)
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_birthday"
checked={formData.newsletter_publish_birthday}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_birthday', checked)}
/>
<Label htmlFor="newsletter_publish_birthday" className="text-base cursor-pointer">
Birthday
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter_publish_none"
checked={formData.newsletter_publish_none}
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_none', checked)}
/>
<Label htmlFor="newsletter_publish_none" className="text-base cursor-pointer">
Do not publish any of my information
</Label>
</div>
</div>
</Card>
{/* Referral */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Referral
</h3>
<div>
<Label htmlFor="referred_by_member_name">Name of a LOAF Member who already knows you</Label>
<Input
id="referred_by_member_name"
name="referred_by_member_name"
value={formData.referred_by_member_name}
onChange={handleInputChange}
placeholder="Enter member name or email"
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
If referred by a current member, you may skip the event attendance requirement.
</p>
</div>
</Card>
{/* Volunteer Interests */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Volunteer Interests (Optional)
</h3>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{volunteerOptions.map((option) => (
<div key={option} className="flex items-center space-x-2">
<Checkbox
id={`volunteer_${option}`}
checked={formData.volunteer_interests.includes(option)}
onCheckedChange={() => handleVolunteerChange(option)}
/>
<Label htmlFor={`volunteer_${option}`} className="text-base cursor-pointer">
{option}
</Label>
</div>
))}
</div>
</Card>
{/* Scholarship Request */}
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center space-x-2">
<Checkbox
id="scholarship_requested"
checked={formData.scholarship_requested}
onCheckedChange={(checked) => handleCheckboxChange('scholarship_requested', checked)}
/>
<Label htmlFor="scholarship_requested" className="text-base cursor-pointer font-semibold">
I am requesting for scholarship
</Label>
</div>
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Scholarship information is kept confidential
</p>
{formData.scholarship_requested && (
<div className="mt-4">
<Label htmlFor="scholarship_reason">Please explain your situation *</Label>
<Textarea
id="scholarship_reason"
name="scholarship_reason"
value={formData.scholarship_reason}
onChange={handleInputChange}
placeholder="Tell us why you're requesting a scholarship..."
rows={4}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</div>
)}
</Card>
</>
)}
{/* Additional Information (if available) */}
{(user.address || user.city || user.state || user.zip_code) && (
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
@@ -156,6 +510,19 @@ const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
</div>
<DialogFooter>
<div className="flex-1 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{isSaving && 'Saving changes...'}
{!isSaving && hasUnsavedChanges && 'Unsaved changes'}
{!isSaving && !hasUnsavedChanges && 'All changes saved'}
</div>
<Button
type="button"
onClick={() => saveProfile(true)}
disabled={!hasUnsavedChanges || isSaving}
className="rounded-xl border-2 border-[var(--neutral-800)] bg-white text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]"
>
Save All
</Button>
<Button
type="button"
onClick={() => onOpenChange(false)}

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api';
import { Card } from '../components/ui/card';
import { Button } from '../components/ui/button';
@@ -9,11 +8,10 @@ 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, ArrowLeft, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog';
import TransactionHistory from '../components/TransactionHistory';
import { useNavigate } from 'react-router-dom';
const Profile = () => {
const { user } = useAuth();
@@ -24,13 +22,13 @@ const Profile = () => {
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 [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
const [activeTab, setActiveTab] = useState('account');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [initialFormData, setInitialFormData] = useState(null);
const navigate = useNavigate();
const [formData, setFormData] = useState({
// Personal Information
first_name: '',
last_name: '',
phone: '',
@@ -38,19 +36,15 @@ const Profile = () => {
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: '',
@@ -63,9 +57,16 @@ const Profile = () => {
useEffect(() => {
fetchConfig();
fetchProfile();
fetchTransactions();
}, []);
// Track unsaved changes
useEffect(() => {
if (initialFormData) {
const hasChanges = JSON.stringify(formData) !== JSON.stringify(initialFormData);
setHasUnsavedChanges(hasChanges);
}
}, [formData, initialFormData]);
const fetchConfig = async () => {
try {
const response = await api.get('/config');
@@ -73,7 +74,6 @@ const Profile = () => {
setMaxFileSizeBytes(response.data.max_file_size_bytes);
} catch (error) {
console.error('Failed to fetch config, using defaults:', error);
// Keep default values if fetch fails
}
};
@@ -83,8 +83,7 @@ const Profile = () => {
setProfileData(response.data);
setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url);
setFormData({
// Personal Information
const newFormData = {
first_name: response.data.first_name || '',
last_name: response.data.last_name || '',
phone: response.data.phone || '',
@@ -92,19 +91,15 @@ const Profile = () => {
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 || '',
@@ -112,25 +107,14 @@ const Profile = () => {
directory_phone: response.data.directory_phone || '',
directory_dob: response.data.directory_dob || '',
directory_partner_name: response.data.directory_partner_name || ''
});
};
setFormData(newFormData);
setInitialFormData(newFormData);
} catch (error) {
toast.error('Failed to load profile');
}
};
const fetchTransactions = async () => {
try {
setTransactionsLoading(true);
const response = await api.get('/members/transactions');
setTransactions(response.data);
} catch (error) {
console.error('Failed to load transactions:', error);
// Don't show error toast - transactions are optional
} finally {
setTransactionsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -150,7 +134,6 @@ const Profile = () => {
}));
};
// Volunteer interest options
const volunteerOptions = [
'Event Planning',
'Social Media',
@@ -168,13 +151,11 @@ const Profile = () => {
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;
@@ -222,6 +203,8 @@ const Profile = () => {
try {
await api.put('/users/profile', formData);
toast.success('Profile updated successfully!');
setInitialFormData(formData);
setHasUnsavedChanges(false);
fetchProfile();
} catch (error) {
toast.error('Failed to update profile');
@@ -230,9 +213,15 @@ const Profile = () => {
}
};
const tabs = [
{ id: 'account', label: 'Account & Privacy', shortLabel: 'Account', icon: Lock },
{ id: 'bio', label: 'My Bio & Directory', shortLabel: 'Bio & Directory', icon: User },
{ id: 'engagement', label: 'Engagement', shortLabel: 'Engagement', icon: Handshake }
];
if (!profileData) {
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-white dark:bg-[var(--purple-deep)]">
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading profile...</p>
@@ -241,80 +230,77 @@ const Profile = () => {
);
}
return (
<div className="min-h-screen bg-white">
<Navbar />
// Account & Privacy Tab Content
const AccountPrivacyContent = () => (
<div className="space-y-6 ">
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8 flex justify-between">
<div classname="">
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
My Profile
</h1>
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Update your personal information below.
</p>
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-xl -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Account & Privacy</h3>
</div>
{/* Todo: functional back button */}
<Button
onClick={() => navigate(-1)}
className="h-fit bg-brand-purple hover:bg-brand-purple/80">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Login Email</p>
<p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
</div>
<Card className="p-8 bg-white rounded-2xl border border-[var(--neutral-800)] shadow-lg">
{/* Read-only Information */}
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<User className="h-6 w-6 text-brand-purple " />
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.email}</p>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Password</p>
<p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}></p>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Status</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.status.replace('_', ' ')}</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{profileData.role}</p>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Date of Birth</p>
<p className="text-[var(--purple-ink)] 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-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6 py-3"
className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
>
<Lock className="h-4 w-4 mr-2" />
Change Password
Change
</Button>
</div>
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Status</p>
<p className="text-[var(--purple-ink)] dark:text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{profileData.status?.replace('_', ' ') || 'Active'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</p>
<div className="flex items-center gap-2">
<CreditCard className="h-6 w-6 text-brand-purple" />
</div>
</div>
<Button
type="button"
variant="outline"
className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
>
Manage
</Button>
</div>
</Card>
</div>
);
// My Bio & Directory Tab Content
const BioDirectoryContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>My Bio & Directory</h3>
</div>
{/* Profile Photo Section */}
<div className="pb-8 mb-8 border-b border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Camera className="h-6 w-6 text-brand-purple " />
<div className="pb-6 border-b border-[var(--neutral-800)]">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] mb-4 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Camera className="h-5 w-5 text-brand-purple" />
Profile Photo
</h2>
</h4>
<div className="flex flex-col md:flex-row items-center gap-6">
<Avatar className="h-32 w-32 border-4 border-[var(--neutral-800)]">
<Avatar className="h-24 w-24 border-4 border-[var(--neutral-800)]">
<AvatarImage src={previewImage} alt="Profile" />
<AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-3xl">
<AvatarFallback className="bg-[var(--lavender-300)] text-brand-purple text-2xl">
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
</AvatarFallback>
</Avatar>
@@ -332,7 +318,7 @@ const Profile = () => {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto}
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6 py-3"
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-4 py-2"
>
<Upload className="h-4 w-4 mr-2" />
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
@@ -344,7 +330,7 @@ const Profile = () => {
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"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Photo
@@ -352,19 +338,19 @@ const Profile = () => {
)}
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Upload a profile photo (Max {maxFileSizeMB}MB)
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-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
{/* Personal Information */}
<div className="space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Personal Information
</h2>
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
@@ -372,7 +358,7 @@ const Profile = () => {
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="first-name-input"
/>
</div>
@@ -383,7 +369,7 @@ const Profile = () => {
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="last-name-input"
/>
</div>
@@ -397,7 +383,7 @@ const Profile = () => {
type="tel"
value={formData.phone}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="phone-input"
/>
</div>
@@ -409,12 +395,12 @@ const Profile = () => {
name="address"
value={formData.address}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
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 className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<Label htmlFor="city">City</Label>
<Input
@@ -422,7 +408,7 @@ const Profile = () => {
name="city"
value={formData.city}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="city-input"
/>
</div>
@@ -433,7 +419,7 @@ const Profile = () => {
name="state"
value={formData.state}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="state-input"
/>
</div>
@@ -444,20 +430,132 @@ const Profile = () => {
name="zipcode"
value={formData.zipcode}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
data-testid="zipcode-input"
/>
</div>
</div>
</div>
{/* Section 2: Partner Information */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-6 w-6 text-[var(--orange-light)]" />
{/* Member Directory Settings */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-5 w-5 text-[var(--orange-light)]" />
Member Directory Settings
</h4>
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory.
</p>
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
<input
type="checkbox"
id="show_in_directory"
name="show_in_directory"
checked={formData.show_in_directory}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
Include me in the member directory
</Label>
</div>
{formData.show_in_directory && (
<div className="space-y-4 pl-4 border-l-4 border-[var(--neutral-800)]">
<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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
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-[var(--neutral-800)] focus:border-brand-purple 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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
/>
</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-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional - partner name to show in directory"
/>
</div>
</div>
)}
</div>
</Card>
);
// Engagement Tab Content
const EngagementContent = () => (
<Card className="space-y-6 px-6 pb-6">
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-6 mb-6">
<h3 className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>Engagement</h3>
</div>
{/* Partner Information */}
<div className="space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
Partner Information
</h2>
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="partner_first_name">Partner First Name</Label>
<Input
@@ -465,7 +563,7 @@ const Profile = () => {
name="partner_first_name"
value={formData.partner_first_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional"
/>
</div>
@@ -476,7 +574,7 @@ const Profile = () => {
name="partner_last_name"
value={formData.partner_last_name}
onChange={handleInputChange}
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
placeholder="Optional"
/>
</div>
@@ -488,7 +586,6 @@ const Profile = () => {
id="partner_is_member"
name="partner_is_member"
checked={formData.partner_is_member}
accent-color="var(--brand-white)"
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
@@ -511,15 +608,14 @@ const Profile = () => {
</div>
</div>
</div>
</div>
{/* Section 3: Newsletter Preferences */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6 text-[var(--green-light)]" />
{/* Newsletter Preferences */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-5 w-5 text-[var(--green-light)]" />
Newsletter Preferences
</h2>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</h4>
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Choose what information you'd like published in our member newsletter.
</p>
<div className="space-y-3">
@@ -578,13 +674,13 @@ const Profile = () => {
</div>
</div>
{/* Section 4: Volunteer Interests */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-6 w-6 text-brand-purple " />
{/* Volunteer Interests */}
<div className="pt-6 border-t border-[var(--neutral-800)] space-y-4">
<h4 className="text-lg font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-5 w-5 text-brand-purple" />
Volunteer Interests
</h2>
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</h4>
<p className="text-brand-purple text-sm" 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">
@@ -607,133 +703,133 @@ const Profile = () => {
))}
</div>
</div>
</Card>
);
{/* Section 5: Member Directory Settings */}
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<BookUser className="h-6 w-6 text-[var(--orange-light)]" />
Member Directory Settings
</h2>
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Control your visibility and information in the member directory.
</p>
return (
<div className="min-h-screen bg-background flex flex-col">
<Navbar />
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-400)] rounded-lg">
<input
type="checkbox"
id="show_in_directory"
name="show_in_directory"
checked={formData.show_in_directory}
onChange={handleCheckboxChange}
className="ui-checkbox"
/>
<Label htmlFor="show_in_directory" className="cursor-pointer text-[var(--purple-ink)] font-medium">
Include me in the member directory
</Label>
<div className="flex-1 flex flex-col">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 w-full flex-1 pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className='space-y-4'>
<h1 className="text-4xl md:text-4xl font-semibold " style={{ fontFamily: "'Inter', sans-serif" }}>
My Profile
</h1>
<p className='text-brand-purple text-md'>Update your personal information below.</p>
</div>
{/* <Button
type="button"
variant="outline"
className="border-2 hover:bg-white/10 rounded-lg px-4 py-2"
>
<Eye className="h-4 w-4 mr-2 md:mr-2" />
<span className="hidden md:inline">Public Profile Preview</span>
<span className="md:hidden">Preview</span>
</Button> */}
</div>
{formData.show_in_directory && (
<div className="space-y-6 pl-4 border-l-4 border-[var(--neutral-800)]">
<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-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - email to show in directory"
/>
{/* Main Content Div */}
<div className="overflow-hidden ">
<form onSubmit={handleSubmit} data-testid="profile-form">
{/* Mobile Tabs */}
<div className="md:hidden flex border-b border-[var(--neutral-800)] mb-4 gap-1 ">
{tabs.map((tab) => {
const IconComponent = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex flex-col items-center rounded-xl gap-1 px-3 py-3 text-xs font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span className="whitespace-nowrap">{tab.shortLabel}</span>
</button>
);
})}
</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-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
placeholder="Tell other members about yourself..."
/>
{/* Desktop Layout */}
<div className="flex">
{/* Desktop Sidebar Tabs */}
<div className="hidden md:flex flex-col w-64 border-[var(--neutral-800)] mr-4 gap-2">
{tabs.map((tab) => {
const IconComponent = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-left font-medium transition-colors ${activeTab === tab.id
? 'bg-brand-purple text-white'
: 'text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]'
}`}
>
<IconComponent className="h-5 w-5" />
<span>{tab.label}</span>
</button>
);
})}
</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-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - address to show in directory"
/>
{/* Content Area */}
<div className="flex-1 p-6 min-h-[500px]">
{activeTab === 'account' && <AccountPrivacyContent />}
{activeTab === 'bio' && <BioDirectoryContent />}
{activeTab === 'engagement' && <EngagementContent />}
</div>
</div>
</form>
</div>
</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-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - phone to show in directory"
/>
</div>
{/* Sticky Footer */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-[var(--neutral-800)] px-4 sm:px-6 py-4 z-50">
<div className="max-w-5xl px-6 mx-auto flex items-center justify-between">
<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-[var(--neutral-800)] focus:border-brand-purple "
/>
</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-[var(--neutral-800)] focus:border-brand-purple "
placeholder="Optional - partner name to show in directory"
/>
</div>
</div>
<div className='flex gap-2 w-full lg:justify-between md:mr-5'>
<Button
onClick={() => navigate(-1)}
className="h-fit bg-brand-purple hover:bg-brand-purple/80 rounded-lg px-6 py-2 font-medium shadow-lg w-full md:w-auto">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex items-center gap-2">
{hasUnsavedChanges && (
<>
<span className="h-3 w-3 rounded-full bg-[var(--orange-light)]"></span>
<span className="text-sm text-[var(--purple-ink)] " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Unsaved changes
</span>
</>
)}
</div>
</div>
<div className="pt-8 mt-8 border-t border-[var(--neutral-800)]">
<Button
type="submit"
type="button"
onClick={handleSubmit}
disabled={loading}
className="bg-brand-purple text-white hover:bg-brand-dark-lavender rounded-full px-8 py-6 text-lg font-medium shadow-lg disabled:opacity-50"
className="bg-brand-purple text-white hover:bg-brand-dark-lavender rounded-lg px-6 py-2 font-medium shadow-lg disabled:opacity-50 w-full md:w-auto"
data-testid="save-profile-button"
>
<Save className="h-5 w-5 mr-2" />
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Card>
</div>
</div>
</div>
<ChangePasswordDialog
open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen}
/>
</div>
<MemberFooter />
</div>
);
};