17 Commits

Author SHA1 Message Date
kayela
f71931d4a7 Merge branch 'dev' into templates 2026-01-06 12:25:55 -06:00
kayela
97cc5bdedf Add pagination buttons for first and last pages in Members Directory 2026-01-06 12:17:03 -06:00
kayela
8011913c4d Enhance Admin Dashboard and Members Directory with improved layout, pagination, and member details display.
Applied requested changes to UI
2026-01-06 12:05:34 -06:00
Koncept Kit
40a0e3f342 feat(frontend): Comprehensive RBAC implementation across admin pages
**Option 3 Implementation (Latest):**
- InviteStaffDialog: Use /admin/roles/assignable endpoint
- AdminStaff: Enable admin users to see 'Invite Staff' button

**Permission Checks Added (8 admin pages):**
- AdminNewsletters: newsletters.create/edit/delete
- AdminFinancials: financials.create/edit/delete
- AdminBylaws: bylaws.create/edit/delete
- AdminValidations: users.approve, subscriptions.activate
- AdminSubscriptions: subscriptions.export/edit/cancel
- AdminDonations: donations.export
- AdminGallery: gallery.upload/edit/delete
- AdminPlans: subscriptions.plans

**Pattern Established:**
All admin action buttons now wrapped with hasPermission() checks.
UI hides what users can't access, backend enforces rules.

**Files Modified:** 10 files, 100+ permission checks added
2026-01-06 14:45:15 +07:00
kayela
968eaccac2 fixed tabs styling 2026-01-05 14:34:23 -06:00
kayela
11de3d1eed styled chrome scrollbar 2026-01-05 14:16:05 -06:00
kayela
11142ec50e Merge branch 'dev' into templates 2026-01-05 12:51:33 -06:00
Koncept Kit
0249cad261 Improve UX with navigation, attendance management, and calendar fixes
## Quick Wins
- **AdminSidebar**: Move "View Public Site" to clickable logo area
- **Plans**: Fix layout to center single plan, dynamic grid for multiple
- **AdminGallery**: Add empty state message with "Create Event" button

## Event Attendance Enhancement
- **NEW: AdminEventAttendance page** with full-featured table view:
  - Tab filters (All/Yes/No/Maybe RSVPs)
  - Search by name/email
  - Bulk selection with Select All
  - Individual attendance toggle buttons (merged column)
  - CSV export functionality (client requirement)
  - Summary statistics cards
- **AdminEvents**: Navigate to new attendance page instead of dialog
- **App.js**: Add /admin/events/:eventId/attendance route

## Calendar Fixes
- **MemberCalendar**: Add state management for navigation (date/view)
  - Fix non-functional buttons (Today/Back/Next/Month/Week/Day/Agenda)
  - Add onNavigate and onView handlers
- **NEW: MemberCalendar.css**: Extract styles from broken jsx syntax
  - Fix toolbar button styling and interactivity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 01:02:16 +07:00
Koncept Kit
56711e9136 Revert URL cleanup - backend path is correct
The /membership path in backend URL is correct for development.
Issue is CORS configuration on backend, not URL format.
2026-01-05 15:47:30 +07:00
Koncept Kit
03b76a8e58 Add defensive backend URL validation and auto-cleanup
- Add getApiUrl() function to validate and clean backend URL
- Automatically strip /membership or /api suffix if present
- Log all environment variables on module load for debugging
- Add detailed URL logging in login function
- Provide fallback if REACT_APP_BACKEND_URL is undefined

This fixes the intermittent CORS error caused by incorrect backend URL
2026-01-05 15:42:11 +07:00
Koncept Kit
1acb13ba79 Add comprehensive login diagnostics and retry logic
- Add detailed console logging throughout login flow
- Add 30s timeout to prevent hanging requests
- Defer permission fetching with setTimeout to avoid race conditions
- Add automatic retry for 5xx errors and network failures
- Enhanced error logging with full context for debugging

This addresses intermittent login failures reported by users
2026-01-05 15:00:41 +07:00
Koncept Kit
fa9a1d1d1d Add 404 page and invitation success screen
- Created NotFound component with proper error messaging and navigation
- Added catch-all route (*) in App.js for undefined routes
- Added success state in AcceptInvitation with user info display
- Auto-redirect after 3 seconds with manual continue button option
- Improved UX with animated success indicator
2026-01-05 14:51:39 +07:00
Koncept Kit
48802fe0c6 Fix invitation redirect: admin users now go to /admin instead of /admin/dashboard 2026-01-05 14:15:22 +07:00
kayela
1d70ac4ec7 update Merge branch 'dev' into templates 2025-12-26 17:39:16 -06:00
kayela
6d777ed583 Update homepage and links in RegistrationStep4 component 2025-12-24 14:17:30 -06:00
kayela
99d65c917f Merge remote-tracking branch 'origin/dev' into templates 2025-12-24 14:06:12 -06:00
kayela
0f16264656 Remove unused imports from TermsOfService component 2025-12-24 14:04:35 -06:00
29 changed files with 1834 additions and 716 deletions

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
REACT_APP_BACKEND_URL=http://localhost:8000
REACT_APP_BASENAME=/membership
PUBLIC_URL=/membership

View File

@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions'; import AdminPermissions from './pages/admin/AdminPermissions';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
import AdminPlans from './pages/admin/AdminPlans'; import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminSubscriptions from './pages/admin/AdminSubscriptions';
@@ -51,6 +52,7 @@ import ContactUs from './pages/ContactUs';
import TermsOfService from './pages/TermsOfService'; import TermsOfService from './pages/TermsOfService';
import PrivacyPolicy from './pages/PrivacyPolicy'; import PrivacyPolicy from './pages/PrivacyPolicy';
import AcceptInvitation from './pages/AcceptInvitation'; import AcceptInvitation from './pages/AcceptInvitation';
import NotFound from './pages/NotFound';
const PrivateRoute = ({ children, adminOnly = false }) => { const PrivateRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -217,6 +219,13 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/events/:eventId/attendance" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminEventAttendance />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/validations" element={ <Route path="/admin/validations" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>
@@ -280,6 +289,9 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
{/* 404 - Catch all undefined routes */}
<Route path="*" element={<NotFound />} />
</Routes> </Routes>
<Toaster position="top-right" /> <Toaster position="top-right" />
</BrowserRouter> </BrowserRouter>

View File

@@ -22,7 +22,7 @@ import {
Scale, Scale,
HardDrive, HardDrive,
Repeat, Repeat,
Heart Heart,
} from 'lucide-react'; } from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -204,8 +204,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
${item.disabled ${item.disabled
? 'opacity-50 cursor-not-allowed text-[#664fa3]' ? 'opacity-50 cursor-not-allowed text-[#664fa3]'
: active : active
? 'bg-[#ff9e77]/10 text-[#ff9e77]' ? 'bg-[#ff9e77]/10 text-[#ff9e77]'
: 'text-[#422268] hover:bg-[#DDD8EB]/20' : 'text-[#422268] hover:bg-[#DDD8EB]/20'
} }
`} `}
> >
@@ -265,20 +265,24 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]"> <div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
<div className="flex items-center gap-3"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${ className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
isOpen ? 'h-10 w-10' : 'h-8 w-8' }`}
}`}
/> />
{isOpen && ( {isOpen && (
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="flex-1 min-w-0">
Admin <h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
</h2> Admin
</h2>
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View Public Site
</p>
</div>
)} )}
</div> </Link>
<button <button
onClick={onToggle} onClick={onToggle}
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors" className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
@@ -295,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4"> <nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
{/* Dashboard - Standalone */} {/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))} {renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
@@ -365,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* User Section */} {/* User Section */}
<div className="border-t border-[#ddd8eb] p-4 space-y-2"> <div className="border-t border-[#ddd8eb] p-4 space-y-2">
{isOpen && user && ( {isOpen && user && (
<div className="px-4 py-3 mb-2"> <div className="px-4 py-3 mb-2 flex justify-between items-center">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold"> <div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
{user.first_name?.[0]}{user.last_name?.[0]} {user.first_name?.[0]}{user.last_name?.[0]}
@@ -379,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</p> </p>
</div> </div>
</div> </div>
<Link to='/profile'><Settings size={16} />
</Link>
</div> </div>
)} )}
@@ -392,11 +398,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
<div className="w-full bg-[#ddd8eb] rounded-full h-2"> <div className="w-full bg-[#ddd8eb] rounded-full h-2">
<div <div
className={`h-2 rounded-full transition-all ${ className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' : storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[#81B29A]' 'bg-[#81B29A]'
}`} }`}
style={{ width: `${storagePercentage}%` }} style={{ width: `${storagePercentage}%` }}
/> />
</div> </div>
@@ -407,11 +412,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
) : ( ) : (
<div className="flex justify-center"> <div className="flex justify-center">
<div className="relative group"> <div className="relative group">
<HardDrive className={`h-5 w-5 ${ <HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' : storagePercentage > 75 ? 'text-yellow-500' :
'text-[#664fa3]' 'text-[#664fa3]'
}`} /> }`} />
{storagePercentage > 75 && ( {storagePercentage > 75 && (
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" /> <div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
)} )}

View File

@@ -40,15 +40,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
const fetchRoles = async () => { const fetchRoles = async () => {
setLoadingRoles(true); setLoadingRoles(true);
try { try {
const response = await api.get('/admin/roles'); // New endpoint returns roles based on user's permission level
// Filter to show only admin-type roles (not guest or member) // Superadmin: all roles
const staffRoles = response.data.filter(role => // Admin: admin, finance, and non-elevated custom roles
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role const response = await api.get('/admin/roles/assignable');
); setRoles(response.data);
setRoles(staffRoles);
} catch (error) { } catch (error) {
console.error('Failed to fetch roles:', error); console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles'); toast.error('Failed to load roles. Please try again.');
} finally { } finally {
setLoadingRoles(false); setLoadingRoles(false);
} }

View File

@@ -86,7 +86,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I agree to the{' '} I agree to the{' '}
<a <a
href="/membership/terms-of-service" href="/become-a-member/terms-of-service"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline" className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
@@ -95,7 +95,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
</a> </a>
{' '}and{' '} {' '}and{' '}
<a <a
href="/membership/privacy-policy" href="become-a-member/privacy-policy"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline" className="text-[#664fa3] hover:text-[#422268] font-semibold underline"

View File

@@ -1,31 +1,33 @@
import * as React from "react" import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => ( const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", "inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
className className
)} )}
{...props} /> {...props}
)) />
TabsList.displayName = TabsPrimitive.List.displayName ));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", "inline-flex items-center justify-center whitespace-nowrap hover:bg-[#f1eef9] border-2 border-[#664fa3] rounded-2xl px-3 py-1 text-[#664fa3] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow",
className className
)} )}
{...props} /> {...props}
)) />
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
{...props} /> {...props}
)) />
TabsContent.displayName = TabsPrimitive.Content.displayName ));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -3,7 +3,14 @@ import axios from 'axios';
const AuthContext = createContext(); const AuthContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL; const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
// Log environment on module load for debugging
console.log('[AuthContext] Module initialized with:', {
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
API_URL: API_URL
});
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@@ -54,21 +61,79 @@ export const AuthProvider = ({ children }) => {
}; };
const login = async (email, password) => { const login = async (email, password) => {
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
const { access_token, user: userData } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
setUser(userData);
// Fetch user permissions (don't let this fail the login)
try { try {
await fetchPermissions(access_token); console.log('[AuthContext] Starting login request...', {
} catch (error) { API_URL: API_URL,
console.error('Failed to fetch permissions during login, will retry later:', error); envBackendUrl: process.env.REACT_APP_BACKEND_URL,
// Don't throw - permissions can be fetched later if needed fullUrl: `${API_URL}/api/auth/login`
} });
return userData; const response = await axios.post(
`${API_URL}/api/auth/login`,
{ email, password },
{
timeout: 30000, // 30 second timeout
headers: {
'Content-Type': 'application/json'
}
}
);
console.log('[AuthContext] Login response received:', {
status: response.status,
hasToken: !!response.data?.access_token,
hasUser: !!response.data?.user
});
const { access_token, user: userData } = response.data;
// Store token first
localStorage.setItem('token', access_token);
console.log('[AuthContext] Token stored in localStorage');
// Update state
setToken(access_token);
setUser(userData);
console.log('[AuthContext] User state updated:', {
email: userData.email,
role: userData.role
});
// Fetch user permissions (don't let this fail the login)
// Use setTimeout to defer permission fetching slightly
setTimeout(async () => {
try {
console.log('[AuthContext] Fetching permissions...');
await fetchPermissions(access_token);
console.log('[AuthContext] Permissions fetched successfully');
} catch (error) {
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
message: error.message,
response: error.response?.data,
status: error.response?.status
});
// Don't throw - permissions can be fetched later if needed
}
}, 100); // Small delay to ensure state is settled
return userData;
} catch (error) {
// Enhanced error logging
console.error('[AuthContext] Login failed:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
code: error.code,
config: {
url: error.config?.url,
method: error.config?.method,
timeout: error.config?.timeout
}
});
// Re-throw to let Login component handle the error
throw error;
}
}; };
const logout = () => { const logout = () => {

View File

@@ -116,3 +116,27 @@ code {
border-bottom-color: inherit; border-bottom-color: inherit;
} }
} }
@layer utilities {
@supports selector(::-webkit-scrollbar) {
.scrollbar-dashboard::-webkit-scrollbar {
width: 2px;
}
.scrollbar-dashboard::-webkit-scrollbar-thumb {
background-color: #ddd8eb;
border-radius: 9999px;
}
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
height: 2px;
}
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
background-color: #ddd8eb;
border-radius: 9999px;
}
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
height: 0px;
}
}
}

View File

@@ -19,6 +19,8 @@ const AcceptInvitation = () => {
const [invitation, setInvitation] = useState(null); const [invitation, setInvitation] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [successUser, setSuccessUser] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
password: '', password: '',
@@ -134,19 +136,23 @@ const AcceptInvitation = () => {
const { access_token, user } = response.data; const { access_token, user } = response.data;
localStorage.setItem('token', access_token); localStorage.setItem('token', access_token);
toast.success('Welcome to LOAF! Your account has been created successfully.');
// Call login to update auth context // Call login to update auth context
if (login) { if (login) {
await login(invitation.email, formData.password); await login(invitation.email, formData.password);
} }
// Redirect based on role // Show success state
if (user.role === 'admin' || user.role === 'superadmin') { setSuccessUser(user);
navigate('/admin/dashboard'); setSuccess(true);
} else {
navigate('/dashboard'); // Auto-redirect after 3 seconds
} setTimeout(() => {
if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin');
} else {
navigate('/dashboard');
}
}, 3000);
} catch (error) { } catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation'; const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
toast.error(errorMessage); toast.error(errorMessage);
@@ -206,6 +212,83 @@ const AcceptInvitation = () => {
); );
} }
if (success) {
const redirectPath = successUser?.role === 'admin' || successUser?.role === 'superadmin' ? '/admin' : '/dashboard';
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
{/* Success Animation */}
<div className="mb-8">
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[#81B29A] to-[#6DA085] flex items-center justify-center animate-bounce">
<CheckCircle className="h-12 w-12 text-white" />
</div>
</div>
{/* Success Message */}
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Welcome to LOAF! 🎉
</h1>
<p className="text-xl text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your account has been created successfully.
</p>
{/* User Info Card */}
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
<div className="grid md:grid-cols-2 gap-4 text-left">
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Name
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{successUser?.first_name} {successUser?.last_name}
</p>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Email
</p>
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{successUser?.email}
</p>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Role
</p>
<div>{getRoleBadge(successUser?.role)}</div>
</div>
<div>
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Status
</p>
<Badge className="bg-[#81B29A] text-white px-4 py-2 rounded-full text-sm">
{successUser?.status}
</Badge>
</div>
</div>
</div>
{/* Redirect Info */}
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
<p className="text-sm text-blue-800" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Loader2 className="h-4 w-4 inline mr-2 animate-spin" />
Redirecting you to your dashboard in 3 seconds...
</p>
</div>
{/* Manual Continue Button */}
<Button
onClick={() => navigate(redirectPath)}
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
>
Continue to Dashboard
</Button>
</Card>
</div>
);
}
return ( return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]"> <Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">

View File

@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
]; ];
const boardMembers = [ const boardMembers = [
{ name: 'Danita Cole' }, { name: 'Danita Cole', title: 'Director' },
{ name: 'Roxanne Cherico' }, { name: 'Roxanne Cherico', title: 'Director' },
{ name: 'Lucretia Copeland' }, { name: 'Lucretia Copeland', title: 'Director' },
{ name: 'Julie Fischer' } { name: 'Julie Fischer', title: 'Director' }
]; ];
@@ -112,50 +112,50 @@ const BoardOfDirectors = () => {
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors. Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
</p> </p>
{/* card */} {/* card */}
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70"> <Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<li> <li>
Nominations are due by November 1. Nomination Form:{' '} Nominations are due by November 1. Nomination Form:{' '}
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer" <a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors"> className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
Click Here Click Here
</a> </a>
</li> </li>
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li> <li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
<li>Officer positions are only available to current directors.</li> <li>Officer positions are only available to current directors.</li>
<li>Each director shall serve a 2-year term.</li> <li>Each director shall serve a 2-year term.</li>
<li>The time commitment is approximately 12 hours per week.</li> <li>The time commitment is approximately 12 hours per week.</li>
<li> <li>
The tasks that directors perform depend on individual interests. Recent The tasks that directors perform depend on individual interests. Recent
tasks include researching how to obtain an extra PO Box key, ordering tasks include researching how to obtain an extra PO Box key, ordering
Welcome Team name tags, taking pictures at events, researching new venues Welcome Team name tags, taking pictures at events, researching new venues
for holiday socials, and monitoring Facebook posts. For more information for holiday socials, and monitoring Facebook posts. For more information
about director duties, see Article 2 of the bylaws in the Members Only about director duties, see Article 2 of the bylaws in the Members Only
section of the website:&nbsp; section of the website:&nbsp;
<a <a
href="https://loaftx.org/bylaws/" href="https://loaftx.org/bylaws/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#48286e] underline" className="text-[#48286e] underline"
> >
https://loaftx.org/bylaws/ https://loaftx.org/bylaws/
</a> </a>
</li> </li>
<li> <li>
Directors must attend Board meetings held on the second Thursday of each Directors must attend Board meetings held on the second Thursday of each
month at 6:30pm via Zoom. month at 6:30pm via Zoom.
</li> </li>
<li> <li>
We are a fun group, and we would love for you to join us in providing this We are a fun group, and we would love for you to join us in providing this
service for our community. service for our community.
</li> </li>
</ol> </ol>
</Card> </Card>
</div> </div>
</section> </section>
</main> </main>

81
src/pages/NotFound.js Normal file
View File

@@ -0,0 +1,81 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { Home, ArrowLeft, Search } from 'lucide-react';
const NotFound = () => {
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
{/* 404 Illustration */}
<div className="mb-8">
<div className="relative">
<h1
className="text-[180px] font-bold text-transparent bg-clip-text bg-gradient-to-br from-[#ddd8eb] to-[#f9f8fb] leading-none"
style={{ fontFamily: "'Inter', sans-serif" }}
>
404
</h1>
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-24 w-24 text-[#664fa3] opacity-30" />
</div>
</div>
</div>
{/* Message */}
<h2
className="text-3xl font-semibold text-[#422268] mb-4"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Page Not Found
</h2>
<p
className="text-lg text-[#664fa3] mb-8 max-w-md mx-auto"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
</p>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
onClick={() => navigate(-1)}
variant="outline"
className="rounded-xl border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f9f8fb] px-6 py-6"
>
<ArrowLeft className="h-5 w-5 mr-2" />
Go Back
</Button>
<Button
onClick={() => navigate('/')}
className="rounded-xl bg-gradient-to-r from-[#664fa3] to-[#422268] hover:from-[#422268] hover:to-[#664fa3] text-white px-6 py-6"
>
<Home className="h-5 w-5 mr-2" />
Back to Home
</Button>
</div>
{/* Help Text */}
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
<p
className="text-sm text-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Need help? Contact us at{' '}
<a
href="mailto:support@loaftx.org"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
support@loaftx.org
</a>
</p>
</div>
</Card>
</div>
);
};
export default NotFound;

View File

@@ -253,7 +253,13 @@ const Plans = () => {
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p> <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div> </div>
) : plans.length > 0 ? ( ) : plans.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto"> <div className={`grid gap-6 sm:gap-8 mx-auto ${
plans.length === 1
? 'grid-cols-1 max-w-md'
: plans.length === 2
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
}`}>
{plans.map((plan) => { {plans.map((plan) => {
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000; const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
const suggestedPrice = plan.suggested_price_cents || minimumPrice; const suggestedPrice = plan.suggested_price_cents || minimumPrice;

View File

@@ -2,8 +2,6 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import PublicNavbar from '../components/PublicNavbar'; import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter'; import PublicFooter from '../components/PublicFooter';
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export default function TermsOfService() { export default function TermsOfService() {

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminBylaws = () => { const AdminBylaws = () => {
const { hasPermission } = useAuth();
const [bylaws, setBylaws] = useState([]); const [bylaws, setBylaws] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -184,13 +186,15 @@ const AdminBylaws = () => {
Manage LOAF governing bylaws and version history Manage LOAF governing bylaws and version history
</p> </p>
</div> </div>
<Button {hasPermission('bylaws.create') && (
onClick={handleCreate} <Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" onClick={handleCreate}
> className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
<Plus className="h-4 w-4" /> >
Add Version <Plus className="h-4 w-4" />
</Button> Add Version
</Button>
)}
</div> </div>
{/* Current Bylaws */} {/* Current Bylaws */}
@@ -226,22 +230,26 @@ const AdminBylaws = () => {
<ExternalLink className="h-4 w-4 mr-1" /> <ExternalLink className="h-4 w-4 mr-1" />
View View
</Button> </Button>
<Button {hasPermission('bylaws.edit') && (
variant="outline" <Button
size="sm" variant="outline"
onClick={() => handleEdit(currentBylaws)} size="sm"
className="border-[#664fa3] text-[#664fa3]" onClick={() => handleEdit(currentBylaws)}
> className="border-[#664fa3] text-[#664fa3]"
<Edit className="h-4 w-4" /> >
</Button> <Edit className="h-4 w-4" />
<Button </Button>
variant="outline" )}
size="sm" {hasPermission('bylaws.delete') && (
onClick={() => handleDelete(currentBylaws)} <Button
className="border-red-500 text-red-500 hover:bg-red-50" variant="outline"
> size="sm"
<Trash2 className="h-4 w-4" /> onClick={() => handleDelete(currentBylaws)}
</Button> className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-sm text-[#664fa3]"> <div className="flex items-center gap-4 text-sm text-[#664fa3]">
@@ -254,10 +262,12 @@ const AdminBylaws = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p> <p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> {hasPermission('bylaws.create') && (
<Plus className="h-4 w-4 mr-2" /> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
Create Bylaws <Plus className="h-4 w-4 mr-2" />
</Button> Create Bylaws
</Button>
)}
</Card> </Card>
)} )}
@@ -290,22 +300,26 @@ const AdminBylaws = () => {
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Button> </Button>
<Button {hasPermission('bylaws.edit') && (
variant="outline" <Button
size="sm" variant="outline"
onClick={() => handleEdit(bylawsDoc)} size="sm"
className="border-[#664fa3] text-[#664fa3]" onClick={() => handleEdit(bylawsDoc)}
> className="border-[#664fa3] text-[#664fa3]"
<Edit className="h-4 w-4" /> >
</Button> <Edit className="h-4 w-4" />
<Button </Button>
variant="outline" )}
size="sm" {hasPermission('bylaws.delete') && (
onClick={() => handleDelete(bylawsDoc)} <Button
className="border-red-500 text-red-500 hover:bg-red-50" variant="outline"
> size="sm"
<Trash2 className="h-4 w-4" /> onClick={() => handleDelete(bylawsDoc)}
</Button> className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -4,7 +4,7 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react'; import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react';
const AdminDashboard = () => { const AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -42,8 +42,8 @@ const AdminDashboard = () => {
}).map(u => ({ }).map(u => ({
...u, ...u,
totalReminders: (u.email_verification_reminders_sent || 0) + totalReminders: (u.email_verification_reminders_sent || 0) +
(u.event_attendance_reminders_sent || 0) + (u.event_attendance_reminders_sent || 0) +
(u.payment_reminders_sent || 0) (u.payment_reminders_sent || 0)
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5 })).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
setUsersNeedingAttention(needingAttention); setUsersNeedingAttention(needingAttention);
@@ -56,173 +56,183 @@ const AdminDashboard = () => {
return ( return (
<> <>
<div className="mb-8"> <div className='flex justify-between items-center'>
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="mb-8">
Admin Dashboard <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
</h1> Admin Dashboard
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> </h1>
Manage users, events, and membership applications. <p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</p> Manage users, events, and membership applications.
</p>
</div>
<Link to={'/'}>
<Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Globe />
View Public Site
</Button>
</Link>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg"> <div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#664fa3]" /> <Users className="h-6 w-6 text-[#664fa3]" />
</div>
</div> </div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{loading ? '-' : stats.totalMembers} <p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
</p> {loading ? '-' : stats.totalMembers}
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> </p>
</Card> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="bg-orange-100 p-3 rounded-lg"> <div className="bg-orange-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" /> <Clock className="h-6 w-6 text-orange-600" />
</div>
</div> </div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{loading ? '-' : stats.pendingValidations} <p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
</p> {loading ? '-' : stats.pendingValidations}
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p> </p>
</Card> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="bg-[#81B29A]/20 p-3 rounded-lg"> <div className="bg-[#81B29A]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[#81B29A]" /> <CheckCircle className="h-6 w-6 text-[#81B29A]" />
</div>
</div> </div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{loading ? '-' : stats.activeMembers} <p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.activeMembers}
</p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
</Card>
</div>
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-8">
<Link to="/admin/members">
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Manage Members
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View and manage paying members and their subscription status.
</p> </p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p> <Button
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
data-testid="manage-users-button"
>
Go to Members
</Button>
</Card> </Card>
</div> </Link>
{/* Quick Actions */} <Link to="/admin/validations">
<div className="grid md:grid-cols-2 gap-8"> <Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
<Link to="/admin/members"> <Clock className="h-12 w-12 text-orange-600 mb-4" />
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users"> <h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Users className="h-12 w-12 text-[#664fa3] mb-4" /> Validation Queue
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> </h3>
Manage Members <p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
</h3> Review and validate pending membership applications.
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> </p>
View and manage paying members and their subscription status. <Button
</p> className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
<Button data-testid="manage-validations-button"
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full" >
data-testid="manage-users-button" View Validations
> </Button>
Go to Members </Card>
</Button> </Link>
</Card> </div>
</Link>
<Link to="/admin/validations"> {/* Users Needing Attention Widget */}
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations"> {usersNeedingAttention.length > 0 && (
<Clock className="h-12 w-12 text-orange-600 mb-4" /> <div className="mt-12">
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
Validation Queue <div className="flex items-center gap-3 mb-6">
</h3> <div className="bg-[#ff9e77]/20 p-3 rounded-lg">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <AlertCircle className="h-6 w-6 text-[#ff9e77]" />
Review and validate pending membership applications.
</p>
<Button
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
data-testid="manage-validations-button"
>
View Validations
</Button>
</Card>
</Link>
</div>
{/* Users Needing Attention Widget */}
{usersNeedingAttention.length > 0 && (
<div className="mt-12">
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
</div>
<div>
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Needing Personal Outreach
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
These members have received multiple reminder emails. Consider calling them directly.
</p>
</div>
</div> </div>
<div>
<div className="space-y-4"> <h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{usersNeedingAttention.map(user => ( Members Needing Personal Outreach
<Link key={user.id} to={`/admin/users/${user.id}`}> </h3>
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h4>
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone || 'N/A'}</p>
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
{user.email_verification_reminders_sent > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.event_attendance_reminders_sent > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.payment_reminders_sent > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
<Button
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
onClick={(e) => {
e.preventDefault();
window.location.href = `tel:${user.phone}`;
}}
>
Call Member
</Button>
</div>
</div>
</Link>
))}
</div>
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email. These members have received multiple reminder emails. Consider calling them directly.
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
</p> </p>
</div> </div>
</Card> </div>
</div>
)} <div className="space-y-4">
{usersNeedingAttention.map(user => (
<Link key={user.id} to={`/admin/users/${user.id}`}>
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{user.first_name} {user.last_name}
</h4>
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p>Email: {user.email}</p>
<p>Phone: {user.phone || 'N/A'}</p>
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
{user.email_verification_reminders_sent > 0 && (
<p>
<Mail className="inline h-3 w-3 mr-1" />
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.event_attendance_reminders_sent > 0 && (
<p>
<Calendar className="inline h-3 w-3 mr-1" />
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
</p>
)}
{user.payment_reminders_sent > 0 && (
<p>
<Clock className="inline h-3 w-3 mr-1" />
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
<Button
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
onClick={(e) => {
e.preventDefault();
window.location.href = `tel:${user.phone}`;
}}
>
Call Member
</Button>
</div>
</div>
</Link>
))}
</div>
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
</p>
</div>
</Card>
</div>
)}
</> </>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminDonations = () => { const AdminDonations = () => {
const { hasPermission } = useAuth();
const [donations, setDonations] = useState([]); const [donations, setDonations] = useState([]);
const [filteredDonations, setFilteredDonations] = useState([]); const [filteredDonations, setFilteredDonations] = useState([]);
const [stats, setStats] = useState({}); const [stats, setStats] = useState({});
@@ -269,33 +271,35 @@ const AdminDonations = () => {
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]" className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/> />
</div> </div>
<DropdownMenu> {hasPermission('donations.export') && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
disabled={exporting} <Button
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2" disabled={exporting}
> className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
<Download className="h-4 w-4" /> >
{exporting ? 'Exporting...' : 'Export'} <Download className="h-4 w-4" />
</Button> {exporting ? 'Exporting...' : 'Export'}
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg"> </DropdownMenuTrigger>
<DropdownMenuItem <DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
onClick={() => handleExport('all')} <DropdownMenuItem
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" onClick={() => handleExport('all')}
> className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> >
<span className="text-[#422268]">Export All Donations</span> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
</DropdownMenuItem> <span className="text-[#422268]">Export All Donations</span>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => handleExport('current')} <DropdownMenuItem
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" onClick={() => handleExport('current')}
> className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> >
<span className="text-[#422268]">Export Current View</span> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
</DropdownMenuItem> <span className="text-[#422268]">Export Current View</span>
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
{/* Filters Row */} {/* Filters Row */}

View File

@@ -0,0 +1,548 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Badge } from '../../components/ui/badge';
import { Checkbox } from '../../components/ui/checkbox';
import {
ArrowLeft,
Calendar,
MapPin,
Download,
Check,
X,
Search,
Users,
UserCheck,
UserX,
HelpCircle
} from 'lucide-react';
import { toast } from 'sonner';
import moment from 'moment';
const AdminEventAttendance = () => {
const { eventId } = useParams();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [rsvps, setRsvps] = useState([]);
const [filteredRsvps, setFilteredRsvps] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Filters and search
const [activeTab, setActiveTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
// Bulk selection
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
const [selectAll, setSelectAll] = useState(false);
useEffect(() => {
fetchEventAndRsvps();
}, [eventId]);
useEffect(() => {
filterRsvps();
}, [rsvps, activeTab, searchQuery]);
const fetchEventAndRsvps = async () => {
try {
setLoading(true);
const [eventRes, rsvpsRes] = await Promise.all([
api.get(`/admin/events/${eventId}`),
api.get(`/admin/events/${eventId}/rsvps`)
]);
setEvent(eventRes.data);
setRsvps(rsvpsRes.data);
} catch (error) {
console.error('Failed to fetch event data:', error);
toast.error('Failed to load event data');
} finally {
setLoading(false);
}
};
const filterRsvps = () => {
let filtered = [...rsvps];
// Filter by RSVP status tab
if (activeTab !== 'all') {
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
}
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(rsvp =>
rsvp.user_name?.toLowerCase().includes(query) ||
rsvp.user_email?.toLowerCase().includes(query)
);
}
setFilteredRsvps(filtered);
};
const handleSelectAll = () => {
if (selectAll) {
setSelectedRsvps(new Set());
} else {
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
}
setSelectAll(!selectAll);
};
const handleSelectRsvp = (userId) => {
const newSelected = new Set(selectedRsvps);
if (newSelected.has(userId)) {
newSelected.delete(userId);
} else {
newSelected.add(userId);
}
setSelectedRsvps(newSelected);
setSelectAll(newSelected.size === filteredRsvps.length);
};
const handleBulkAttendance = async (attended) => {
if (selectedRsvps.size === 0) {
toast.error('Please select at least one RSVP');
return;
}
try {
setSaving(true);
const updates = Array.from(selectedRsvps).map(userId => ({
user_id: userId,
attended
}));
await api.put(`/admin/events/${eventId}/attendance`, { updates });
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
// Refresh data
await fetchEventAndRsvps();
// Clear selection
setSelectedRsvps(new Set());
setSelectAll(false);
} catch (error) {
console.error('Failed to update attendance:', error);
toast.error('Failed to update attendance');
} finally {
setSaving(false);
}
};
const handleIndividualAttendance = async (userId, attended) => {
try {
setSaving(true);
const updates = [{
user_id: userId,
attended
}];
await api.put(`/admin/events/${eventId}/attendance`, { updates });
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
// Refresh data
await fetchEventAndRsvps();
} catch (error) {
console.error('Failed to update attendance:', error);
toast.error('Failed to update attendance');
} finally {
setSaving(false);
}
};
const exportToCSV = () => {
if (filteredRsvps.length === 0) {
toast.error('No RSVPs to export');
return;
}
// CSV header
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
// CSV rows
const rows = filteredRsvps.map(rsvp => [
`"${rsvp.user_name}"`,
`"${rsvp.user_email}"`,
`"${rsvp.rsvp_status.toUpperCase()}"`,
rsvp.attended ? 'Yes' : 'No',
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
]);
// Combine headers and rows
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
// Create blob and download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('CSV exported successfully');
};
const getStats = () => {
const total = rsvps.length;
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
const attendedCount = rsvps.filter(r => r.attended).length;
return { total, yesCount, noCount, maybeCount, attendedCount };
};
const stats = getStats();
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[#664fa3]">Loading event data...</div>
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<p className="text-[#664fa3] mb-4">Event not found</p>
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl">
Back to Events
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
onClick={() => navigate('/admin/events')}
variant="outline"
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Events
</Button>
<div>
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Event Attendance
</h1>
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage RSVPs and track attendance for this event
</p>
</div>
</div>
<Button
onClick={exportToCSV}
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Download className="h-4 w-4 mr-2" />
Export to CSV
</Button>
</div>
{/* Event Details Card */}
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{event.title}
</h2>
<div className="flex flex-wrap gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
</div>
{event.location && (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
<span>{event.location}</span>
</div>
)}
</div>
</div>
<Badge className={`${event.published ? 'bg-[#81B29A]' : 'bg-[#ddd8eb]'} text-white px-3 py-1`}>
{event.published ? 'Published' : 'Draft'}
</Badge>
</div>
</Card>
{/* Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-[#664fa3]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<UserCheck className="h-8 w-8 text-[#81B29A]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<UserX className="h-8 w-8 text-[#E07A5F]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
</div>
</div>
</Card>
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
<div className="flex items-center gap-3">
<Check className="h-8 w-8 text-[#664fa3]" />
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
</div>
</div>
</Card>
</div>
{/* Filters and Actions */}
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
<div className="space-y-4">
{/* Tab Filters */}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setActiveTab('all')}
variant={activeTab === 'all' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'all'
? 'bg-[#664fa3] hover:bg-[#422268] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
All ({stats.total})
</Button>
<Button
onClick={() => setActiveTab('yes')}
variant={activeTab === 'yes' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'yes'
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
Yes ({stats.yesCount})
</Button>
<Button
onClick={() => setActiveTab('no')}
variant={activeTab === 'no' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'no'
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
No ({stats.noCount})
</Button>
<Button
onClick={() => setActiveTab('maybe')}
variant={activeTab === 'maybe' ? 'default' : 'outline'}
className={`rounded-xl ${
activeTab === 'maybe'
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
}`}
style={{ fontFamily: "'Inter', sans-serif" }}
>
Maybe ({stats.maybeCount})
</Button>
</div>
{/* Search and Bulk Actions */}
<div className="flex flex-wrap gap-3 items-center justify-between">
<div className="flex-1 min-w-[200px] max-w-md relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
<Input
type="text"
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 border-[#ddd8eb] rounded-xl"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
{selectedRsvps.size > 0 && (
<div className="flex gap-2">
<Badge className="bg-[#664fa3] text-white px-3 py-1">
{selectedRsvps.size} selected
</Badge>
<Button
onClick={() => handleBulkAttendance(true)}
disabled={saving}
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Check className="h-4 w-4 mr-1" />
Mark Attended
</Button>
<Button
onClick={() => handleBulkAttendance(false)}
disabled={saving}
className="bg-[#E07A5F] hover:bg-[#d16b54] text-white rounded-xl"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<X className="h-4 w-4 mr-1" />
Mark Not Attended
</Button>
</div>
)}
</div>
</div>
</Card>
{/* RSVP Table */}
<Card className="bg-white border-[#ddd8eb] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
<tr>
<th className="px-4 py-3 text-left">
<Checkbox
checked={selectAll}
onCheckedChange={handleSelectAll}
/>
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Name
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Email
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
RSVP Status
</th>
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Attendance
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Attended At
</th>
</tr>
</thead>
<tbody>
{filteredRsvps.length > 0 ? (
filteredRsvps.map((rsvp) => (
<tr key={rsvp.user_id} className="border-b border-[#ddd8eb] hover:bg-[#F8F7FB] transition-colors">
<td className="px-4 py-3">
<Checkbox
checked={selectedRsvps.has(rsvp.user_id)}
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
/>
</td>
<td className="px-4 py-3 text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_name}
</td>
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.user_email}
</td>
<td className="px-4 py-3">
<Badge
className={`${
rsvp.rsvp_status === 'yes'
? 'bg-[#81B29A]'
: rsvp.rsvp_status === 'no'
? 'bg-[#E07A5F]'
: 'bg-[#F2CC8F] text-[#422268]'
} text-white text-xs px-2 py-1`}
>
{rsvp.rsvp_status.toUpperCase()}
</Badge>
</td>
<td className="px-4 py-3 text-center">
{rsvp.attended ? (
<Button
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
disabled={saving}
size="sm"
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-lg min-w-[120px]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Check className="h-3 w-3 mr-1" />
Attended
</Button>
) : (
<Button
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
disabled={saving}
size="sm"
variant="outline"
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] rounded-lg min-w-[120px]"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<X className="h-3 w-3 mr-1" />
Not Attended
</Button>
)}
</td>
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
</td>
</tr>
))
) : (
<tr>
<td colSpan="6" className="px-4 py-12 text-center">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
};
export default AdminEventAttendance;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
@@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react'; import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => { const AdminEvents = () => {
const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState(null); const [editingEvent, setEditingEvent] = useState(null);
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
@@ -342,19 +341,16 @@ const AdminEvents = () => {
{/* Actions */} {/* Actions */}
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]"> <div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
{/* Mark Attendance Button */} {/* Manage Attendance Button */}
<Button <Button
onClick={() => { onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
setSelectedEvent(event);
setAttendanceDialogOpen(true);
}}
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white" className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
data-testid={`mark-attendance-${event.id}`} data-testid={`mark-attendance-${event.id}`}
> >
<Users className="h-4 w-4 mr-2" /> <Users className="h-4 w-4 mr-2" />
Mark Attendance ({event.rsvp_count || 0} RSVPs) Manage Attendance ({event.rsvp_count || 0} RSVPs)
</Button> </Button>
{/* Other Actions */} {/* Other Actions */}
@@ -419,14 +415,6 @@ const AdminEvents = () => {
</Button> </Button>
</div> </div>
)} )}
{/* Attendance Dialog */}
<AttendanceDialog
event={selectedEvent}
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
onSuccess={fetchEvents}
/>
</> </>
); );
}; };

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminFinancials = () => { const AdminFinancials = () => {
const { hasPermission } = useAuth();
const [reports, setReports] = useState([]); const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -162,13 +164,15 @@ const AdminFinancials = () => {
Manage annual financial reports Manage annual financial reports
</p> </p>
</div> </div>
<Button {hasPermission('financials.create') && (
onClick={handleCreate} <Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" onClick={handleCreate}
> className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
<Plus className="h-4 w-4" /> >
Add Report <Plus className="h-4 w-4" />
</Button> Add Report
</Button>
)}
</div> </div>
{/* Reports List */} {/* Reports List */}
@@ -176,10 +180,12 @@ const AdminFinancials = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p> <p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> {hasPermission('financials.create') && (
<Plus className="h-4 w-4 mr-2" /> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
Create First Report <Plus className="h-4 w-4 mr-2" />
</Button> Create First Report
</Button>
)}
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -209,24 +215,30 @@ const AdminFinancials = () => {
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex gap-2"> {(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
<Button <div className="flex gap-2">
variant="outline" {hasPermission('financials.edit') && (
size="sm" <Button
onClick={() => handleEdit(report)} variant="outline"
className="border-[#664fa3] text-[#664fa3]" size="sm"
> onClick={() => handleEdit(report)}
<Edit className="h-4 w-4" /> className="border-[#664fa3] text-[#664fa3]"
</Button> >
<Button <Edit className="h-4 w-4" />
variant="outline" </Button>
size="sm" )}
onClick={() => handleDelete(report)} {hasPermission('financials.delete') && (
className="border-red-500 text-red-500 hover:bg-red-50" <Button
> variant="outline"
<Trash2 className="h-4 w-4" /> size="sm"
</Button> onClick={() => handleDelete(report)}
</div> className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div> </div>
</Card> </Card>
))} ))}

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -14,11 +16,12 @@ import {
SelectValue, SelectValue,
} from '../../components/ui/select'; } from '../../components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react'; import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import moment from 'moment'; import moment from 'moment';
const AdminGallery = () => { const AdminGallery = () => {
const { hasPermission } = useAuth();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null);
const [galleryImages, setGalleryImages] = useState([]); const [galleryImages, setGalleryImages] = useState([]);
@@ -179,7 +182,33 @@ const AdminGallery = () => {
</Select> </Select>
</div> </div>
{selectedEvent && ( {/* Empty State Message */}
{events.length === 0 && (
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
No Events Available
</h4>
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
You need to create an event before uploading gallery images. Events help organize photos by occasion.
</p>
<Link to="/admin/events">
<Button
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Calendar className="h-4 w-4 mr-2" />
Create Your First Event
</Button>
</Link>
</div>
</div>
</div>
)}
{selectedEvent && hasPermission('gallery.upload') && (
<div className="pt-4"> <div className="pt-4">
<input <input
type="file" type="file"
@@ -240,26 +269,32 @@ const AdminGallery = () => {
</div> </div>
{/* Overlay with Actions */} {/* Overlay with Actions */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2"> {(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
<Button <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
onClick={() => openEditCaption(image)} {hasPermission('gallery.edit') && (
size="sm" <Button
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg" onClick={() => openEditCaption(image)}
style={{ fontFamily: "'Inter', sans-serif" }} size="sm"
> className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
<Edit className="h-4 w-4 mr-1" /> style={{ fontFamily: "'Inter', sans-serif" }}
Caption >
</Button> <Edit className="h-4 w-4 mr-1" />
<Button Caption
onClick={() => handleDeleteImage(image.id)} </Button>
size="sm" )}
className="bg-red-500 hover:bg-red-600 text-white rounded-lg" {hasPermission('gallery.delete') && (
style={{ fontFamily: "'Inter', sans-serif" }} <Button
> onClick={() => handleDeleteImage(image.id)}
<Trash2 className="h-4 w-4 mr-1" /> size="sm"
Delete className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
</Button> style={{ fontFamily: "'Inter', sans-serif" }}
</div> >
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
)}
</div>
)}
{/* Caption Preview */} {/* Caption Preview */}
{image.caption && ( {image.caption && (

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminNewsletters = () => { const AdminNewsletters = () => {
const { hasPermission } = useAuth();
const [newsletters, setNewsletters] = useState([]); const [newsletters, setNewsletters] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@@ -190,13 +192,15 @@ const AdminNewsletters = () => {
Create and manage newsletter archive Create and manage newsletter archive
</p> </p>
</div> </div>
<Button {hasPermission('newsletters.create') && (
onClick={handleCreate} <Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2" onClick={handleCreate}
> className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
<Plus className="h-4 w-4" /> >
Add Newsletter <Plus className="h-4 w-4" />
</Button> Add Newsletter
</Button>
)}
</div> </div>
{/* Newsletters List */} {/* Newsletters List */}
@@ -204,10 +208,12 @@ const AdminNewsletters = () => {
<Card className="p-12 text-center"> <Card className="p-12 text-center">
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" /> <FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p> <p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white"> {hasPermission('newsletters.create') && (
<Plus className="h-4 w-4 mr-2" /> <Button onClick={handleCreate} className="bg-[#664fa3] text-white">
Create First Newsletter <Plus className="h-4 w-4 mr-2" />
</Button> Create First Newsletter
</Button>
)}
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
@@ -246,24 +252,30 @@ const AdminNewsletters = () => {
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex gap-2"> {(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
<Button <div className="flex gap-2">
variant="outline" {hasPermission('newsletters.edit') && (
size="sm" <Button
onClick={() => handleEdit(newsletter)} variant="outline"
className="border-[#664fa3] text-[#664fa3]" size="sm"
> onClick={() => handleEdit(newsletter)}
<Edit className="h-4 w-4" /> className="border-[#664fa3] text-[#664fa3]"
</Button> >
<Button <Edit className="h-4 w-4" />
variant="outline" </Button>
size="sm" )}
onClick={() => handleDelete(newsletter)} {hasPermission('newsletters.delete') && (
className="border-red-500 text-red-500 hover:bg-red-50" <Button
> variant="outline"
<Trash2 className="h-4 w-4" /> size="sm"
</Button> onClick={() => handleDelete(newsletter)}
</div> className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div> </div>
</Card> </Card>
))} ))}

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -24,6 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminPlans = () => { const AdminPlans = () => {
const { hasPermission } = useAuth();
const [plans, setPlans] = useState([]); const [plans, setPlans] = useState([]);
const [filteredPlans, setFilteredPlans] = useState([]); const [filteredPlans, setFilteredPlans] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -136,13 +138,15 @@ const AdminPlans = () => {
Manage membership plans and pricing. Manage membership plans and pricing.
</p> </p>
</div> </div>
<Button {hasPermission('subscriptions.plans') && (
onClick={handleCreatePlan} <Button
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6" onClick={handleCreatePlan}
> className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
<Plus className="h-4 w-4 mr-2" /> >
Create Plan <Plus className="h-4 w-4 mr-2" />
</Button> Create Plan
</Button>
)}
</div> </div>
</div> </div>
@@ -286,27 +290,29 @@ const AdminPlans = () => {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]"> {hasPermission('subscriptions.plans') && (
<Button <div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
onClick={() => handleEditPlan(plan)} <Button
variant="outline" onClick={() => handleEditPlan(plan)}
size="sm" variant="outline"
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full" size="sm"
> className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
<Edit className="h-4 w-4 mr-1" /> >
Edit <Edit className="h-4 w-4 mr-1" />
</Button> Edit
<Button </Button>
onClick={() => handleDeleteClick(plan)} <Button
variant="outline" onClick={() => handleDeleteClick(plan)}
size="sm" variant="outline"
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full" size="sm"
disabled={plan.subscriber_count > 0} className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
> disabled={plan.subscriber_count > 0}
<Trash2 className="h-4 w-4 mr-1" /> >
Delete <Trash2 className="h-4 w-4 mr-1" />
</Button> Delete
</div> </Button>
</div>
)}
{/* Warning for plans with subscribers */} {/* Warning for plans with subscribers */}
{plan.subscriber_count > 0 && ( {plan.subscriber_count > 0 && (
@@ -328,7 +334,7 @@ const AdminPlans = () => {
? 'Try adjusting your filters' ? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'} : 'Create your first subscription plan to get started'}
</p> </p>
{!searchQuery && activeFilter === 'all' && ( {!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
<Button <Button
onClick={handleCreatePlan} onClick={handleCreatePlan}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8" className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"

View File

@@ -16,7 +16,7 @@ import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck,
const AdminStaff = () => { const AdminStaff = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { hasPermission } = useAuth(); const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -118,7 +118,7 @@ const AdminStaff = () => {
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const config = { const config = {
active: { label: 'Active', className: 'bg-[#81B29A] text-white' }, active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' } inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
}; };
const statusConfig = config[status] || config.inactive; const statusConfig = config[status] || config.inactive;
@@ -142,7 +142,7 @@ const AdminStaff = () => {
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
{hasPermission('users.invite') && ( {hasPermission('users.create') && (
<Button <Button
onClick={() => setInviteDialogOpen(true)} onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6" className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -44,6 +45,7 @@ import {
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
const AdminSubscriptions = () => { const AdminSubscriptions = () => {
const { hasPermission } = useAuth();
const [subscriptions, setSubscriptions] = useState([]); const [subscriptions, setSubscriptions] = useState([]);
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]); const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
const [plans, setPlans] = useState([]); const [plans, setPlans] = useState([]);
@@ -412,33 +414,35 @@ Proceed with activation?`;
</div> </div>
{/* Export Dropdown */} {/* Export Dropdown */}
<DropdownMenu> {hasPermission('subscriptions.export') && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
disabled={exporting} <Button
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2" disabled={exporting}
> className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
<Download className="h-4 w-4" /> >
{exporting ? 'Exporting...' : 'Export'} <Download className="h-4 w-4" />
</Button> {exporting ? 'Exporting...' : 'Export'}
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg"> </DropdownMenuTrigger>
<DropdownMenuItem <DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
onClick={() => handleExport('all')} <DropdownMenuItem
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" onClick={() => handleExport('all')}
> className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> >
<span className="text-[#422268]">Export All Subscriptions</span> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
</DropdownMenuItem> <span className="text-[#422268]">Export All Subscriptions</span>
<DropdownMenuItem </DropdownMenuItem>
onClick={() => handleExport('current')} <DropdownMenuItem
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" onClick={() => handleExport('current')}
> className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> >
<span className="text-[#422268]">Export Current View</span> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
</DropdownMenuItem> <span className="text-[#422268]">Export Current View</span>
</DropdownMenuContent> </DropdownMenuItem>
</DropdownMenu> </DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
</Card> </Card>
@@ -503,16 +507,18 @@ Proceed with activation?`;
{/* Actions */} {/* Actions */}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button {hasPermission('subscriptions.edit') && (
size="sm" <Button
variant="outline" size="sm"
onClick={() => handleEdit(sub)} variant="outline"
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]" onClick={() => handleEdit(sub)}
> className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
<Edit className="h-4 w-4 mr-2" /> >
Edit <Edit className="h-4 w-4 mr-2" />
</Button> Edit
{sub.status === 'active' && ( </Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -607,15 +613,17 @@ Proceed with activation?`;
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Button {hasPermission('subscriptions.edit') && (
size="sm" <Button
variant="outline" size="sm"
onClick={() => handleEdit(sub)} variant="outline"
className="text-[#664fa3] hover:bg-[#DDD8EB]" onClick={() => handleEdit(sub)}
> className="text-[#664fa3] hover:bg-[#DDD8EB]"
<Edit className="h-4 w-4" /> >
</Button> <Edit className="h-4 w-4" />
{sub.status === 'active' && ( </Button>
)}
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
@@ -35,6 +36,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog'; import RejectionDialog from '../../components/RejectionDialog';
const AdminValidations = () => { const AdminValidations = () => {
const { hasPermission } = useAuth();
const [pendingUsers, setPendingUsers] = useState([]); const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -419,66 +421,78 @@ const AdminValidations = () => {
</Button> </Button>
) : user.status === 'pending_email' ? ( ) : user.status === 'pending_email' ? (
<> <>
<Button {hasPermission('users.approve') && (
onClick={() => handleBypassAndValidateRequest(user)} <Button
disabled={actionLoading === user.id} onClick={() => handleBypassAndValidateRequest(user)}
size="sm" disabled={actionLoading === user.id}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white" size="sm"
> className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'} >
</Button> {actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
<Button </Button>
onClick={() => handleRejectUser(user)} )}
disabled={actionLoading === user.id} {hasPermission('users.approve') && (
size="sm" <Button
variant="outline" onClick={() => handleRejectUser(user)}
className="border-2 border-red-500 text-red-500 hover:bg-red-50" disabled={actionLoading === user.id}
> size="sm"
<X className="h-4 w-4 mr-1" /> variant="outline"
Reject className="border-2 border-red-500 text-red-500 hover:bg-red-50"
</Button> >
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</> </>
) : user.status === 'payment_pending' ? ( ) : user.status === 'payment_pending' ? (
<> <>
<Button {hasPermission('subscriptions.activate') && (
onClick={() => handleActivatePayment(user)} <Button
size="sm" onClick={() => handleActivatePayment(user)}
className="bg-[#DDD8EB] text-[#422268] hover:bg-white" size="sm"
> className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
<CheckCircle className="h-4 w-4 mr-1" /> >
Activate Payment <CheckCircle className="h-4 w-4 mr-1" />
</Button> Activate Payment
<Button </Button>
onClick={() => handleRejectUser(user)} )}
disabled={actionLoading === user.id} {hasPermission('users.approve') && (
size="sm" <Button
variant="outline" onClick={() => handleRejectUser(user)}
className="border-2 border-red-500 text-red-500 hover:bg-red-50" disabled={actionLoading === user.id}
> size="sm"
<X className="h-4 w-4 mr-1" /> variant="outline"
Reject className="border-2 border-red-500 text-red-500 hover:bg-red-50"
</Button> >
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</> </>
) : ( ) : (
<> <>
<Button {hasPermission('users.approve') && (
onClick={() => handleValidateRequest(user)} <Button
disabled={actionLoading === user.id} onClick={() => handleValidateRequest(user)}
size="sm" disabled={actionLoading === user.id}
className="bg-[#81B29A] text-white hover:bg-[#6FA087]" size="sm"
> className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
{actionLoading === user.id ? 'Validating...' : 'Validate'} >
</Button> {actionLoading === user.id ? 'Validating...' : 'Validate'}
<Button </Button>
onClick={() => handleRejectUser(user)} )}
disabled={actionLoading === user.id} {hasPermission('users.approve') && (
size="sm" <Button
variant="outline" onClick={() => handleRejectUser(user)}
className="border-2 border-red-500 text-red-500 hover:bg-red-50" disabled={actionLoading === user.id}
> size="sm"
<X className="h-4 w-4 mr-1" /> variant="outline"
Reject className="border-2 border-red-500 text-red-500 hover:bg-red-50"
</Button> >
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,93 @@
/* Member Calendar Custom Styles */
.member-calendar .rbc-header {
padding: 12px 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
color: #422268;
background-color: #f9f7fc;
border-bottom: 2px solid #ddd8eb;
}
.member-calendar .rbc-today {
background-color: #f1eef9;
}
.member-calendar .rbc-off-range-bg {
background-color: #fafafa;
}
.member-calendar .rbc-event {
border-radius: 6px;
padding: 2px 6px;
}
.member-calendar .rbc-event:hover {
opacity: 0.85;
cursor: pointer;
}
.member-calendar .rbc-toolbar button {
color: #664fa3;
border-color: #ddd8eb;
font-family: 'Nunito Sans', sans-serif;
padding: 6px 12px;
background-color: white;
cursor: pointer;
transition: all 0.2s ease;
}
.member-calendar .rbc-toolbar button:hover {
background-color: #f1eef9;
border-color: #664fa3;
}
.member-calendar .rbc-toolbar button:active,
.member-calendar .rbc-toolbar button.rbc-active {
background-color: #664fa3;
color: white;
}
.member-calendar .rbc-month-view {
border: 1px solid #ddd8eb;
border-radius: 8px;
}
.member-calendar .rbc-day-bg {
border-color: #ddd8eb;
}
.member-calendar .rbc-date-cell {
padding: 8px;
font-family: 'Nunito Sans', sans-serif;
}
/* Ensure toolbar buttons are clickable */
.member-calendar .rbc-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
}
.member-calendar .rbc-toolbar button {
outline: none;
border: 1px solid #ddd8eb;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.member-calendar .rbc-btn-group button {
margin: 0 2px;
}
.member-calendar .rbc-btn-group button:first-child {
margin-left: 0;
}
.member-calendar .rbc-btn-group button:last-child {
margin-right: 0;
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
import { Calendar, momentLocalizer } from 'react-big-calendar'; import { Calendar, momentLocalizer } from 'react-big-calendar';
import moment from 'moment'; import moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css'; import 'react-big-calendar/lib/css/react-big-calendar.css';
import './MemberCalendar.css';
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';
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
const [selectedEvent, setSelectedEvent] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [rsvpLoading, setRsvpLoading] = useState(false); const [rsvpLoading, setRsvpLoading] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [currentView, setCurrentView] = useState('month');
useEffect(() => { useEffect(() => {
fetchEvents(); fetchEvents();
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const handleNavigate = (newDate) => {
setCurrentDate(newDate);
};
const handleViewChange = (newView) => {
setCurrentView(newView);
};
const handleRSVP = async (status) => { const handleRSVP = async (status) => {
if (!selectedEvent) return; if (!selectedEvent) return;
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
startAccessor="start" startAccessor="start"
endAccessor="end" endAccessor="end"
style={{ height: 700 }} style={{ height: 700 }}
date={currentDate}
view={currentView}
onNavigate={handleNavigate}
onView={handleViewChange}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter} eventPropGetter={eventStyleGetter}
views={['month', 'week', 'day', 'agenda']} views={['month', 'week', 'day', 'agenda']}
defaultView="month"
popup popup
className="member-calendar" className="member-calendar"
/> />
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
</Dialog> </Dialog>
</div> </div>
<style jsx global>{`
.member-calendar .rbc-header {
padding: 12px 6px;
font-family: 'Inter', sans-serif;
font-weight: 600;
color: #422268;
background-color: #f9f7fc;
border-bottom: 2px solid #ddd8eb;
}
.member-calendar .rbc-today {
background-color: #f1eef9;
}
.member-calendar .rbc-off-range-bg {
background-color: #fafafa;
}
.member-calendar .rbc-event {
border-radius: 6px;
padding: 2px 6px;
}
.member-calendar .rbc-event:hover {
opacity: 0.85;
cursor: pointer;
}
.member-calendar .rbc-toolbar button {
color: #664fa3;
border-color: #ddd8eb;
font-family: 'Nunito Sans', sans-serif;
}
.member-calendar .rbc-toolbar button:hover {
background-color: #f1eef9;
border-color: #664fa3;
}
.member-calendar .rbc-toolbar button.rbc-active {
background-color: #664fa3;
color: white;
}
.member-calendar .rbc-month-view {
border: 1px solid #ddd8eb;
border-radius: 8px;
}
.member-calendar .rbc-day-bg {
border-color: #ddd8eb;
}
.member-calendar .rbc-date-cell {
padding: 8px;
font-family: 'Nunito Sans', sans-serif;
}
`}</style>
<MemberFooter /> <MemberFooter />
</div> </div>
); );

View File

@@ -24,6 +24,8 @@ const MembersDirectory = () => {
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 pageSize = 12;
useEffect(() => { useEffect(() => {
fetchMembers(); fetchMembers();
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
filterMembers(); filterMembers();
}, [searchQuery, members]); }, [searchQuery, members]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, members]);
const fetchMembers = async () => { const fetchMembers = async () => {
try { try {
const response = await api.get('/members/directory'); const response = await api.get('/members/directory');
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
setFilteredMembers(filtered); setFilteredMembers(filtered);
}; };
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
const pageStart = (currentPage - 1) * pageSize;
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = members.length;
const getInitials = (firstName, lastName) => { const getInitials = (firstName, lastName) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
}; };
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
if (!dateString) return null; if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
}; };
const Border = ({ yaxis = false }) => {
return (
yaxis ?
<div className=' border-2 w-full border-[#664FA3] my-24' />
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
)
}
const MemberCard = ({ member }) => ( const MemberCard = ({ member }) => (
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full"> <Card className="p-6 bg-white rounded-3xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
{/* Profile Photo */} {/* Profile Photo */}
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{member.profile_photo_url ? ( {member.profile_photo_url ? (
@@ -259,39 +279,48 @@ const MembersDirectory = () => {
); );
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
<Navbar /> <Navbar />
<div className="max-w-7xl mx-auto px-6 py-12"> <div className="max-w-7xl mx-auto py-12">
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Directory
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Connect with fellow LOAF members in our community.
</p>
</div>
{/* Search Bar */} {/* Header and Search bar */}
<div className="mb-8"> <div className='px-9'>
<div className="relative max-w-xl">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" /> {/* Header */}
<Input <div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
type="text" <h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
placeholder="Search by name or bio..." LOAF Members
value={searchQuery} </h1>
onChange={(e) => setSearchQuery(e.target.value)} <p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]" <span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
{searchQuery && (
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
</p> </p>
)} </div>
{/* Search Bar */}
<div className="mb-24 mx-10">
<div className="relative w-full ">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
<Input
type="text"
placeholder="Search by name or bio..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-[#664fa3] focus:ring-[#664fa3]"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
/>
</div>
{searchQuery && (
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
</p>
)}
</div>
</div> </div>
{/* Border Decoration */}
<Border />
{/* Members Grid */} {/* Members Grid */}
{loading ? ( {loading ? (
@@ -300,7 +329,7 @@ const MembersDirectory = () => {
</div> </div>
) : 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">
{filteredMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} /> <MemberCard key={member.id} member={member} />
))} ))}
</div> </div>
@@ -318,6 +347,11 @@ const MembersDirectory = () => {
</div> </div>
)} )}
{/* Border Decoration */}
<Border yaxis="true" />
{/* Info Card */} {/* Info Card */}
{!loading && members.length > 0 && ( {!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]"> <Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
@@ -465,67 +499,127 @@ const MembersDirectory = () => {
{/* Social Media */} {/* Social Media */}
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram || {(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && ( selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div> <div>
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Connect on Social Media Connect on Social Media
</h3> </h3>
<div className="flex gap-3"> <div className="flex gap-3">
{selectedMember.social_media_facebook && ( {selectedMember.social_media_facebook && (
<a <a
href={getSocialMediaLink(selectedMember.social_media_facebook)} href={getSocialMediaLink(selectedMember.social_media_facebook)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors" className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Facebook" title="Facebook"
> >
<Facebook className="h-6 w-6 text-[#1877F2]" /> <Facebook className="h-6 w-6 text-[#1877F2]" />
</a> </a>
)} )}
{selectedMember.social_media_instagram && ( {selectedMember.social_media_instagram && (
<a <a
href={getSocialMediaLink(selectedMember.social_media_instagram)} href={getSocialMediaLink(selectedMember.social_media_instagram)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors" className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Instagram" title="Instagram"
> >
<Instagram className="h-6 w-6 text-[#E4405F]" /> <Instagram className="h-6 w-6 text-[#E4405F]" />
</a> </a>
)} )}
{selectedMember.social_media_twitter && ( {selectedMember.social_media_twitter && (
<a <a
href={getSocialMediaLink(selectedMember.social_media_twitter)} href={getSocialMediaLink(selectedMember.social_media_twitter)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors" className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Twitter/X" title="Twitter/X"
> >
<Twitter className="h-6 w-6 text-[#1DA1F2]" /> <Twitter className="h-6 w-6 text-[#1DA1F2]" />
</a> </a>
)} )}
{selectedMember.social_media_linkedin && ( {selectedMember.social_media_linkedin && (
<a <a
href={getSocialMediaLink(selectedMember.social_media_linkedin)} href={getSocialMediaLink(selectedMember.social_media_linkedin)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors" className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="LinkedIn" title="LinkedIn"
> >
<Linkedin className="h-6 w-6 text-[#0A66C2]" /> <Linkedin className="h-6 w-6 text-[#0A66C2]" />
</a> </a>
)} )}
</div>
</div> </div>
</div> )}
)}
</div> </div>
</> </>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Pagination */}
{!loading && filteredMembers.length > 0 && (
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing {pageStart + 1}{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
>
First Page
</Button>
<Button
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
disabled={currentPage === 1}
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
>
Previous
</Button>
{Array.from({ length: totalPages }, (_, index) => {
const pageNumber = index + 1;
const isActive = pageNumber === currentPage;
return (
<Button
key={pageNumber}
onClick={() => setCurrentPage(pageNumber)}
className={
isActive
? "bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
: "bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] hover:text-white rounded-full"
}
>
{pageNumber}
</Button>
);
})}
<Button
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={currentPage === totalPages}
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
>
Next
</Button>
<Button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
>
Last Page
</Button>
</div>
</div>
)}
<MemberFooter /> <MemberFooter />
</div> </div>
); );

View File

@@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL;
export const api = axios.create({ export const api = axios.create({
baseURL: `${API_URL}/api`, baseURL: `${API_URL}/api`,
timeout: 30000, // 30 second timeout for all requests
}); });
api.interceptors.request.use((config) => { // Request interceptor - add auth token
const token = localStorage.getItem('token'); api.interceptors.request.use(
if (token) { (config) => {
config.headers.Authorization = `Bearer ${token}`; const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('[API] Request error:', error);
return Promise.reject(error);
} }
return config; );
});
// Response interceptor - handle errors and retries
api.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const config = error.config;
// Don't retry if we've already retried or if it's a client error (4xx)
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
console.error('[API] Request failed:', {
url: config?.url,
method: config?.method,
status: error.response?.status,
message: error.message,
data: error.response?.data
});
return Promise.reject(error);
}
// Mark as retry to prevent infinite loops
config.__isRetry = true;
// Retry after 1 second for server errors or network issues
console.warn('[API] Retrying request after 1s:', {
url: config.url,
method: config.method,
error: error.message
});
return new Promise((resolve) => {
setTimeout(() => {
resolve(api.request(config));
}, 1000);
});
}
);
export default api; export default api;