16 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
Koncept Kit
8c351773ba Fix staff invitation acceptance & add delete/deactivate buttons 2026-01-05 00:11:30 +07:00
Koncept Kit
3511e7a9c8 LOAF Prod 2026-01-04 19:47:49 +07:00
Koncept Kit
33a4d8f4c4 Document Upload Dialogue update 2026-01-02 15:35:30 +07:00
28 changed files with 1945 additions and 741 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

@@ -2,7 +2,7 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
"homepage": "/become-a-member",
"homepage": "/",
"dependencies": {
"@fontsource/dm-sans": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",

View File

@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions';
import AdminRoles from './pages/admin/AdminRoles';
import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations';
import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
@@ -50,6 +51,8 @@ import Resources from './pages/Resources';
import ContactUs from './pages/ContactUs';
import TermsOfService from './pages/TermsOfService';
import PrivacyPolicy from './pages/PrivacyPolicy';
import AcceptInvitation from './pages/AcceptInvitation';
import NotFound from './pages/NotFound';
const PrivateRoute = ({ children, adminOnly = false }) => {
const { user, loading } = useAuth();
@@ -82,6 +85,7 @@ function App() {
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invitation" element={<AcceptInvitation />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/change-password-required" element={
@@ -215,6 +219,13 @@ function App() {
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/events/:eventId/attendance" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminEventAttendance />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/validations" element={
<PrivateRoute adminOnly>
<AdminLayout>
@@ -278,6 +289,9 @@ function App() {
</AdminLayout>
</PrivateRoute>
} />
{/* 404 - Catch all undefined routes */}
<Route path="*" element={<NotFound />} />
</Routes>
<Toaster position="top-right" />
</BrowserRouter>

View File

@@ -22,7 +22,7 @@ import {
Scale,
HardDrive,
Repeat,
Heart
Heart,
} from 'lucide-react';
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
@@ -204,8 +204,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
${item.disabled
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
: active
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
}
`}
>
@@ -265,20 +265,24 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
>
{/* Header */}
<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
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${
isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`}
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`}
/>
{isOpen && (
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin
</h2>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
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
onClick={onToggle}
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
@@ -295,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div>
{/* 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 */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
@@ -365,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* User Section */}
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
{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="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]}
@@ -379,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</p>
</div>
</div>
<Link to='/profile'><Settings size={16} />
</Link>
</div>
)}
@@ -392,11 +398,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div>
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
storagePercentage > 90 ? 'bg-red-500' :
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[#81B29A]'
}`}
'bg-[#81B29A]'
}`}
style={{ width: `${storagePercentage}%` }}
/>
</div>
@@ -407,11 +412,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
) : (
<div className="flex justify-center">
<div className="relative group">
<HardDrive className={`h-5 w-5 ${
storagePercentage > 90 ? 'text-red-500' :
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' :
'text-[#664fa3]'
}`} />
'text-[#664fa3]'
}`} />
{storagePercentage > 75 && (
<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 () => {
setLoadingRoles(true);
try {
const response = await api.get('/admin/roles');
// Filter to show only admin-type roles (not guest or member)
const staffRoles = response.data.filter(role =>
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role
);
setRoles(staffRoles);
// New endpoint returns roles based on user's permission level
// Superadmin: all roles
// Admin: admin, finance, and non-elevated custom roles
const response = await api.get('/admin/roles/assignable');
setRoles(response.data);
} catch (error) {
console.error('Failed to fetch roles:', error);
toast.error('Failed to load roles');
console.error('Failed to fetch assignable roles:', error);
toast.error('Failed to load roles. Please try again.');
} finally {
setLoadingRoles(false);
}

View File

@@ -1,31 +1,33 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react";
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) => (
<TabsPrimitive.List
ref={ref}
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
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
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
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<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",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
{...props}
/>
));
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 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 }) => {
const [user, setUser] = useState(null);
@@ -54,21 +61,79 @@ export const AuthProvider = ({ children }) => {
};
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 {
await fetchPermissions(access_token);
} catch (error) {
console.error('Failed to fetch permissions during login, will retry later:', error);
// Don't throw - permissions can be fetched later if needed
}
console.log('[AuthContext] Starting login request...', {
API_URL: API_URL,
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
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 = () => {

View File

@@ -116,3 +116,27 @@ code {
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 [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [successUser, setSuccessUser] = useState(null);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
password: '',
@@ -134,19 +136,23 @@ const AcceptInvitation = () => {
const { access_token, user } = response.data;
localStorage.setItem('token', access_token);
toast.success('Welcome to LOAF! Your account has been created successfully.');
// Call login to update auth context
if (login) {
await login(invitation.email, formData.password);
}
// Redirect based on role
if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin/dashboard');
} else {
navigate('/dashboard');
}
// Show success state
setSuccessUser(user);
setSuccess(true);
// Auto-redirect after 3 seconds
setTimeout(() => {
if (user.role === 'admin' || user.role === 'superadmin') {
navigate('/admin');
} else {
navigate('/dashboard');
}
}, 3000);
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
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 (
<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]">

View File

@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
];
const boardMembers = [
{ name: 'Danita Cole' },
{ name: 'Roxanne Cherico' },
{ name: 'Lucretia Copeland' },
{ name: 'Julie Fischer' }
{ name: 'Danita Cole', title: 'Director' },
{ name: 'Roxanne Cherico', title: 'Director' },
{ name: 'Lucretia Copeland', title: 'Director' },
{ 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.
</p>
{/* card */}
<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" }}>
<li>
Nominations are due by November 1. Nomination Form:{' '}
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
Click Here
</a>
</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>
<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" }}>
<li>
Nominations are due by November 1. Nomination Form:{' '}
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
Click Here
</a>
</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>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>
The tasks that directors perform depend on individual interests. Recent
tasks include researching how to obtain an extra PO Box key, ordering
Welcome Team name tags, taking pictures at events, researching new venues
for holiday socials, and monitoring Facebook posts. For more information
about director duties, see Article 2 of the bylaws in the Members Only
section of the website:&nbsp;
<a
href="https://loaftx.org/bylaws/"
target="_blank"
rel="noopener noreferrer"
className="text-[#48286e] underline"
>
https://loaftx.org/bylaws/
</a>
</li>
<li>
The tasks that directors perform depend on individual interests. Recent
tasks include researching how to obtain an extra PO Box key, ordering
Welcome Team name tags, taking pictures at events, researching new venues
for holiday socials, and monitoring Facebook posts. For more information
about director duties, see Article 2 of the bylaws in the Members Only
section of the website:&nbsp;
<a
href="https://loaftx.org/bylaws/"
target="_blank"
rel="noopener noreferrer"
className="text-[#48286e] underline"
>
https://loaftx.org/bylaws/
</a>
</li>
<li>
Directors must attend Board meetings held on the second Thursday of each
month at 6:30pm via Zoom.
</li>
<li>
Directors must attend Board meetings held on the second Thursday of each
month at 6:30pm via Zoom.
</li>
<li>
We are a fun group, and we would love for you to join us in providing this
service for our community.
</li>
</ol>
</Card>
<li>
We are a fun group, and we would love for you to join us in providing this
service for our community.
</li>
</ol>
</Card>
</div>
</section>
</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>
</div>
) : 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) => {
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
const suggestedPrice = plan.suggested_price_cents || minimumPrice;

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react';
const AdminBylaws = () => {
const { hasPermission } = useAuth();
const [bylaws, setBylaws] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -44,7 +46,7 @@ const AdminBylaws = () => {
version: '',
effective_date: '',
document_url: '',
document_type: 'google_drive',
document_type: 'link',
is_current: false
});
const [submitting, setSubmitting] = useState(false);
@@ -71,9 +73,10 @@ const AdminBylaws = () => {
version: '',
effective_date: new Date().toISOString().split('T')[0],
document_url: '',
document_type: 'google_drive',
document_type: 'link',
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
});
setUploadedFile(null);
setDialogOpen(true);
};
@@ -183,13 +186,15 @@ const AdminBylaws = () => {
Manage LOAF governing bylaws and version history
</p>
</div>
<Button
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
</Button>
{hasPermission('bylaws.create') && (
<Button
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
</Button>
)}
</div>
{/* Current Bylaws */}
@@ -225,38 +230,44 @@ const AdminBylaws = () => {
<ExternalLink className="h-4 w-4 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(currentBylaws)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(currentBylaws)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
{hasPermission('bylaws.edit') && (
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(currentBylaws)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{hasPermission('bylaws.delete') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(currentBylaws)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
<span></span>
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
</div>
</Card>
) : (
<Card className="p-12 text-center">
<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>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create Bylaws
</Button>
{hasPermission('bylaws.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create Bylaws
</Button>
)}
</Card>
)}
@@ -289,22 +300,26 @@ const AdminBylaws = () => {
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(bylawsDoc)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(bylawsDoc)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
{hasPermission('bylaws.edit') && (
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(bylawsDoc)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{hasPermission('bylaws.delete') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(bylawsDoc)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</Card>
@@ -363,14 +378,16 @@ const AdminBylaws = () => {
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
onValueChange={(value) => {
setFormData({ ...formData, document_type: value, document_url: '' });
setUploadedFile(null);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_drive">Google Drive</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
@@ -391,6 +408,11 @@ const AdminBylaws = () => {
Selected: {uploadedFile.name}
</p>
)}
{selectedBylaws && !uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Current file will be kept if no new file is selected
</p>
)}
</div>
) : (
<div>
@@ -399,12 +421,11 @@ const AdminBylaws = () => {
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://drive.google.com/file/d/..."
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
</p>
</div>
)}

View File

@@ -4,7 +4,7 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
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 [stats, setStats] = useState({
@@ -42,8 +42,8 @@ const AdminDashboard = () => {
}).map(u => ({
...u,
totalReminders: (u.email_verification_reminders_sent || 0) +
(u.event_attendance_reminders_sent || 0) +
(u.payment_reminders_sent || 0)
(u.event_attendance_reminders_sent || 0) +
(u.payment_reminders_sent || 0)
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
setUsersNeedingAttention(needingAttention);
@@ -56,173 +56,183 @@ const AdminDashboard = () => {
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Dashboard
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage users, events, and membership applications.
</p>
<div className='flex justify-between items-center'>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Admin Dashboard
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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>
{/* Stats Grid */}
<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">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#664fa3]" />
</div>
{/* Stats Grid */}
<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">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#664fa3]" />
</div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.totalMembers}
</p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
</Card>
</div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.totalMembers}
</p>
<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">
<div className="flex items-center justify-between mb-4">
<div className="bg-orange-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
<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="bg-orange-100 p-3 rounded-lg">
<Clock className="h-6 w-6 text-orange-600" />
</div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.pendingValidations}
</p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
</Card>
</div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.pendingValidations}
</p>
<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">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
</div>
<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="bg-[#81B29A]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
</div>
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{loading ? '-' : stats.activeMembers}
</div>
<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 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>
</div>
</Link>
{/* 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>
<Button
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
data-testid="manage-users-button"
>
Go to Members
</Button>
</Card>
</Link>
<Link to="/admin/validations">
<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">
<Clock className="h-12 w-12 text-orange-600 mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Validation Queue
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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>
<Link to="/admin/validations">
<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">
<Clock className="h-12 w-12 text-orange-600 mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Validation Queue
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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>
{/* 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 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]">
<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" }}>
<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.
These members have received multiple reminder emails. Consider calling them directly.
</p>
</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 { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react';
const AdminDonations = () => {
const { hasPermission } = useAuth();
const [donations, setDonations] = useState([]);
const [filteredDonations, setFilteredDonations] = useState([]);
const [stats, setStats] = useState({});
@@ -269,33 +271,35 @@ const AdminDonations = () => {
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
<DropdownMenuItem
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>
</DropdownMenuItem>
<DropdownMenuItem
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>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasPermission('donations.export') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
<DropdownMenuItem
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>
</DropdownMenuItem>
<DropdownMenuItem
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>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 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 { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
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 { toast } from 'sonner';
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [formData, setFormData] = useState({
title: '',
@@ -342,19 +341,16 @@ const AdminEvents = () => {
{/* Actions */}
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
{/* Mark Attendance Button */}
{/* Manage Attendance Button */}
<Button
onClick={() => {
setSelectedEvent(event);
setAttendanceDialogOpen(true);
}}
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
variant="outline"
size="sm"
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
data-testid={`mark-attendance-${event.id}`}
>
<Users className="h-4 w-4 mr-2" />
Mark Attendance ({event.rsvp_count || 0} RSVPs)
Manage Attendance ({event.rsvp_count || 0} RSVPs)
</Button>
{/* Other Actions */}
@@ -419,14 +415,6 @@ const AdminEvents = () => {
</Button>
</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 { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
@@ -31,6 +32,7 @@ import {
} from 'lucide-react';
const AdminFinancials = () => {
const { hasPermission } = useAuth();
const [reports, setReports] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -42,7 +44,7 @@ const AdminFinancials = () => {
year: new Date().getFullYear(),
title: '',
document_url: '',
document_type: 'google_drive'
document_type: 'link'
});
const [submitting, setSubmitting] = useState(false);
@@ -67,8 +69,9 @@ const AdminFinancials = () => {
year: new Date().getFullYear(),
title: '',
document_url: '',
document_type: 'google_drive'
document_type: 'link'
});
setUploadedFile(null);
setDialogOpen(true);
};
@@ -161,13 +164,15 @@ const AdminFinancials = () => {
Manage annual financial reports
</p>
</div>
<Button
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
</Button>
{hasPermission('financials.create') && (
<Button
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
</Button>
)}
</div>
{/* Reports List */}
@@ -175,10 +180,12 @@ const AdminFinancials = () => {
<Card className="p-12 text-center">
<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>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Report
</Button>
{hasPermission('financials.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Report
</Button>
)}
</Card>
) : (
<div className="space-y-4">
@@ -208,24 +215,30 @@ const AdminFinancials = () => {
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(report)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(report)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
<div className="flex gap-2">
{hasPermission('financials.edit') && (
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(report)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{hasPermission('financials.delete') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(report)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
</Card>
))}
@@ -274,14 +287,16 @@ const AdminFinancials = () => {
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
onValueChange={(value) => {
setFormData({ ...formData, document_type: value, document_url: '' });
setUploadedFile(null);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_drive">Google Drive</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
@@ -302,6 +317,11 @@ const AdminFinancials = () => {
Selected: {uploadedFile.name}
</p>
)}
{selectedReport && !uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Current file will be kept if no new file is selected
</p>
)}
</div>
) : (
<div>
@@ -310,12 +330,11 @@ const AdminFinancials = () => {
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://drive.google.com/file/d/..."
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
</p>
</div>
)}

View File

@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
@@ -14,11 +16,12 @@ import {
SelectValue,
} from '../../components/ui/select';
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 moment from 'moment';
const AdminGallery = () => {
const { hasPermission } = useAuth();
const [events, setEvents] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null);
const [galleryImages, setGalleryImages] = useState([]);
@@ -179,7 +182,33 @@ const AdminGallery = () => {
</Select>
</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">
<input
type="file"
@@ -240,26 +269,32 @@ const AdminGallery = () => {
</div>
{/* 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">
<Button
onClick={() => openEditCaption(image)}
size="sm"
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Edit className="h-4 w-4 mr-1" />
Caption
</Button>
<Button
onClick={() => handleDeleteImage(image.id)}
size="sm"
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
<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') && (
<Button
onClick={() => openEditCaption(image)}
size="sm"
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Edit className="h-4 w-4 mr-1" />
Caption
</Button>
)}
{hasPermission('gallery.delete') && (
<Button
onClick={() => handleDeleteImage(image.id)}
size="sm"
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
style={{ fontFamily: "'Inter', sans-serif" }}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
)}
</div>
)}
{/* Caption Preview */}
{image.caption && (

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
@@ -32,6 +33,7 @@ import {
} from 'lucide-react';
const AdminNewsletters = () => {
const { hasPermission } = useAuth();
const [newsletters, setNewsletters] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -44,7 +46,7 @@ const AdminNewsletters = () => {
description: '',
published_date: '',
document_url: '',
document_type: 'google_docs'
document_type: 'link'
});
const [submitting, setSubmitting] = useState(false);
@@ -70,8 +72,9 @@ const AdminNewsletters = () => {
description: '',
published_date: new Date().toISOString().split('T')[0],
document_url: '',
document_type: 'google_docs'
document_type: 'link'
});
setUploadedFile(null);
setDialogOpen(true);
};
@@ -189,13 +192,15 @@ const AdminNewsletters = () => {
Create and manage newsletter archive
</p>
</div>
<Button
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
</Button>
{hasPermission('newsletters.create') && (
<Button
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
</Button>
)}
</div>
{/* Newsletters List */}
@@ -203,10 +208,12 @@ const AdminNewsletters = () => {
<Card className="p-12 text-center">
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Newsletter
</Button>
{hasPermission('newsletters.create') && (
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
<Plus className="h-4 w-4 mr-2" />
Create First Newsletter
</Button>
)}
</Card>
) : (
<div className="space-y-6">
@@ -232,7 +239,7 @@ const AdminNewsletters = () => {
{formatDate(newsletter.published_date)}
</Badge>
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
</Badge>
<Button
variant="ghost"
@@ -245,24 +252,30 @@ const AdminNewsletters = () => {
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(newsletter)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(newsletter)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
<div className="flex gap-2">
{hasPermission('newsletters.edit') && (
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(newsletter)}
className="border-[#664fa3] text-[#664fa3]"
>
<Edit className="h-4 w-4" />
</Button>
)}
{hasPermission('newsletters.delete') && (
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(newsletter)}
className="border-red-500 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
</Card>
))}
@@ -322,14 +335,16 @@ const AdminNewsletters = () => {
<Label htmlFor="document_type">Document Type *</Label>
<Select
value={formData.document_type}
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
onValueChange={(value) => {
setFormData({ ...formData, document_type: value, document_url: '' });
setUploadedFile(null);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google_docs">Google Docs</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="upload">Upload</SelectItem>
</SelectContent>
</Select>
@@ -350,6 +365,11 @@ const AdminNewsletters = () => {
Selected: {uploadedFile.name}
</p>
)}
{selectedNewsletter && !uploadedFile && (
<p className="text-sm text-[#664fa3] mt-1">
Current file will be kept if no new file is selected
</p>
)}
</div>
) : (
<div>
@@ -358,12 +378,11 @@ const AdminNewsletters = () => {
id="document_url"
value={formData.document_url}
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
placeholder="https://docs.google.com/document/d/..."
placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf"
required
/>
<p className="text-sm text-[#664fa3] mt-1">
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
</p>
</div>
)}

View File

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

View File

@@ -12,11 +12,11 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog';
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
import { toast } from 'sonner';
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye } from 'lucide-react';
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react';
const AdminStaff = () => {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { hasPermission, user } = useAuth();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -71,6 +71,32 @@ const AdminStaff = () => {
setFilteredUsers(filtered);
};
const handleToggleStatus = async (userId, currentStatus) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try {
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
fetchStaff(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update user status');
}
};
const handleDeleteUser = async (userId, userName) => {
if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) {
return;
}
try {
await api.delete(`/admin/users/${userId}`);
toast.success('User deleted successfully');
fetchStaff(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete user');
}
};
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
@@ -92,7 +118,7 @@ const AdminStaff = () => {
const getStatusBadge = (status) => {
const config = {
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;
@@ -116,7 +142,7 @@ const AdminStaff = () => {
</p>
</div>
<div className="flex gap-3">
{hasPermission('users.invite') && (
{hasPermission('users.create') && (
<Button
onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
@@ -250,7 +276,7 @@ const AdminStaff = () => {
</div>
{/* Actions */}
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => navigate(`/admin/users/${user.id}`)}
variant="outline"
@@ -259,6 +285,41 @@ const AdminStaff = () => {
<Edit className="h-4 w-4 mr-2" />
Manage
</Button>
{hasPermission('users.status') && (
<Button
onClick={() => handleToggleStatus(user.id, user.status)}
variant="outline"
className={`border-2 rounded-full px-4 py-2 ${
user.status === 'active'
? 'border-orange-500 text-orange-600 hover:bg-orange-50'
: 'border-green-500 text-green-600 hover:bg-green-50'
}`}
>
{user.status === 'active' ? (
<>
<UserX className="h-4 w-4 mr-2" />
Deactivate
</>
) : (
<>
<UserCheck className="h-4 w-4 mr-2" />
Activate
</>
)}
</Button>
)}
{hasPermission('users.delete') && (
<Button
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
variant="outline"
className="border-2 border-red-500 text-red-600 hover:bg-red-50 rounded-full px-4 py-2"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
)}
</div>
</div>
</Card>

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
@@ -35,6 +36,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog';
const AdminValidations = () => {
const { hasPermission } = useAuth();
const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -419,66 +421,78 @@ const AdminValidations = () => {
</Button>
) : user.status === 'pending_email' ? (
<>
<Button
onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
>
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
</Button>
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
{hasPermission('users.approve') && (
<Button
onClick={() => handleBypassAndValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
>
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
) : user.status === 'payment_pending' ? (
<>
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
{hasPermission('subscriptions.activate') && (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
) : (
<>
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
{hasPermission('users.approve') && (
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
)}
</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 moment from 'moment';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import './MemberCalendar.css';
import api from '../../utils/api';
import Navbar from '../../components/Navbar';
import MemberFooter from '../../components/MemberFooter';
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
const [selectedEvent, setSelectedEvent] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [rsvpLoading, setRsvpLoading] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [currentView, setCurrentView] = useState('month');
useEffect(() => {
fetchEvents();
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
setIsDialogOpen(true);
};
const handleNavigate = (newDate) => {
setCurrentDate(newDate);
};
const handleViewChange = (newView) => {
setCurrentView(newView);
};
const handleRSVP = async (status) => {
if (!selectedEvent) return;
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
startAccessor="start"
endAccessor="end"
style={{ height: 700 }}
date={currentDate}
view={currentView}
onNavigate={handleNavigate}
onView={handleViewChange}
onSelectEvent={handleSelectEvent}
eventPropGetter={eventStyleGetter}
views={['month', 'week', 'day', 'agenda']}
defaultView="month"
popup
className="member-calendar"
/>
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
</Dialog>
</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 />
</div>
);

View File

@@ -24,6 +24,8 @@ const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null);
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12;
useEffect(() => {
fetchMembers();
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
filterMembers();
}, [searchQuery, members]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, members]);
const fetchMembers = async () => {
try {
const response = await api.get('/members/directory');
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
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) => {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
};
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
if (!dateString) return null;
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
};
const Border = ({ yaxis = false }) => {
return (
yaxis ?
<div className=' border-2 w-full border-[#664FA3] my-24' />
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
)
}
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 */}
<div className="flex justify-center mb-4">
{member.profile_photo_url ? (
@@ -259,39 +279,48 @@ const MembersDirectory = () => {
);
return (
<div className="min-h-screen bg-white">
<div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
<Navbar />
<div className="max-w-7xl mx-auto px-6 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>
<div className="max-w-7xl mx-auto py-12">
{/* Search Bar */}
<div className="mb-8">
<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]" />
<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-lg border-[#ddd8eb] rounded-xl 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'}
{/* Header and Search bar */}
<div className='px-9'>
{/* Header */}
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
<h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
LOAF Members
</h1>
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
</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>
{/* Border Decoration */}
<Border />
{/* Members Grid */}
{loading ? (
@@ -300,7 +329,7 @@ const MembersDirectory = () => {
</div>
) : filteredMembers.length > 0 ? (
<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} />
))}
</div>
@@ -318,6 +347,11 @@ const MembersDirectory = () => {
</div>
)}
{/* Border Decoration */}
<Border yaxis="true" />
{/* Info Card */}
{!loading && members.length > 0 && (
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
@@ -465,67 +499,127 @@ const MembersDirectory = () => {
{/* Social Media */}
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Connect on Social Media
</h3>
<div className="flex gap-3">
{selectedMember.social_media_facebook && (
<a
href={getSocialMediaLink(selectedMember.social_media_facebook)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Facebook"
>
<Facebook className="h-6 w-6 text-[#1877F2]" />
</a>
)}
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
Connect on Social Media
</h3>
<div className="flex gap-3">
{selectedMember.social_media_facebook && (
<a
href={getSocialMediaLink(selectedMember.social_media_facebook)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Facebook"
>
<Facebook className="h-6 w-6 text-[#1877F2]" />
</a>
)}
{selectedMember.social_media_instagram && (
<a
href={getSocialMediaLink(selectedMember.social_media_instagram)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Instagram"
>
<Instagram className="h-6 w-6 text-[#E4405F]" />
</a>
)}
{selectedMember.social_media_instagram && (
<a
href={getSocialMediaLink(selectedMember.social_media_instagram)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Instagram"
>
<Instagram className="h-6 w-6 text-[#E4405F]" />
</a>
)}
{selectedMember.social_media_twitter && (
<a
href={getSocialMediaLink(selectedMember.social_media_twitter)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Twitter/X"
>
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
</a>
)}
{selectedMember.social_media_twitter && (
<a
href={getSocialMediaLink(selectedMember.social_media_twitter)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="Twitter/X"
>
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
</a>
)}
{selectedMember.social_media_linkedin && (
<a
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="LinkedIn"
>
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
</a>
)}
{selectedMember.social_media_linkedin && (
<a
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
title="LinkedIn"
>
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
</a>
)}
</div>
</div>
</div>
)}
)}
</div>
</>
)}
</DialogContent>
</Dialog>
{/* Pagination */}
{!loading && filteredMembers.length > 0 && (
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
<p className="text-sm text-[#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 />
</div>
);

View File

@@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL;
export const api = axios.create({
baseURL: `${API_URL}/api`,
timeout: 30000, // 30 second timeout for all requests
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// Request interceptor - add auth token
api.interceptors.request.use(
(config) => {
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;