Merge pull request 'Merge Kayela works to Dev' (#12) from templates into dev

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-01-07 06:18:06 +00:00
9 changed files with 445 additions and 317 deletions

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'
} }
`} `}
> >
@@ -269,9 +269,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${ className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
isOpen ? 'h-10 w-10' : 'h-8 w-8' }`}
}`}
/> />
{isOpen && ( {isOpen && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -300,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4"> <nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
{/* Dashboard - Standalone */} {/* Dashboard - Standalone */}
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))} {renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
@@ -370,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
{/* User Section */} {/* User Section */}
<div className="border-t border-[#ddd8eb] p-4 space-y-2"> <div className="border-t border-[#ddd8eb] p-4 space-y-2">
{isOpen && user && ( {isOpen && user && (
<div className="px-4 py-3 mb-2"> <div className="px-4 py-3 mb-2 flex justify-between items-center">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold"> <div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
{user.first_name?.[0]}{user.last_name?.[0]} {user.first_name?.[0]}{user.last_name?.[0]}
@@ -384,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</p> </p>
</div> </div>
</div> </div>
<Link to='/profile'><Settings size={16} />
</Link>
</div> </div>
)} )}
@@ -397,11 +398,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
</div> </div>
<div className="w-full bg-[#ddd8eb] rounded-full h-2"> <div className="w-full bg-[#ddd8eb] rounded-full h-2">
<div <div
className={`h-2 rounded-full transition-all ${ className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 90 ? 'bg-red-500' :
storagePercentage > 75 ? 'bg-yellow-500' : storagePercentage > 75 ? 'bg-yellow-500' :
'bg-[#81B29A]' 'bg-[#81B29A]'
}`} }`}
style={{ width: `${storagePercentage}%` }} style={{ width: `${storagePercentage}%` }}
/> />
</div> </div>
@@ -412,11 +412,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
) : ( ) : (
<div className="flex justify-center"> <div className="flex justify-center">
<div className="relative group"> <div className="relative group">
<HardDrive className={`h-5 w-5 ${ <HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 90 ? 'text-red-500' :
storagePercentage > 75 ? 'text-yellow-500' : storagePercentage > 75 ? 'text-yellow-500' :
'text-[#664fa3]' 'text-[#664fa3]'
}`} /> }`} />
{storagePercentage > 75 && ( {storagePercentage > 75 && (
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" /> <div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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