feat: implement UsersContext and refactor user management hooks for improved user data handling
This commit is contained in:
179
README.md
179
README.md
@@ -999,3 +999,182 @@ api.interceptors.response.use(
|
|||||||
**Last Updated**: December 18, 2024
|
**Last Updated**: December 18, 2024
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0.0
|
||||||
**Maintainer**: LOAF Development Team
|
**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`
|
||||||
@@ -9,14 +9,22 @@ const getInitials = (firstName, lastName) => {
|
|||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberCard = ({ member }) => {
|
// Helper function to ensure social media URLs have proper protocol
|
||||||
const joinedDate = member.member_since || member.created_at;
|
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 (
|
return (
|
||||||
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
|
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
|
||||||
{/* Profile Photo */}
|
{/* Profile Photo */}
|
||||||
<div className='flex justify-end items-center'>
|
<div className='flex justify-end items-center'>
|
||||||
{/* todo: get correct status to pass to StatusBadge */}
|
<StatusBadge status={member.membership_status || member.status} />
|
||||||
<StatusBadge status={member.membership_status} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{member.profile_photo_url ? (
|
{member.profile_photo_url ? (
|
||||||
@@ -165,7 +173,7 @@ const MemberCard = ({ member }) => {
|
|||||||
{/* View Profile Button */}
|
{/* View Profile Button */}
|
||||||
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
|
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
|
||||||
<Button
|
<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"
|
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" />
|
<UserCircle className="h-4 w-4 mr-2" />
|
||||||
|
|||||||
93
src/context/UsersContext.js
Normal file
93
src/context/UsersContext.js
Normal 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;
|
||||||
@@ -2,12 +2,23 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import api from '../utils/api';
|
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 = ({
|
const useMembers = ({
|
||||||
|
endpoint = '/admin/users',
|
||||||
initialFilter = 'active',
|
initialFilter = 'active',
|
||||||
initialSearch = '',
|
initialSearch = '',
|
||||||
filterKey = 'status',
|
filterKey = 'status',
|
||||||
allowedRoles = ['member'],
|
allowedRoles = ['member'],
|
||||||
|
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||||
fetchErrorMessage = 'Failed to fetch members',
|
fetchErrorMessage = 'Failed to fetch members',
|
||||||
|
searchAccessor,
|
||||||
|
transform,
|
||||||
|
onFetchError,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
@@ -17,18 +28,25 @@ const useMembers = ({
|
|||||||
|
|
||||||
const fetchMembers = useCallback(async () => {
|
const fetchMembers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/users');
|
const response = await api.get(endpoint);
|
||||||
let filtered = response.data;
|
let filtered = response.data;
|
||||||
|
if (typeof transform === 'function') {
|
||||||
|
filtered = transform(filtered);
|
||||||
|
}
|
||||||
if (allowedRoles && allowedRoles.length) {
|
if (allowedRoles && allowedRoles.length) {
|
||||||
filtered = filtered.filter(user => allowedRoles.includes(user.role));
|
filtered = filtered.filter(user => allowedRoles.includes(user.role));
|
||||||
}
|
}
|
||||||
setUsers(filtered);
|
setUsers(filtered);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (typeof onFetchError === 'function') {
|
||||||
|
onFetchError(error);
|
||||||
|
} else {
|
||||||
toast.error(fetchErrorMessage);
|
toast.error(fetchErrorMessage);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [allowedRoles, fetchErrorMessage]);
|
}, [allowedRoles, endpoint, fetchErrorMessage, onFetchError, transform]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
@@ -43,15 +61,19 @@ const useMembers = ({
|
|||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(user =>
|
filtered = filtered.filter(user => {
|
||||||
user.first_name.toLowerCase().includes(query) ||
|
const values = typeof searchAccessor === 'function'
|
||||||
user.last_name.toLowerCase().includes(query) ||
|
? searchAccessor(user)
|
||||||
user.email.toLowerCase().includes(query)
|
: searchFields.map(field => user?.[field]);
|
||||||
);
|
|
||||||
|
return values
|
||||||
|
.filter(Boolean)
|
||||||
|
.some(value => value.toString().toLowerCase().includes(query));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilteredUsers(filtered);
|
setFilteredUsers(filtered);
|
||||||
}, [users, searchQuery, filterKey, filterValue]);
|
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users,
|
users,
|
||||||
|
|||||||
171
src/hooks/use-users.js
Normal file
171
src/hooks/use-users.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import AdminSidebar from '../components/AdminSidebar';
|
import AdminSidebar from '../components/AdminSidebar';
|
||||||
|
import { UsersProvider } from '../context/UsersContext';
|
||||||
|
|
||||||
const AdminLayout = ({ children }) => {
|
const AdminLayout = ({ children }) => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
@@ -47,6 +48,7 @@ const AdminLayout = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<UsersProvider>
|
||||||
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
|
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<AdminSidebar
|
<AdminSidebar
|
||||||
@@ -87,6 +89,7 @@ const AdminLayout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</UsersProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import InviteStaffDialog from '../../components/InviteStaffDialog';
|
|||||||
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { StatCard } from '@/components/StatCard';
|
import { StatCard } from '@/components/StatCard';
|
||||||
import useMembers from '../../hooks/use-members';
|
import { useMembers } from '../../hooks/use-users';
|
||||||
|
|
||||||
const AdminMembers = () => {
|
const AdminMembers = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -35,7 +35,7 @@ const AdminMembers = () => {
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
filterValue: statusFilter,
|
filterValue: statusFilter,
|
||||||
setFilterValue: setStatusFilter,
|
setFilterValue: setStatusFilter,
|
||||||
fetchMembers,
|
refetch,
|
||||||
} = useMembers();
|
} = useMembers();
|
||||||
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
||||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||||
@@ -53,7 +53,7 @@ const AdminMembers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePaymentSuccess = () => {
|
const handlePaymentSuccess = () => {
|
||||||
fetchMembers(); // Refresh list
|
refetch(); // Refresh list
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
||||||
@@ -74,7 +74,7 @@ const AdminMembers = () => {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||||
toast.success('Member status updated successfully');
|
toast.success('Member status updated successfully');
|
||||||
fetchMembers(); // Refresh list
|
refetch(); // Refresh list
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.detail || 'Failed to update status');
|
toast.error(error.response?.data?.detail || 'Failed to update status');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -520,19 +520,19 @@ const AdminMembers = () => {
|
|||||||
<CreateMemberDialog
|
<CreateMemberDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={fetchMembers}
|
onSuccess={refetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InviteStaffDialog
|
<InviteStaffDialog
|
||||||
open={inviteDialogOpen}
|
open={inviteDialogOpen}
|
||||||
onOpenChange={setInviteDialogOpen}
|
onOpenChange={setInviteDialogOpen}
|
||||||
onSuccess={fetchMembers}
|
onSuccess={refetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WordPressImportWizard
|
<WordPressImportWizard
|
||||||
open={importDialogOpen}
|
open={importDialogOpen}
|
||||||
onOpenChange={setImportDialogOpen}
|
onOpenChange={setImportDialogOpen}
|
||||||
onSuccess={fetchMembers}
|
onSuccess={refetch}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck,
|
|||||||
import StatusBadge from '../../components/StatusBadge';
|
import StatusBadge from '../../components/StatusBadge';
|
||||||
import { StatCard } from '@/components/StatCard';
|
import { StatCard } from '@/components/StatCard';
|
||||||
import { CircleMinus, CreditCard, Users } from 'lucide-react';
|
import { CircleMinus, CreditCard, Users } from 'lucide-react';
|
||||||
import useMembers from '../../hooks/use-members';
|
import { useStaff } from '../../hooks/use-users';
|
||||||
|
|
||||||
// Staff roles (non-guest, non-member) - includes all admin-type roles
|
|
||||||
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
|
|
||||||
|
|
||||||
const AdminStaff = () => {
|
const AdminStaff = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -31,12 +28,10 @@ const AdminStaff = () => {
|
|||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
filterValue: roleFilter,
|
filterValue: roleFilter,
|
||||||
setFilterValue: setRoleFilter,
|
setFilterValue: setRoleFilter,
|
||||||
fetchMembers,
|
refetch,
|
||||||
} = useMembers({
|
} = useStaff({
|
||||||
initialFilter: 'all',
|
initialFilter: 'all',
|
||||||
filterKey: 'role',
|
filterKey: 'role',
|
||||||
allowedRoles: STAFF_ROLES,
|
|
||||||
fetchErrorMessage: 'Failed to fetch staff',
|
|
||||||
});
|
});
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||||
@@ -48,7 +43,7 @@ const AdminStaff = () => {
|
|||||||
try {
|
try {
|
||||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||||
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
||||||
fetchMembers(); // Refresh list
|
refetch(); // Refresh list
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.detail || 'Failed to update user status');
|
toast.error(error.response?.data?.detail || 'Failed to update user status');
|
||||||
}
|
}
|
||||||
@@ -62,7 +57,7 @@ const AdminStaff = () => {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/admin/users/${userId}`);
|
await api.delete(`/admin/users/${userId}`);
|
||||||
toast.success('User deleted successfully');
|
toast.success('User deleted successfully');
|
||||||
fetchMembers(); // Refresh list
|
refetch(); // Refresh list
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.response?.data?.detail || 'Failed to delete user');
|
toast.error(error.response?.data?.detail || 'Failed to delete user');
|
||||||
}
|
}
|
||||||
@@ -114,7 +109,7 @@ const AdminStaff = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<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
|
//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}
|
value={users.filter(u => ['admin', 'superadmin', 'finance', 'staff', 'media', 'moderator'].includes(u.role)).length}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
@@ -304,7 +299,7 @@ const AdminStaff = () => {
|
|||||||
<CreateStaffDialog
|
<CreateStaffDialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={fetchMembers}
|
onSuccess={refetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InviteStaffDialog
|
<InviteStaffDialog
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from '../../components/Navbar';
|
||||||
import MemberFooter from '../../components/MemberFooter';
|
import MemberFooter from '../../components/MemberFooter';
|
||||||
@@ -17,62 +17,49 @@ import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter,
|
|||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
import MemberCard from '../../components/MemberCard';
|
import MemberCard from '../../components/MemberCard';
|
||||||
|
import useMembers from '../../hooks/use-members';
|
||||||
|
|
||||||
const MembersDirectory = () => {
|
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 [selectedMember, setSelectedMember] = useState(null);
|
||||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
|
const allowedRoles = useMemo(() => [], []);
|
||||||
useEffect(() => {
|
const searchAccessor = useCallback(
|
||||||
fetchMembers();
|
(member) => [
|
||||||
}, []);
|
`${member.first_name} ${member.last_name}`,
|
||||||
|
member.directory_bio || ''
|
||||||
useEffect(() => {
|
],
|
||||||
filterMembers();
|
[]
|
||||||
}, [searchQuery, members]);
|
);
|
||||||
|
const handleFetchError = useCallback(() => {
|
||||||
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({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to load members directory. Please try again.",
|
description: "Failed to load members directory. Please try again.",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
}, [toast]);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterMembers = () => {
|
const {
|
||||||
if (!searchQuery.trim()) {
|
users: members,
|
||||||
setFilteredMembers(members);
|
filteredUsers: filteredMembers,
|
||||||
return;
|
loading,
|
||||||
}
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
const query = searchQuery.toLowerCase();
|
} = useMembers({
|
||||||
const filtered = members.filter(member => {
|
endpoint: '/members/directory',
|
||||||
const fullName = `${member.first_name} ${member.last_name}`.toLowerCase();
|
initialFilter: 'active',
|
||||||
const bio = (member.directory_bio || '').toLowerCase();
|
filterKey: 'status',
|
||||||
return fullName.includes(query) || bio.includes(query);
|
allowedRoles,
|
||||||
|
searchAccessor,
|
||||||
|
fetchErrorMessage: 'Failed to load members directory. Please try again.',
|
||||||
|
onFetchError: handleFetchError
|
||||||
});
|
});
|
||||||
|
|
||||||
setFilteredMembers(filtered);
|
useEffect(() => {
|
||||||
};
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, members]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
||||||
|
|
||||||
@@ -171,7 +158,7 @@ const MembersDirectory = () => {
|
|||||||
) : filteredMembers.length > 0 ? (
|
) : filteredMembers.length > 0 ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{paginatedMembers.map((member) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberCard key={member.id} member={member} />
|
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,8 +210,7 @@ const MembersDirectory = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<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}
|
{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.membership_status || selectedMember.status} />
|
||||||
<StatusBadge status={selectedMember.status} />
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{selectedMember.directory_partner_name && (
|
{selectedMember.directory_partner_name && (
|
||||||
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
|||||||
@@ -102,6 +102,5 @@ module.exports = {
|
|||||||
plugins: [
|
plugins: [
|
||||||
require("tailwindcss-animate"),
|
require("tailwindcss-animate"),
|
||||||
require("@tailwindcss/typography"),
|
require("@tailwindcss/typography"),
|
||||||
require('@tailwindcss/line-clamp')
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user