Merge from dev to loaf-prod for DEMO #25

Merged
andika merged 45 commits from dev into loaf-prod 2026-02-02 11:12:58 +00:00
10 changed files with 576 additions and 120 deletions
Showing only changes of commit 4548d959d7 - Show all commits

179
README.md
View File

@@ -999,3 +999,182 @@ api.interceptors.response.use(
**Last Updated**: December 18, 2024
**Version**: 1.0.0
**Maintainer**: LOAF Development Team
**Backend API**
**Auth**
- POST `/api/auth/register`
- GET `/api/auth/verify-email`
- POST `/api/auth/resend-verification-email`
- POST `/api/auth/login`
- POST `/api/auth/forgot-password`
- POST `/api/auth/reset-password`
- GET `/api/auth/me`
- GET `/api/auth/permissions`
**Users**
- PUT `/api/users/change-password`
- GET `/api/users/profile`
- PUT `/api/users/profile`
**Members**
- GET `/api/members/directory` (defined twice in code)
- GET `/api/members/directory/{user_id}`
- GET `/api/members/profile`
- PUT `/api/members/profile`
- POST `/api/members/profile/upload-photo`
- DELETE `/api/members/profile/delete-photo`
- GET `/api/members/calendar/events`
- GET `/api/members/gallery`
- GET `/api/members/event-activity`
**Events (public/member)**
- GET `/api/events`
- GET `/api/events/{event_id}`
- GET `/api/events/{event_id}/gallery`
- POST `/api/events/{event_id}/rsvp`
- GET `/api/events/{event_id}/download.ics`
**Calendars**
- GET `/api/calendars/subscribe.ics`
- GET `/api/calendars/all-events.ics`
**Newsletters (public)**
- GET `/api/newsletters`
- GET `/api/newsletters/years`
**Financials (public)**
- GET `/api/financials`
**Bylaws (public)**
- GET `/api/bylaws/current`
- GET `/api/bylaws/history`
**Config/Diagnostics**
- GET `/api/config`
- GET `/api/config/limits`
- GET `/api/diagnostics/cors`
**Invitations**
- GET `/api/invitations/verify/{token}`
- POST `/api/invitations/accept`
**Subscriptions**
- GET `/api/subscriptions/plans`
- POST `/api/subscriptions/checkout`
**Donations**
- POST `/api/donations/checkout`
**Contact**
- POST `/api/contact`
**Admin Calendar**
- POST `/api/admin/calendar/sync/{event_id}`
- DELETE `/api/admin/calendar/unsync/{event_id}`
**Admin Event Gallery**
- POST `/api/admin/events/{event_id}/gallery`
- DELETE `/api/admin/event-gallery/{image_id}`
- PUT `/api/admin/event-gallery/{image_id}`
**Admin Events**
- POST `/api/admin/events`
- PUT `/api/admin/events/{event_id}`
- GET `/api/admin/events/{event_id}`
- GET `/api/admin/events/{event_id}/rsvps`
- PUT `/api/admin/events/{event_id}/attendance`
- GET `/api/admin/events`
- DELETE `/api/admin/events/{event_id}`
**Admin Storage**
- GET `/api/admin/storage/usage`
- GET `/api/admin/storage/breakdown`
**Admin Users & Invitations**
- GET `/api/admin/users`
- GET `/api/admin/users/invitations`
- GET `/api/admin/users/export`
- GET `/api/admin/users/{user_id}`
- PUT `/api/admin/users/{user_id}`
- PUT `/api/admin/users/{user_id}/validate`
- PUT `/api/admin/users/{user_id}/status`
- POST `/api/admin/users/{user_id}/reject`
- POST `/api/admin/users/{user_id}/activate-payment`
- PUT `/api/admin/users/{user_id}/reset-password`
- PUT `/api/admin/users/{user_id}/role`
- POST `/api/admin/users/{user_id}/resend-verification`
- POST `/api/admin/users/{user_id}/upload-photo`
- DELETE `/api/admin/users/{user_id}/delete-photo`
- POST `/api/admin/users/create`
- POST `/api/admin/users/invite`
- POST `/api/admin/users/invitations/{invitation_id}/resend`
- DELETE `/api/admin/users/invitations/{invitation_id}`
- POST `/api/admin/users/import`
- GET `/api/admin/users/import-jobs`
- GET `/api/admin/users/import-jobs/{job_id}`
**Admin Imports**
- POST `/api/admin/import/upload-csv`
- GET `/api/admin/import/{job_id}/preview`
- POST `/api/admin/import/{job_id}/execute`
- POST `/api/admin/import/{job_id}/rollback`
- GET `/api/admin/import/{job_id}/status`
- GET `/api/admin/import/{job_id}/errors/download`
**Admin Subscriptions**
- GET `/api/admin/subscriptions/plans`
- GET `/api/admin/subscriptions/plans/{plan_id}`
- POST `/api/admin/subscriptions/plans`
- PUT `/api/admin/subscriptions/plans/{plan_id}`
- DELETE `/api/admin/subscriptions/plans/{plan_id}`
- GET `/api/admin/subscriptions`
- GET `/api/admin/subscriptions/stats`
- PUT `/api/admin/subscriptions/{subscription_id}`
- POST `/api/admin/subscriptions/{subscription_id}/cancel`
- GET `/api/admin/subscriptions/export`
**Admin Donations**
- GET `/api/admin/donations`
- GET `/api/admin/donations/stats`
- GET `/api/admin/donations/export`
**Admin Newsletters**
- POST `/api/admin/newsletters`
- PUT `/api/admin/newsletters/{newsletter_id}`
- DELETE `/api/admin/newsletters/{newsletter_id}`
**Admin Financials**
- POST `/api/admin/financials`
- PUT `/api/admin/financials/{report_id}`
- DELETE `/api/admin/financials/{report_id}`
**Admin Bylaws**
- POST `/api/admin/bylaws`
- PUT `/api/admin/bylaws/{bylaws_id}`
- DELETE `/api/admin/bylaws/{bylaws_id}`
**Admin Roles**
- GET `/api/admin/roles`
- GET `/api/admin/roles/assignable`
- POST `/api/admin/roles`
- GET `/api/admin/roles/{role_id}`
- PUT `/api/admin/roles/{role_id}`
- DELETE `/api/admin/roles/{role_id}`
- GET `/api/admin/roles/{role_id}/permissions`
- PUT `/api/admin/roles/{role_id}/permissions`
**Admin Permissions**
- GET `/api/admin/permissions`
- GET `/api/admin/permissions/modules`
- GET `/api/admin/permissions/roles/{role}`
- PUT `/api/admin/permissions/roles/{role}`
- POST `/api/admin/permissions/seed`
**Admin Stripe Settings**
- GET `/api/admin/settings/stripe/status`
- POST `/api/admin/settings/stripe/test-connection`
- PUT `/api/admin/settings/stripe`
**Webhooks**
- POST `/api/webhooks/stripe`

View File

@@ -9,14 +9,22 @@ const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
const MemberCard = ({ member }) => {
const joinedDate = member.member_since || member.created_at;
// Helper function to ensure social media URLs have proper protocol
const getSocialMediaLink = (url) => {
if (!url) return null;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
};
const MemberCard = ({ member, onViewProfile }) => {
const joinedDate = member.created_at;
return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Profile Photo */}
<div className='flex justify-end items-center'>
{/* todo: get correct status to pass to StatusBadge */}
<StatusBadge status={member.membership_status} />
<StatusBadge status={member.membership_status || member.status} />
</div>
<div className="flex justify-center mb-4">
{member.profile_photo_url ? (
@@ -165,7 +173,7 @@ const MemberCard = ({ member }) => {
{/* View Profile Button */}
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
<Button
onClick={() => handleViewProfile(member.id)}
onClick={() => onViewProfile?.(member.id)}
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full py-5"
>
<UserCircle className="h-4 w-4 mr-2" />

View File

@@ -0,0 +1,93 @@
import React, { createContext, useState, useContext, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import api from '../utils/api';
const UsersContext = createContext();
// Role definitions
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
const MEMBER_ROLES = ['member'];
export const UsersProvider = ({ children }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await api.get('/admin/users');
setUsers(response.data);
} catch (err) {
setError(err);
toast.error('Failed to fetch users');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// Filtered views based on role
const staff = useMemo(
() => users.filter(user => STAFF_ROLES.includes(user.role)),
[users]
);
const members = useMemo(
() => users.filter(user => MEMBER_ROLES.includes(user.role)),
[users]
);
const allUsers = users;
// Update a single user in the local state (useful after edits)
const updateUser = useCallback((updatedUser) => {
setUsers(prev => prev.map(user =>
user.id === updatedUser.id ? updatedUser : user
));
}, []);
// Remove a user from local state
const removeUser = useCallback((userId) => {
setUsers(prev => prev.filter(user => user.id !== userId));
}, []);
// Add a user to local state
const addUser = useCallback((newUser) => {
setUsers(prev => [...prev, newUser]);
}, []);
return (
<UsersContext.Provider value={{
// All data
users: allUsers,
staff,
members,
// State
loading,
error,
// Actions
refetch: fetchUsers,
updateUser,
removeUser,
addUser,
}}>
{children}
</UsersContext.Provider>
);
};
// Base hook to access the context
export const useUsers = () => {
const context = useContext(UsersContext);
if (!context) {
throw new Error('useUsers must be used within a UsersProvider');
}
return context;
};
export default UsersContext;

View File

@@ -2,12 +2,23 @@ import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import api from '../utils/api';
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
/**
* Hook for fetching users from a custom endpoint (e.g., member-facing directory).
* For admin pages, use hooks from use-users.js instead which share a centralized context.
*/
const useMembers = ({
endpoint = '/admin/users',
initialFilter = 'active',
initialSearch = '',
filterKey = 'status',
allowedRoles = ['member'],
searchFields = DEFAULT_SEARCH_FIELDS,
fetchErrorMessage = 'Failed to fetch members',
searchAccessor,
transform,
onFetchError,
} = {}) => {
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
@@ -17,18 +28,25 @@ const useMembers = ({
const fetchMembers = useCallback(async () => {
try {
const response = await api.get('/admin/users');
const response = await api.get(endpoint);
let filtered = response.data;
if (typeof transform === 'function') {
filtered = transform(filtered);
}
if (allowedRoles && allowedRoles.length) {
filtered = filtered.filter(user => allowedRoles.includes(user.role));
}
setUsers(filtered);
} catch (error) {
toast.error(fetchErrorMessage);
if (typeof onFetchError === 'function') {
onFetchError(error);
} else {
toast.error(fetchErrorMessage);
}
} finally {
setLoading(false);
}
}, [allowedRoles, fetchErrorMessage]);
}, [allowedRoles, endpoint, fetchErrorMessage, onFetchError, transform]);
useEffect(() => {
fetchMembers();
@@ -43,15 +61,19 @@ const useMembers = ({
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
filtered = filtered.filter(user => {
const values = typeof searchAccessor === 'function'
? searchAccessor(user)
: searchFields.map(field => user?.[field]);
return values
.filter(Boolean)
.some(value => value.toString().toLowerCase().includes(query));
});
}
setFilteredUsers(filtered);
}, [users, searchQuery, filterKey, filterValue]);
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
return {
users,

171
src/hooks/use-users.js Normal file
View File

@@ -0,0 +1,171 @@
import { useState, useMemo } from 'react';
import { useUsers } from '../context/UsersContext';
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
/**
* Base hook that adds search and filter functionality to any user list
*/
const useFilteredUsers = ({
users,
initialFilter = 'all',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [filterValue, setFilterValue] = useState(initialFilter);
const filteredUsers = useMemo(() => {
let filtered = users;
// Apply filter
if (filterValue && filterValue !== 'all') {
filtered = filtered.filter(user => user[filterKey] === filterValue);
}
// Apply search
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user => {
const values = typeof searchAccessor === 'function'
? searchAccessor(user)
: searchFields.map(field => user?.[field]);
return values
.filter(Boolean)
.some(value => value.toString().toLowerCase().includes(query));
});
}
return filtered;
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
return {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
};
};
/**
* Hook for staff users (admin, superadmin, finance roles)
*/
export const useStaff = ({
initialFilter = 'all',
filterKey = 'role',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { staff, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users: staff,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users: staff,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};
/**
* Hook for member users (non-admin roles)
*/
export const useMembers = ({
initialFilter = 'active',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { members, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users: members,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users: members,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};
/**
* Hook for all users (both staff and members)
*/
export const useAllUsers = ({
initialFilter = 'all',
filterKey = 'status',
searchFields = DEFAULT_SEARCH_FIELDS,
searchAccessor,
} = {}) => {
const { users, loading, error, refetch, updateUser, removeUser } = useUsers();
const {
filteredUsers,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
} = useFilteredUsers({
users,
initialFilter,
filterKey,
searchFields,
searchAccessor,
});
return {
users,
filteredUsers,
loading,
error,
searchQuery,
setSearchQuery,
filterValue,
setFilterValue,
refetch,
updateUser,
removeUser,
};
};

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
import { Menu } from 'lucide-react';
import AdminSidebar from '../components/AdminSidebar';
import { UsersProvider } from '../context/UsersContext';
const AdminLayout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -47,46 +48,48 @@ const AdminLayout = ({ children }) => {
};
return (
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
onClick={closeSidebar}
<UsersProvider>
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
{/* Sidebar */}
<AdminSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
isMobile={isMobile}
/>
)}
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
{isMobile && (
<div className="sticky top-0 z-20 bg-background/90 backdrop-blur border-b border-[var(--neutral-800)] px-4 py-3 flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
aria-label={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
>
<Menu className="h-5 w-5 text-primary" />
</button>
<span
className="text-sm font-semibold text-primary"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Menu
</span>
</div>
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
onClick={closeSidebar}
/>
)}
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
{isMobile && (
<div className="sticky top-0 z-20 bg-background/90 backdrop-blur border-b border-[var(--neutral-800)] px-4 py-3 flex items-center gap-3">
<button
onClick={toggleSidebar}
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
aria-label={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
>
<Menu className="h-5 w-5 text-primary" />
</button>
<span
className="text-sm font-semibold text-primary"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Menu
</span>
</div>
)}
<div className="max-w-7xl mx-auto px-6 py-8">
{children}
</div>
</main>
</div>
</UsersProvider>
);
};

View File

@@ -21,7 +21,7 @@ import InviteStaffDialog from '../../components/InviteStaffDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import useMembers from '../../hooks/use-members';
import { useMembers } from '../../hooks/use-users';
const AdminMembers = () => {
const navigate = useNavigate();
@@ -35,7 +35,7 @@ const AdminMembers = () => {
setSearchQuery,
filterValue: statusFilter,
setFilterValue: setStatusFilter,
fetchMembers,
refetch,
} = useMembers();
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
@@ -53,7 +53,7 @@ const AdminMembers = () => {
};
const handlePaymentSuccess = () => {
fetchMembers(); // Refresh list
refetch(); // Refresh list
};
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
@@ -74,7 +74,7 @@ const AdminMembers = () => {
try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success('Member status updated successfully');
fetchMembers(); // Refresh list
refetch(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update status');
} finally {
@@ -520,19 +520,19 @@ const AdminMembers = () => {
<CreateMemberDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
<InviteStaffDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
<WordPressImportWizard
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
</>
);

View File

@@ -15,10 +15,7 @@ import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck,
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { CircleMinus, CreditCard, Users } from 'lucide-react';
import useMembers from '../../hooks/use-members';
// Staff roles (non-guest, non-member) - includes all admin-type roles
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
import { useStaff } from '../../hooks/use-users';
const AdminStaff = () => {
const navigate = useNavigate();
@@ -31,12 +28,10 @@ const AdminStaff = () => {
setSearchQuery,
filterValue: roleFilter,
setFilterValue: setRoleFilter,
fetchMembers,
} = useMembers({
refetch,
} = useStaff({
initialFilter: 'all',
filterKey: 'role',
allowedRoles: STAFF_ROLES,
fetchErrorMessage: 'Failed to fetch staff',
});
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
@@ -48,7 +43,7 @@ const AdminStaff = () => {
try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
fetchMembers(); // Refresh list
refetch(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update user status');
}
@@ -62,7 +57,7 @@ const AdminStaff = () => {
try {
await api.delete(`/admin/users/${userId}`);
toast.success('User deleted successfully');
fetchMembers(); // Refresh list
refetch(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete user');
}
@@ -114,7 +109,7 @@ const AdminStaff = () => {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="Total Members"
title="Total Staff"
//TODO: refractor codebase to have a central admin and user roles config - when user adds roles, they should be added to the config
value={users.filter(u => ['admin', 'superadmin', 'finance', 'staff', 'media', 'moderator'].includes(u.role)).length}
icon={Users}
@@ -304,7 +299,7 @@ const AdminStaff = () => {
<CreateStaffDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={fetchMembers}
onSuccess={refetch}
/>
<InviteStaffDialog

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
@@ -17,63 +17,50 @@ import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter,
import { useToast } from '../../hooks/use-toast';
import StatusBadge from '@/components/StatusBadge';
import MemberCard from '../../components/MemberCard';
import useMembers from '../../hooks/use-members';
const MembersDirectory = () => {
const [members, setMembers] = useState([]);
const [filteredMembers, setFilteredMembers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [selectedMember, setSelectedMember] = useState(null);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12;
const allowedRoles = useMemo(() => [], []);
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]);
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterMembers();
}, [searchQuery, members]);
const {
users: members,
filteredUsers: filteredMembers,
loading,
searchQuery,
setSearchQuery,
} = useMembers({
endpoint: '/members/directory',
initialFilter: 'active',
filterKey: 'status',
allowedRoles,
searchAccessor,
fetchErrorMessage: 'Failed to load members directory. Please try again.',
onFetchError: handleFetchError
});
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, members]);
const fetchMembers = async () => {
try {
const response = await api.get('/members/directory');
setMembers(response.data);
setFilteredMembers(response.data);
} catch (error) {
console.error('Failed to fetch members:', error);
toast({
title: "Error",
description: "Failed to load members directory. Please try again.",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const filterMembers = () => {
if (!searchQuery.trim()) {
setFilteredMembers(members);
return;
}
const query = searchQuery.toLowerCase();
const filtered = members.filter(member => {
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
const bio = (member.directory_bio || '').toLowerCase();
return fullName.includes(query) || bio.includes(query);
});
setFilteredMembers(filtered);
};
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
const pageStart = (currentPage - 1) * pageSize;
@@ -171,7 +158,7 @@ const MembersDirectory = () => {
) : 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} />
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} />
))}
</div>
) : (
@@ -223,8 +210,7 @@ const MembersDirectory = () => {
<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}
{/* todo: figure out the correct selection to get the status of the user and pass into badge */}
<StatusBadge status={selectedMember.status} />
<StatusBadge status={selectedMember.membership_status || selectedMember.status} />
</DialogTitle>
{selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>

View File

@@ -102,6 +102,5 @@ module.exports = {
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require('@tailwindcss/line-clamp')
],
};