3 Commits

25 changed files with 698 additions and 1537 deletions

View File

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

View File

@@ -23,7 +23,6 @@ import AdminMembers from './pages/admin/AdminMembers';
import AdminPermissions from './pages/admin/AdminPermissions'; import AdminPermissions from './pages/admin/AdminPermissions';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
import AdminPlans from './pages/admin/AdminPlans'; import AdminPlans from './pages/admin/AdminPlans';
import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminSubscriptions from './pages/admin/AdminSubscriptions';
@@ -219,13 +218,6 @@ function App() {
</AdminLayout> </AdminLayout>
</PrivateRoute> </PrivateRoute>
} /> } />
<Route path="/admin/events/:eventId/attendance" element={
<PrivateRoute adminOnly>
<AdminLayout>
<AdminEventAttendance />
</AdminLayout>
</PrivateRoute>
} />
<Route path="/admin/validations" element={ <Route path="/admin/validations" element={
<PrivateRoute adminOnly> <PrivateRoute adminOnly>
<AdminLayout> <AdminLayout>

View File

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

View File

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

View File

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

View File

@@ -1,33 +1,31 @@
import * as React from "react"; import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"; import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root; const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => ( const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground", "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className className
)} )}
{...props} {...props} />
/> ))
)); TabsList.displayName = TabsPrimitive.List.displayName
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap 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", "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",
className className
)} )}
{...props} {...props} />
/> ))
)); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content <TabsPrimitive.Content
@@ -36,9 +34,8 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
{...props} {...props} />
/> ))
)); TabsContent.displayName = TabsPrimitive.Content.displayName
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }; export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react'; import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
const AdminDashboard = () => { const AdminDashboard = () => {
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -42,8 +42,8 @@ const AdminDashboard = () => {
}).map(u => ({ }).map(u => ({
...u, ...u,
totalReminders: (u.email_verification_reminders_sent || 0) + totalReminders: (u.email_verification_reminders_sent || 0) +
(u.event_attendance_reminders_sent || 0) + (u.event_attendance_reminders_sent || 0) +
(u.payment_reminders_sent || 0) (u.payment_reminders_sent || 0)
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5 })).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
setUsersNeedingAttention(needingAttention); setUsersNeedingAttention(needingAttention);
@@ -56,183 +56,173 @@ const AdminDashboard = () => {
return ( return (
<> <>
<div className='flex justify-between items-center'> <div className="mb-8">
<div className="mb-8"> <h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> Admin Dashboard
Admin Dashboard </h1>
</h1> <p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> Manage users, events, and membership applications.
Manage users, events, and membership applications. </p>
</p>
</div>
<Link to={'/'}>
<Button
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
>
<Globe />
View Public Site
</Button>
</Link>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users"> <Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg"> <div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#664fa3]" /> <Users className="h-6 w-6 text-[#664fa3]" />
</div>
</div>
<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>
</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>
</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>
<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>
{/* 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>
<div> </div>
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Needing Personal Outreach {loading ? '-' : stats.totalMembers}
</h3> </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>
</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>
</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>
<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>
{/* Users Needing Attention Widget */}
{usersNeedingAttention.length > 0 && (
<div className="mt-12">
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
<div className="flex items-center gap-3 mb-6">
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
</div>
<div>
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Needing Personal Outreach
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
These members have received multiple reminder emails. Consider calling them directly.
</p>
</div>
</div>
<div 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" }}> <p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
These members have received multiple reminder emails. Consider calling them directly. <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> </p>
</div> </div>
</div> </Card>
</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,5 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
@@ -32,7 +31,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const AdminDonations = () => { const AdminDonations = () => {
const { hasPermission } = useAuth();
const [donations, setDonations] = useState([]); const [donations, setDonations] = useState([]);
const [filteredDonations, setFilteredDonations] = useState([]); const [filteredDonations, setFilteredDonations] = useState([]);
const [stats, setStats] = useState({}); const [stats, setStats] = useState({});
@@ -271,35 +269,33 @@ const AdminDonations = () => {
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]" className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/> />
</div> </div>
{hasPermission('donations.export') && ( <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button
<Button disabled={exporting}
disabled={exporting} className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
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" />
<Download className="h-4 w-4" /> {exporting ? 'Exporting...' : 'Export'}
{exporting ? 'Exporting...' : 'Export'} </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg"> <DropdownMenuItem
<DropdownMenuItem onClick={() => handleExport('all')}
onClick={() => handleExport('all')} className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" >
> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> <span className="text-[#422268]">Export All Donations</span>
<span className="text-[#422268]">Export All Donations</span> </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem onClick={() => handleExport('current')}
onClick={() => handleExport('current')} className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3" >
> <FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" /> <span className="text-[#422268]">Export Current View</span>
<span className="text-[#422268]">Export Current View</span> </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu>
)}
</div> </div>
{/* Filters Row */} {/* Filters Row */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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