Merge from Dev to LOAF Production #13

Merged
andika merged 14 commits from dev into loaf-prod 2026-01-07 08:44:10 +00:00
14 changed files with 797 additions and 349 deletions

5
public/health.json Normal file
View File

@@ -0,0 +1,5 @@
{
"status": "healthy",
"mode": "production",
"build": "optimized"
}

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import IdleSessionWarning from './components/IdleSessionWarning';
import Landing from './pages/Landing'; import Landing from './pages/Landing';
import Register from './pages/Register'; import Register from './pages/Register';
import Login from './pages/Login'; import Login from './pages/Login';
@@ -294,6 +295,7 @@ function App() {
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
<Toaster position="top-right" /> <Toaster position="top-right" />
<IdleSessionWarning />
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
); );

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,18 +269,17 @@ 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">
<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" }}> {/* <p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
View Public Site View Public Site
</p> </p> */}
</div> </div>
)} )}
</Link> </Link>
@@ -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

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import logger from '../utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { AlertTriangle, RefreshCw } from 'lucide-react';
/**
* IdleSessionWarning Component
*
* Monitors user activity and warns before session expiration
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
* - Auto-logout on expiration
* - "Stay Logged In" extends session
*/
const IdleSessionWarning = () => {
const { user, logout, refreshUser } = useAuth();
const navigate = useNavigate();
// Configuration
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const WARNING_BEFORE_EXPIRY = 1 * 60 * 1000; // Warn 1 minute before expiry
const WARNING_TIME = SESSION_DURATION - WARNING_BEFORE_EXPIRY; // 29 minutes
const [showWarning, setShowWarning] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
const [isExtending, setIsExtending] = useState(false);
const activityTimeoutRef = useRef(null);
const warningTimeoutRef = useRef(null);
const countdownIntervalRef = useRef(null);
const lastActivityRef = useRef(Date.now());
// Reset activity timer
const resetActivityTimer = useCallback(() => {
lastActivityRef.current = Date.now();
// Clear existing timers
if (activityTimeoutRef.current) {
clearTimeout(activityTimeoutRef.current);
}
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
// Hide warning if showing
if (showWarning) {
setShowWarning(false);
}
// Set new warning timer
warningTimeoutRef.current = setTimeout(() => {
// Show warning
setShowWarning(true);
setTimeRemaining(60); // 60 seconds until logout
// Start countdown
countdownIntervalRef.current = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1) {
// Time's up - logout
handleSessionExpired();
return 0;
}
return prev - 1;
});
}, 1000);
// Set auto-logout timer
activityTimeoutRef.current = setTimeout(() => {
handleSessionExpired();
}, WARNING_BEFORE_EXPIRY);
}, WARNING_TIME);
}, [showWarning]);
// Handle session expiration
const handleSessionExpired = useCallback(() => {
// Clear all timers
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
setShowWarning(false);
logout();
navigate('/login', {
state: { message: 'Your session has expired due to inactivity. Please log in again.' }
});
}, [logout, navigate]);
// Handle "Stay Logged In" button
const handleExtendSession = async () => {
setIsExtending(true);
try {
// Refresh user data to get new token
await refreshUser();
// Reset activity timer
resetActivityTimer();
logger.log('[IdleSessionWarning] Session extended successfully');
} catch (error) {
logger.error('[IdleSessionWarning] Failed to extend session:', error);
// If refresh fails, logout
handleSessionExpired();
} finally {
setIsExtending(false);
}
};
// Track user activity
useEffect(() => {
if (!user) return;
const activityEvents = [
'mousedown',
'mousemove',
'keypress',
'scroll',
'touchstart',
'click'
];
// Throttle activity detection to avoid too many resets
let throttleTimeout = null;
const handleActivity = () => {
if (throttleTimeout) return;
throttleTimeout = setTimeout(() => {
resetActivityTimer();
throttleTimeout = null;
}, 1000); // Throttle to once per second
};
// Add event listeners
activityEvents.forEach(event => {
document.addEventListener(event, handleActivity, { passive: true });
});
// Initialize timer
resetActivityTimer();
// Cleanup
return () => {
activityEvents.forEach(event => {
document.removeEventListener(event, handleActivity);
});
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
if (throttleTimeout) clearTimeout(throttleTimeout);
};
}, [user, resetActivityTimer]);
// Don't render if user is not logged in
if (!user) return null;
return (
<Dialog open={showWarning} onOpenChange={(open) => {
if (!open) {
// Prevent closing dialog by clicking outside
// User must click a button
return;
}
}}>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="bg-[#ff9e77]/10 p-3 rounded-full">
<AlertTriangle className="h-6 w-6 text-[#ff9e77]" />
</div>
<DialogTitle className="text-[#422268]">
Session About to Expire
</DialogTitle>
</div>
<DialogDescription className="text-[#664fa3]">
Your session will expire in <strong className="text-[#422268] text-lg">{timeRemaining}</strong> seconds due to inactivity.
<div className="mt-4 p-4 bg-[#f1eef9] rounded-lg border border-[#ddd8eb]">
<p className="text-sm text-[#422268]">
Click <strong>"Stay Logged In"</strong> to continue your session, or you will be automatically logged out.
</p>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col sm:flex-row gap-3 mt-4">
<Button
variant="outline"
onClick={handleSessionExpired}
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9]"
>
Log Out Now
</Button>
<Button
onClick={handleExtendSession}
disabled={isExtending}
className="bg-[#664fa3] hover:bg-[#422268] text-white"
>
{isExtending ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Extending...
</>
) : (
'Stay Logged In'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default IdleSessionWarning;

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

@@ -1,12 +1,14 @@
import React, { createContext, useState, useContext, useEffect } from 'react'; import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import api from '../utils/api';
import logger from '../utils/logger';
const AuthContext = createContext(); const AuthContext = createContext();
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin; const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
// Log environment on module load for debugging // Log environment on module load for debugging
console.log('[AuthContext] Module initialized with:', { logger.log('[AuthContext] Module initialized with:', {
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL, REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME, REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
API_URL: API_URL API_URL: API_URL
@@ -55,31 +57,31 @@ export const AuthProvider = ({ children }) => {
}); });
setPermissions(response.data.permissions || []); setPermissions(response.data.permissions || []);
} catch (error) { } catch (error) {
console.error('Failed to fetch permissions:', error); logger.error('Failed to fetch permissions:', error);
setPermissions([]); setPermissions([]);
} }
}; };
const login = async (email, password) => { const login = async (email, password) => {
try { try {
console.log('[AuthContext] Starting login request...', { logger.log('[AuthContext] Starting login request...', {
API_URL: API_URL, API_URL: API_URL,
envBackendUrl: process.env.REACT_APP_BACKEND_URL, envBackendUrl: process.env.REACT_APP_BACKEND_URL,
fullUrl: `${API_URL}/api/auth/login` fullUrl: `${API_URL}/api/auth/login`
}); });
const response = await axios.post( // Use api instance for retry logic
`${API_URL}/api/auth/login`, const response = await api.post(
'/auth/login',
{ email, password }, { email, password },
{ {
timeout: 30000, // 30 second timeout
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }
); );
console.log('[AuthContext] Login response received:', { logger.log('[AuthContext] Login response received:', {
status: response.status, status: response.status,
hasToken: !!response.data?.access_token, hasToken: !!response.data?.access_token,
hasUser: !!response.data?.user hasUser: !!response.data?.user
@@ -87,39 +89,46 @@ export const AuthProvider = ({ children }) => {
const { access_token, user: userData } = response.data; const { access_token, user: userData } = response.data;
// Store token first if (!access_token || !userData) {
localStorage.setItem('token', access_token); throw new Error('Invalid response from server - missing token or user data');
console.log('[AuthContext] Token stored in localStorage'); }
// Update state // Store token FIRST and verify it was stored
localStorage.setItem('token', access_token);
const storedToken = localStorage.getItem('token');
if (storedToken !== access_token) {
throw new Error('Failed to store token in localStorage');
}
logger.log('[AuthContext] Token stored and verified in localStorage');
// Update state in correct order
setToken(access_token); setToken(access_token);
setUser(userData); setUser(userData);
console.log('[AuthContext] User state updated:', { logger.log('[AuthContext] User state updated:', {
email: userData.email, email: userData.email,
role: userData.role role: userData.role
}); });
// Fetch user permissions (don't let this fail the login) // Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
// Use setTimeout to defer permission fetching slightly try {
setTimeout(async () => { logger.log('[AuthContext] Fetching permissions...');
try { await fetchPermissions(access_token);
console.log('[AuthContext] Fetching permissions...'); logger.log('[AuthContext] Permissions fetched successfully');
await fetchPermissions(access_token); } catch (permError) {
console.log('[AuthContext] Permissions fetched successfully'); logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
} catch (error) { message: permError.message,
console.error('[AuthContext] Failed to fetch permissions (non-critical):', { response: permError.response?.data,
message: error.message, status: permError.response?.status
response: error.response?.data, });
status: error.response?.status // Set empty permissions array so hasPermission doesn't break
}); setPermissions([]);
// Don't throw - permissions can be fetched later if needed // Don't throw - login succeeded even if permissions failed
} }
}, 100); // Small delay to ensure state is settled
return userData; return userData;
} catch (error) { } catch (error) {
// Enhanced error logging // Enhanced error logging
console.error('[AuthContext] Login failed:', { logger.error('[AuthContext] Login failed:', {
message: error.message, message: error.message,
response: error.response?.data, response: error.response?.data,
status: error.response?.status, status: error.response?.status,
@@ -131,6 +140,12 @@ export const AuthProvider = ({ children }) => {
} }
}); });
// Clear any partial state
localStorage.removeItem('token');
setToken(null);
setUser(null);
setPermissions([]);
// Re-throw to let Login component handle the error // Re-throw to let Login component handle the error
throw error; throw error;
} }
@@ -160,7 +175,7 @@ export const AuthProvider = ({ children }) => {
setUser(response.data); setUser(response.data);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Failed to refresh user:', error); logger.error('Failed to refresh user:', error);
// If token expired, logout // If token expired, logout
if (error.response?.status === 401) { if (error.response?.status === 401) {
logout(); logout();

View File

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

View File

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

66
src/utils/logger.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Production-safe logging utility
*
* In production (NODE_ENV=production), logs are disabled by default
* to prevent exposing sensitive information in browser console.
*
* In development, all logs are shown for debugging.
*
* Usage:
* import logger from '../utils/logger';
* logger.log('[Component]', 'message', data);
* logger.error('[Component]', 'error message', error);
* logger.warn('[Component]', 'warning message');
*/
const isDevelopment = process.env.NODE_ENV === 'development';
// Force enable logs with REACT_APP_DEBUG_LOGS=true in .env
const debugEnabled = process.env.REACT_APP_DEBUG_LOGS === 'true';
const shouldLog = isDevelopment || debugEnabled;
const logger = {
log: (...args) => {
if (shouldLog) {
console.log(...args);
}
},
error: (...args) => {
// Always log errors, but sanitize in production
if (shouldLog) {
console.error(...args);
} else {
// In production, only log error type without details
console.error('An error occurred. Enable debug logs for details.');
}
},
warn: (...args) => {
if (shouldLog) {
console.warn(...args);
}
},
info: (...args) => {
if (shouldLog) {
console.info(...args);
}
},
debug: (...args) => {
if (shouldLog) {
console.debug(...args);
}
},
// Special method for sensitive data - NEVER logs in production
sensitive: (...args) => {
if (isDevelopment) {
console.log('[SENSITIVE]', ...args);
}
}
};
export default logger;