Merge from dev to loaf-prod for DEMO #25
179
README.md
179
README.md
@@ -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`
|
||||
@@ -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" />
|
||||
|
||||
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 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
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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -102,6 +102,5 @@ module.exports = {
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography"),
|
||||
require('@tailwindcss/line-clamp')
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user