485 lines
20 KiB
JavaScript
485 lines
20 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||
import api from '../../utils/api';
|
||
import Navbar from '../../components/Navbar';
|
||
import MemberFooter from '../../components/MemberFooter';
|
||
import { Link } from 'react-router-dom';
|
||
import { Card } from '../../components/ui/card';
|
||
import { Input } from '../../components/ui/input';
|
||
import { Badge } from '../../components/ui/badge';
|
||
import { Button } from '../../components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '../../components/ui/dialog';
|
||
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
|
||
import { useToast } from '../../hooks/use-toast';
|
||
import MemberCard from '../../components/MemberCard';
|
||
import MemberBadge from '../../components/MemberBadge';
|
||
import useMembers from '../../hooks/use-members';
|
||
import useMemberTiers from '../../hooks/use-member-tiers';
|
||
|
||
const MembersDirectory = () => {
|
||
const [selectedMember, setSelectedMember] = useState(null);
|
||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||
const { toast } = useToast();
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const pageSize = 12;
|
||
const { tiers } = useMemberTiers();
|
||
const allowedRoles = useMemo(() => [], []);
|
||
const normalizeStatus = useCallback((status) => {
|
||
if (typeof status === 'string') {
|
||
return status.toLowerCase();
|
||
}
|
||
return status;
|
||
}, []);
|
||
const normalizeMembers = useCallback(
|
||
(data) => {
|
||
const list = Array.isArray(data)
|
||
? data
|
||
: data?.members || data?.results || data?.items || data?.data || [];
|
||
|
||
return list.map((member) => ({
|
||
...member,
|
||
status: normalizeStatus(member.status ?? member.membership_status ?? member.member_status),
|
||
}));
|
||
},
|
||
[normalizeStatus]
|
||
);
|
||
const searchAccessor = useCallback(
|
||
(member) => [
|
||
`${member.first_name} ${member.last_name}`,
|
||
member.directory_bio || ''
|
||
],
|
||
[]
|
||
);
|
||
const handleFetchError = useCallback(() => {
|
||
toast({
|
||
title: "Error",
|
||
description: "Failed to load members directory. Please try again.",
|
||
variant: "destructive"
|
||
});
|
||
}, [toast]);
|
||
|
||
const {
|
||
users: members,
|
||
filteredUsers: filteredMembers,
|
||
loading,
|
||
searchQuery,
|
||
setSearchQuery,
|
||
filterValue,
|
||
} = useMembers({
|
||
endpoint: '/members/directory',
|
||
initialFilter: 'all',
|
||
filterKey: 'status',
|
||
allowedRoles,
|
||
searchAccessor,
|
||
transform: normalizeMembers,
|
||
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
||
onFetchError: handleFetchError
|
||
});
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchQuery, members]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
||
|
||
const pageStart = (currentPage - 1) * pageSize;
|
||
|
||
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
|
||
|
||
const totalMembers = useMemo(() => {
|
||
if (!filterValue || filterValue === 'all') {
|
||
return members.length;
|
||
}
|
||
return members.filter((member) => member.status === filterValue).length;
|
||
}, [members, filterValue]);
|
||
|
||
|
||
|
||
const getSocialMediaLink = (url) => {
|
||
if (!url) return null;
|
||
// Ensure URL has protocol
|
||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||
return `https://${url}`;
|
||
}
|
||
return url;
|
||
};
|
||
|
||
const handleViewProfile = async (memberId) => {
|
||
try {
|
||
const response = await api.get(`/members/directory/${memberId}`);
|
||
setSelectedMember(response.data);
|
||
setProfileDialogOpen(true);
|
||
} catch (error) {
|
||
toast({
|
||
title: "Error",
|
||
description: "Failed to load member profile. Please try again.",
|
||
variant: "destructive"
|
||
});
|
||
}
|
||
};
|
||
|
||
const formatDate = (dateString) => {
|
||
if (!dateString) return null;
|
||
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||
};
|
||
const Border = ({ yaxis = false }) => {
|
||
return (
|
||
yaxis ?
|
||
<div className=' border-2 w-full border-brand-purple my-24' />
|
||
: <div className=' border-2 w-full border-brand-purple mb-24' />
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-bl from-white to-muted">
|
||
<Navbar />
|
||
|
||
<div className="max-w-7xl mx-auto py-12">
|
||
|
||
{/* Header and Search bar */}
|
||
<div className='px-9'>
|
||
|
||
{/* Header */}
|
||
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
|
||
<h1 className="text-4xl md:text-5xl font-bold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||
LOAF Members
|
||
</h1>
|
||
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
<span className='text-foreground'>Number of current members in the directory: </span> <span className='text-brand-purple font-medium'>{totalMembers}</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Search Bar */}
|
||
<div className="mb-24 w-full">
|
||
<div className="relative w-full ">
|
||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||
<Input
|
||
type="text"
|
||
placeholder="Search by name or bio..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-brand-purple focus:ring-brand-purple "
|
||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||
/>
|
||
</div>
|
||
{searchQuery && (
|
||
<p className="mt-3 text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
{/* Border Decoration */}
|
||
|
||
<Border />
|
||
|
||
{/* Members Grid */}
|
||
{loading ? (
|
||
<div className="text-center py-20">
|
||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
|
||
</div>
|
||
) : filteredMembers.length > 0 ? (
|
||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||
{paginatedMembers.map((member) => (
|
||
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-20">
|
||
<User className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
{searchQuery ? 'No Members Found' : 'No Members in Directory'}
|
||
</h3>
|
||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
{searchQuery
|
||
? 'Try adjusting your search query.'
|
||
: 'Members who opt in to the directory will appear here.'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
|
||
|
||
{/* Border Decoration */}
|
||
<Border yaxis="true" />
|
||
{/* todo: use badge to display if member */}
|
||
{/* Info Card */}
|
||
{!loading && members.length > 0 && (
|
||
<Card className="mt-12 p-6 bg-[var(--lavender-500)] border-[var(--neutral-800)]">
|
||
<div className="flex items-start gap-4">
|
||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||
<User className="h-6 w-6 text-brand-purple " />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
Want to appear in the directory?
|
||
</h3>
|
||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
Update your profile settings to show in the directory and add your photo, bio, and contact information.{' '}
|
||
|
||
<Link to="/profile" className="text-[var(--orange-light)] hover:underline font-medium">
|
||
Edit your profile →
|
||
</Link>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
{/* Profile Detail Dialog */}
|
||
<Dialog open={profileDialogOpen} onOpenChange={setProfileDialogOpen}>
|
||
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||
{selectedMember && (
|
||
<>
|
||
<DialogHeader>
|
||
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
{selectedMember.first_name} {selectedMember.last_name}
|
||
<MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
|
||
</DialogTitle>
|
||
{selectedMember.directory_partner_name && (
|
||
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||
<span className="text-brand-purple ">Partner: {selectedMember.directory_partner_name}</span>
|
||
</DialogDescription>
|
||
)}
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-6 py-4">
|
||
{/* Bio */}
|
||
{selectedMember.directory_bio && (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
About
|
||
</h3>
|
||
<p className="text-brand-purple leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
{selectedMember.directory_bio}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Contact Information */}
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
Contact Information
|
||
</h3>
|
||
<div className="space-y-3">
|
||
{selectedMember.directory_email && (
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||
<Mail className="h-5 w-5 text-brand-purple " />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||
<a
|
||
href={`mailto:${selectedMember.directory_email}`}
|
||
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||
>
|
||
{selectedMember.directory_email}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedMember.directory_phone && (
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||
<Phone className="h-5 w-5 text-brand-purple " />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Phone</p>
|
||
<a
|
||
href={`tel:${selectedMember.directory_phone}`}
|
||
className="text-[var(--purple-ink)] hover:text-brand-purple font-medium"
|
||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||
>
|
||
{selectedMember.directory_phone}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedMember.directory_address && (
|
||
<div className="flex items-start gap-3">
|
||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||
<MapPin className="h-5 w-5 text-brand-purple " />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Address</p>
|
||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
{selectedMember.directory_address}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedMember.directory_dob && (
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 rounded-lg bg-[var(--lavender-500)]">
|
||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Birthday</p>
|
||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
{formatDate(selectedMember.directory_dob)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Volunteer Interests */}
|
||
{selectedMember.volunteer_interests && selectedMember.volunteer_interests.length > 0 && (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
Volunteer Interests
|
||
</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedMember.volunteer_interests.map((interest, index) => (
|
||
<Badge
|
||
key={index}
|
||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white"
|
||
>
|
||
{interest}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Social Media */}
|
||
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||
Connect on Social Media
|
||
</h3>
|
||
<div className="flex gap-3">
|
||
{selectedMember.social_media_facebook && (
|
||
<a
|
||
href={getSocialMediaLink(selectedMember.social_media_facebook)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-3 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||
title="Facebook"
|
||
>
|
||
<Facebook className="h-6 w-6 text-[var(--blue-facebook)]" />
|
||
</a>
|
||
)}
|
||
|
||
{selectedMember.social_media_instagram && (
|
||
<a
|
||
href={getSocialMediaLink(selectedMember.social_media_instagram)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-3 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||
title="Instagram"
|
||
>
|
||
<Instagram className="h-6 w-6 text-[var(--red-instagram)]" />
|
||
</a>
|
||
)}
|
||
|
||
{selectedMember.social_media_twitter && (
|
||
<a
|
||
href={getSocialMediaLink(selectedMember.social_media_twitter)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-3 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||
title="Twitter/X"
|
||
>
|
||
<Twitter className="h-6 w-6 text-[var(--blue-twitter)]" />
|
||
</a>
|
||
)}
|
||
|
||
{selectedMember.social_media_linkedin && (
|
||
<a
|
||
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-3 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||
title="LinkedIn"
|
||
>
|
||
<Linkedin className="h-6 w-6 text-[var(--blue-linkedin)]" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Pagination */}
|
||
{!loading && filteredMembers.length > 0 && (
|
||
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
|
||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
|
||
</p>
|
||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||
<Button
|
||
onClick={() => setCurrentPage(1)}
|
||
disabled={currentPage === 1}
|
||
className="bg-[var(--neutral-800)] rounded-full text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white"
|
||
>
|
||
First Page
|
||
</Button>
|
||
<Button
|
||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||
disabled={currentPage === 1}
|
||
className="bg-[var(--neutral-800)] rounded-full text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white"
|
||
>
|
||
Previous
|
||
</Button>
|
||
|
||
{Array.from({ length: totalPages }, (_, index) => {
|
||
const pageNumber = index + 1;
|
||
const isActive = pageNumber === currentPage;
|
||
return (
|
||
<Button
|
||
key={pageNumber}
|
||
onClick={() => setCurrentPage(pageNumber)}
|
||
className={
|
||
isActive
|
||
? "bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
|
||
: "bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full"
|
||
}
|
||
>
|
||
{pageNumber}
|
||
</Button>
|
||
);
|
||
})}
|
||
|
||
<Button
|
||
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple rounded-full hover:text-white"
|
||
>
|
||
Next
|
||
</Button>
|
||
<Button
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
disabled={currentPage === totalPages}
|
||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple rounded-full hover:text-white"
|
||
>
|
||
Last Page
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<MemberFooter />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MembersDirectory;
|